Merge feat/803-heidenhain-lsv2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
|
||||
@@ -120,6 +122,7 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj" />
|
||||
|
||||
@@ -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/<name>` (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/<inst>/apikey` |
|
||||
| `env:OTOPCUA_HEIDENHAIN_<NAME>` (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": "<address>", "dataType": "<DriverDataType name>", "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:<AREA>:<INDEX>` + `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:<path>` + `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
|
||||
(`<host:port>.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).
|
||||
@@ -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).)
|
||||
|
||||
|
||||
@@ -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: <model> NC <version>"` | 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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Heidenhain LSV2 deploy gates (lmxopcua#803). All reuse the driver's own Contracts parser, so the gate and
|
||||
/// the runtime cannot disagree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <list type="bullet">
|
||||
/// <item><b><see cref="WritableCode"/></b> — the driver is read-only (no <c>IWritable</c>), so a ReadWrite
|
||||
/// access level would advertise a writable OPC node that can never be written. Refused, so no Heidenhain
|
||||
/// node is ever writable.</item>
|
||||
/// <item><b><see cref="CredentialCode"/></b> — a password field that is not blank / <c>secret:</c> /
|
||||
/// <c>env:</c> would store a controller password in the config database (replicated to every node). The
|
||||
/// message never echoes the value.</item>
|
||||
/// <item><b><see cref="ConfigCode"/></b> — out-of-range driver settings, or an invalid endpoint on a device
|
||||
/// that carries tags (the driver would fail to start).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class HeidenhainDeployChecks
|
||||
{
|
||||
/// <summary>A Heidenhain tag authored ReadWrite.</summary>
|
||||
public const string WritableCode = "HeidenhainTagWritable";
|
||||
|
||||
/// <summary>A literal password in a Heidenhain DriverConfig/DeviceConfig.</summary>
|
||||
public const string CredentialCode = "HeidenhainCredentialPersisted";
|
||||
|
||||
/// <summary>A Heidenhain tag whose <c>Tag.DataType</c> disagrees with the type the driver publishes.</summary>
|
||||
public const string DataTypeCode = "HeidenhainTagDataTypeMismatch";
|
||||
|
||||
/// <summary>Invalid Heidenhain settings or endpoint.</summary>
|
||||
public const string ConfigCode = "HeidenhainConfigInvalid";
|
||||
|
||||
/// <summary>Runs every Heidenhain gate.</summary>
|
||||
/// <param name="draft">The draft.</param>
|
||||
/// <returns>The errors.</returns>
|
||||
public static IReadOnlyList<ValidationError> Errors(DraftSnapshot draft)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@
|
||||
<!-- lmxopcua#781: the Sql deploy checks reuse the driver's own tag parse. Cycle-safe — Sql.Contracts
|
||||
references only Core.Abstractions. -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
|
||||
<!-- lmxopcua#803: the Heidenhain read-only + credential deploy gates reuse the Contracts parser. -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -62,6 +62,9 @@ public static class DriverTypeNames
|
||||
/// opt-in observation write-back via the Agent's AllowPut POST surface.</summary>
|
||||
public const string MTConnect = "MTConnect";
|
||||
|
||||
/// <summary>Heidenhain LSV2 CNC driver — native, read-only (logins + read requests only) over TCP 19000.</summary>
|
||||
public const string Heidenhain = "Heidenhain";
|
||||
|
||||
/// <summary>
|
||||
/// Every driver-type string declared above, for callers that need to enumerate
|
||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||
@@ -80,5 +83,6 @@ public static class DriverTypeNames
|
||||
Sql,
|
||||
Mqtt,
|
||||
MTConnect,
|
||||
Heidenhain,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>The PLC memory areas a <c>PLC:</c> binding may name (advertised by the control's <c>R_PR</c> layout).</summary>
|
||||
public enum HeidenhainPlcArea
|
||||
{
|
||||
Marker,
|
||||
Input,
|
||||
Output,
|
||||
Counter,
|
||||
Timer,
|
||||
Byte,
|
||||
Word,
|
||||
Dword,
|
||||
String,
|
||||
InputWord,
|
||||
OutputWord,
|
||||
}
|
||||
|
||||
/// <summary>What an address resolves to.</summary>
|
||||
public enum HeidenhainAddressKind
|
||||
{
|
||||
/// <summary>A fixed catalog leaf (<see cref="HeidenhainCatalog"/>).</summary>
|
||||
Standard,
|
||||
|
||||
/// <summary>A PLC memory element read via <c>R_MB</c>.</summary>
|
||||
Plc,
|
||||
|
||||
/// <summary>A data-path value read via <c>R_DP</c> (DATA role).</summary>
|
||||
DataPath,
|
||||
}
|
||||
|
||||
/// <summary>A parsed, validated Heidenhain tag address.</summary>
|
||||
/// <param name="Kind">The address family.</param>
|
||||
/// <param name="Text">The canonical address text.</param>
|
||||
/// <param name="Catalog">The fixed leaf, for <see cref="HeidenhainAddressKind.Standard"/>.</param>
|
||||
/// <param name="Area">The PLC area, for <see cref="HeidenhainAddressKind.Plc"/>.</param>
|
||||
/// <param name="Index">The PLC <b>element</b> index (not an absolute address), for <see cref="HeidenhainAddressKind.Plc"/>.</param>
|
||||
/// <param name="DataPath">The data path as authored, for <see cref="HeidenhainAddressKind.DataPath"/>.</param>
|
||||
public sealed record HeidenhainAddress(
|
||||
HeidenhainAddressKind Kind,
|
||||
string Text,
|
||||
HeidenhainCatalogEntry? Catalog = null,
|
||||
HeidenhainPlcArea? Area = null,
|
||||
long Index = 0,
|
||||
string? DataPath = null)
|
||||
{
|
||||
/// <summary>Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header).</summary>
|
||||
public const int MaxPayload = 247;
|
||||
|
||||
/// <summary>Address prefix of a PLC binding.</summary>
|
||||
public const string PlcPrefix = "PLC:";
|
||||
|
||||
/// <summary>Address prefix of a DATA-path binding.</summary>
|
||||
public const string DataPrefix = "DATA:";
|
||||
|
||||
/// <summary>The data types a custom (PLC/DATA) binding may declare — the reference's BOOLEAN/INT16/INT32/INT64/DOUBLE/STRING.</summary>
|
||||
public static readonly IReadOnlySet<DriverDataType> CustomDataTypes = new HashSet<DriverDataType>
|
||||
{
|
||||
DriverDataType.Boolean, DriverDataType.Int16, DriverDataType.Int32,
|
||||
DriverDataType.Int64, DriverDataType.Float64, DriverDataType.String,
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, HeidenhainPlcArea> AreaNames =
|
||||
new Dictionary<string, HeidenhainPlcArea>(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,
|
||||
};
|
||||
|
||||
/// <summary>The wire spelling of a PLC area (<c>MARKER</c>, <c>INPUT_WORD</c>, …).</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <returns>Its address token.</returns>
|
||||
public static string AreaToken(HeidenhainPlcArea area) => AreaNames.First(kv => kv.Value == area).Key;
|
||||
|
||||
/// <summary>The PLC area tokens, in declaration order.</summary>
|
||||
public static IReadOnlyList<string> AreaTokens { get; } = AreaNames.Keys.ToList();
|
||||
|
||||
/// <summary>True for the one-byte-per-entry Boolean bit areas.</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <returns>Whether it is a bit area.</returns>
|
||||
public static bool IsBitArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Marker or HeidenhainPlcArea.Input
|
||||
or HeidenhainPlcArea.Output or HeidenhainPlcArea.Counter or HeidenhainPlcArea.Timer;
|
||||
|
||||
/// <summary>True for the integer areas (BYTE/WORD/DWORD signed; INPUT_WORD/OUTPUT_WORD unsigned 16-bit).</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <returns>Whether it is an integer area.</returns>
|
||||
public static bool IsIntegerArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Byte or HeidenhainPlcArea.Word
|
||||
or HeidenhainPlcArea.Dword or HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord;
|
||||
|
||||
/// <summary>
|
||||
/// Parses an address. Never throws; <paramref name="error"/> explains a rejection.
|
||||
/// </summary>
|
||||
/// <param name="text">The authored address.</param>
|
||||
/// <param name="address">The parsed address on success.</param>
|
||||
/// <param name="error">Why the address was rejected.</param>
|
||||
/// <returns>True when the address is valid.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reference's data-path rules: non-blank, NUL-free Latin-1, and short enough that the
|
||||
/// <c>R_DP</c> payload (4 zero bytes + normalised path + NUL) fits the 247-byte legacy payload.
|
||||
/// </summary>
|
||||
/// <param name="path">The data path.</param>
|
||||
/// <param name="error">Why the path was rejected.</param>
|
||||
/// <returns>True when the path is sendable.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reference's datatype-vs-area rules for a <c>PLC:</c> binding: Boolean only for bit areas, String
|
||||
/// only for STRING, and integers wide enough for the native range (never narrowing or wrapping).
|
||||
/// </summary>
|
||||
/// <param name="area">The PLC area.</param>
|
||||
/// <param name="type">The declared data type.</param>
|
||||
/// <returns>Null when acceptable, else the reason.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The normalised data path the <c>R_DP</c> request carries (<c>/</c>→<c>\</c>, <c>"</c>→<c>'</c>).</summary>
|
||||
/// <param name="path">The authored path.</param>
|
||||
/// <returns>The normalised path.</returns>
|
||||
public static string NormalizeDataPath(string path) => path.Replace('/', '\\').Replace('"', '\'');
|
||||
|
||||
/// <summary>Latin-1 encoding used by every LSV2 string.</summary>
|
||||
public static Encoding Latin1 => Encoding.Latin1;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>The three acquisition cadences a Heidenhain tag can ride.</summary>
|
||||
public enum HeidenhainPollGroup
|
||||
{
|
||||
/// <summary>Execution/program state, program, overrides, axes (<c>Polling.StatusMs</c>).</summary>
|
||||
Status,
|
||||
|
||||
/// <summary>Configured PLC / DATA bindings (<c>Polling.CustomMs</c>).</summary>
|
||||
Custom,
|
||||
|
||||
/// <summary>Identity, tool, alarms, timers and capability strings (<c>Polling.SlowMs</c>).</summary>
|
||||
Slow,
|
||||
}
|
||||
|
||||
/// <summary>One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it.</summary>
|
||||
/// <param name="Address">The device-relative address, e.g. <c>Status/ExecutionState</c>.</param>
|
||||
/// <param name="DataType">The OPC UA data type the driver publishes.</param>
|
||||
/// <param name="IsArray">True for the variable-length array leaves (axes, alarms).</param>
|
||||
/// <param name="Group">The poll cadence.</param>
|
||||
/// <param name="Feature">
|
||||
/// The feature the leaf is acquired through (one wire read per feature per batch); for a
|
||||
/// <c>Capabilities/</c> leaf the feature whose state it reports.
|
||||
/// </param>
|
||||
public sealed record HeidenhainCatalogEntry(
|
||||
string Address, DriverDataType DataType, bool IsArray, HeidenhainPollGroup Group, string Feature)
|
||||
{
|
||||
/// <summary>True for a local <c>Capabilities/</c> string, which never causes a wire read.</summary>
|
||||
public bool IsCapability => Address.StartsWith(HeidenhainCatalog.CapabilitiesFolder + "/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stable, general-purpose device-relative namespace (ported from the ignitionoee v0.20.3 reference
|
||||
/// <c>HeidenhainTags</c>). 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.
|
||||
/// </summary>
|
||||
public static class HeidenhainCatalog
|
||||
{
|
||||
/// <summary>The capability-string folder.</summary>
|
||||
public const string CapabilitiesFolder = "Capabilities";
|
||||
|
||||
/// <summary>Feature id for the version / identity reply.</summary>
|
||||
public const string IdentityFeature = "Identity";
|
||||
|
||||
/// <summary>Feature id reported for the R_PR memory layout used by PLC bindings.</summary>
|
||||
public const string PlcLayoutFeature = "PlcLayout";
|
||||
|
||||
/// <summary>Feature id reported for the optional DNC login.</summary>
|
||||
public const string DncLoginFeature = "DncLogin";
|
||||
|
||||
/// <summary>Feature id reported for the opt-in DATA login.</summary>
|
||||
public const string DataLoginFeature = "DataLogin";
|
||||
|
||||
/// <summary>Experimental timer leaf names, in selector order (30..34).</summary>
|
||||
public static readonly IReadOnlyList<string> TimerNames =
|
||||
["NcUptime", "MachineUptime", "MachineRuntime", "Spindle0Runtime", "Spindle1Runtime"];
|
||||
|
||||
/// <summary>Feature-state strings published on <c>Capabilities/</c> leaves.</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>Every fixed leaf, value leaves first then the capability strings.</summary>
|
||||
public static IReadOnlyList<HeidenhainCatalogEntry> Entries { get; } = Build();
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, HeidenhainCatalogEntry> ByAddress =
|
||||
Entries.ToDictionary(e => e.Address, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Looks up a fixed leaf by its exact (case-sensitive) address.</summary>
|
||||
/// <param name="address">The address.</param>
|
||||
/// <param name="entry">The entry, when found.</param>
|
||||
/// <returns>True when <paramref name="address"/> is a fixed leaf.</returns>
|
||||
public static bool TryGet(string address, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainCatalogEntry? entry)
|
||||
=> ByAddress.TryGetValue(address, out entry);
|
||||
|
||||
/// <summary>The value features (excluding capability-only features), in catalog order.</summary>
|
||||
public static IReadOnlyList<string> ValueFeatures { get; } =
|
||||
Entries.Where(e => !e.IsCapability).Select(e => e.Feature).Distinct().ToList();
|
||||
|
||||
private static List<HeidenhainCatalogEntry> Build()
|
||||
{
|
||||
var list = new List<HeidenhainCatalogEntry>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The feature id of an experimental timer leaf.</summary>
|
||||
/// <param name="timerName">One of <see cref="TimerNames"/>.</param>
|
||||
/// <returns><c>Timer_<name></c>.</returns>
|
||||
public static string TimerFeature(string timerName) => "Timer_" + timerName;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// Heidenhain LSV2 driver configuration (lmxopcua#803). One driver instance serves N controls; each
|
||||
/// <c>/raw</c> Device is one controller endpoint (<see cref="HeidenhainDeviceOptions"/>), merged in by
|
||||
/// <c>DriverDeviceConfigMerger</c> as the <c>Devices</c> array.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>No enum-typed fields, by design.</b> 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.</para>
|
||||
/// <para><b>Read-only by construction.</b> Nothing in this document can enable a write: the driver does not
|
||||
/// implement <c>IWritable</c>, and its outbound LSV2 allowlist admits only logins and read requests.</para>
|
||||
/// </remarks>
|
||||
public sealed class HeidenhainDriverOptions
|
||||
{
|
||||
/// <summary>Default LSV2 TCP port.</summary>
|
||||
public const int DefaultPort = 19000;
|
||||
|
||||
/// <summary>The configured controllers (one per <c>/raw</c> Device).</summary>
|
||||
public IReadOnlyList<HeidenhainDeviceOptions> Devices { get; init; } = [];
|
||||
|
||||
/// <summary>The authored raw tags this driver serves, injected by the deploy artifact.</summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
|
||||
/// <summary>Connect + whole-response transaction deadline, ms (100–60000). Default 5000.</summary>
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <summary>Credentials and optional-role access.</summary>
|
||||
public HeidenhainAccessOptions Access { get; init; } = new();
|
||||
|
||||
/// <summary>Poll cadences and acquisition limits.</summary>
|
||||
public HeidenhainPollingOptions Polling { get; init; } = new();
|
||||
|
||||
/// <summary>Bounded reconnect backoff after a lost or failed session.</summary>
|
||||
public HeidenhainReconnectOptions Reconnect { get; init; } = new();
|
||||
|
||||
/// <summary>Timeout for the AdminUI Test Connect probe, seconds (1–60). Default 10.</summary>
|
||||
public int ProbeTimeoutSeconds { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>One Heidenhain control endpoint — a <c>/raw</c> Device's <c>DeviceConfig</c>.</summary>
|
||||
public sealed class HeidenhainDeviceOptions
|
||||
{
|
||||
/// <summary>Hostname or IP address of the control. Required; there is no default endpoint.</summary>
|
||||
public string Host { get; init; } = "";
|
||||
|
||||
/// <summary>LSV2 TCP port (1–65535). Default 19000.</summary>
|
||||
public int Port { get; init; } = HeidenhainDriverOptions.DefaultPort;
|
||||
|
||||
/// <summary>The <c>/raw</c> Device name (the RawPath device segment) — the tag routing key.</summary>
|
||||
public string? DeviceName { get; init; }
|
||||
|
||||
/// <summary>Optional per-device INSPECT password reference; overrides <see cref="HeidenhainAccessOptions.InspectPassword"/>.</summary>
|
||||
public string? InspectPassword { get; init; }
|
||||
|
||||
/// <summary>Optional per-device DNC password reference; overrides <see cref="HeidenhainAccessOptions.DncPassword"/>.</summary>
|
||||
public string? DncPassword { get; init; }
|
||||
|
||||
/// <summary>Optional per-device DATA password reference; overrides <see cref="HeidenhainAccessOptions.DataPassword"/>.</summary>
|
||||
public string? DataPassword { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access settings. Password fields are <b>references</b>, never literals: blank (no password),
|
||||
/// <c>secret:<name></c> (the node's encrypted secret store) or <c>env:<VARIABLE></c>. A literal is
|
||||
/// refused at parse and at the deploy gate, so a controller password never lands in the config database.
|
||||
/// </summary>
|
||||
public sealed class HeidenhainAccessOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool TelemetryEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool DncLoginEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>Opt-in DATA login, attempted only when a <c>DATA:</c> binding needs it. Default off.</summary>
|
||||
public bool DataEnabled { get; init; }
|
||||
|
||||
/// <summary>INSPECT password reference (blank / <c>secret:</c> / <c>env:</c>).</summary>
|
||||
public string? InspectPassword { get; init; }
|
||||
|
||||
/// <summary>DNC password reference (blank / <c>secret:</c> / <c>env:</c>).</summary>
|
||||
public string? DncPassword { get; init; }
|
||||
|
||||
/// <summary>DATA password reference (blank / <c>secret:</c> / <c>env:</c>).</summary>
|
||||
public string? DataPassword { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Poll cadences (each 100–3600000 ms) and limits.</summary>
|
||||
public sealed class HeidenhainPollingOptions
|
||||
{
|
||||
/// <summary>Status group: execution/program state, program, overrides, axes. Default 1000 ms.</summary>
|
||||
public int StatusMs { get; init; } = 1000;
|
||||
|
||||
/// <summary>Custom group: PLC and DATA bindings. Default 1000 ms.</summary>
|
||||
public int CustomMs { get; init; } = 1000;
|
||||
|
||||
/// <summary>Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms.</summary>
|
||||
public int SlowMs { get; init; } = 10000;
|
||||
|
||||
/// <summary>Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64.</summary>
|
||||
public int MaxAlarms { get; init; } = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Experimental timer selectors 30–34 (reference-derived; units/semantics unverified on a real control).
|
||||
/// Default <b>off</b>.
|
||||
/// </summary>
|
||||
public bool TimersEnabled { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Reconnect backoff: doubling from <see cref="InitialDelayMs"/> to <see cref="MaxDelayMs"/>.</summary>
|
||||
public sealed class HeidenhainReconnectOptions
|
||||
{
|
||||
/// <summary>First retry delay, ms (100–600000). Default 1000.</summary>
|
||||
public int InitialDelayMs { get; init; } = 1000;
|
||||
|
||||
/// <summary>Retry delay ceiling, ms (InitialDelayMs–600000). Default 30000.</summary>
|
||||
public int MaxDelayMs { get; init; } = 30000;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// The one parser + validator of the Heidenhain <c>DriverConfig</c> 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.
|
||||
/// </summary>
|
||||
public static class HeidenhainOptionsParser
|
||||
{
|
||||
/// <summary>Prefix of a secret-store password reference.</summary>
|
||||
public const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>Prefix of an environment-variable password reference.</summary>
|
||||
public const string EnvPrefix = "env:";
|
||||
|
||||
/// <summary>
|
||||
/// The ONLY secret-store namespace a Heidenhain password may name: <c>secret:heidenhain/<name></c> (the
|
||||
/// store's slash-separated, driver-scoped naming, as in <c>galaxy/<inst>/apikey</c>). Scoping is the
|
||||
/// exfiltration guard: the reference is resolved on the node and sent in cleartext in <c>A_LG</c> to a Host
|
||||
/// the author chose, so it must never be able to name another subsystem's secret.
|
||||
/// </summary>
|
||||
public const string SecretNamespace = "heidenhain/";
|
||||
|
||||
/// <summary>
|
||||
/// The ONLY environment variables a Heidenhain password may name: <c>env:OTOPCUA_HEIDENHAIN_<NAME></c>
|
||||
/// (exact upper-case prefix; <c>NAME</c> is <c>[A-Z0-9_]+</c>) — never <c>ConnectionStrings__*</c>,
|
||||
/// <c>ServerHistorian__ApiKey</c> or any other process variable.
|
||||
/// </summary>
|
||||
public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_";
|
||||
|
||||
/// <summary>The password fields a Heidenhain DriverConfig/DeviceConfig may carry.</summary>
|
||||
public static readonly IReadOnlyList<string> PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"];
|
||||
|
||||
/// <summary>The kind of a parsed credential reference.</summary>
|
||||
public enum CredentialKind
|
||||
{
|
||||
/// <summary>Blank — no password.</summary>
|
||||
None,
|
||||
|
||||
/// <summary><c>secret:heidenhain/…</c>.</summary>
|
||||
Secret,
|
||||
|
||||
/// <summary><c>env:OTOPCUA_HEIDENHAIN_…</c>.</summary>
|
||||
Environment,
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Deserialises a config document (null sections normalised to defaults). Throws
|
||||
/// <see cref="InvalidOperationException"/> on unreadable JSON.
|
||||
/// </summary>
|
||||
/// <param name="json">The DriverConfig JSON (blank ⇒ defaults).</param>
|
||||
/// <returns>The options.</returns>
|
||||
public static HeidenhainDriverOptions Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new HeidenhainDriverOptions();
|
||||
try
|
||||
{
|
||||
return Normalize(JsonSerializer.Deserialize<HeidenhainDriverOptions>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces JSON-<c>null</c> sections (<c>"Access": null</c>, <c>"Polling": null</c>, …) and null list entries
|
||||
/// with their defaults, so nothing downstream can dereference a null section.
|
||||
/// </summary>
|
||||
/// <param name="o">The options as deserialised (or constructed).</param>
|
||||
/// <returns>An options instance with every section non-null.</returns>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates driver-level ranges and every password reference. Device endpoints are validated per device
|
||||
/// by <see cref="ValidateDevice"/> (a driver may be authored before its devices).
|
||||
/// </summary>
|
||||
/// <param name="o">The options.</param>
|
||||
/// <returns>Human-readable errors; empty when valid. Never echoes a credential value.</returns>
|
||||
public static IReadOnlyList<string> Validate(HeidenhainDriverOptions o)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(o);
|
||||
o = Normalize(o);
|
||||
var errors = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Validates one device endpoint.</summary>
|
||||
/// <param name="d">The device.</param>
|
||||
/// <returns>Errors; empty when valid.</returns>
|
||||
public static IReadOnlyList<string> ValidateDevice(HeidenhainDeviceOptions d)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="value"/> is an acceptable password field: blank,
|
||||
/// <c>secret:heidenhain/<name></c> or <c>env:OTOPCUA_HEIDENHAIN_<NAME></c>.
|
||||
/// </summary>
|
||||
/// <param name="value">The field value.</param>
|
||||
/// <returns>Whether it is a namespaced reference (or blank).</returns>
|
||||
public static bool IsCredentialReference(string? value) => TryParseCredentialReference(value, out _, out _);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="value">The field value.</param>
|
||||
/// <param name="kind">The reference kind.</param>
|
||||
/// <param name="name">The full secret name (incl. <c>heidenhain/</c>) or the environment variable name.</param>
|
||||
/// <returns>True for blank or a namespaced reference.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Access</c>, and under every
|
||||
/// <c>Devices[]</c> 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.
|
||||
/// </summary>
|
||||
/// <param name="json">The config JSON.</param>
|
||||
/// <returns>The problems; empty when every password field is acceptable.</returns>
|
||||
public static IReadOnlyList<string> DescribeCredentialProblems(string? json)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The one wording of the credential rule (never includes the value).</summary>
|
||||
/// <param name="field">The field.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string CredentialRuleMessage(string field) =>
|
||||
$"{field} must be blank, 'secret:{SecretNamespace}<name>' or 'env:{EnvVariablePrefix}<NAME>' — a literal "
|
||||
+ "password, or a reference outside the Heidenhain namespace, is refused.";
|
||||
|
||||
private static void ScanObject(JsonElement obj, string prefix, List<string> 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<string> errors, string field, string? value)
|
||||
{
|
||||
if (!IsCredentialReference(value)) errors.Add(CredentialRuleMessage(field));
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>One authored Heidenhain raw tag, resolved.</summary>
|
||||
/// <param name="RawPath">The RawPath identity (driver wire reference).</param>
|
||||
/// <param name="DeviceName">The <c>/raw</c> Device the tag lives under.</param>
|
||||
/// <param name="Address">The parsed address.</param>
|
||||
/// <param name="DataType">The OPC data type the driver publishes.</param>
|
||||
/// <param name="IsArray">Whether the value is a 1-D array.</param>
|
||||
public sealed record HeidenhainTagDefinition(
|
||||
string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray)
|
||||
{
|
||||
/// <summary>The poll cadence this tag rides.</summary>
|
||||
public HeidenhainPollGroup Group => Address.Kind == HeidenhainAddressKind.Standard
|
||||
? Address.Catalog!.Group
|
||||
: HeidenhainPollGroup.Custom;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single authority on a Heidenhain raw tag's <c>TagConfig</c>: consumed by the driver's table build,
|
||||
/// the deploy-time gate (<see cref="Inspect"/>) and the AdminUI typed editor's <c>Validate()</c>, so the
|
||||
/// editor, the deploy gate and the runtime accept exactly the same blobs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Shape: <c>{"address": "Status/ExecutionState"}</c>,
|
||||
/// <c>{"address": "PLC:MARKER:32", "dataType": "Boolean"}</c>,
|
||||
/// <c>{"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}</c>. Array leaves
|
||||
/// (<c>Axes/*</c>, <c>Alarms/Classes|Groups|Numbers|Messages</c>) must carry <c>"isArray": true</c> so the
|
||||
/// node's rank matches what the driver publishes; every other address must not.</para>
|
||||
/// <para><b>Never a write flag.</b> There is no <c>writable</c> key: the driver is read-only and the deploy
|
||||
/// gate refuses a ReadWrite access level on any Heidenhain tag.</para>
|
||||
/// <para><b>Data types are parsed by NAME only.</b> A digits-only value (a numerically-serialized enum) is
|
||||
/// rejected rather than mapped to the ordinal's member.</para>
|
||||
/// </remarks>
|
||||
public static class HeidenhainTagDefinitionFactory
|
||||
{
|
||||
/// <summary>Parses one tag. Never throws.</summary>
|
||||
/// <param name="rawPath">The tag's RawPath.</param>
|
||||
/// <param name="deviceName">The tag's Device name.</param>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <param name="definition">The resolved definition on success.</param>
|
||||
/// <param name="error">Why the blob was rejected.</param>
|
||||
/// <returns>True when the driver will serve this tag.</returns>
|
||||
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<DriverDataType>())}.";
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection: empty when <see cref="TryFromTagConfig"/> accepts the blob (never stricter),
|
||||
/// otherwise one warning explaining the rejection. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string? tagConfig)
|
||||
{
|
||||
if (TryFromTagConfig("inspect", "", tagConfig, out _, out var error)) return [];
|
||||
return [$"Heidenhain TagConfig rejected: {error} The driver will skip this tag (BadNodeIdUnknown)."];
|
||||
}
|
||||
|
||||
/// <summary>Parses a data type by NAME; digits-only text is refused (the numeric-enum trap).</summary>
|
||||
/// <param name="raw">The authored text.</param>
|
||||
/// <param name="parsed">The parsed type.</param>
|
||||
/// <returns>True when <paramref name="raw"/> names a defined member.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>RawTreeService</c>)
|
||||
/// and the deploy gate, so authoring and deploy can never disagree.
|
||||
/// </summary>
|
||||
public static class HeidenhainTagRules
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>Tag.DataType</c> (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).
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <returns>e.g. <c>Int32</c>, <c>Double</c>, <c>String</c>; null when unresolvable.</returns>
|
||||
public static string? ExpectedTagDataType(string? tagConfig) =>
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("rule", "", tagConfig, out var def, out _)
|
||||
? OpcTypeName(def.DataType)
|
||||
: null;
|
||||
|
||||
/// <summary>The OPC UA built-in type name (the <c>Tag.DataType</c> vocabulary) for a published data type.</summary>
|
||||
/// <param name="type">The driver data type.</param>
|
||||
/// <returns>The type name.</returns>
|
||||
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(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Why a Heidenhain tag row may not be saved / deployed: a writable access level (the driver is read-only),
|
||||
/// or a <c>Tag.DataType</c> that disagrees with what the driver publishes for its address.
|
||||
/// </summary>
|
||||
/// <param name="readWrite">Whether the row's access level is ReadWrite.</param>
|
||||
/// <param name="tagDataType">The row's <c>Tag.DataType</c>.</param>
|
||||
/// <param name="tagConfig">The row's TagConfig.</param>
|
||||
/// <returns>The reason, or null when the row is acceptable.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
<!-- Pure options records, the fixed namespace catalog and the TagConfig address grammar, shared by the
|
||||
driver, the deploy gate and the AdminUI typed editor. NO PackageReference: only the zero-dependency
|
||||
Core.Abstractions leaf, mirroring Driver.FOCAS.Contracts / Driver.MTConnect.Contracts. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A binding's value is outside its advertised region / range.</summary>
|
||||
public sealed class HeidenhainRangeException(string message) : Exception(message);
|
||||
|
||||
/// <summary>A value cannot be represented in the binding's declared type without truncation or wrap.</summary>
|
||||
public sealed class HeidenhainTypeMismatchException(string message) : Exception(message);
|
||||
|
||||
/// <summary>An absolute PLC memory range.</summary>
|
||||
/// <param name="Address">Absolute address.</param>
|
||||
/// <param name="Bytes">Byte count.</param>
|
||||
public readonly record struct HeidenhainMemoryRange(long Address, int Bytes);
|
||||
|
||||
/// <summary>
|
||||
/// The reference's PLC/DATA binding rules (port of <c>HeidenhainBindings</c>): element indexes, byte
|
||||
/// capacities, exact lengths, strict little-endian decodes and non-truncating coercion.
|
||||
/// </summary>
|
||||
public static class HeidenhainBindingCodec
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <param name="index">The element index.</param>
|
||||
/// <param name="layout">The control's layout.</param>
|
||||
/// <returns>The absolute range.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Decodes PLC bytes for an area and coerces to the declared type.</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <param name="bytes">The bytes read.</param>
|
||||
/// <param name="type">The declared type.</param>
|
||||
/// <returns>The value.</returns>
|
||||
public static object DecodePlc(HeidenhainPlcArea area, ReadOnlySpan<byte> 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<byte> bytes, int length, ReadOnlySpanFunc decode)
|
||||
{
|
||||
Lsv2Codec.Length(bytes, length);
|
||||
return decode(bytes);
|
||||
}
|
||||
|
||||
private delegate object ReadOnlySpanFunc(ReadOnlySpan<byte> bytes);
|
||||
|
||||
/// <summary>
|
||||
/// Strict coercion: same-kind Boolean/String pass; numbers convert only exactly (no fractional truncation,
|
||||
/// no wrap, finite only). Anything else is <see cref="HeidenhainTypeMismatchException"/>.
|
||||
/// </summary>
|
||||
/// <param name="value">The native value.</param>
|
||||
/// <param name="type">The declared type.</param>
|
||||
/// <returns>The coerced value.</returns>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>A password reference that cannot be resolved (or may not be resolved). Never carries the value.</summary>
|
||||
public sealed class HeidenhainCredentialException(string message) : InvalidOperationException(message);
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
/// <c>secret:heidenhain/…</c> and <c>env:OTOPCUA_HEIDENHAIN_…</c> are resolved — anything else (a literal, another
|
||||
/// subsystem's secret, <c>ConnectionStrings__*</c>), a <c>secret:</c> with no store, an absent secret or an unset
|
||||
/// variable throws <see cref="HeidenhainCredentialException"/>. 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.
|
||||
/// </summary>
|
||||
internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<string, string?>? environment = null)
|
||||
{
|
||||
private readonly Func<string, string?> _environment = environment ?? Environment.GetEnvironmentVariable;
|
||||
|
||||
/// <summary>The password for a role on a device (device override, then driver-level).</summary>
|
||||
public async ValueTask<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>Resolves one reference.</summary>
|
||||
public async ValueTask<string> 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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Connection state of one controller.</summary>
|
||||
public enum HeidenhainConnectionState
|
||||
{
|
||||
/// <summary>Not started (no subscription/read yet) or between generations.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>Opening a session.</summary>
|
||||
Connecting,
|
||||
|
||||
/// <summary>A session is established and polling.</summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>The last connect failed or the session was lost; waiting out the backoff.</summary>
|
||||
Backoff,
|
||||
|
||||
/// <summary>Stopped (driver shutdown).</summary>
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// <summary>Builds the client for an endpoint (test seam).</summary>
|
||||
internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>HeidenhainAcquisition</c> + <c>HeidenhainDevice</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Generation guard.</b> Each established session is a new generation. Publication checks the
|
||||
/// generation under <see cref="_publication"/>, 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.</para>
|
||||
/// <para><b>Health vs. per-tag quality.</b> 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 (<see cref="DataHealth"/>).</para>
|
||||
/// </remarks>
|
||||
internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
{
|
||||
private readonly HeidenhainDeviceOptions _device;
|
||||
private readonly HeidenhainDriverOptions _options;
|
||||
private readonly IReadOnlyList<HeidenhainTagDefinition> _tags;
|
||||
private readonly IReadOnlyDictionary<HeidenhainPollGroup, IReadOnlyList<HeidenhainTagDefinition>> _groups;
|
||||
private readonly HeidenhainSession.PasswordSource _passwords;
|
||||
private readonly Action<string, DataValueSnapshot> _publish;
|
||||
private readonly Action<HeidenhainDeviceRuntime, HeidenhainConnectionState, HeidenhainConnectionState> _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<HeidenhainPollGroup, bool> _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;
|
||||
|
||||
/// <summary>Consecutive sessions that ended without one published poll batch (test/diagnostic).</summary>
|
||||
internal int ConsecutiveFailures => Volatile.Read(ref _consecutiveFailures);
|
||||
|
||||
public HeidenhainDeviceRuntime(
|
||||
HeidenhainDeviceOptions device,
|
||||
HeidenhainDriverOptions options,
|
||||
IReadOnlyList<HeidenhainTagDefinition> tags,
|
||||
HeidenhainSession.PasswordSource passwords,
|
||||
Action<string, DataValueSnapshot> publish,
|
||||
Action<HeidenhainDeviceRuntime, HeidenhainConnectionState, HeidenhainConnectionState> 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<HeidenhainTagDefinition>)g.ToList());
|
||||
}
|
||||
|
||||
/// <summary>The <c>host:port</c> key used for host status and per-call host resolution.</summary>
|
||||
public string HostName => $"{_device.Host}:{_device.Port}";
|
||||
|
||||
/// <summary>The <c>/raw</c> Device name.</summary>
|
||||
public string DeviceName => _device.DeviceName ?? "";
|
||||
|
||||
/// <summary>The device's authored tags.</summary>
|
||||
public IReadOnlyList<HeidenhainTagDefinition> Tags => _tags;
|
||||
|
||||
/// <summary>Current connection state.</summary>
|
||||
public HeidenhainConnectionState State { get { lock (_publication) return _state; } }
|
||||
|
||||
/// <summary>When <see cref="State"/> last changed.</summary>
|
||||
public DateTime StateChangedUtc { get { lock (_publication) return _stateChangedUtc; } }
|
||||
|
||||
/// <summary>The last time a batch completed with the session alive (liveness evidence for the watchdog).</summary>
|
||||
public DateTime? LastSuccessUtc { get { lock (_publication) return _lastSuccessUtc; } }
|
||||
|
||||
/// <summary>The last connect/transport error (never a credential).</summary>
|
||||
public string? LastError { get { lock (_publication) return _lastError; } }
|
||||
|
||||
/// <summary>The reference's poll-group aggregate for this generation: Good / Partial / Lost / Idle.</summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The identity of the current session's control, when connected.</summary>
|
||||
public string? ControllerModel { get { lock (_publication) return _session?.Identity?.Model; } }
|
||||
|
||||
/// <summary>Starts the acquisition loop (idempotent).</summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The reconnect delay for the n-th consecutive failure, clamped to sane bounds (never a hot loop).</summary>
|
||||
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, TimeSpan>
|
||||
{
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Publishes one batch iff its generation is still current. False when the session is lost/retired.</summary>
|
||||
private bool Publish(long generation, HeidenhainSession session, HeidenhainPollGroup group,
|
||||
IReadOnlyList<HeidenhainTagDefinition> tags, IReadOnlyList<HeidenhainSample> 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);
|
||||
}
|
||||
|
||||
/// <summary>Retires the session and invalidates every remote value (capabilities report CommunicationLost).</summary>
|
||||
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"); }
|
||||
}
|
||||
|
||||
/// <summary>Stops the loop: closes the session first (unblocks a read), cancels, joins ≤ 5 s, invalidates.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>/raw</c> Device.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Read-only at three layers.</b> (1) This type does not implement <see cref="IWritable"/>, 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
|
||||
/// <see cref="Lsv2ReadOnlyPolicy"/> admits only logins and read requests, payload-checked, at the last step
|
||||
/// before bytes are written.</para>
|
||||
/// <para><b>Cached reads.</b> Acquisition runs on one background loop per control at the configured poll
|
||||
/// cadences; <see cref="ReadAsync"/> 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 <see cref="SubscribeAsync"/> / <see cref="ReadAsync"/>, not at
|
||||
/// <see cref="InitializeAsync"/> — a throwaway browse instance (the static catalog needs no connection)
|
||||
/// therefore never opens a session to a production control.</para>
|
||||
/// <para><b>Comms loss.</b> A lost session publishes <c>BadNoCommunication</c> on every remote value of that
|
||||
/// control at once (capability strings read <c>CommunicationLost</c>), marks the driver Degraded with
|
||||
/// <see cref="DriverHealth.LastSuccessfulRead"/> 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).</para>
|
||||
/// </remarks>
|
||||
public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe,
|
||||
IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Most custom (PLC/DATA) bindings one control may carry (reference limit).</summary>
|
||||
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<string, DataValueSnapshot> _cache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<long, Subscription> _subscriptions = new();
|
||||
private HeidenhainDriverOptions _options;
|
||||
private IReadOnlyDictionary<string, HeidenhainDeviceRuntime> _byRawPath = new Dictionary<string, HeidenhainDeviceRuntime>();
|
||||
private IReadOnlyList<HeidenhainDeviceRuntime> _devices = [];
|
||||
private volatile bool _initialized;
|
||||
private long _nextSubscriptionId;
|
||||
private DriverState _state = DriverState.Unknown;
|
||||
private string? _initError;
|
||||
|
||||
/// <summary>Production constructor.</summary>
|
||||
/// <param name="options">The parsed options.</param>
|
||||
/// <param name="driverInstanceId">The driver instance id.</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
/// <param name="secretResolver">Resolver for <c>secret:</c> password references.</param>
|
||||
public HeidenhainDriver(HeidenhainDriverOptions options, string driverInstanceId,
|
||||
ILogger<HeidenhainDriver>? 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => DriverTypeNames.Heidenhain;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
/// <summary>Parses + validates a config document (the one authority the factory and the driver share).</summary>
|
||||
/// <param name="driverConfigJson">The config JSON.</param>
|
||||
/// <returns>The options.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<HeidenhainDeviceOptions, List<HeidenhainTagDefinition>>();
|
||||
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<HeidenhainDeviceRuntime>();
|
||||
var byPath = new Dictionary<string, HeidenhainDeviceRuntime>(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<HeidenhainDeviceOptions> 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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
|
||||
InitializeAsync(driverConfigJson, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string, HeidenhainDeviceRuntime>();
|
||||
foreach (var d in devices) await d.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(IReadOnlyList<string> 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<IReadOnlyList<DataValueSnapshot>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(IReadOnlyList<string> 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<ISubscriptionHandle>(sub);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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); }
|
||||
}
|
||||
|
||||
/// <summary>Value equality including element-wise array comparison (the (value, status) dedup).</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
_devices.Select(d => new HostConnectivityStatus(d.HostName, ToHostState(d.State), d.StateChangedUtc)).ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ResolveHost(string fullReference) =>
|
||||
_byRawPath.TryGetValue(fullReference, out var d) ? d.HostName : _driverInstanceId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport? GetSkippedTags() => _skipped.Snapshot();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, string> GetDiagnostics()
|
||||
{
|
||||
var bag = new Dictionary<string, string>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folders = new Dictionary<string, IAddressSpaceBuilder>(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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
private sealed class Subscription(long id, HashSet<string> references) : ISubscriptionHandle
|
||||
{
|
||||
public long Id { get; } = id;
|
||||
public HashSet<string> References { get; } = references;
|
||||
public string DiagnosticId => $"heidenhain-sub-{Id}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Factory registration for <see cref="HeidenhainDriver"/>. Construction opens nothing (the discovery browser
|
||||
/// builds throwaway instances), there is exactly one config parser (<see cref="HeidenhainDriver.ParseOptions"/>),
|
||||
/// and the instance is returned as its concrete type so every optional capability is dispatched — and,
|
||||
/// because it implements no <see cref="IWritable"/>, no write is.
|
||||
/// </summary>
|
||||
public static class HeidenhainDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>The <c>DriverInstance.DriverType</c> this factory answers to.</summary>
|
||||
public const string DriverTypeName = DriverTypeNames.Heidenhain;
|
||||
|
||||
/// <summary>Registers the factory.</summary>
|
||||
/// <param name="registry">The registry.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <param name="secretResolver">Resolver for <c>secret:</c> password references.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Creates an instance from a config document.</summary>
|
||||
/// <param name="driverInstanceId">The instance id.</param>
|
||||
/// <param name="driverConfigJson">The config JSON.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <param name="secretResolver">Optional secret resolver.</param>
|
||||
/// <returns>The driver.</returns>
|
||||
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<HeidenhainDriver>(), secretResolver: secretResolver);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Registered on admin nodes as well as driver nodes (the Test-Connect singleton is admin-pinned). The
|
||||
/// resolver is optional: without one a <c>secret:</c> INSPECT password fails closed with a clear message.
|
||||
/// </remarks>
|
||||
public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null) : IDriverProbe
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string DriverType => DriverTypeNames.Heidenhain;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>One acquired leaf: value (null when Bad) + status.</summary>
|
||||
/// <param name="Value">The value, or null.</param>
|
||||
/// <param name="Status">The OPC UA status code.</param>
|
||||
public readonly record struct HeidenhainSample(object? Value, uint Status);
|
||||
|
||||
/// <summary>What a session needs from the driver configuration.</summary>
|
||||
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<HeidenhainTagDefinition> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generation-local read policy (port of the reference <c>HeidenhainSession</c>): 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.
|
||||
/// </summary>
|
||||
internal sealed class HeidenhainSession : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Resolves a role's password on demand; plaintext is never retained in session state.</summary>
|
||||
public delegate ValueTask<string> 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<string, string> _capabilities = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, uint> _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;
|
||||
}
|
||||
|
||||
/// <summary>True once a transport failure ended this session.</summary>
|
||||
public bool CommunicationLost => _lost || _client.IsClosed;
|
||||
|
||||
/// <summary>The identity read at initialize.</summary>
|
||||
public Lsv2Versions? Identity => _identity;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<uint> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="HeidenhainStatus.BadConfigurationError"/> (never sent, never a session failure). Refusals and
|
||||
/// transport failures propagate unchanged.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>BadNoCommunication</c> and
|
||||
/// <see cref="CommunicationLost"/> becomes true.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<HeidenhainSample>> ReadBatchAsync(IReadOnlyList<HeidenhainTagDefinition> tags, CancellationToken ct)
|
||||
{
|
||||
var outcomes = new Dictionary<string, Outcome>(StringComparer.Ordinal);
|
||||
var wire = new Dictionary<string, Raw>(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;
|
||||
|
||||
/// <summary>The current capability string for a feature.</summary>
|
||||
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<Outcome> AcquireAsync(HeidenhainTagDefinition tag, Dictionary<string, Raw> 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<long> 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<object> CustomAsync(HeidenhainTagDefinition tag, Dictionary<string, Raw> wire, CancellationToken ct)
|
||||
{
|
||||
var a = tag.Address;
|
||||
if (a.Kind == HeidenhainAddressKind.DataPath)
|
||||
{
|
||||
await RequireLoginAsync("DATA", wire, ct).ConfigureAwait(false);
|
||||
var bytes = await WireAsync(wire, "DATA:" + a.DataPath, c => _client.ReadDataPathAsync(a.DataPath!, c), ct).ConfigureAwait(false);
|
||||
return HeidenhainBindingCodec.Coerce(Lsv2Data.DataPath(bytes), tag.DataType);
|
||||
}
|
||||
|
||||
if (_layoutStatus != HeidenhainStatus.Good)
|
||||
{
|
||||
if (HeidenhainStatus.IsStable(_layoutStatus) && _layoutStatus != NotAttempted)
|
||||
throw new QualityException(_layoutStatus, HeidenhainStatus.State(_layoutStatus));
|
||||
try
|
||||
{
|
||||
_layout = Lsv2Data.Layout(await WireAsync(wire, "Layout", c => _client.ReadParametersAsync(c), ct).ConfigureAwait(false));
|
||||
_layoutStatus = HeidenhainStatus.Good;
|
||||
}
|
||||
catch (Lsv2RefusalException e)
|
||||
{
|
||||
_layoutStatus = HeidenhainStatus.FromRefusal(e.Code);
|
||||
throw new QualityException(_layoutStatus, HeidenhainStatus.State(_layoutStatus));
|
||||
}
|
||||
catch (Lsv2DecodeException)
|
||||
{
|
||||
_layoutStatus = HeidenhainStatus.BadDecodingError;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
var range = HeidenhainBindingCodec.Range(a.Area!.Value, a.Index, _layout);
|
||||
var raw = await WireAsync(wire, "PLC:" + range.Address + ":" + range.Bytes, c => _client.ReadMemoryAsync(range.Address, range.Bytes, c), ct).ConfigureAwait(false);
|
||||
if (raw.Length != range.Bytes) throw new Lsv2DecodeException("PLC reply length mismatch.");
|
||||
return HeidenhainBindingCodec.DecodePlc(a.Area.Value, raw, tag.DataType);
|
||||
}
|
||||
|
||||
private async Task RequireLoginAsync(string role, Dictionary<string, Raw> wire, CancellationToken ct)
|
||||
{
|
||||
var dnc = role == "DNC";
|
||||
var status = dnc ? _dncStatus : _dataStatus;
|
||||
if (status == HeidenhainStatus.Good) return;
|
||||
var enabled = dnc ? _settings.TelemetryEnabled && _settings.DncLoginEnabled : _settings.DataEnabled;
|
||||
var effective = status == NotAttempted ? HeidenhainStatus.BadNotSupported : status;
|
||||
if (!enabled || status == NotAttempted || HeidenhainStatus.IsStable(effective))
|
||||
throw new QualityException(effective, enabled ? HeidenhainStatus.State(effective) : HeidenhainCatalog.States.Disabled);
|
||||
try
|
||||
{
|
||||
await WireAsync(wire, "Login_" + role, async c =>
|
||||
{
|
||||
await LoginWithCredentialAsync(role, c).ConfigureAwait(false);
|
||||
return [];
|
||||
}, ct).ConfigureAwait(false);
|
||||
if (dnc) _dncStatus = HeidenhainStatus.Good;
|
||||
else _dataStatus = HeidenhainStatus.Good;
|
||||
}
|
||||
catch (Lsv2RefusalException e)
|
||||
{
|
||||
if (dnc) _dncStatus = HeidenhainStatus.FromRefusal(e.Code);
|
||||
else _dataStatus = HeidenhainStatus.FromRefusal(e.Code);
|
||||
throw;
|
||||
}
|
||||
catch (QualityException e)
|
||||
{
|
||||
// Credential problem: stable for this session (a fixed secret/variable is picked up on reconnect).
|
||||
if (dnc) _dncStatus = e.Status;
|
||||
else _dataStatus = e.Status;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrepareTelemetryAsync(Dictionary<string, Raw> wire, CancellationToken ct)
|
||||
{
|
||||
if (!_settings.TelemetryEnabled) throw new QualityException(HeidenhainStatus.BadNotSupported, HeidenhainCatalog.States.Disabled);
|
||||
// Real iTNC 530 controls serve several selectors under INSPECT despite denying DNC. Retrying a
|
||||
// temporary optional-login failure is useful, but only each read reply determines that feature's
|
||||
// access. A transport failure still ends the session (it propagates).
|
||||
if (_dncStatus != HeidenhainStatus.Good && _dncStatus != NotAttempted && !HeidenhainStatus.IsStable(_dncStatus))
|
||||
{
|
||||
try { await RequireLoginAsync("DNC", wire, ct).ConfigureAwait(false); }
|
||||
catch (Lsv2RefusalException) { /* issue the allowlisted read anyway */ }
|
||||
catch (QualityException) { /* issue the allowlisted read anyway */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<Lsv2Alarm>> AlarmsAsync(CancellationToken ct)
|
||||
{
|
||||
var records = new List<Lsv2Alarm>();
|
||||
var selector = 27;
|
||||
while (true)
|
||||
{
|
||||
byte[] bytes;
|
||||
try { bytes = await _client.ReadRuntimeAsync(selector, ct).ConfigureAwait(false); }
|
||||
catch (Lsv2RefusalException e) when (e.Code == 57) { return records; } // only 57 proves end/empty
|
||||
|
||||
if (records.Count >= _settings.MaxAlarms)
|
||||
throw new QualityException(HeidenhainStatus.BadOutOfRange, HeidenhainCatalog.States.InvalidData);
|
||||
records.Add(Lsv2Data.Alarm(bytes));
|
||||
selector = 28;
|
||||
}
|
||||
}
|
||||
|
||||
private static object? Leaf(HeidenhainTagDefinition tag, object? value)
|
||||
{
|
||||
if (tag.Address.Kind != HeidenhainAddressKind.Standard) return value;
|
||||
var address = tag.Address.Catalog!.Address;
|
||||
var name = address[(address.IndexOf('/') + 1)..];
|
||||
switch (value)
|
||||
{
|
||||
case Lsv2Versions v:
|
||||
return name switch { "Model" => v.Model, "NcVersion" => v.Nc, "PlcVersion" => v.Plc, _ => v.Options };
|
||||
case Lsv2Program p:
|
||||
return name switch { "Main" => p.Main, "Current" => p.Current, _ => (object)p.Line };
|
||||
case double[] o when address.StartsWith("Overrides/", StringComparison.Ordinal):
|
||||
return o[name switch { "Feed" => 0, "Spindle" => 1, _ => 2 }];
|
||||
case Lsv2Axes a:
|
||||
return name == "Names" ? a.Names : a.Positions;
|
||||
case Lsv2Tool t:
|
||||
return name switch
|
||||
{
|
||||
"Number" => t.Number,
|
||||
"Index" => t.Index,
|
||||
"AxisCode" => t.Axis,
|
||||
"Length" => t.Length,
|
||||
_ => t.Radius,
|
||||
};
|
||||
case List<Lsv2Alarm> list:
|
||||
return name switch
|
||||
{
|
||||
"Count" => list.Count,
|
||||
"Messages" => list.Select(x => x.Message).ToArray(),
|
||||
"Classes" => list.Select(x => x.ErrorClass).ToArray(),
|
||||
"Groups" => list.Select(x => x.Group).ToArray(),
|
||||
_ => list.Select(x => x.Number).ToArray(),
|
||||
};
|
||||
case int i:
|
||||
return i;
|
||||
case long l:
|
||||
return l;
|
||||
default:
|
||||
throw new HeidenhainTypeMismatchException("Unexpected leaf value.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> WireAsync(Dictionary<string, Raw> wire, string key, Func<CancellationToken, Task<byte[]>> request, CancellationToken ct)
|
||||
{
|
||||
if (!wire.TryGetValue(key, out var cached))
|
||||
{
|
||||
try { cached = new Raw(await request(ct).ConfigureAwait(false), null); }
|
||||
catch (Exception ex) when (ex is Lsv2RefusalException or QualityException or Lsv2TransportException) { cached = new Raw(null, ex); }
|
||||
wire[key] = cached;
|
||||
}
|
||||
|
||||
if (cached.Error is { } error) System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(error);
|
||||
return cached.Bytes!;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_closed = true;
|
||||
_client.Close();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Closes the client immediately (interrupts a blocked read without waiting).</summary>
|
||||
public void Close()
|
||||
{
|
||||
_closed = true;
|
||||
_client.Close();
|
||||
}
|
||||
|
||||
private readonly record struct Outcome(object? Value, uint Status);
|
||||
|
||||
private readonly record struct Raw(byte[]? Bytes, Exception? Error);
|
||||
|
||||
private sealed class QualityException(uint status, string state) : Exception(state)
|
||||
{
|
||||
public uint Status { get; } = status;
|
||||
public string State { get; } = state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes this driver publishes (canonical <c>Opc.Ua.StatusCodes</c> numerics, pinned by
|
||||
/// <c>HeidenhainStatusTests</c>), with the reference's failure interpretation.
|
||||
/// </summary>
|
||||
public static class HeidenhainStatus
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
|
||||
/// <summary>Transport lost — every remote leaf, immediately.</summary>
|
||||
public const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
/// <summary>Refusal 22/24, disabled feature, or a feature absent from the reply (8-byte tool geometry).</summary>
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
|
||||
/// <summary>Refusal 23/49.</summary>
|
||||
public const uint BadUserAccessDenied = 0x801F0000u;
|
||||
|
||||
/// <summary>Malformed complete record.</summary>
|
||||
public const uint BadDecodingError = 0x80070000u;
|
||||
|
||||
/// <summary>Binding type mismatch.</summary>
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Out-of-region binding, or alarm-list overflow.</summary>
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
|
||||
/// <summary>Any other controller refusal (retried next poll).</summary>
|
||||
public const uint BadUnexpectedError = 0x80010000u;
|
||||
|
||||
/// <summary>
|
||||
/// An optional-role (DNC/DATA) credential cannot be used — unresolvable reference, or a password the login
|
||||
/// payload cannot carry (NUL, non-Latin-1, too long). Degrades only the reads that need that role.
|
||||
/// </summary>
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
|
||||
/// <summary>A RawPath this driver has no authored tag for.</summary>
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
/// <summary>Authored but not yet acquired.</summary>
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>Maps a controller refusal code to a value status (22/24 unsupported, 23/49 denied, else unexpected).</summary>
|
||||
/// <param name="code">The refusal code.</param>
|
||||
/// <returns>The status.</returns>
|
||||
public static uint FromRefusal(int code) => code switch
|
||||
{
|
||||
22 or 24 => BadNotSupported,
|
||||
23 or 49 => BadUserAccessDenied,
|
||||
_ => BadUnexpectedError,
|
||||
};
|
||||
|
||||
/// <summary>True for the stable refusals that may be suppressed for the rest of a session.</summary>
|
||||
/// <param name="status">The status.</param>
|
||||
/// <returns>Whether it is stable.</returns>
|
||||
public static bool IsStable(uint status) => status is BadNotSupported or BadUserAccessDenied or BadConfigurationError;
|
||||
|
||||
/// <summary>The capability state for a failure status.</summary>
|
||||
/// <param name="status">The status.</param>
|
||||
/// <returns>The state string.</returns>
|
||||
public static string State(uint status) => status switch
|
||||
{
|
||||
BadUserAccessDenied => HeidenhainCatalog.States.AccessDenied,
|
||||
BadNotSupported => HeidenhainCatalog.States.Unsupported,
|
||||
BadDecodingError or BadTypeMismatch or BadOutOfRange => HeidenhainCatalog.States.InvalidData,
|
||||
BadNoCommunication => HeidenhainCatalog.States.CommunicationLost,
|
||||
BadConfigurationError => HeidenhainCatalog.States.ConfigurationError,
|
||||
_ => HeidenhainCatalog.States.Unknown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
/// <summary>
|
||||
/// One serialized native LSV2 TCP session (port of the reference <c>Lsv2Client</c>). Exactly one transaction
|
||||
/// is on the wire at a time; <see cref="Close"/> never waits on the transaction gate, so it always interrupts a
|
||||
/// blocked read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The public surface is the read-only surface.</b> There is no method taking a command string: the
|
||||
/// only requests are the typed login/read methods below, and the private exchange routes every frame through
|
||||
/// <see cref="Lsv2Codec.Frame"/>, which applies <see cref="Lsv2ReadOnlyPolicy.Check"/> before any byte is
|
||||
/// written.</para>
|
||||
/// <para><b>Framing discipline.</b> Each transaction gets ONE monotonic deadline covering write + header +
|
||||
/// body, so a peer trickling single bytes cannot extend it. EOF, deadline, an oversized or unexpected frame,
|
||||
/// a malformed refusal, or any socket error closes the client: a later request can never consume bytes left
|
||||
/// over from a failed transaction. A complete <c>T_ER</c>/<c>T_BD</c> refusal leaves the stream synchronised
|
||||
/// and surfaces as <see cref="Lsv2RefusalException"/> without closing.</para>
|
||||
/// </remarks>
|
||||
public sealed class Lsv2Client : IAsyncDisposable
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private Socket? _socket;
|
||||
private NetworkStream? _stream;
|
||||
private volatile bool _closed;
|
||||
private int _opened;
|
||||
|
||||
/// <summary>Creates an unopened client.</summary>
|
||||
/// <param name="host">The control host.</param>
|
||||
/// <param name="port">The LSV2 port.</param>
|
||||
/// <param name="timeout">Connect + per-transaction deadline.</param>
|
||||
public Lsv2Client(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host) || port is < 1 or > 65535 || timeout <= TimeSpan.Zero)
|
||||
throw new ArgumentException("Invalid LSV2 endpoint/timeout.");
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
/// <summary>True once the client has been closed (explicitly or by a transport failure).</summary>
|
||||
public bool IsClosed => _closed;
|
||||
|
||||
/// <summary>Total request frames written (diagnostics/tests).</summary>
|
||||
public long RequestsSent => Interlocked.Read(ref _requestsSent);
|
||||
|
||||
private long _requestsSent;
|
||||
|
||||
/// <summary>Connects, bounded by the configured timeout.</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
public async Task OpenAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _opened, 1) != 0 || _closed)
|
||||
throw new Lsv2TransportException("LSV2 client is not new.");
|
||||
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
_socket = socket;
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadline.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
if (_closed) throw new Lsv2TransportException("LSV2 client closed.");
|
||||
await socket.ConnectAsync(_host, _port, deadline.Token).ConfigureAwait(false);
|
||||
if (_closed) throw new Lsv2TransportException("LSV2 client closed.");
|
||||
_stream = new NetworkStream(socket, ownsSocket: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not Lsv2TransportException)
|
||||
{
|
||||
Close();
|
||||
if (ct.IsCancellationRequested) throw;
|
||||
throw new Lsv2TransportException(
|
||||
ex is OperationCanceledException ? "LSV2 connect timed out." : $"LSV2 connect failed: {ex.Message}", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Close();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>A_LG</c> → <c>T_OK</c>. Only INSPECT/DNC/DATA are accepted by the policy.</summary>
|
||||
/// <param name="role">The role.</param>
|
||||
/// <param name="password">The resolved password, or empty.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
public Task LoginAsync(string role, string password, CancellationToken ct) =>
|
||||
ExchangeAsync("A_LG", Lsv2ReadOnlyPolicy.LoginPayload(role, password), "T_OK", ct);
|
||||
|
||||
/// <summary><c>R_VR</c> → <c>S_VR</c> combined version record.</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw record.</returns>
|
||||
public Task<byte[]> ReadVersionAsync(CancellationToken ct) => ExchangeAsync("R_VR", [], "S_VR", ct);
|
||||
|
||||
/// <summary><c>R_PR</c> → <c>S_PR</c> system/memory-layout record.</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw record.</returns>
|
||||
public Task<byte[]> ReadParametersAsync(CancellationToken ct) => ExchangeAsync("R_PR", [], "S_PR", ct);
|
||||
|
||||
/// <summary><c>R_RI</c> → <c>S_RI</c> runtime information for an allowlisted selector.</summary>
|
||||
/// <param name="selector">The selector.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw record.</returns>
|
||||
public Task<byte[]> ReadRuntimeAsync(int selector, CancellationToken ct) =>
|
||||
ExchangeAsync("R_RI", Lsv2ReadOnlyPolicy.RuntimePayload(selector), "S_RI", ct);
|
||||
|
||||
/// <summary><c>R_MB</c> → <c>S_MB</c> PLC memory bytes.</summary>
|
||||
/// <param name="address">Absolute address.</param>
|
||||
/// <param name="count">Byte count (1–247).</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw bytes.</returns>
|
||||
public Task<byte[]> ReadMemoryAsync(long address, int count, CancellationToken ct) =>
|
||||
ExchangeAsync("R_MB", Lsv2ReadOnlyPolicy.MemoryPayload(address, count), "S_MB", ct);
|
||||
|
||||
/// <summary><c>R_DP</c> → <c>S_DP</c> data-path value.</summary>
|
||||
/// <param name="path">The data path.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw record.</returns>
|
||||
public Task<byte[]> ReadDataPathAsync(string path, CancellationToken ct) =>
|
||||
ExchangeAsync("R_DP", Lsv2ReadOnlyPolicy.DataPathPayload(path), "S_DP", ct);
|
||||
|
||||
/// <summary>
|
||||
/// The single transaction primitive. <c>internal</c> (not public) and still policy-checked: the frame is
|
||||
/// built — and refused — before the gate is taken or the socket is touched.
|
||||
/// </summary>
|
||||
internal async Task<byte[]> ExchangeAsync(string command, byte[] payload, string expected, CancellationToken ct)
|
||||
{
|
||||
var request = Lsv2Codec.Frame(command, payload);
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var stream = _stream;
|
||||
if (_closed || stream is null) throw new Lsv2TransportException("LSV2 session closed.");
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadline.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
Interlocked.Increment(ref _requestsSent);
|
||||
await stream.WriteAsync(request, deadline.Token).ConfigureAwait(false);
|
||||
var header = new byte[Lsv2Codec.HeaderSize];
|
||||
await stream.ReadExactlyAsync(header, deadline.Token).ConfigureAwait(false);
|
||||
var length = BinaryPrimitives.ReadUInt32BigEndian(header);
|
||||
if (length > Lsv2Codec.MaxPayload) throw new Lsv2TransportException("Oversized LSV2 response.");
|
||||
var response = Encoding.ASCII.GetString(header, 4, 4);
|
||||
var body = new byte[length];
|
||||
await stream.ReadExactlyAsync(body, deadline.Token).ConfigureAwait(false);
|
||||
if (response is "T_ER" or "T_BD")
|
||||
{
|
||||
if (body.Length != 2) throw new Lsv2TransportException("Malformed LSV2 refusal.");
|
||||
throw new Lsv2RefusalException(body[0], body[1]);
|
||||
}
|
||||
|
||||
if (!string.Equals(response, expected, StringComparison.Ordinal))
|
||||
throw new Lsv2TransportException("Unexpected LSV2 response type.");
|
||||
if (expected == "T_OK" && body.Length != 0) throw new Lsv2TransportException("Malformed LSV2 T_OK.");
|
||||
return body;
|
||||
}
|
||||
catch (Lsv2RefusalException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Lsv2TransportException)
|
||||
{
|
||||
Close();
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
Close(); // the stream position is unknown — never reuse it
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException or EndOfStreamException)
|
||||
{
|
||||
Close();
|
||||
throw new Lsv2TransportException(
|
||||
ex is OperationCanceledException ? "LSV2 transaction deadline exceeded." : $"LSV2 transport failure: {ex.GetType().Name}", ex);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Closes the socket without waiting for an in-flight transaction (which then fails).</summary>
|
||||
public void Close()
|
||||
{
|
||||
_closed = true;
|
||||
var socket = _socket;
|
||||
try { _stream?.Dispose(); } catch { /* already unavailable */ }
|
||||
try { socket?.Dispose(); } catch { /* already unavailable */ }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Close();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
/// <summary>A complete, malformed controller record (wrong length, missing terminator, NaN, …).</summary>
|
||||
public sealed class Lsv2DecodeException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// A complete controller refusal frame (<c>T_ER</c>/<c>T_BD</c>). Unlike a transport failure it leaves the
|
||||
/// framing synchronised, so the session stays usable.
|
||||
/// </summary>
|
||||
public sealed class Lsv2RefusalException(int type, int code)
|
||||
: Exception($"LSV2 controller refusal type={type} code={code}")
|
||||
{
|
||||
/// <summary>The refusal type byte.</summary>
|
||||
public int Type { get; } = type;
|
||||
|
||||
/// <summary>The refusal code byte (22/24 unsupported, 23/49 access denied, 57 no next error, …).</summary>
|
||||
public int Code { get; } = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A transport failure (EOF, deadline, oversized/unexpected frame, socket error). The client has been closed;
|
||||
/// the session is over.
|
||||
/// </summary>
|
||||
public sealed class Lsv2TransportException(string message, Exception? inner = null) : IOException(message, inner);
|
||||
|
||||
/// <summary>Legacy LSV2 framing and Latin-1 primitives. No buffer negotiation is performed.</summary>
|
||||
public static class Lsv2Codec
|
||||
{
|
||||
/// <summary>Frame header size: uint32be payload length + 4 ASCII command bytes.</summary>
|
||||
public const int HeaderSize = 8;
|
||||
|
||||
/// <summary>Legacy maximum payload (255-byte frame − header).</summary>
|
||||
public const int MaxPayload = HeidenhainAddress.MaxPayload;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a request frame. The read-only policy is applied HERE, the last step before bytes exist, so no
|
||||
/// code path can produce an unchecked frame.
|
||||
/// </summary>
|
||||
/// <param name="command">The command.</param>
|
||||
/// <param name="payload">The payload.</param>
|
||||
/// <returns>The frame bytes.</returns>
|
||||
public static byte[] Frame(string command, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Lsv2ReadOnlyPolicy.Check(command, payload);
|
||||
var frame = new byte[HeaderSize + payload.Length];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)payload.Length);
|
||||
Encoding.ASCII.GetBytes(command, frame.AsSpan(4, 4));
|
||||
payload.CopyTo(frame.AsSpan(HeaderSize));
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>NUL-terminated Latin-1 encoding; refuses embedded NULs and non-Latin-1 text.</summary>
|
||||
/// <param name="value">The text.</param>
|
||||
/// <returns>The bytes, NUL included.</returns>
|
||||
public static byte[] Terminated(string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
foreach (var c in value)
|
||||
if (c == '\0' || c > 0xFF) throw new ArgumentException("Expected NUL-free Latin-1 text.");
|
||||
var bytes = new byte[value.Length + 1];
|
||||
Encoding.Latin1.GetBytes(value, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>Requires an exact record length.</summary>
|
||||
/// <param name="bytes">The record.</param>
|
||||
/// <param name="expected">The expected length.</param>
|
||||
public static void Length(ReadOnlySpan<byte> bytes, int expected)
|
||||
{
|
||||
if (bytes.Length != expected) throw new Lsv2DecodeException("Invalid LSV2 record length.");
|
||||
}
|
||||
|
||||
/// <summary>Decodes a single NUL-terminated Latin-1 string that may only be followed by NUL padding.</summary>
|
||||
/// <param name="bytes">The record.</param>
|
||||
/// <returns>The text.</returns>
|
||||
public static string Text(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length == 0 || bytes[^1] != 0) throw new Lsv2DecodeException("Unterminated LSV2 string.");
|
||||
var end = bytes.IndexOf((byte)0);
|
||||
for (var i = end; i < bytes.Length; i++)
|
||||
if (bytes[i] != 0) throw new Lsv2DecodeException("Trailing bytes after LSV2 string.");
|
||||
return Encoding.Latin1.GetString(bytes[..end]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
/// <summary>Controller identity from <c>R_VR</c>.</summary>
|
||||
public sealed record Lsv2Versions(string Model, string Nc, string Plc, string Options);
|
||||
|
||||
/// <summary>Program stack from <c>R_RI 24</c>.</summary>
|
||||
public sealed record Lsv2Program(long Line, string Main, string Current);
|
||||
|
||||
/// <summary>Tool from <c>R_RI 51</c>; geometry is null on the 8-byte identity-only reply.</summary>
|
||||
public sealed record Lsv2Tool(long Number, int Index, int Axis, double? Length, double? Radius);
|
||||
|
||||
/// <summary>One alarm record from <c>R_RI 27/28</c>.</summary>
|
||||
public sealed record Lsv2Alarm(int ErrorClass, int Group, int Number, string Message);
|
||||
|
||||
/// <summary>Aligned axis names and positions from <c>R_RI 22</c> (controller units, no inference).</summary>
|
||||
public sealed record Lsv2Axes(string[] Names, double[] Positions);
|
||||
|
||||
/// <summary>An advertised PLC region: base address, capacity in bytes, native element width.</summary>
|
||||
public sealed record Lsv2Region(long Base, long Bytes, int Width);
|
||||
|
||||
/// <summary>The <c>R_PR</c> PLC memory layout.</summary>
|
||||
public sealed record Lsv2Layout(IReadOnlyDictionary<HeidenhainPlcArea, Lsv2Region> Regions)
|
||||
{
|
||||
/// <summary>The region for an area, or null.</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <returns>The region.</returns>
|
||||
public Lsv2Region? Region(HeidenhainPlcArea area) => Regions.TryGetValue(area, out var r) ? r : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure reference-derived decoders (port of <c>Lsv2Data</c>). Malformed data is never repaired into a Good
|
||||
/// value: every rule throws <see cref="Lsv2DecodeException"/>.
|
||||
/// </summary>
|
||||
public static class Lsv2Data
|
||||
{
|
||||
/// <summary>Exactly four or five NUL-terminated fields; an optional SPLC 4th field is skipped; empty final options are preserved.</summary>
|
||||
public static Lsv2Versions Versions(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var parts = Strings(bytes, 0);
|
||||
// Empty final options are meaningful; trimming them would relabel SPLC as options.
|
||||
if ((parts.Count != 4 && parts.Count != 5) || string.IsNullOrWhiteSpace(parts[0])) throw Bad();
|
||||
return new Lsv2Versions(parts[0], parts[1], parts[2], parts[^1]);
|
||||
}
|
||||
|
||||
/// <summary>Exactly two bytes, uint16be.</summary>
|
||||
public static int U16(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Lsv2Codec.Length(bytes, 2);
|
||||
return BinaryPrimitives.ReadUInt16BigEndian(bytes);
|
||||
}
|
||||
|
||||
/// <summary>Exactly four bytes, uint32be.</summary>
|
||||
public static long U32(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Lsv2Codec.Length(bytes, 4);
|
||||
return BinaryPrimitives.ReadUInt32BigEndian(bytes);
|
||||
}
|
||||
|
||||
/// <summary>uint32be line, main NUL, current NUL, optional NUL padding only.</summary>
|
||||
public static Lsv2Program Program(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 6) throw Bad();
|
||||
var parts = Strings(bytes, 4);
|
||||
if (parts.Count < 2 || parts.Skip(2).Any(s => s.Length != 0)) throw Bad();
|
||||
return new Lsv2Program(BinaryPrimitives.ReadUInt32BigEndian(bytes), parts[0], parts[1]);
|
||||
}
|
||||
|
||||
/// <summary>Three uint32be hundredths of percent: feed, spindle, rapid.</summary>
|
||||
public static double[] Overrides(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Lsv2Codec.Length(bytes, 12);
|
||||
return
|
||||
[
|
||||
BinaryPrimitives.ReadUInt32BigEndian(bytes) / 100.0,
|
||||
BinaryPrimitives.ReadUInt32BigEndian(bytes[4..]) / 100.0,
|
||||
BinaryPrimitives.ReadUInt32BigEndian(bytes[8..]) / 100.0,
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>8-byte identity-only or 24-byte reply with two float64le geometry values.</summary>
|
||||
public static Lsv2Tool Tool(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != 8 && bytes.Length != 24) throw Bad();
|
||||
long number = BinaryPrimitives.ReadUInt32BigEndian(bytes);
|
||||
int index = BinaryPrimitives.ReadUInt16BigEndian(bytes[4..]);
|
||||
int axis = BinaryPrimitives.ReadUInt16BigEndian(bytes[6..]);
|
||||
double? length = null, radius = null;
|
||||
if (bytes.Length == 24)
|
||||
{
|
||||
length = Finite(BinaryPrimitives.ReadDoubleLittleEndian(bytes[8..]));
|
||||
radius = Finite(BinaryPrimitives.ReadDoubleLittleEndian(bytes[16..]));
|
||||
}
|
||||
|
||||
return new Lsv2Tool(number, index, axis, length, radius);
|
||||
}
|
||||
|
||||
/// <summary>uint16be class, uint16be group, int32be number, terminated text.</summary>
|
||||
public static Lsv2Alarm Alarm(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 9) throw Bad();
|
||||
return new Lsv2Alarm(
|
||||
BinaryPrimitives.ReadUInt16BigEndian(bytes),
|
||||
BinaryPrimitives.ReadUInt16BigEndian(bytes[2..]),
|
||||
BinaryPrimitives.ReadInt32BigEndian(bytes[4..]),
|
||||
Lsv2Codec.Text(bytes[8..]));
|
||||
}
|
||||
|
||||
/// <summary>Opaque byte, uint8 count, count numeric strings, count unique non-blank names.</summary>
|
||||
public static Lsv2Axes Axes(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 2) throw Bad();
|
||||
int count = bytes[1];
|
||||
if (count > 64) throw Bad();
|
||||
var parts = Strings(bytes, 2);
|
||||
if (parts.Count != count * 2) throw Bad();
|
||||
var names = new string[count];
|
||||
var values = new double[count];
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
names[i] = parts[i + count];
|
||||
if (string.IsNullOrWhiteSpace(names[i]) || !seen.Add(names[i])) throw Bad();
|
||||
if (!double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) throw Bad();
|
||||
values[i] = Finite(v);
|
||||
}
|
||||
|
||||
return new Lsv2Axes(names, values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>R_PR</c>: 120 or 124 bytes. Numeric R_PR counts are BYTE capacities (live-verified on three iTNC 530:
|
||||
/// B9999/W9998/D9996 are the last valid values for a count of 10000); bit and string counts are entries.
|
||||
/// The next populated distinct area remains a defensive cap for inconsistent layouts.
|
||||
/// </summary>
|
||||
public static Lsv2Layout Layout(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != 120 && bytes.Length != 124) throw Bad();
|
||||
var regions = new Dictionary<HeidenhainPlcArea, Lsv2Region>();
|
||||
HeidenhainPlcArea[] bits = [HeidenhainPlcArea.Marker, HeidenhainPlcArea.Input, HeidenhainPlcArea.Output, HeidenhainPlcArea.Counter, HeidenhainPlcArea.Timer];
|
||||
for (var i = 0; i < bits.Length; i++) Add(regions, bytes, bits[i], i * 8, 1, 1);
|
||||
Add(regions, bytes, HeidenhainPlcArea.Byte, 40, 1, 1);
|
||||
Add(regions, bytes, HeidenhainPlcArea.Word, 40, 1, 2);
|
||||
Add(regions, bytes, HeidenhainPlcArea.Dword, 40, 1, 4);
|
||||
int stringWidth = bytes[56];
|
||||
Add(regions, bytes, HeidenhainPlcArea.String, 48, stringWidth, stringWidth);
|
||||
Add(regions, bytes, HeidenhainPlcArea.InputWord, 64, 1, 2);
|
||||
Add(regions, bytes, HeidenhainPlcArea.OutputWord, 72, 1, 2);
|
||||
CapNumericRegionsAtNextArea(regions);
|
||||
return new Lsv2Layout(regions);
|
||||
}
|
||||
|
||||
private static void CapNumericRegionsAtNextArea(Dictionary<HeidenhainPlcArea, Lsv2Region> regions)
|
||||
{
|
||||
// Same-base BYTE/WORD/DWORD views intentionally share an area, not a boundary.
|
||||
var starts = new SortedSet<long>(regions.Values.Where(r => r.Bytes > 0 && r.Width > 0).Select(r => r.Base));
|
||||
foreach (var area in new[] { HeidenhainPlcArea.Byte, HeidenhainPlcArea.Word, HeidenhainPlcArea.Dword, HeidenhainPlcArea.InputWord, HeidenhainPlcArea.OutputWord })
|
||||
{
|
||||
var region = regions[area];
|
||||
var next = starts.GetViewBetween(region.Base + 1, long.MaxValue).Cast<long?>().FirstOrDefault();
|
||||
if (next is { } n && n - region.Base < region.Bytes)
|
||||
regions[area] = region with { Bytes = n - region.Base };
|
||||
}
|
||||
}
|
||||
|
||||
private static void Add(Dictionary<HeidenhainPlcArea, Lsv2Region> r, ReadOnlySpan<byte> b, HeidenhainPlcArea area, int offset, int unit, int width)
|
||||
{
|
||||
long @base = BinaryPrimitives.ReadUInt32BigEndian(b[offset..]);
|
||||
long size = (long)BinaryPrimitives.ReadUInt32BigEndian(b[(offset + 4)..]) * unit;
|
||||
if (@base + size > 0x1_0000_0000L) throw Bad();
|
||||
r[area] = new Lsv2Region(@base, size, width);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>R_DP</c> value: uint32be type then 2=int16be, 3=int32be, 5=float64le, 8=terminated text,
|
||||
/// 11=Boolean byte (00 false; 01/FF true — FF observed on real iTNC 530 I/O), 16=int8, 17=uint8.
|
||||
/// </summary>
|
||||
public static object DataPath(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 4) throw Bad();
|
||||
var type = BinaryPrimitives.ReadUInt32BigEndian(bytes);
|
||||
switch (type)
|
||||
{
|
||||
case 2:
|
||||
Lsv2Codec.Length(bytes, 6);
|
||||
return (int)BinaryPrimitives.ReadInt16BigEndian(bytes[4..]);
|
||||
case 3:
|
||||
Lsv2Codec.Length(bytes, 8);
|
||||
return BinaryPrimitives.ReadInt32BigEndian(bytes[4..]);
|
||||
case 5:
|
||||
Lsv2Codec.Length(bytes, 12);
|
||||
return Finite(BinaryPrimitives.ReadDoubleLittleEndian(bytes[4..]));
|
||||
case 8:
|
||||
return Lsv2Codec.Text(bytes[4..]);
|
||||
case 11:
|
||||
Lsv2Codec.Length(bytes, 5);
|
||||
if (bytes[4] is not (0 or 1 or 0xFF)) throw Bad();
|
||||
return bytes[4] != 0;
|
||||
case 16:
|
||||
Lsv2Codec.Length(bytes, 5);
|
||||
return (int)(sbyte)bytes[4];
|
||||
case 17:
|
||||
Lsv2Codec.Length(bytes, 5);
|
||||
return (int)bytes[4];
|
||||
default:
|
||||
throw Bad();
|
||||
}
|
||||
}
|
||||
|
||||
private static double Finite(double value) => double.IsFinite(value) ? value : throw Bad();
|
||||
|
||||
private static List<string> Strings(ReadOnlySpan<byte> bytes, int offset)
|
||||
{
|
||||
if (offset > bytes.Length || (offset < bytes.Length && bytes[^1] != 0)) throw Bad();
|
||||
var result = new List<string>();
|
||||
var start = offset;
|
||||
for (var i = offset; i < bytes.Length; i++)
|
||||
{
|
||||
if (bytes[i] != 0) continue;
|
||||
result.Add(Encoding.Latin1.GetString(bytes[start..i]));
|
||||
start = i + 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Lsv2DecodeException Bad() => new("Malformed LSV2 data record.");
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
/// <summary>A request the read-only policy refused. Raised BEFORE any byte reaches the socket.</summary>
|
||||
public sealed class Lsv2PolicyException(string message) : InvalidOperationException(message);
|
||||
|
||||
/// <summary>
|
||||
/// The payload-aware, final outbound gate (port of the reference <c>Lsv2ReadOnly</c>). There is deliberately
|
||||
/// no unrestricted mode, no configuration knob and no public raw-telegram API: every frame
|
||||
/// <see cref="Lsv2Codec.Frame"/> builds passes <see cref="Check"/> immediately before it is written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The only telegrams this driver can emit:</para>
|
||||
/// <list type="table">
|
||||
/// <item><term><c>A_LG</c></term><description>role ∈ {INSPECT, DNC, DATA}, NUL; optional NUL-free Latin-1 password, NUL.</description></item>
|
||||
/// <item><term><c>R_VR</c>, <c>R_PR</c></term><description>empty payload.</description></item>
|
||||
/// <item><term><c>R_RI</c></term><description>exactly a uint16be selector ∈ {22–28, 30–34, 51}.</description></item>
|
||||
/// <item><term><c>R_MB</c></term><description>uint32be address + uint8 count, 1 ≤ count ≤ 247, no 32-bit wrap.</description></item>
|
||||
/// <item><term><c>R_DP</c></term><description>four zero bytes + NUL-free path + NUL.</description></item>
|
||||
/// </list>
|
||||
/// <para>Everything else — every <c>C_*</c> (file / PLC / parameter / buffer-size writes), <c>A_LO</c>,
|
||||
/// <c>R_MC</c>/<c>R_FL</c> file reads, keyboard or scope operations, PLCDEBUG/MONITOR/FILE roles, any
|
||||
/// response-type mnemonic — is refused. Do not broaden this list to work around a controller refusal.</para>
|
||||
/// </remarks>
|
||||
public static class Lsv2ReadOnlyPolicy
|
||||
{
|
||||
/// <summary>The read-only roles a login may request.</summary>
|
||||
public static readonly IReadOnlySet<string> Roles = new HashSet<string>(StringComparer.Ordinal) { "INSPECT", "DNC", "DATA" };
|
||||
|
||||
/// <summary>The runtime-information selectors a <c>R_RI</c> may carry.</summary>
|
||||
public static readonly IReadOnlySet<int> Selectors = new HashSet<int> { 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 51 };
|
||||
|
||||
/// <summary>The permitted request commands.</summary>
|
||||
public static readonly IReadOnlySet<string> Commands = new HashSet<string>(StringComparer.Ordinal)
|
||||
{ "A_LG", "R_VR", "R_PR", "R_RI", "R_MB", "R_DP" };
|
||||
|
||||
/// <summary>Builds a validated login payload.</summary>
|
||||
/// <param name="role">INSPECT, DNC or DATA.</param>
|
||||
/// <param name="password">The resolved password, or empty.</param>
|
||||
/// <returns>The payload.</returns>
|
||||
public static byte[] LoginPayload(string role, string password)
|
||||
{
|
||||
if (!Roles.Contains(role)) throw new Lsv2PolicyException("LSV2 role is not read-only permitted.");
|
||||
var r = Lsv2Codec.Terminated(role);
|
||||
var result = string.IsNullOrEmpty(password) ? r : [.. r, .. Lsv2Codec.Terminated(password)];
|
||||
Check("A_LG", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Builds a validated data-path payload (reference slash normalisation applied).</summary>
|
||||
/// <param name="path">The authored data path.</param>
|
||||
/// <returns>The payload.</returns>
|
||||
public static byte[] DataPathPayload(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) throw new Lsv2PolicyException("Blank LSV2 data path.");
|
||||
byte[] text;
|
||||
try { text = Lsv2Codec.Terminated(HeidenhainAddress.NormalizeDataPath(path)); }
|
||||
catch (ArgumentException ex) { throw new Lsv2PolicyException(ex.Message); }
|
||||
var result = new byte[4 + text.Length];
|
||||
text.CopyTo(result, 4);
|
||||
Check("R_DP", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Builds a validated runtime-information payload.</summary>
|
||||
/// <param name="selector">The selector.</param>
|
||||
/// <returns>The payload.</returns>
|
||||
public static byte[] RuntimePayload(int selector)
|
||||
{
|
||||
if (selector is < 0 or > 0xFFFF) throw new Lsv2PolicyException("Invalid LSV2 selector.");
|
||||
var payload = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(payload, (ushort)selector);
|
||||
Check("R_RI", payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// <summary>Builds a validated memory-read payload.</summary>
|
||||
/// <param name="address">Absolute address.</param>
|
||||
/// <param name="count">Byte count.</param>
|
||||
/// <returns>The payload.</returns>
|
||||
public static byte[] MemoryPayload(long address, int count)
|
||||
{
|
||||
if (address is < 0 or > 0xFFFF_FFFFL || count is < 1 or > Lsv2Codec.MaxPayload)
|
||||
throw new Lsv2PolicyException("Invalid LSV2 memory range.");
|
||||
var payload = new byte[5];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(payload, (uint)address);
|
||||
payload[4] = (byte)count;
|
||||
Check("R_MB", payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The final gate: throws <see cref="Lsv2PolicyException"/> unless the command AND its payload are on the
|
||||
/// allowlist.
|
||||
/// </summary>
|
||||
/// <param name="command">The four-character command.</param>
|
||||
/// <param name="payload">The payload.</param>
|
||||
public static void Check(string? command, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (command is null || command.Length != 4 || payload.Length > Lsv2Codec.MaxPayload)
|
||||
throw new Lsv2PolicyException("Invalid LSV2 request size.");
|
||||
var allowed = command switch
|
||||
{
|
||||
"R_VR" or "R_PR" => payload.Length == 0,
|
||||
"R_RI" => payload.Length == 2 && Selectors.Contains(BinaryPrimitives.ReadUInt16BigEndian(payload)),
|
||||
"R_MB" => payload.Length == 5 && payload[4] > 0 && payload[4] <= Lsv2Codec.MaxPayload
|
||||
&& (long)BinaryPrimitives.ReadUInt32BigEndian(payload) + payload[4] <= 0x1_0000_0000L,
|
||||
"A_LG" => ValidLogin(payload),
|
||||
"R_DP" => payload.Length > 5 && BinaryPrimitives.ReadUInt32BigEndian(payload) == 0 && ValidText(payload, 4),
|
||||
_ => false,
|
||||
};
|
||||
if (!allowed) throw new Lsv2PolicyException($"LSV2 read-only policy refused request '{Sanitize(command)}'.");
|
||||
}
|
||||
|
||||
private static string Sanitize(string command) =>
|
||||
new(command.Select(c => c is >= ' ' and <= '~' ? c : '?').ToArray());
|
||||
|
||||
private static bool ValidText(ReadOnlySpan<byte> p, int offset)
|
||||
{
|
||||
if (p.Length <= offset || p[^1] != 0) return false;
|
||||
for (var i = offset; i < p.Length - 1; i++) if (p[i] == 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidLogin(ReadOnlySpan<byte> p)
|
||||
{
|
||||
if (p.Length == 0 || p[^1] != 0) return false;
|
||||
var end = p.IndexOf((byte)0);
|
||||
var role = Encoding.Latin1.GetString(p[..end]);
|
||||
return Roles.Contains(role) && (end == p.Length - 1 || ValidText(p, end + 1));
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Heidenhain</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- No vendor SDK, no Java/Python runtime: the LSV2 transport is a managed TCP client (System.Net.Sockets).
|
||||
Secrets.Abstractions only for the ISecretResolver seam that resolves secret: password references. -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
@* Embeddable Heidenhain device (one LSV2 control) form body — authors Host/Port and optional per-device
|
||||
password REFERENCES into the Device's DeviceConfig (PascalCase, matching HeidenhainDeviceOptions). Hosted by
|
||||
DeviceModal. Literal passwords are refused here and at the deploy gate (HeidenhainCredentialPersisted). *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
|
||||
<section class="panel rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">LSV2 endpoint</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="hhHost">Host</label>
|
||||
<InputText id="hhHost" @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="10.20.30.40" />
|
||||
<div class="form-text">Control hostname or IP. No default endpoint. Plain LSV2 is unencrypted — route it over an externally managed secure network.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="hhPort">Port</label>
|
||||
<InputNumber id="hhPort" @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 19000.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Per-device credentials (optional overrides)</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="hhInspect">INSPECT password ref</label>
|
||||
<InputText id="hhInspect" @bind-Value="_model.InspectPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:heidenhain/inspect" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="hhDnc">DNC password ref</label>
|
||||
<InputText id="hhDnc" @bind-Value="_model.DncPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:heidenhain/dnc" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="hhData">DATA password ref</label>
|
||||
<InputText id="hhData" @bind-Value="_model.DataPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="env:OTOPCUA_HEIDENHAIN_DATA_PASSWORD" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Blank = use the driver-level reference. <span class="mono">secret:<name></span> resolves from the node's secret store, <span class="mono">env:<VAR></span> from the environment, at each login. A literal password is never stored.</div>
|
||||
@if (_model.Validate() is { } error)
|
||||
{
|
||||
<div class="text-danger small mt-2">@error</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON.</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private HeidenhainDeviceModel _model = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = HeidenhainDeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// The Heidenhain device (one control's endpoint) DeviceConfig, PascalCase keys matching
|
||||
/// <see cref="HeidenhainDeviceOptions"/>. Unknown keys are preserved. Password fields hold REFERENCES only
|
||||
/// (blank / <c>secret:</c> / <c>env:</c>); <see cref="Validate"/> uses the driver's own rule.
|
||||
/// </summary>
|
||||
public sealed class HeidenhainDeviceModel
|
||||
{
|
||||
public string Host { get; set; } = "";
|
||||
public int Port { get; set; } = HeidenhainDriverOptions.DefaultPort;
|
||||
public string? InspectPassword { get; set; }
|
||||
public string? DncPassword { get; set; }
|
||||
public string? DataPassword { get; set; }
|
||||
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
public static HeidenhainDeviceModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new HeidenhainDeviceModel
|
||||
{
|
||||
Host = TagConfigJson.GetString(o, "Host") ?? "",
|
||||
Port = TagConfigJson.GetInt(o, "Port", HeidenhainDriverOptions.DefaultPort),
|
||||
InspectPassword = TagConfigJson.GetString(o, "InspectPassword"),
|
||||
DncPassword = TagConfigJson.GetString(o, "DncPassword"),
|
||||
DataPassword = TagConfigJson.GetString(o, "DataPassword"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "Host", Host.Trim());
|
||||
TagConfigJson.Set(_bag, "Port", Port);
|
||||
TagConfigJson.Set(_bag, "InspectPassword", Blank(InspectPassword));
|
||||
TagConfigJson.Set(_bag, "DncPassword", Blank(DncPassword));
|
||||
TagConfigJson.Set(_bag, "DataPassword", Blank(DataPassword));
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
public string? Validate()
|
||||
{
|
||||
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList();
|
||||
foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) })
|
||||
if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v)))
|
||||
errors.Add($"{name} password must be blank, secret:<name> or env:<VARIABLE> — never a literal.");
|
||||
return errors.Count == 0 ? null : string.Join(" ", errors);
|
||||
}
|
||||
|
||||
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
|
||||
}
|
||||
@@ -62,6 +62,10 @@
|
||||
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
|
||||
</div>
|
||||
|
||||
@if (DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is { } gateError)
|
||||
{
|
||||
<div class="panel notice notice-alert mt-3">@gateError</div>
|
||||
}
|
||||
@if (_saveError is not null)
|
||||
{
|
||||
<div class="panel notice notice-alert mt-3">@_saveError</div>
|
||||
@@ -73,7 +77,8 @@
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||
@if (_loadError is null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveAsync"
|
||||
disabled="@(_busy || DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is not null)">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(_isNew ? "Create device" : "Save changes")
|
||||
</button>
|
||||
@@ -195,7 +200,9 @@
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_busy = true; _saveError = null;
|
||||
_saveError = DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson);
|
||||
if (_saveError is not null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
UnsMutationResult result;
|
||||
|
||||
+9
-2
@@ -63,6 +63,10 @@
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (DriverConfigSaveGate.Validate(_driverType, _driverConfigJson) is { } gateError)
|
||||
{
|
||||
<div class="panel notice notice-alert mt-3">@gateError</div>
|
||||
}
|
||||
@if (_saveError is not null)
|
||||
{
|
||||
<div class="panel notice notice-alert mt-3">@_saveError</div>
|
||||
@@ -74,7 +78,8 @@
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||
@if (_loadError is null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveAsync"
|
||||
disabled="@(_busy || DriverConfigSaveGate.Validate(_driverType, _driverConfigJson) is not null)">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(_isNew ? "Create driver" : "Save changes")
|
||||
</button>
|
||||
@@ -185,7 +190,9 @@
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_busy = true; _saveError = null;
|
||||
_saveError = DriverConfigSaveGate.Validate(_driverType, _driverConfigJson);
|
||||
if (_saveError is not null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
UnsMutationResult result;
|
||||
|
||||
@@ -31,6 +31,7 @@ public static class DriverConfigFormMap
|
||||
[DriverTypeNames.Sql] = typeof(SqlDriverForm),
|
||||
[DriverTypeNames.Mqtt] = typeof(MqttDriverForm),
|
||||
[DriverTypeNames.MTConnect] = typeof(MTConnectDriverForm),
|
||||
[DriverTypeNames.Heidenhain] = typeof(HeidenhainDriverForm),
|
||||
[DriverTypeNames.Calculation] = typeof(CalculationDriverForm),
|
||||
};
|
||||
|
||||
@@ -67,6 +68,7 @@ public static class DeviceFormMap
|
||||
[DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDeviceForm),
|
||||
[DriverTypeNames.Galaxy] = typeof(GalaxyDeviceForm),
|
||||
[DriverTypeNames.Mqtt] = typeof(MqttDeviceForm),
|
||||
[DriverTypeNames.Heidenhain] = typeof(HeidenhainDeviceForm),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
@* Embeddable Heidenhain LSV2 driver config form body (lmxopcua#803). The per-control endpoint (Host/Port)
|
||||
lives on HeidenhainDeviceForm (one /raw Device per control). The driver is READ-ONLY: there is no write
|
||||
setting to author — the outbound LSV2 allowlist admits only logins and read requests. A thin shell over
|
||||
HeidenhainDriverFormModel, which owns every rule and preserves keys it does not own (Devices). *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="hhTimeout">Transaction timeout (ms)</label>
|
||||
<InputNumber id="hhTimeout" @bind-Value="_m.TimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">100–60000. Bounds connect and each complete response. Default 5000.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="hhReconnect">Reconnect initial delay (ms)</label>
|
||||
<InputNumber id="hhReconnect" @bind-Value="_m.ReconnectInitialMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="hhReconnectMax">Reconnect max delay (ms)</label>
|
||||
<InputNumber id="hhReconnectMax" @bind-Value="_m.ReconnectMaxMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Backoff doubles from the initial delay up to this ceiling.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="hhProbe">Test Connect timeout (s)</label>
|
||||
<InputNumber id="hhProbe" @bind-Value="_m.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Access (read-only roles)</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="hhTelemetry" @bind-Value="_m.TelemetryEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
|
||||
<label class="form-check-label" for="hhTelemetry">Telemetry reads</label>
|
||||
</div>
|
||||
<div class="form-text">Execution/program state, program, overrides, axes, tool, alarms. Off = no runtime-info requests.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="hhDnc" @bind-Value="_m.DncLoginEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
|
||||
<label class="form-check-label" for="hhDnc">Optional DNC login</label>
|
||||
</div>
|
||||
<div class="form-text">A refused DNC login never blocks reads the control permits under INSPECT.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="hhData" @bind-Value="_m.DataEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
|
||||
<label class="form-check-label" for="hhData">DATA path reads</label>
|
||||
</div>
|
||||
<div class="form-text">Opt-in DATA login, only when a <span class="mono">DATA:</span> binding needs it (reference-derived; iTNC only).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="hhInspectPw">INSPECT password ref</label>
|
||||
<InputText id="hhInspectPw" @bind-Value="_m.InspectPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:heidenhain/inspect" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="hhDncPw">DNC password ref</label>
|
||||
<InputText id="hhDncPw" @bind-Value="_m.DncPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:heidenhain/dnc" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="hhDataPw">DATA password ref</label>
|
||||
<InputText id="hhDataPw" @bind-Value="_m.DataPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="env:OTOPCUA_HEIDENHAIN_DATA_PASSWORD" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">References only: blank, <span class="mono">secret:<name></span> (node secret store) or <span class="mono">env:<VAR></span>. Resolved at each login; a literal password is refused.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.11s">
|
||||
<div class="panel-head">Polling & limits</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="hhStatus">Status (ms)</label>
|
||||
<InputNumber id="hhStatus" @bind-Value="_m.StatusMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="hhCustom">Custom (ms)</label>
|
||||
<InputNumber id="hhCustom" @bind-Value="_m.CustomMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="hhSlow">Slow (ms)</label>
|
||||
<InputNumber id="hhSlow" @bind-Value="_m.SlowMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="hhAlarms">Max alarms</label>
|
||||
<InputNumber id="hhAlarms" @bind-Value="_m.MaxAlarms" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox id="hhTimers" @bind-Value="_m.TimersEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
|
||||
<label class="form-check-label" for="hhTimers">Experimental timer reads</label>
|
||||
</div>
|
||||
<div class="form-text">Selectors 30–34. Units/semantics unverified on real controls — confirm before using as durations.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Intervals 100–3600000 ms. OPC reads and subscriptions are served from the cache; sampling cannot exceed these cadences.</div>
|
||||
@if (_m.Validate() is { } error)
|
||||
{
|
||||
<div class="text-danger small mt-2">@error</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON.</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired whenever a field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
private HeidenhainDriverFormModel _m = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_m = HeidenhainDriverFormModel.FromJson(DriverConfigJson);
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current config.</summary>
|
||||
public string GetConfigJson() => _m.ToJson();
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Flat, bindable model of the Heidenhain driver-level DriverConfig. Round-trips through a JSON bag so every key
|
||||
/// the form does not own (the multi-device <c>Devices</c> collection, future keys) survives a load→save. Every
|
||||
/// field is an int/bool/string — there is no enum to serialize numerically — and <see cref="Validate"/> asks the
|
||||
/// driver's own <see cref="HeidenhainOptionsParser"/>, so the form accepts exactly what the factory accepts.
|
||||
/// </summary>
|
||||
public sealed class HeidenhainDriverFormModel
|
||||
{
|
||||
public int TimeoutMs { get; set; } = 5000;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 10;
|
||||
public bool TelemetryEnabled { get; set; } = true;
|
||||
public bool DncLoginEnabled { get; set; } = true;
|
||||
public bool DataEnabled { get; set; }
|
||||
public string? InspectPassword { get; set; }
|
||||
public string? DncPassword { get; set; }
|
||||
public string? DataPassword { get; set; }
|
||||
public int StatusMs { get; set; } = 1000;
|
||||
public int CustomMs { get; set; } = 1000;
|
||||
public int SlowMs { get; set; } = 10000;
|
||||
public int MaxAlarms { get; set; } = 64;
|
||||
public bool TimersEnabled { get; set; }
|
||||
public int ReconnectInitialMs { get; set; } = 1000;
|
||||
public int ReconnectMaxMs { get; set; } = 30000;
|
||||
|
||||
/// <summary>
|
||||
/// The one serializer this form writes config through. The document carries no enum today; the string-enum
|
||||
/// converter is registered anyway so a future enum field can never serialize as a number (the repo-wide
|
||||
/// form/factory trap) — guarded by <c>DriverPageJsonConverterTests</c>.
|
||||
/// </summary>
|
||||
public static readonly System.Text.Json.JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
public static HeidenhainDriverFormModel FromJson(string? json)
|
||||
{
|
||||
JsonObject bag;
|
||||
try { bag = JsonNode.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json) as JsonObject ?? new JsonObject(); }
|
||||
catch (System.Text.Json.JsonException) { bag = new JsonObject(); }
|
||||
HeidenhainDriverOptions o;
|
||||
try { o = HeidenhainOptionsParser.Parse(bag.ToJsonString()); }
|
||||
catch (InvalidOperationException) { o = new HeidenhainDriverOptions(); }
|
||||
return new HeidenhainDriverFormModel
|
||||
{
|
||||
TimeoutMs = o.TimeoutMs,
|
||||
ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
TelemetryEnabled = o.Access.TelemetryEnabled,
|
||||
DncLoginEnabled = o.Access.DncLoginEnabled,
|
||||
DataEnabled = o.Access.DataEnabled,
|
||||
InspectPassword = o.Access.InspectPassword,
|
||||
DncPassword = o.Access.DncPassword,
|
||||
DataPassword = o.Access.DataPassword,
|
||||
StatusMs = o.Polling.StatusMs,
|
||||
CustomMs = o.Polling.CustomMs,
|
||||
SlowMs = o.Polling.SlowMs,
|
||||
MaxAlarms = o.Polling.MaxAlarms,
|
||||
TimersEnabled = o.Polling.TimersEnabled,
|
||||
ReconnectInitialMs = o.Reconnect.InitialDelayMs,
|
||||
ReconnectMaxMs = o.Reconnect.MaxDelayMs,
|
||||
_bag = bag,
|
||||
};
|
||||
}
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
// Remove any case-variant of the keys this form owns so the bag never carries two spellings.
|
||||
foreach (var k in new[] { "TimeoutMs", "ProbeTimeoutSeconds", "Access", "Polling", "Reconnect" })
|
||||
foreach (var existing in _bag.Select(p => p.Key).Where(p => string.Equals(p, k, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
_bag.Remove(existing);
|
||||
_bag["TimeoutMs"] = TimeoutMs;
|
||||
_bag["ProbeTimeoutSeconds"] = ProbeTimeoutSeconds;
|
||||
var access = new JsonObject
|
||||
{
|
||||
["TelemetryEnabled"] = TelemetryEnabled,
|
||||
["DncLoginEnabled"] = DncLoginEnabled,
|
||||
["DataEnabled"] = DataEnabled,
|
||||
};
|
||||
if (Blank(InspectPassword) is { } i) access["InspectPassword"] = i;
|
||||
if (Blank(DncPassword) is { } d) access["DncPassword"] = d;
|
||||
if (Blank(DataPassword) is { } a) access["DataPassword"] = a;
|
||||
_bag["Access"] = access;
|
||||
_bag["Polling"] = new JsonObject
|
||||
{
|
||||
["StatusMs"] = StatusMs,
|
||||
["CustomMs"] = CustomMs,
|
||||
["SlowMs"] = SlowMs,
|
||||
["MaxAlarms"] = MaxAlarms,
|
||||
["TimersEnabled"] = TimersEnabled,
|
||||
};
|
||||
_bag["Reconnect"] = new JsonObject { ["InitialDelayMs"] = ReconnectInitialMs, ["MaxDelayMs"] = ReconnectMaxMs };
|
||||
return _bag.ToJsonString(JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>The driver's own verdict on this config (device endpoints are validated on the device form).</summary>
|
||||
public string? Validate()
|
||||
{
|
||||
var o = HeidenhainOptionsParser.Parse(ToJson());
|
||||
var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions
|
||||
{
|
||||
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access,
|
||||
Polling = o.Polling, Reconnect = o.Reconnect,
|
||||
});
|
||||
return errors.Count == 0 ? null : string.Join(" ", errors);
|
||||
}
|
||||
|
||||
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
("Sql", DriverTypeNames.Sql),
|
||||
("Calculation", DriverTypeNames.Calculation),
|
||||
("MTConnect", DriverTypeNames.MTConnect),
|
||||
("Heidenhain", DriverTypeNames.Heidenhain),
|
||||
];
|
||||
|
||||
private string _type = DriverTypeNames.Modbus;
|
||||
|
||||
+7
-1
@@ -59,6 +59,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" value="@row.AccessLevel"
|
||||
disabled="@RawTagRowDefaults.IsReadOnlyDriver(DriverType)"
|
||||
@onchange="@(e => row.AccessLevel = ParseAccess(e.Value))">
|
||||
<option value="@TagAccessLevel.Read">Read</option>
|
||||
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
|
||||
@@ -209,7 +210,11 @@
|
||||
private IDictionary<string, object> BuildEditorParameters(RowModel row) => new Dictionary<string, object>
|
||||
{
|
||||
["ConfigJson"] = row.TagConfig,
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => row.TagConfig = v),
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v =>
|
||||
{
|
||||
row.TagConfig = v;
|
||||
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
|
||||
}),
|
||||
["DriverType"] = DriverType,
|
||||
["GetDriverConfigJson"] = (Func<string>)(() => _mergedConfigJson ?? "{}"),
|
||||
};
|
||||
@@ -255,6 +260,7 @@
|
||||
var failed = 0;
|
||||
foreach (var row in _rows)
|
||||
{
|
||||
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
|
||||
var input = new RawTagInput(
|
||||
row.Name,
|
||||
row.DataType,
|
||||
|
||||
@@ -57,10 +57,18 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="raw-tag-access">Access level</label>
|
||||
<InputSelect id="raw-tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
|
||||
<option value="@TagAccessLevel.Read">Read</option>
|
||||
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
|
||||
</InputSelect>
|
||||
@if (IsReadOnlyDriver)
|
||||
{
|
||||
<input id="raw-tag-access" class="form-control form-control-sm" value="Read" disabled readonly />
|
||||
<div class="form-text">This driver is read-only.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<InputSelect id="raw-tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
|
||||
<option value="@TagAccessLevel.Read">Read</option>
|
||||
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
|
||||
</InputSelect>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">WriteIdempotent</label>
|
||||
@@ -226,7 +234,11 @@
|
||||
{
|
||||
["ConfigJson"] = _form.TagConfig,
|
||||
["ConfigJsonChanged"] = _configJsonChanged ??=
|
||||
EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||
EventCallback.Factory.Create<string>(this, v =>
|
||||
{
|
||||
_form.TagConfig = v;
|
||||
RawTagRowDefaults.Apply(DriverType, _form.TagConfig, a => _form.AccessLevel = a, d => _form.DataType = d);
|
||||
}),
|
||||
["DriverType"] = DriverType ?? "",
|
||||
// Live accessor, not a snapshot: the picker reads it when the operator clicks Browse.
|
||||
["GetDriverConfigJson"] = _getDriverConfigJson ??= () => _mergedConfigJson ?? "{}",
|
||||
@@ -258,6 +270,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
RawTagRowDefaults.Apply(DriverType, _form.TagConfig, a => _form.AccessLevel = a, d => _form.DataType = d);
|
||||
var input = new RawTagInput(
|
||||
_form.Name,
|
||||
_form.DataType,
|
||||
@@ -283,6 +296,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsReadOnlyDriver => RawTagRowDefaults.IsReadOnlyDriver(DriverType);
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
Visible = false;
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
@* Typed tag-config editor for a Heidenhain LSV2 tag (lmxopcua#803). A thin shell over HeidenhainTagConfigModel —
|
||||
every rule lives there (and, through it, in the driver's HeidenhainTagDefinitionFactory).
|
||||
|
||||
The driver is READ-ONLY: there is no Writable toggle, and the deploy gate refuses a ReadWrite access level.
|
||||
Fixed leaves take the catalog's data type and array rank; only PLC:/DATA: bindings choose a type. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="hh-address">Address</label>
|
||||
<input id="hh-address" type="text" class="form-control form-control-sm mono" list="hh-catalog"
|
||||
value="@_m.Address" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? ""))"
|
||||
placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8" />
|
||||
<datalist id="hh-catalog">
|
||||
@foreach (var e in HeidenhainCatalog.Entries) { <option value="@e.Address"></option> }
|
||||
</datalist>
|
||||
<div class="form-text">
|
||||
A fixed leaf, <span class="mono">PLC:AREA:INDEX</span> (element index into the control's advertised region; AREA one of
|
||||
@string.Join(", ", HeidenhainAddress.AreaTokens)) or <span class="mono">DATA:/path</span> (needs DATA access enabled on the driver).
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="hh-datatype">Data type</label>
|
||||
@if (_m.IsCatalogAddress)
|
||||
{
|
||||
<input id="hh-datatype" type="text" class="form-control form-control-sm mono" value="@CatalogShape()" disabled readonly />
|
||||
<div class="form-text">Fixed by the namespace catalog.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<select id="hh-datatype" class="form-select form-select-sm" value="@_m.DataType"
|
||||
@onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, DriverDataType.Int32)))">
|
||||
@foreach (var v in HeidenhainAddress.CustomDataTypes) { <option value="@v">@v</option> }
|
||||
</select>
|
||||
<div class="form-text">Must hold the area's native range (no narrowing): Boolean for bit areas, String for STRING.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_m.Validate() is { } error)
|
||||
{
|
||||
<div class="text-danger small mt-2">@error</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
/// <summary>Two-way config callback.</summary>
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
/// <summary>DriverType of the selected driver — supplied by the hosting modal.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
/// <summary>Live accessor for the selected driver's DriverConfig JSON.</summary>
|
||||
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||
|
||||
private HeidenhainTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = HeidenhainTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
private string CatalogShape() =>
|
||||
HeidenhainCatalog.TryGet(_m.Address.Trim(), out var e) ? (e.IsArray ? $"{e.DataType}[]" : e.DataType.ToString()) : "";
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side refusal of a DriverConfig / DeviceConfig that must never reach the config database. Called by
|
||||
/// <see cref="RawTreeService"/> on every driver/device create + update (the authority — a modal that skips
|
||||
/// its own check, the API or a hand-crafted circuit message still hits it) and by the driver/device modals to
|
||||
/// block Save while the form is invalid.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Today it carries one rule: a Heidenhain password field must be blank or a reference in the Heidenhain
|
||||
/// namespace (<see cref="HeidenhainOptionsParser.DescribeCredentialProblems"/>) — otherwise a literal password
|
||||
/// would be persisted in a draft (and replicated) even though the deploy gate would later refuse it.
|
||||
/// </remarks>
|
||||
public static class DriverConfigSaveGate
|
||||
{
|
||||
/// <summary>The reason a config may not be saved for a driver type, or null when it may.</summary>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="configJson">The DriverConfig or DeviceConfig JSON about to be stored.</param>
|
||||
/// <returns>The refusal (never containing a credential value), or null.</returns>
|
||||
public static string? Validate(string? driverType, string? configJson)
|
||||
{
|
||||
if (!string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var problems = HeidenhainOptionsParser.DescribeCredentialProblems(configJson);
|
||||
return problems.Count == 0 ? null : string.Join(" ", problems);
|
||||
}
|
||||
}
|
||||
@@ -280,6 +280,10 @@ public static class RawBrowseCommitMapper
|
||||
}.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.Galaxy))
|
||||
return WriteSingleKey("attributeRef", address);
|
||||
// Heidenhain: a browsed leaf is a fixed catalog address; the typed model derives its data type and array
|
||||
// flag from the catalog, so the committed tag opens in the editor and passes the driver's gate unchanged.
|
||||
if (Is(driverType, DriverTypeNames.Heidenhain))
|
||||
return new HeidenhainTagConfigModel { Address = address }.ToJson();
|
||||
// Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel
|
||||
// needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields.
|
||||
// Without this branch a Sql commit fell through to the generic "address" key below, which the typed
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803): for a Heidenhain tag the
|
||||
/// access level is always Read and <c>Tag.DataType</c> follows the address (catalog type, or the binding's
|
||||
/// declared type). Used by the tag modal and the manual-entry grid so the operator never has to pick values the
|
||||
/// server (<c>RawTreeService</c>) and the deploy gate would refuse.
|
||||
/// </summary>
|
||||
public static class RawTagRowDefaults
|
||||
{
|
||||
/// <summary>True for a driver whose tags can never be writable.</summary>
|
||||
/// <param name="driverType">The driver type.</param>
|
||||
/// <returns>Whether the access level is fixed at Read.</returns>
|
||||
public static bool IsReadOnlyDriver(string? driverType) =>
|
||||
string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Applies the driver-dictated access level and data type for a row, when the driver dictates them.</summary>
|
||||
/// <param name="driverType">The driver type.</param>
|
||||
/// <param name="tagConfig">The row's TagConfig.</param>
|
||||
/// <param name="setAccess">Sets the row's access level.</param>
|
||||
/// <param name="setDataType">Sets the row's <c>Tag.DataType</c>.</param>
|
||||
public static void Apply(string? driverType, string? tagConfig, Action<TagAccessLevel> setAccess, Action<string> setDataType)
|
||||
{
|
||||
if (!IsReadOnlyDriver(driverType)) return;
|
||||
setAccess(TagAccessLevel.Read);
|
||||
if (HeidenhainTagRules.ExpectedTagDataType(tagConfig) is { } dataType) setDataType(dataType);
|
||||
}
|
||||
}
|
||||
@@ -483,6 +483,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
if (nameError is not null) return new UnsMutationResult(false, nameError);
|
||||
if (string.IsNullOrWhiteSpace(driverType))
|
||||
return new UnsMutationResult(false, "Pick a driver type.");
|
||||
if (DriverConfigSaveGate.Validate(driverType, driverConfigJson) is { } configRefusal)
|
||||
return new UnsMutationResult(false, configRefusal);
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
@@ -619,6 +621,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
var owner = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
||||
if (owner is null)
|
||||
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
|
||||
if (DriverConfigSaveGate.Validate(owner.DriverType, deviceConfigJson) is { } configRefusal)
|
||||
return new UnsMutationResult(false, configRefusal);
|
||||
|
||||
// MTConnect owns one agent client per driver — mirror the deploy gate
|
||||
// (DraftValidator.ValidateMTConnectSingleDevice) so the second Device is refused where it is authored.
|
||||
@@ -855,6 +859,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
if (await ValidateTagConfigAsync(db, deviceId, tagConfig, ct) is { } configError)
|
||||
return new UnsMutationResult(false, configError);
|
||||
if (TagRowProblem(await ResolveDriverTypeAsync(db, deviceId, ct), input, tagConfig) is { } rowError)
|
||||
return new UnsMutationResult(false, rowError);
|
||||
|
||||
var id = NewId("TAG");
|
||||
db.Tags.Add(new Tag
|
||||
@@ -896,6 +902,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
if (await ValidateTagConfigAsync(db, entity.DeviceId, tagConfig, ct) is { } configError)
|
||||
return new UnsMutationResult(false, configError);
|
||||
if (TagRowProblem(await ResolveDriverTypeAsync(db, entity.DeviceId, ct), input, tagConfig) is { } rowError)
|
||||
return new UnsMutationResult(false, rowError);
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
entity.Name = input.Name;
|
||||
@@ -989,6 +997,12 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TagRowProblem(driverType, row.Tag, tagConfig) is { } rowError)
|
||||
{
|
||||
errors.Add($"{rowLabel}: {rowError}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!takenNames.Add((groupId, row.Tag.Name)))
|
||||
{
|
||||
errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path.");
|
||||
@@ -1102,6 +1116,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
||||
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
||||
if (DriverConfigSaveGate.Validate(entity.DriverType, driverConfigJson) is { } configRefusal)
|
||||
return new UnsMutationResult(false, configRefusal);
|
||||
|
||||
// Sibling uniqueness in the same scope RenameDriverAsync uses (cluster + folder).
|
||||
if (await db.DriverInstances.AnyAsync(
|
||||
@@ -1162,6 +1178,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
|
||||
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
||||
if (DriverConfigSaveGate.Validate(await ResolveDriverTypeAsync(db, deviceId, ct), deviceConfigJson) is { } configRefusal)
|
||||
return new UnsMutationResult(false, configRefusal);
|
||||
|
||||
if (await db.Devices.AnyAsync(
|
||||
dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == name && dev.DeviceId != deviceId, ct))
|
||||
@@ -1275,6 +1293,16 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
/// <param name="tagConfig">The normalised TagConfig JSON about to be persisted.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>An operator-facing error, or <see langword="null"/> when the config is acceptable.</returns>
|
||||
/// <summary>
|
||||
/// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only
|
||||
/// and carry the data type the driver publishes — the same rule the deploy gate applies.
|
||||
/// </summary>
|
||||
private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) =>
|
||||
string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)
|
||||
? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem(
|
||||
input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig)
|
||||
: null;
|
||||
|
||||
private static async Task<string?> ValidateTagConfigAsync(
|
||||
OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -635,6 +635,15 @@ public static class CsvColumnMap
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtDevice", "mtDevice", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtComponent", "mtComponent", null), CsvRawKeyKind.String),
|
||||
}),
|
||||
|
||||
// Heidenhain (#803): address + the binding data type NAME + the array flag the catalog's array leaves
|
||||
// require. The driver's HeidenhainTagDefinitionFactory is the gate a CSV row is validated against.
|
||||
new RawKeyCsvDriverTagMap(DriverTypeNames.Heidenhain, new[]
|
||||
{
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("HhAddress", "address", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("HhDataType", "dataType", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("HhIsArray", "isArray", null), CsvRawKeyKind.Bool),
|
||||
}),
|
||||
};
|
||||
|
||||
return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Typed model of a Heidenhain raw tag's <c>TagConfig</c> (lmxopcua#803). Keys: <c>address</c>, <c>dataType</c>
|
||||
/// (a NAME), <c>isArray</c>. Unknown keys (historian intent, …) are preserved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The driver is the only authority.</b> <see cref="Validate"/> asks
|
||||
/// <see cref="HeidenhainTagDefinitionFactory.TryFromTagConfig"/> about the blob this model would save (or, on
|
||||
/// first load, the blob it was loaded from — CSV import and the API save verbatim), so the editor, the deploy
|
||||
/// gate and the runtime accept exactly the same tags.</para>
|
||||
/// <para><b>Shape is derived, not authored, for fixed leaves.</b> For a catalog address <see cref="ToJson"/>
|
||||
/// writes the catalog's data type and its array flag, so the node's rank always matches what the driver
|
||||
/// publishes. A PLC/DATA binding carries the operator-chosen type. A stray <c>writable</c> key is dropped —
|
||||
/// the driver is read-only.</para>
|
||||
/// </remarks>
|
||||
public sealed class HeidenhainTagConfigModel
|
||||
{
|
||||
/// <summary>The address: a catalog leaf, <c>PLC:AREA:INDEX</c> or <c>DATA:/path</c>.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
/// <summary>The binding data type (ignored for fixed leaves, whose type is the catalog's).</summary>
|
||||
public DriverDataType DataType { get; set; } = DriverDataType.Int32;
|
||||
|
||||
private JsonObject _bag = new();
|
||||
private string? _source;
|
||||
|
||||
/// <summary>True when <see cref="Address"/> is a fixed catalog leaf.</summary>
|
||||
public bool IsCatalogAddress => HeidenhainCatalog.TryGet(Address.Trim(), out _);
|
||||
|
||||
public static HeidenhainTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
var address = TagConfigJson.GetString(o, "address") ?? "";
|
||||
var typeText = TagConfigJson.GetString(o, "dataType");
|
||||
DriverDataType type;
|
||||
if (HeidenhainCatalog.TryGet(address.Trim(), out var entry)) type = entry.DataType;
|
||||
else if (typeText is not null && HeidenhainTagDefinitionFactory.TryParseDataTypeName(typeText, out var parsed)) type = parsed;
|
||||
else type = DriverDataType.Int32;
|
||||
return new HeidenhainTagConfigModel { Address = address, DataType = type, _bag = o, _source = json ?? "" };
|
||||
}
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
var address = Address.Trim();
|
||||
TagConfigJson.Set(_bag, "address", address);
|
||||
TagConfigJson.Set(_bag, "writable", null);
|
||||
if (HeidenhainCatalog.TryGet(address, out var entry))
|
||||
{
|
||||
TagConfigJson.Set(_bag, "dataType", entry.DataType);
|
||||
TagConfigJson.Set(_bag, "isArray", entry.IsArray ? true : null);
|
||||
}
|
||||
else
|
||||
{
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "isArray", null);
|
||||
}
|
||||
|
||||
return _source = TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
public string? Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Address)) return "An address is required (e.g. Status/ExecutionState, PLC:WORD:4, DATA:/PLC/memory/W/8).";
|
||||
var blob = _source is null ? ToJsonPreview() : _source;
|
||||
return HeidenhainTagDefinitionFactory.TryFromTagConfig(TagConfigParseGate.AuthoringRawPath, "", blob, out _, out var error)
|
||||
? null
|
||||
: error;
|
||||
}
|
||||
|
||||
private string ToJsonPreview()
|
||||
{
|
||||
var saved = _source;
|
||||
var json = ToJson();
|
||||
_source = saved;
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public static class TagConfigEditorMap
|
||||
[DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
||||
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
|
||||
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
|
||||
[DriverTypeNames.Heidenhain] = typeof(Components.Shared.Uns.TagEditors.HeidenhainTagConfigEditor),
|
||||
};
|
||||
|
||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||
|
||||
@@ -27,6 +27,7 @@ public static class TagConfigValidator
|
||||
[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.Heidenhain] = j => HeidenhainTagConfigModel.FromJson(j).Validate(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
|
||||
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
|
||||
|
||||
+1
@@ -65,6 +65,7 @@ public static class EquipmentTagConfigInspector
|
||||
[DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.Heidenhain] = Driver.Heidenhain.HeidenhainTagDefinitionFactory.Inspect,
|
||||
};
|
||||
|
||||
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
used to pull in transitively left with NamespaceMap, to Driver.OpcUaClient.Session. -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -24,6 +24,7 @@ using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
|
||||
using SqlProbe = Driver.Sql.SqlDriverProbe;
|
||||
using MqttProbe = Driver.Mqtt.MqttDriverProbe;
|
||||
using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;
|
||||
using HeidenhainProbe = Driver.Heidenhain.HeidenhainDriverProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
||||
@@ -195,6 +196,11 @@ public static class DriverFactoryBootstrap
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());
|
||||
// Factory-built so the probe resolves secret: INSPECT password references through the node's resolver
|
||||
// (registered on every node by AddZbSecrets; optional so a test graph without one still builds — a
|
||||
// secret: ref then fails closed with a clear probe message rather than sending the literal).
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, HeidenhainProbe>(
|
||||
sp => new HeidenhainProbe(sp.GetService<ISecretResolver>())));
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -253,6 +259,9 @@ public static class DriverFactoryBootstrap
|
||||
// tier either — SubscribeAsync returns synchronously after starting the pump on a background
|
||||
// task, so the Tier A 5s Subscribe budget never covers the stream's lifetime.
|
||||
Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
// Tier A (default): managed TCP only, no native SDK. Read-only by construction (no IWritable; LSV2
|
||||
// outbound allowlist). The resolver backs secret: password references, resolved per login.
|
||||
Driver.Heidenhain.HeidenhainDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||
Driver.S7.S7DriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>lmxopcua#803: the Heidenhain read-only, credential and endpoint deploy gates.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorHeidenhainTests
|
||||
{
|
||||
private static DriverInstance Driver(string config = "{}", string type = "Heidenhain") => new()
|
||||
{
|
||||
DriverInstanceId = "hh", ClusterId = "c", Name = "hh", DriverType = type, DriverConfig = config,
|
||||
};
|
||||
|
||||
private static Device Dev(string config = """{"Host":"10.0.0.5"}""") => new()
|
||||
{
|
||||
DeviceId = "d1", DriverInstanceId = "hh", Name = "dev1", DeviceConfig = config,
|
||||
};
|
||||
|
||||
private static Tag T(TagAccessLevel access) => new()
|
||||
{
|
||||
TagId = "t1", DeviceId = "d1", Name = "ExecState", DataType = "Int32", AccessLevel = access,
|
||||
TagConfig = """{"address":"Status/ExecutionState"}""",
|
||||
};
|
||||
|
||||
private static IReadOnlyList<ValidationError> Validate(DriverInstance d, Device dev, params Tag[] tags) =>
|
||||
DraftValidator.Validate(new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c", DriverInstances = [d], Devices = [dev], Tags = tags,
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void A_read_only_tag_on_a_configured_control_is_valid()
|
||||
=> Validate(Driver(), Dev(), T(TagAccessLevel.Read)).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void A_ReadWrite_tag_is_refused_so_no_node_is_ever_writable()
|
||||
{
|
||||
var e = Validate(Driver(), Dev(), T(TagAccessLevel.ReadWrite)).Single(x => x.Code == HeidenhainDeployChecks.WritableCode);
|
||||
e.Context.ShouldBe("t1");
|
||||
e.Message.ShouldContain("read-only");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"Access":{"InspectPassword":"hunter2"}}""", "{\"Host\":\"10.0.0.5\"}")]
|
||||
[InlineData("{}", """{"Host":"10.0.0.5","DncPassword":"hunter2"}""")]
|
||||
public void A_literal_password_is_refused_without_echoing_it(string driverConfig, string deviceConfig)
|
||||
{
|
||||
var errors = Validate(Driver(driverConfig), Dev(deviceConfig), T(TagAccessLevel.Read));
|
||||
errors.ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode);
|
||||
errors.ShouldAllBe(e => !e.Message.Contains("hunter2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Secret_and_env_references_are_accepted()
|
||||
=> Validate(Driver("""{"Access":{"InspectPassword":"secret:heidenhain/inspect","DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC"}}"""), Dev(), T(TagAccessLevel.Read))
|
||||
.ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void A_device_with_tags_but_no_endpoint_is_refused_while_an_empty_device_is_not()
|
||||
{
|
||||
Validate(Driver(), Dev("{}"), T(TagAccessLevel.Read)).ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode && e.Context == "d1");
|
||||
Validate(Driver(), Dev("{}")).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tag_data_type_that_disagrees_with_the_published_type_is_refused()
|
||||
{
|
||||
var tag = T(TagAccessLevel.Read);
|
||||
tag.DataType = "Float";
|
||||
var e = Validate(Driver(), Dev(), tag).Single(x => x.Code == HeidenhainDeployChecks.DataTypeCode);
|
||||
e.Context.ShouldBe("t1");
|
||||
e.Message.ShouldContain("Int32");
|
||||
var binding = T(TagAccessLevel.Read);
|
||||
binding.TagConfig = """{"address":"PLC:DWORD:0","dataType":"Int64"}""";
|
||||
binding.DataType = "Int64";
|
||||
Validate(Driver(), Dev(), binding).ShouldNotContain(x => x.Code == HeidenhainDeployChecks.DataTypeCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb")]
|
||||
[InlineData("secret:galaxy/inst1/apikey")]
|
||||
public void An_out_of_namespace_reference_is_refused(string reference)
|
||||
=> Validate(Driver("{\"Access\":{\"InspectPassword\":\"" + reference + "\"}}"), Dev(), T(TagAccessLevel.Read))
|
||||
.ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode);
|
||||
|
||||
[Fact]
|
||||
public void Out_of_range_settings_are_refused()
|
||||
=> Validate(Driver("""{"Polling":{"MaxAlarms":999}}"""), Dev(), T(TagAccessLevel.Read))
|
||||
.ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode);
|
||||
|
||||
[Fact]
|
||||
public void Other_drivers_are_unaffected()
|
||||
=> Validate(Driver("""{"Access":{"InspectPassword":"x"}}""", type: "Modbus"), Dev(), T(TagAccessLevel.ReadWrite))
|
||||
.ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
|
||||
}
|
||||
+1
@@ -39,6 +39,7 @@
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
|
||||
<!-- Ships no driver factory (it is a historian backend, not an Equipment driver), so the
|
||||
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
|
||||
puts it in StatusCodeParityTests' scope. -->
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Real iTNC530 replies captured 2026-11-03; INSPECT only, no DNC login.
|
||||
# Provenance: docs/recipes/evidence/heidenhain-live-2026-11-03/direct-inspect-only.json
|
||||
hermle49.23=S_RI:0004
|
||||
hermle49.26=S_RI:0007
|
||||
hermle49.24=S_RI:00000000544e433a5c5041525450524f475c30303538383030313330322e690000
|
||||
hermle49.25=T_ER:0117
|
||||
hermle49.22=S_RI:ff06302e30383339320031302e34333034300032352e373236373300302e30303030003237302e30303030002d39302e3030303000580059005a00430061004100
|
||||
hermle49.51=T_ER:0118
|
||||
hermle49.27=S_RI:00070004ffffffff52756e2074657374206f66206375746f7574206368616e6e656c7300
|
||||
hermle49.28=T_ER:0139
|
||||
hermle59.23=S_RI:0004
|
||||
hermle59.26=S_RI:0007
|
||||
hermle59.24=S_RI:00000000544e433a5c7061727470726f675c30303538383030313330322e690000
|
||||
hermle59.25=T_ER:0117
|
||||
hermle59.22=S_RI:ff06302e30313131390031302e35313739330032352e333130373300302e30303030003138302e30303030002d39302e3030303000580059005a00430061004100
|
||||
hermle59.51=S_RI:000000000000000200000000000000000000000000000000
|
||||
hermle59.27=S_RI:00070004ffffffff52756e2074657374206f66206375746f7574206368616e6e656c7300
|
||||
hermle59.28=T_ER:0139
|
||||
hermle49.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff10000000000000000000000000fd00fa00000000000000000000
|
||||
hermle59.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff10000000000000000000000006fd00fa0000071e029000000000
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# Real PLC boundary replies, 2026-11-03. DATA + INSPECT only; no writes.
|
||||
# Evidence: docs/recipes/evidence/heidenhain-plc-units-2026-11-03/
|
||||
hermle59.version=69544e43353330003334303439302030342053503130006943333076372e315f3100253030303031313131303030303031313100
|
||||
hermle59.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff10000000000000000000000006fd00fa0000bbd5e9c300000000
|
||||
hermle59.B.0=S_DP:0000001000
|
||||
hermle59.B.1=S_DP:0000001000
|
||||
hermle59.B.9999=S_DP:0000001000
|
||||
hermle59.B.10000=T_ER:0118
|
||||
hermle59.B.19999=T_ER:0118
|
||||
hermle59.B.20000=T_ER:0118
|
||||
hermle59.W.0=S_DP:000000020000
|
||||
hermle59.W.1=T_ER:0118
|
||||
hermle59.W.2=S_DP:000000020000
|
||||
hermle59.W.9998=S_DP:000000020000
|
||||
hermle59.W.9999=T_ER:0118
|
||||
hermle59.W.10000=T_ER:0118
|
||||
hermle59.W.19998=T_ER:0118
|
||||
hermle59.D.0=S_DP:0000000300000000
|
||||
hermle59.D.1=T_ER:0118
|
||||
hermle59.D.2=T_ER:0118
|
||||
hermle59.D.3=T_ER:0118
|
||||
hermle59.D.4=S_DP:000000036aea246c
|
||||
hermle59.D.9996=S_DP:0000000300000000
|
||||
hermle59.D.9997=T_ER:0118
|
||||
hermle59.D.10000=T_ER:0118
|
||||
hermle59.D.19996=T_ER:0118
|
||||
hermle59.IW.0=S_DP:000000020000
|
||||
hermle59.IW.1=T_ER:0118
|
||||
hermle59.IW.2=S_DP:000000020000
|
||||
hermle59.IW.998=S_DP:000000020000
|
||||
hermle59.IW.999=T_ER:0118
|
||||
hermle59.IW.1000=T_ER:0118
|
||||
hermle59.IW.1998=T_ER:0118
|
||||
hermle59.OW.0=S_DP:000000020000
|
||||
hermle59.OW.1=T_ER:0118
|
||||
hermle59.OW.2=S_DP:000000020000
|
||||
hermle59.OW.998=S_DP:000000020000
|
||||
hermle59.OW.999=T_ER:0118
|
||||
hermle59.OW.1000=T_ER:0118
|
||||
hermle59.OW.1998=T_ER:0118
|
||||
hermle59.M.0=S_DP:0000000b00
|
||||
hermle59.M.9999=S_DP:0000000b00
|
||||
hermle59.M.10000=T_ER:0118
|
||||
hermle59.I.0=S_DP:0000000b00
|
||||
hermle59.I.999=S_DP:0000000bff
|
||||
hermle59.I.1000=T_ER:0118
|
||||
hermle59.O.0=S_DP:0000000b00
|
||||
hermle59.O.999=S_DP:0000000bff
|
||||
hermle59.O.1000=T_ER:0118
|
||||
hermle59.K.0=T_ER:0118
|
||||
hermle59.T.0=S_DP:0000000b00
|
||||
hermle59.T.999=S_DP:0000000b00
|
||||
hermle59.T.1000=T_ER:0118
|
||||
hermle59.S.0=S_DP:000000083020202035302020313030203135302020202020201a453136302020202020203000
|
||||
hermle59.S.99=S_DP:0000000800
|
||||
hermle59.S.100=T_ER:0118
|
||||
hermle59.C.0=S_DP:0000000b00
|
||||
hermle59.C.1=S_DP:0000000b00
|
||||
hermle59.C.143=S_DP:0000000b00
|
||||
hermle59.C.144=T_ER:0118
|
||||
hermle70.version=69544e43353330003630363432302030332053503400694332325f5632302e315f3253503200253030303031313131303030303031313100
|
||||
hermle70.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff1000000000000000000000000efd00fa0000848c272300000000
|
||||
hermle70.B.0=S_DP:0000001002
|
||||
hermle70.B.1=S_DP:0000001000
|
||||
hermle70.B.9999=S_DP:0000001000
|
||||
hermle70.B.10000=T_ER:0118
|
||||
hermle70.B.19999=T_ER:0118
|
||||
hermle70.B.20000=T_ER:0118
|
||||
hermle70.W.0=S_DP:000000020002
|
||||
hermle70.W.1=T_ER:0118
|
||||
hermle70.W.2=S_DP:000000020000
|
||||
hermle70.W.9998=S_DP:000000020000
|
||||
hermle70.W.9999=T_ER:0118
|
||||
hermle70.W.10000=T_ER:0118
|
||||
hermle70.W.19998=T_ER:0118
|
||||
hermle70.D.0=S_DP:0000000300000002
|
||||
hermle70.D.1=T_ER:0118
|
||||
hermle70.D.2=T_ER:0118
|
||||
hermle70.D.3=T_ER:0118
|
||||
hermle70.D.4=S_DP:000000036aea678d
|
||||
hermle70.D.9996=S_DP:0000000300000000
|
||||
hermle70.D.9997=T_ER:0118
|
||||
hermle70.D.10000=T_ER:0118
|
||||
hermle70.D.19996=T_ER:0118
|
||||
hermle70.IW.0=S_DP:000000020245
|
||||
hermle70.IW.1=T_ER:0118
|
||||
hermle70.IW.2=S_DP:0000000202e0
|
||||
hermle70.IW.998=S_DP:000000020000
|
||||
hermle70.IW.999=T_ER:0118
|
||||
hermle70.IW.1000=T_ER:0118
|
||||
hermle70.IW.1998=T_ER:0118
|
||||
hermle70.OW.0=S_DP:000000020000
|
||||
hermle70.OW.1=T_ER:0118
|
||||
hermle70.OW.2=S_DP:000000020000
|
||||
hermle70.OW.998=S_DP:000000020000
|
||||
hermle70.OW.999=T_ER:0118
|
||||
hermle70.OW.1000=T_ER:0118
|
||||
hermle70.OW.1998=T_ER:0118
|
||||
hermle70.M.0=S_DP:0000000b00
|
||||
hermle70.M.9999=S_DP:0000000b00
|
||||
hermle70.M.10000=T_ER:0118
|
||||
hermle70.I.0=S_DP:0000000b01
|
||||
hermle70.I.999=S_DP:0000000b00
|
||||
hermle70.I.1000=T_ER:0118
|
||||
hermle70.O.0=S_DP:0000000b00
|
||||
hermle70.O.999=S_DP:0000000b00
|
||||
hermle70.O.1000=T_ER:0118
|
||||
hermle70.K.0=S_DP:0000000300000000
|
||||
hermle70.K.143=S_DP:000000030000008f
|
||||
hermle70.K.144=S_DP:0000000300000090
|
||||
hermle70.T.0=S_DP:0000000b00
|
||||
hermle70.T.999=S_DP:0000000b00
|
||||
hermle70.T.1000=T_ER:0118
|
||||
hermle70.S.0=S_DP:000000084f4e2f4f464600
|
||||
hermle70.S.99=S_DP:0000000800
|
||||
hermle70.S.100=T_ER:0118
|
||||
hermle70.C.0=S_DP:0000000b00
|
||||
hermle70.C.1=S_DP:0000000b00
|
||||
hermle70.C.143=S_DP:0000000b00
|
||||
hermle70.C.144=T_ER:0118
|
||||
hermle29.version=69544e43353330003630363432302030332053503400694332325f5632302e315f3253503200253030303031313131303030303031313100
|
||||
hermle29.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff1000000000000000000000000efd00fa0000a57d03a900000000
|
||||
hermle29.B.0=S_DP:0000001002
|
||||
hermle29.B.1=S_DP:0000001000
|
||||
hermle29.B.9999=S_DP:0000001000
|
||||
hermle29.B.10000=T_ER:0118
|
||||
hermle29.B.19999=T_ER:0118
|
||||
hermle29.B.20000=T_ER:0118
|
||||
hermle29.W.0=S_DP:000000020002
|
||||
hermle29.W.1=T_ER:0118
|
||||
hermle29.W.2=S_DP:000000020000
|
||||
hermle29.W.9998=S_DP:000000020000
|
||||
hermle29.W.9999=T_ER:0118
|
||||
hermle29.W.10000=T_ER:0118
|
||||
hermle29.W.19998=T_ER:0118
|
||||
hermle29.D.0=S_DP:0000000300000002
|
||||
hermle29.D.1=T_ER:0118
|
||||
hermle29.D.2=T_ER:0118
|
||||
hermle29.D.3=T_ER:0118
|
||||
hermle29.D.4=S_DP:000000036ae91e99
|
||||
hermle29.D.9996=S_DP:0000000300000000
|
||||
hermle29.D.9997=T_ER:0118
|
||||
hermle29.D.10000=T_ER:0118
|
||||
hermle29.D.19996=T_ER:0118
|
||||
hermle29.IW.0=S_DP:00000002023f
|
||||
hermle29.IW.1=T_ER:0118
|
||||
hermle29.IW.2=S_DP:000000020307
|
||||
hermle29.IW.998=S_DP:000000020000
|
||||
hermle29.IW.999=T_ER:0118
|
||||
hermle29.IW.1000=T_ER:0118
|
||||
hermle29.IW.1998=T_ER:0118
|
||||
hermle29.OW.0=S_DP:000000020000
|
||||
hermle29.OW.1=T_ER:0118
|
||||
hermle29.OW.2=S_DP:000000020000
|
||||
hermle29.OW.998=S_DP:000000020000
|
||||
hermle29.OW.999=T_ER:0118
|
||||
hermle29.OW.1000=T_ER:0118
|
||||
hermle29.OW.1998=T_ER:0118
|
||||
hermle29.M.0=S_DP:0000000b00
|
||||
hermle29.M.9999=S_DP:0000000b00
|
||||
hermle29.M.10000=T_ER:0118
|
||||
hermle29.I.0=S_DP:0000000b01
|
||||
hermle29.I.999=S_DP:0000000b00
|
||||
hermle29.I.1000=T_ER:0118
|
||||
hermle29.O.0=S_DP:0000000b00
|
||||
hermle29.O.999=S_DP:0000000b00
|
||||
hermle29.O.1000=T_ER:0118
|
||||
hermle29.K.0=S_DP:0000000300000000
|
||||
hermle29.K.143=S_DP:000000030000008f
|
||||
hermle29.K.144=S_DP:0000000300000090
|
||||
hermle29.T.0=S_DP:0000000b00
|
||||
hermle29.T.999=S_DP:0000000b00
|
||||
hermle29.T.1000=T_ER:0118
|
||||
hermle29.S.0=S_DP:000000084f4e2f4f464600
|
||||
hermle29.S.99=S_DP:0000000800
|
||||
hermle29.S.100=T_ER:0118
|
||||
hermle29.C.0=S_DP:0000000b00
|
||||
hermle29.C.1=S_DP:0000000b00
|
||||
hermle29.C.143=S_DP:0000000b00
|
||||
hermle29.C.144=T_ER:0118
|
||||
@@ -0,0 +1,249 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>TagConfig grammar, catalog and options validation (reference <c>HeidenhainBindingsTest</c> / <c>HeidenhainConfigTest</c>).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainConfigTests
|
||||
{
|
||||
private static bool Accepts(string json) => HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out _, out _);
|
||||
|
||||
[Fact]
|
||||
public void Parses_both_binding_families_and_preserves_colons_in_data_paths()
|
||||
{
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", """{"address":"PLC:MARKER:32","dataType":"Boolean"}""", out var a, out _).ShouldBeTrue();
|
||||
a!.Address.Area.ShouldBe(HeidenhainPlcArea.Marker);
|
||||
a.Address.Index.ShouldBe(32);
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", """{"address":"DATA:/TABLE/TNC:TOOL/DOC","dataType":"String"}""", out var b, out _).ShouldBeTrue();
|
||||
b!.Address.DataPath.ShouldBe("/TABLE/TNC:TOOL/DOC");
|
||||
b.DataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":"Boolean"}""")] // area/type mismatch
|
||||
[InlineData("""{"address":"PLC:MARKER:0","dataType":"Int32"}""")]
|
||||
[InlineData("""{"address":"PLC:BYTE:-1","dataType":"Int16"}""")]
|
||||
[InlineData("""{"address":"PLC:DWORD:0","dataType":"Int16"}""")] // narrows native range
|
||||
[InlineData("""{"address":"PLC:INPUT_WORD:0","dataType":"Int16"}""")] // uint16 into int16
|
||||
[InlineData("""{"address":"PLC:STRING:0","dataType":"Int32"}""")]
|
||||
[InlineData("""{"address":"PLC:WORD:99999999999","dataType":"Int64"}""")] // > uint32
|
||||
[InlineData("""{"address":"PLC:WORD:4294967296","dataType":"Int64"}""")]
|
||||
[InlineData("""{"address":"PLC:WORDS:0","dataType":"Int64"}""")]
|
||||
[InlineData("""{"address":"PLC:WORD:0"}""")] // custom needs a dataType
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":"Float32"}""")]
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":"UInt16"}""")]
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":"8"}""")] // numerically-serialized enum
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":8}""")]
|
||||
[InlineData("""{"address":"DATA:/a\u0000b","dataType":"String"}""")]
|
||||
[InlineData("""{"address":"DATA:/€","dataType":"String"}""")]
|
||||
[InlineData("""{"address":"DATA:","dataType":"String"}""")]
|
||||
[InlineData("""{"address":"Status/Nope"}""")]
|
||||
[InlineData("""{"address":"status/executionstate"}""")]
|
||||
[InlineData("""{"address":"Status/ExecutionState","dataType":"String"}""")] // catalog type mismatch
|
||||
[InlineData("""{"address":"Axes/Names"}""")] // array leaf without isArray
|
||||
[InlineData("""{"address":"Status/ExecutionState","isArray":true}""")] // scalar marked array
|
||||
[InlineData("""{"address":"Status/ExecutionState","writable":true}""")] // read-only driver
|
||||
[InlineData("""{"address":42}""")]
|
||||
[InlineData("""[]""")]
|
||||
[InlineData("""not json""")]
|
||||
[InlineData("")]
|
||||
public void Rejects_unsafe_mismatched_or_malformed_blobs(string json)
|
||||
{
|
||||
Accepts(json).ShouldBeFalse(json);
|
||||
HeidenhainTagDefinitionFactory.Inspect(json).ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_is_never_stricter_than_the_runtime_parse()
|
||||
{
|
||||
foreach (var e in HeidenhainCatalog.Entries)
|
||||
{
|
||||
var json = TestTags.Config(e.Address);
|
||||
Accepts(json).ShouldBeTrue(json);
|
||||
HeidenhainTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
Accepts("""{"address":"PLC:INPUT_WORD:0","dataType":"Int32","isHistorized":true}""").ShouldBeTrue();
|
||||
Accepts("""{"address":"Status/ExecutionState","dataType":"int32"}""").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Catalog_is_general_purpose_read_only_and_without_oee_mapping()
|
||||
{
|
||||
HeidenhainCatalog.Entries.ShouldContain(e => e.Address == "Axes/Positions" && e.IsArray && e.DataType == DriverDataType.Float64);
|
||||
HeidenhainCatalog.Entries.ShouldContain(e => e.Address == "Status/ExecutionState" && e.DataType == DriverDataType.Int32);
|
||||
HeidenhainCatalog.Entries.ShouldNotContain(e => e.Address.Contains("RunState") || e.Address.Contains("GoodCount") || e.Address.Contains("IdealRate"));
|
||||
HeidenhainCatalog.Entries.Select(e => e.Address).ShouldBeUnique();
|
||||
HeidenhainCatalog.Entries.Where(e => e.IsCapability).ShouldAllBe(e => e.DataType == DriverDataType.String);
|
||||
HeidenhainCatalog.Entries.Count(e => e.Address.StartsWith("Timers/")).ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_have_reference_defaults()
|
||||
{
|
||||
var o = HeidenhainOptionsParser.Parse("{}");
|
||||
o.TimeoutMs.ShouldBe(5000);
|
||||
o.Polling.StatusMs.ShouldBe(1000);
|
||||
o.Polling.CustomMs.ShouldBe(1000);
|
||||
o.Polling.SlowMs.ShouldBe(10000);
|
||||
o.Polling.MaxAlarms.ShouldBe(64);
|
||||
o.Polling.TimersEnabled.ShouldBeFalse();
|
||||
o.Access.TelemetryEnabled.ShouldBeTrue();
|
||||
o.Access.DataEnabled.ShouldBeFalse();
|
||||
new HeidenhainDeviceOptions().Port.ShouldBe(19000);
|
||||
HeidenhainOptionsParser.Validate(o).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"TimeoutMs":99}""")]
|
||||
[InlineData("""{"TimeoutMs":60001}""")]
|
||||
[InlineData("""{"Polling":{"StatusMs":50}}""")]
|
||||
[InlineData("""{"Polling":{"SlowMs":3600001}}""")]
|
||||
[InlineData("""{"Polling":{"MaxAlarms":0}}""")]
|
||||
[InlineData("""{"Polling":{"MaxAlarms":257}}""")]
|
||||
[InlineData("""{"Reconnect":{"InitialDelayMs":5000,"MaxDelayMs":1000}}""")]
|
||||
[InlineData("""{"Access":{"InspectPassword":"hunter2"}}""")]
|
||||
[InlineData("""{"Access":{"DncPassword":"secret:"}}""")]
|
||||
[InlineData("""{"Devices":[{"Host":"h","DataPassword":"plain"}]}""")]
|
||||
public void Invalid_options_are_rejected_without_echoing_credentials(string json)
|
||||
{
|
||||
var errors = HeidenhainOptionsParser.Validate(HeidenhainOptionsParser.Parse(json));
|
||||
errors.ShouldNotBeEmpty();
|
||||
string.Join(" ", errors).ShouldNotContain("hunter2");
|
||||
string.Join(" ", errors).ShouldNotContain("plain'");
|
||||
Should.Throw<InvalidOperationException>(() => HeidenhainDriver.ParseOptions(json));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("secret:heidenhain/inspect")]
|
||||
[InlineData("secret:heidenhain/line3/cnc-7.inspect")]
|
||||
[InlineData("env:OTOPCUA_HEIDENHAIN_DNC_PASSWORD")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Namespaced_credential_references_are_accepted(string? value) =>
|
||||
HeidenhainOptionsParser.IsCredentialReference(value).ShouldBeTrue();
|
||||
|
||||
/// <summary>
|
||||
/// Review finding 2: an unscoped reference let a Designer exfiltrate any node secret by pointing Host at their
|
||||
/// own machine and clicking Test Connect (the value is sent in cleartext in A_LG).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb")]
|
||||
[InlineData("env:ServerHistorian__ApiKey")]
|
||||
[InlineData("env:HEIDENHAIN_PW")]
|
||||
[InlineData("env:otopcua_heidenhain_pw")]
|
||||
[InlineData("env:OTOPCUA_HEIDENHAIN_")]
|
||||
[InlineData("env:OTOPCUA_HEIDENHAIN_A-B")]
|
||||
[InlineData("secret:galaxy/inst1/apikey")]
|
||||
[InlineData("secret:opcua/inst/password")]
|
||||
[InlineData("secret:heidenhain/")]
|
||||
[InlineData("secret:heidenhain/../galaxy/apikey")]
|
||||
[InlineData("secret:heidenhain//x")]
|
||||
[InlineData("secret:HEIDENHAIN/x")]
|
||||
[InlineData("SECRET:heidenhain/x")]
|
||||
[InlineData("secret:")]
|
||||
[InlineData("hunter2")]
|
||||
public void References_outside_the_heidenhain_namespace_are_refused(string value)
|
||||
{
|
||||
HeidenhainOptionsParser.IsCredentialReference(value).ShouldBeFalse(value);
|
||||
HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions { Access = new() { InspectPassword = value } }).ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb")]
|
||||
[InlineData("secret:galaxy/inst1/apikey")]
|
||||
public async Task The_resolver_fails_closed_for_out_of_namespace_references_without_consulting_store_or_environment(string reference)
|
||||
{
|
||||
var resolver = new FakeResolver(new() { ["galaxy/inst1/apikey"] = "GALAXY-KEY" });
|
||||
var envReads = 0;
|
||||
var c = new HeidenhainCredentials(resolver, n => { envReads++; return "CONFIG-DB-CONNSTR"; });
|
||||
var ex = await Should.ThrowAsync<HeidenhainCredentialException>(() => c.ResolveReferenceAsync(reference, "INSPECT", TestContext.Current.CancellationToken).AsTask());
|
||||
ex.Message.ShouldNotContain("GALAXY-KEY");
|
||||
ex.Message.ShouldNotContain("CONFIG-DB-CONNSTR");
|
||||
envReads.ShouldBe(0);
|
||||
resolver.Reads.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_probe_never_sends_an_out_of_namespace_credential()
|
||||
{
|
||||
var sent = 0;
|
||||
await using var sim = Simulator.Lsv2Simulator.WithScript(async (conn, ct) => { while (await conn.ReadByteAsync(ct) >= 0) sent++; });
|
||||
var json = $$$"""{"Devices":[{"Host":"127.0.0.1","Port":{{{sim.Port}}},"DeviceName":"d"}],"Access":{"InspectPassword":"env:ConnectionStrings__ConfigDb"}}""";
|
||||
var result = await new HeidenhainDriverProbe(new FakeResolver(new())).ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
|
||||
result.Ok.ShouldBeFalse();
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
sim.Connections.ShouldBe(0);
|
||||
sent.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"Access":null,"Polling":null,"Reconnect":null,"Devices":null,"RawTags":null}""")]
|
||||
[InlineData("""{"Devices":[null],"RawTags":[null]}""")]
|
||||
public void Null_sections_normalise_to_defaults(string json)
|
||||
{
|
||||
var o = HeidenhainOptionsParser.Parse(json);
|
||||
o.Access.ShouldNotBeNull();
|
||||
o.Polling.MaxAlarms.ShouldBe(64);
|
||||
o.Reconnect.InitialDelayMs.ShouldBe(1000);
|
||||
o.Devices.ShouldBeEmpty();
|
||||
o.RawTags.ShouldBeEmpty();
|
||||
HeidenhainOptionsParser.Validate(o).ShouldBeEmpty();
|
||||
Should.NotThrow(() => HeidenhainDriver.ParseOptions(json));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Raw_config_scan_finds_literals_at_every_level_case_insensitively()
|
||||
{
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("""{"Host":"h","dncpassword":"hunter2"}""").Count.ShouldBe(1);
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("""{"access":{"InspectPassword":"hunter2"}}""").Count.ShouldBe(1);
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("""{"Devices":[{"DataPassword":"x"},{"DataPassword":7}]}""").Count.ShouldBe(2);
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("""{"Access":{"InspectPassword":"secret:heidenhain/a"}}""").ShouldBeEmpty();
|
||||
string.Join(" ", HeidenhainOptionsParser.DescribeCredentialProblems("""{"Access":{"InspectPassword":"hunter2"}}""")).ShouldNotContain("hunter2");
|
||||
HeidenhainOptionsParser.DescribeCredentialProblems("not json").ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("http://host")]
|
||||
[InlineData("host name")]
|
||||
[InlineData("a\\b")]
|
||||
public void Device_hosts_are_validated(string host) =>
|
||||
HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = host }).ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Device_port_is_validated() =>
|
||||
HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = "10.0.0.1", Port = 0 }).ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public async Task Credentials_resolve_from_secret_store_and_environment_and_fail_closed()
|
||||
{
|
||||
var resolver = new FakeResolver(new() { ["heidenhain/lsv2"] = "s3cret" });
|
||||
var env = new Dictionary<string, string> { ["OTOPCUA_HEIDENHAIN_PW"] = "envpw" };
|
||||
var c = new HeidenhainCredentials(resolver, n => env.GetValueOrDefault(n));
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
(await c.ResolveReferenceAsync("secret:heidenhain/lsv2", "INSPECT", ct)).ShouldBe("s3cret");
|
||||
(await c.ResolveReferenceAsync("env:OTOPCUA_HEIDENHAIN_PW", "DNC", ct)).ShouldBe("envpw");
|
||||
(await c.ResolveReferenceAsync(null, "DNC", ct)).ShouldBe("");
|
||||
await Should.ThrowAsync<HeidenhainCredentialException>(() => c.ResolveReferenceAsync("secret:heidenhain/missing", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<HeidenhainCredentialException>(() => c.ResolveReferenceAsync("env:OTOPCUA_HEIDENHAIN_UNSET", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<HeidenhainCredentialException>(() => c.ResolveReferenceAsync("literal", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<HeidenhainCredentialException>(() => new HeidenhainCredentials(null).ResolveReferenceAsync("secret:heidenhain/lsv2", "DNC", ct).AsTask());
|
||||
// device override wins over the driver-level reference
|
||||
var o = new HeidenhainDriverOptions { Access = new() { InspectPassword = "secret:heidenhain/missing" } };
|
||||
(await c.ResolveAsync(o, new HeidenhainDeviceOptions { Host = "h", InspectPassword = "secret:heidenhain/lsv2" }, "INSPECT", ct)).ShouldBe("s3cret");
|
||||
}
|
||||
|
||||
private sealed class FakeResolver(Dictionary<string, string> secrets) : ZB.MOM.WW.Secrets.Abstractions.ISecretResolver
|
||||
{
|
||||
public int Reads { get; private set; }
|
||||
|
||||
public Task<string?> GetAsync(ZB.MOM.WW.Secrets.Abstractions.SecretName name, CancellationToken ct)
|
||||
{
|
||||
Reads++;
|
||||
return Task.FromResult(secrets.TryGetValue(name.Value, out var v) ? v : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end driver behaviour against the in-process LSV2 simulator: cached reads, subscription fan-out,
|
||||
/// all-node invalidation on comms loss, recovery re-publish of IDENTICAL values, bounded reconnect,
|
||||
/// re-init teardown, browse-without-connect and the read-only boundary.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainDriverTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static SimulatedControl Itnc()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
c.Runtime[26] = () => SimulatedControl.U16(7);
|
||||
c.Runtime[25] = () => SimReply.Ok("S_RI", [0, 0, 0x27, 0x10, 0, 0, 0x1F, 0x40, 0, 0, 0, 0]);
|
||||
c.Runtime[27] = () => SimReply.Refuse(57);
|
||||
c.Memory = (_, n) => SimReply.Ok("S_MB", n == 4 ? [42, 0, 0, 0] : new byte[n]);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static HeidenhainDriverOptions Options(int port, params RawTagEntry[] tags) => new()
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" }],
|
||||
RawTags = tags,
|
||||
TimeoutMs = 500,
|
||||
Polling = new HeidenhainPollingOptions { StatusMs = 100, CustomMs = 100, SlowMs = 300 },
|
||||
Reconnect = new HeidenhainReconnectOptions { InitialDelayMs = 100, MaxDelayMs = 400 },
|
||||
};
|
||||
|
||||
private static RawTagEntry[] StandardTags() =>
|
||||
[
|
||||
TestTags.Raw("Status/ExecutionState"),
|
||||
TestTags.Raw("Overrides/Feed"),
|
||||
TestTags.Raw("Controller/Model"),
|
||||
TestTags.Raw("Alarms/Count"),
|
||||
TestTags.Raw("PLC:DWORD:0", "Int64"),
|
||||
TestTags.Raw("Capabilities/Identity"),
|
||||
];
|
||||
|
||||
private sealed class Recorder
|
||||
{
|
||||
public ConcurrentQueue<(string Ref, DataValueSnapshot Snapshot)> Events { get; } = new();
|
||||
public void Attach(HeidenhainDriver d) => d.OnDataChange += (_, e) => Events.Enqueue((e.FullReference, e.Snapshot));
|
||||
public IReadOnlyList<DataValueSnapshot> For(string rawPath) => Events.Where(e => e.Ref == rawPath).Select(e => e.Snapshot).ToList();
|
||||
public DataValueSnapshot? Last(string rawPath) => For(rawPath).LastOrDefault();
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
|
||||
await Task.Delay(20, Ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static HeidenhainDriver Create(HeidenhainDriverOptions o) =>
|
||||
new(o, "hh-1", null, new HeidenhainCredentials(null), null, null);
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_and_discovery_never_contact_the_control()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
await using var d = Create(Options(sim.Port, StandardTags()));
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
var builder = new CapturingBuilder();
|
||||
await d.DiscoverAsync(builder, Ct);
|
||||
await Task.Delay(200, Ct);
|
||||
sim.Connections.ShouldBe(0);
|
||||
builder.Variables.Count.ShouldBe(HeidenhainCatalog.Entries.Count);
|
||||
builder.Variables.ShouldContain(v => v.FullName == "Axes/Positions" && v.IsArray && v.DriverDataType == DriverDataType.Float64);
|
||||
builder.Variables.ShouldAllBe(v => v.SecurityClass == SecurityClassification.ViewOnly);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_pushes_initial_state_then_cached_good_values_and_reads_never_touch_the_wire()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tags = StandardTags();
|
||||
await using var d = Create(Options(sim.Port, tags));
|
||||
var rec = new Recorder();
|
||||
rec.Attach(d);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromMilliseconds(100), Ct);
|
||||
rec.For(tags[0].RawPath)[0].StatusCode.ShouldBe(HeidenhainStatus.BadWaitingForInitialData);
|
||||
await WaitUntil(() => rec.Last(tags[3].RawPath)?.StatusCode == HeidenhainStatus.Good
|
||||
&& rec.Last(tags[4].RawPath)?.StatusCode == HeidenhainStatus.Good);
|
||||
rec.Last(tags[0].RawPath)!.Value.ShouldBe(4);
|
||||
rec.Last(tags[1].RawPath)!.Value.ShouldBe(100.0);
|
||||
rec.Last(tags[2].RawPath)!.Value.ShouldBe("iTNC 530");
|
||||
rec.Last(tags[3].RawPath)!.Value.ShouldBe(0);
|
||||
rec.Last(tags[4].RawPath)!.Value.ShouldBe(42L);
|
||||
rec.Last(tags[5].RawPath)!.Value.ShouldBe("Supported");
|
||||
|
||||
var before = sim.Requests.Count;
|
||||
var read = await d.ReadAsync([tags[0].RawPath, "Plant/H/dev1/nope"], Ct);
|
||||
read[0].Value.ShouldBe(4);
|
||||
read[1].StatusCode.ShouldBe(HeidenhainStatus.BadNodeIdUnknown);
|
||||
sim.Requests.Count.ShouldBeLessThanOrEqualTo(before + 3); // the background loop may tick; the read itself is cache-only
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
d.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
control.Violations.ShouldBeEmpty();
|
||||
sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_values_are_not_republished()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tag = TestTags.Raw("Status/ExecutionState");
|
||||
await using var d = Create(Options(sim.Port, tag));
|
||||
var rec = new Recorder();
|
||||
rec.Attach(d);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync([tag.RawPath], TimeSpan.FromMilliseconds(100), Ct);
|
||||
await WaitUntil(() => sim.Requests.Count(r => r.Command == "R_RI") >= 5);
|
||||
rec.For(tag.RawPath).Count.ShouldBe(2); // initial BadWaitingForInitialData + one Good
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Comms_loss_invalidates_every_remote_value_and_recovery_republishes_identical_values()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tags = StandardTags();
|
||||
await using var d = Create(Options(sim.Port, tags));
|
||||
var rec = new Recorder();
|
||||
rec.Attach(d);
|
||||
var hostEvents = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
d.OnHostStatusChanged += (_, e) => hostEvents.Enqueue(e);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromMilliseconds(100), Ct);
|
||||
await WaitUntil(() => tags.All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.Good));
|
||||
var lastGood = d.GetHealth().LastSuccessfulRead;
|
||||
|
||||
sim.GoOffline();
|
||||
// EVERY remote leaf — fast status, slow identity/alarms and custom PLC — goes Bad at once.
|
||||
await WaitUntil(() => tags.Take(5).All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.BadNoCommunication));
|
||||
rec.Last(tags[5].RawPath)!.Value.ShouldBe("CommunicationLost");
|
||||
rec.Last(tags[5].RawPath)!.StatusCode.ShouldBe(HeidenhainStatus.Good);
|
||||
(await d.ReadAsync([tags[2].RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
await WaitUntil(() => d.GetHealth().State == DriverState.Degraded);
|
||||
d.GetHealth().LastError.ShouldNotBeNullOrEmpty();
|
||||
d.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
hostEvents.ShouldContain(e => e.NewState == HostState.Stopped);
|
||||
var frozen = d.GetHealth().LastSuccessfulRead;
|
||||
await Task.Delay(500, Ct);
|
||||
d.GetHealth().LastSuccessfulRead.ShouldBe(frozen); // frozen while down — the comms-loss watchdog's evidence
|
||||
(frozen <= lastGood!.Value.AddSeconds(1)).ShouldBeTrue();
|
||||
|
||||
sim.GoOnline();
|
||||
// The control comes back holding the SAME readings: the (value, status) dedup must still re-publish.
|
||||
await WaitUntil(() => tags.All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.Good), 8000);
|
||||
foreach (var t in tags.Take(5))
|
||||
{
|
||||
var seq = rec.For(t.RawPath).Select(s => s.StatusCode).ToList();
|
||||
seq.ShouldContain(HeidenhainStatus.BadNoCommunication);
|
||||
seq[^1].ShouldBe(HeidenhainStatus.Good);
|
||||
}
|
||||
|
||||
rec.Last(tags[0].RawPath)!.Value.ShouldBe(4);
|
||||
rec.Last(tags[4].RawPath)!.Value.ShouldBe(42L);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
hostEvents.Last().NewState.ShouldBe(HostState.Running);
|
||||
control.Violations.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconnect_is_bounded_exponential_backoff()
|
||||
{
|
||||
int port;
|
||||
await using (var probe = Lsv2Simulator.WithScript((_, _) => Task.CompletedTask)) port = probe.Port;
|
||||
var tag = TestTags.Raw("Status/ExecutionState");
|
||||
var o = Options(port, tag);
|
||||
var attempts = new ConcurrentQueue<DateTime>();
|
||||
var d = new HeidenhainDriver(o, "hh-1", null, new HeidenhainCredentials(null),
|
||||
(h, p, t) => { attempts.Enqueue(DateTime.UtcNow); return new Lsv2.Lsv2Client(h, p, t); }, null);
|
||||
await using (d)
|
||||
{
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync([tag.RawPath], TimeSpan.FromSeconds(1), Ct);
|
||||
await Task.Delay(2000, Ct);
|
||||
var a = attempts.ToArray();
|
||||
a.Length.ShouldBeInRange(3, 8); // 100, 200, 400, 400, … ms — never a hot loop
|
||||
for (var i = 2; i < a.Length; i++)
|
||||
(a[i] - a[i - 1]).TotalMilliseconds.ShouldBeGreaterThan(150);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
(await d.ReadAsync([tag.RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refusals_are_per_tag_quality_and_do_not_degrade_driver_health()
|
||||
{
|
||||
var control = Itnc();
|
||||
control.Runtime[25] = () => SimReply.Refuse(23);
|
||||
control.Login = (role, _) => role == "DNC" ? SimReply.Refuse(23) : SimReply.Ok("T_OK", []);
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tags = new[] { TestTags.Raw("Status/ExecutionState"), TestTags.Raw("Overrides/Feed"), TestTags.Raw("PLC:WORD:1", "Int32") };
|
||||
control.Memory = null; // R_MB denied, as on every tested iTNC 530
|
||||
await using var d = Create(Options(sim.Port, tags));
|
||||
var rec = new Recorder();
|
||||
rec.Attach(d);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromMilliseconds(100), Ct);
|
||||
await WaitUntil(() => rec.Last(tags[2].RawPath)?.StatusCode == HeidenhainStatus.BadUserAccessDenied);
|
||||
rec.Last(tags[0].RawPath)!.Value.ShouldBe(4);
|
||||
rec.Last(tags[1].RawPath)!.StatusCode.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
d.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
d.GetDiagnostics().Values.ShouldContain("Partial");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_tears_down_the_previous_session_before_reconnecting()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tag = TestTags.Raw("Status/ExecutionState");
|
||||
var json = JsonSerializer.Serialize(Options(sim.Port, tag));
|
||||
await using var d = HeidenhainDriverFactoryExtensions.CreateInstance("hh-1", json);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await d.InitializeAsync(json, Ct);
|
||||
await d.SubscribeAsync([tag.RawPath], TimeSpan.FromMilliseconds(100), Ct);
|
||||
await WaitUntil(() => sim.Connections == i + 1);
|
||||
}
|
||||
|
||||
await d.ShutdownAsync(Ct);
|
||||
await Task.Delay(200, Ct);
|
||||
var after = sim.Requests.Count;
|
||||
await Task.Delay(300, Ct);
|
||||
sim.Requests.Count.ShouldBe(after); // nothing polls after shutdown
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bad_tags_and_unknown_devices_are_skipped_and_reported()
|
||||
{
|
||||
var o = Options(19000,
|
||||
TestTags.Raw("Status/ExecutionState"),
|
||||
new RawTagEntry("Plant/H/dev1/bad", """{"address":"PLC:WORD:0","dataType":"Boolean"}""", false, "dev1"),
|
||||
TestTags.Raw("Status/ProgramState", device: "other"));
|
||||
o = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [o.Devices[0], new HeidenhainDeviceOptions { Host = "127.0.0.2", DeviceName = "dev2" }],
|
||||
RawTags = o.RawTags,
|
||||
};
|
||||
await using var d = Create(o);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
var report = d.GetSkippedTags()!;
|
||||
report.AuthoredCount.ShouldBe(3);
|
||||
report.SkippedCount.ShouldBe(2);
|
||||
(await d.ReadAsync(["Plant/H/dev1/bad"], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNodeIdUnknown);
|
||||
d.ResolveHost("Plant/H/dev1/Status/ExecutionState").ShouldBe("127.0.0.1:19000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Factory_registers_under_the_canonical_type_and_rejects_literal_passwords()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
HeidenhainDriverFactoryExtensions.Register(registry);
|
||||
var json = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"d"}],"Access":{"InspectPassword":"secret:heidenhain/inspect"}}""";
|
||||
var driver = registry.TryGet(DriverTypeNames.Heidenhain)!("hh", json);
|
||||
driver.ShouldBeOfType<HeidenhainDriver>();
|
||||
driver.DriverType.ShouldBe(DriverTypeNames.Heidenhain);
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
HeidenhainDriverFactoryExtensions.CreateInstance("hh", """{"Access":{"InspectPassword":"plain"}}"""));
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class CapturingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<DriverAttributeInfo> Variables { get; } = [];
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add(attributeInfo);
|
||||
return new Handle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullReference) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullReference;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Real-controller commissioning gate — env-gated, skipped unless <c>HEIDENHAIN_LSV2_HOST</c> is set. READ-ONLY:
|
||||
/// it issues only the allowlisted INSPECT login, version, layout and runtime-info reads that the driver itself
|
||||
/// uses (every frame goes through <see cref="Lsv2ReadOnlyPolicy"/>). Run with
|
||||
/// <c>dotnet test --filter Category=LiveIntegration</c>; record the output as .NET commissioning evidence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Env: <c>HEIDENHAIN_LSV2_HOST</c> (required), <c>HEIDENHAIN_LSV2_PORT</c> (default 19000),
|
||||
/// <c>OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD</c> (optional; never logged).
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public sealed class HeidenhainLiveIntegrationTests
|
||||
{
|
||||
private static string? Host => Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_HOST");
|
||||
|
||||
private static int Port => int.TryParse(Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_PORT"), out var p) ? p : 19000;
|
||||
|
||||
[Fact]
|
||||
public async Task Real_control_inspect_identity_layout_and_status()
|
||||
{
|
||||
Assert.SkipWhen(string.IsNullOrWhiteSpace(Host), "HEIDENHAIN_LSV2_HOST not set — real-controller gate skipped.");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var client = new Lsv2Client(Host!, Port, TimeSpan.FromSeconds(5));
|
||||
await client.OpenAsync(ct);
|
||||
await client.LoginAsync("INSPECT", Environment.GetEnvironmentVariable("OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD") ?? "", ct);
|
||||
var versions = Lsv2Data.Versions(await client.ReadVersionAsync(ct));
|
||||
TestContext.Current.SendDiagnosticMessage($"model={versions.Model} nc={versions.Nc} plc={versions.Plc}");
|
||||
versions.Model.ShouldNotBeNullOrWhiteSpace();
|
||||
foreach (var selector in new[] { 23, 26, 24, 25, 22, 51 })
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = await client.ReadRuntimeAsync(selector, ct);
|
||||
TestContext.Current.SendDiagnosticMessage($"R_RI {selector}: {bytes.Length} bytes");
|
||||
}
|
||||
catch (Lsv2RefusalException ex)
|
||||
{
|
||||
TestContext.Current.SendDiagnosticMessage($"R_RI {selector}: refused code {ex.Code} -> {HeidenhainStatus.State(HeidenhainStatus.FromRefusal(ex.Code))}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var layout = Lsv2Data.Layout(await client.ReadParametersAsync(ct));
|
||||
TestContext.Current.SendDiagnosticMessage($"R_PR WORD region base={layout.Region(HeidenhainPlcArea.Word)!.Base} bytes={layout.Region(HeidenhainPlcArea.Word)!.Bytes}");
|
||||
}
|
||||
catch (Lsv2RefusalException ex)
|
||||
{
|
||||
TestContext.Current.SendDiagnosticMessage($"R_PR refused code {ex.Code}");
|
||||
}
|
||||
|
||||
client.IsClosed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_control_driver_end_to_end_status_poll()
|
||||
{
|
||||
Assert.SkipWhen(string.IsNullOrWhiteSpace(Host), "HEIDENHAIN_LSV2_HOST not set — real-controller gate skipped.");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var tags = new[] { TestTags.Raw("Controller/Model"), TestTags.Raw("Status/ExecutionState"), TestTags.Raw("Capabilities/Execution") };
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = Host!, Port = Port, DeviceName = "dev1" }],
|
||||
RawTags = tags,
|
||||
Access = new HeidenhainAccessOptions { InspectPassword = Environment.GetEnvironmentVariable("OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD") is { Length: > 0 } ? "env:OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD" : null },
|
||||
};
|
||||
await using var driver = new HeidenhainDriver(options, "live");
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
await driver.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromSeconds(1), ct);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||
IReadOnlyList<Core.Abstractions.DataValueSnapshot> values;
|
||||
do
|
||||
{
|
||||
await Task.Delay(500, ct);
|
||||
values = await driver.ReadAsync(tags.Select(t => t.RawPath).ToList(), ct);
|
||||
} while (values[0].StatusCode != HeidenhainStatus.Good && DateTime.UtcNow < deadline);
|
||||
|
||||
for (var i = 0; i < tags.Length; i++)
|
||||
TestContext.Current.SendDiagnosticMessage($"{tags[i].RawPath} = {values[i].Value} (0x{values[i].StatusCode:X8})");
|
||||
values[0].StatusCode.ShouldBe(HeidenhainStatus.Good);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Replays the actual DATA boundary replies captured on three iTNC 530 controls (reference
|
||||
/// <c>HeidenhainPlcUnitsTest</c>): R_PR numeric counts are BYTE capacities, binding indexes are ELEMENT
|
||||
/// indexes, and the DATA Boolean FF encoding is accepted.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainPlcUnitsTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle59")]
|
||||
[InlineData("hermle70")]
|
||||
[InlineData("hermle29")]
|
||||
public async Task Real_boundaries_match_region_byte_counts_and_element_widths(string machine)
|
||||
{
|
||||
var f = Fixtures.Load("plc-count-units.properties");
|
||||
var control = new SimulatedControl
|
||||
{
|
||||
Version = Convert.FromHexString(f[machine + ".version"]),
|
||||
Layout = Convert.FromHexString(f[machine + ".layout"]),
|
||||
};
|
||||
foreach (var (key, value) in f)
|
||||
{
|
||||
if (!key.StartsWith(machine + ".", StringComparison.Ordinal) || key.EndsWith(".version", StringComparison.Ordinal) || key.EndsWith(".layout", StringComparison.Ordinal)) continue;
|
||||
var parts = key.Split('.');
|
||||
control.DataPaths[$"/PLC/memory/{parts[1]}/{parts[2]}"] = SimReply.FromFixture(value);
|
||||
}
|
||||
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
await using var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
await client.OpenAsync(Ct);
|
||||
await client.LoginAsync("INSPECT", "", Ct);
|
||||
await client.LoginAsync("DATA", "", Ct);
|
||||
var layout = Lsv2Data.Layout(await client.ReadParametersAsync(Ct));
|
||||
|
||||
foreach (var (token, area) in new[] { ("B", HeidenhainPlcArea.Byte), ("W", HeidenhainPlcArea.Word), ("D", HeidenhainPlcArea.Dword), ("IW", HeidenhainPlcArea.InputWord), ("OW", HeidenhainPlcArea.OutputWord) })
|
||||
{
|
||||
var region = layout.Region(area)!;
|
||||
long expectedBytes = token.Length == 1 ? 10000 : 1000;
|
||||
region.Bytes.ShouldBe(expectedBytes, $"{machine} {area}");
|
||||
var lastOffset = expectedBytes - region.Width;
|
||||
Lsv2Data.DataPath(await client.ReadDataPathAsync($"/PLC/memory/{token}/{lastOffset}", Ct)).ShouldNotBeNull();
|
||||
(await Should.ThrowAsync<Lsv2RefusalException>(() => client.ReadDataPathAsync($"/PLC/memory/{token}/{expectedBytes}", Ct))).Code.ShouldBe(24);
|
||||
HeidenhainBindingCodec.Range(area, lastOffset / region.Width, layout).Address.ShouldBe(region.Base + lastOffset);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(area, lastOffset / region.Width + 1, layout));
|
||||
}
|
||||
|
||||
foreach (var token in new[] { "W", "D", "IW", "OW" })
|
||||
(await Should.ThrowAsync<Lsv2RefusalException>(() => client.ReadDataPathAsync($"/PLC/memory/{token}/1", Ct))).Code.ShouldBe(24);
|
||||
|
||||
foreach (var (token, area, entries) in new[]
|
||||
{
|
||||
("M", HeidenhainPlcArea.Marker, 10000), ("I", HeidenhainPlcArea.Input, 1000), ("O", HeidenhainPlcArea.Output, 1000),
|
||||
("C", HeidenhainPlcArea.Counter, 144), ("T", HeidenhainPlcArea.Timer, 1000), ("S", HeidenhainPlcArea.String, 100),
|
||||
})
|
||||
{
|
||||
var region = layout.Region(area)!;
|
||||
region.Bytes.ShouldBe((long)entries * region.Width);
|
||||
Lsv2Data.DataPath(await client.ReadDataPathAsync($"/PLC/memory/{token}/{entries - 1}", Ct)).ShouldNotBeNull();
|
||||
(await Should.ThrowAsync<Lsv2RefusalException>(() => client.ReadDataPathAsync($"/PLC/memory/{token}/{entries}", Ct))).Code.ShouldBe(24);
|
||||
}
|
||||
|
||||
// Observed FF-true on a real output slot.
|
||||
if (f.TryGetValue(machine + ".O.999", out var o999) && o999.EndsWith("ff", StringComparison.OrdinalIgnoreCase))
|
||||
Lsv2Data.DataPath(await client.ReadDataPathAsync("/PLC/memory/O/999", Ct)).ShouldBe(true);
|
||||
control.Violations.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>Regression tests for the independent-review findings 3–7 on feat/803 (each fails on the pre-fix code).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainReviewFixTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static async Task<(Lsv2Simulator Sim, HeidenhainSession Session)> OpenAsync(
|
||||
SimulatedControl control, HeidenhainSessionSettings settings, HeidenhainSession.PasswordSource passwords)
|
||||
{
|
||||
var sim = Lsv2Simulator.WithControl(control);
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
var session = new HeidenhainSession(client, settings, passwords);
|
||||
await client.OpenAsync(Ct);
|
||||
await session.InitializeAsync(Ct);
|
||||
return (sim, session);
|
||||
}
|
||||
|
||||
// ---- Finding 6: an unusable OPTIONAL-role credential degrades only the reads that need that role. ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("throw")] // unresolvable reference (unset variable / missing secret)
|
||||
[InlineData("nul")] // a NUL the login payload cannot carry
|
||||
[InlineData("non-latin1")] // € is not Latin-1
|
||||
[InlineData("too-long")] // A_LG payload > 247 bytes
|
||||
public async Task Bad_dnc_credential_keeps_inspect_telemetry_and_sends_no_dnc_login(string kind)
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
|
||||
var (sim, session) = await OpenAsync(control, settings, (role, _) => role == "DNC" ? BadPassword(kind) : ValueTask.FromResult(""));
|
||||
await using var _ = sim;
|
||||
await using var __ = session;
|
||||
var tags = new[] { TestTags.Def("Status/ExecutionState"), TestTags.Def("Capabilities/DncLogin") };
|
||||
var s1 = await session.ReadBatchAsync(tags, Ct);
|
||||
var s2 = await session.ReadBatchAsync(tags, Ct);
|
||||
s1[0].Value.ShouldBe(4);
|
||||
s2[0].Value.ShouldBe(4);
|
||||
s1[1].Value.ShouldBe("ConfigurationError");
|
||||
session.CommunicationLost.ShouldBeFalse();
|
||||
control.Logins.Select(l => l.Role).ShouldBe(new[] { "INSPECT" });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("throw")]
|
||||
[InlineData("nul")]
|
||||
[InlineData("too-long")]
|
||||
public async Task Bad_data_credential_is_BadConfigurationError_on_data_bindings_only(string kind)
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
control.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
|
||||
var settings = new HeidenhainSessionSettings(true, false, true, 64, false, false, true);
|
||||
var (sim, session) = await OpenAsync(control, settings, (role, _) => role == "DATA" ? BadPassword(kind) : ValueTask.FromResult(""));
|
||||
await using var _ = sim;
|
||||
await using var __ = session;
|
||||
var tags = new[] { TestTags.Def("DATA:/x", "Int64"), TestTags.Def("Status/ExecutionState"), TestTags.Def("Capabilities/DataLogin") };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var s = await session.ReadBatchAsync(tags, Ct);
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.BadConfigurationError);
|
||||
s[1].Value.ShouldBe(4);
|
||||
s[2].Value.ShouldBe("ConfigurationError");
|
||||
}
|
||||
|
||||
session.CommunicationLost.ShouldBeFalse();
|
||||
sim.Requests.ShouldNotContain(r => r.Command == "R_DP");
|
||||
control.Logins.ShouldNotContain(l => l.Role == "DATA");
|
||||
}
|
||||
|
||||
private static ValueTask<string> BadPassword(string kind) => kind switch
|
||||
{
|
||||
"throw" => throw new HeidenhainCredentialException("DATA password environment variable 'OTOPCUA_HEIDENHAIN_X' is not set (fail-closed)."),
|
||||
"nul" => ValueTask.FromResult("a\0b"),
|
||||
"non-latin1" => ValueTask.FromResult("p€ss"),
|
||||
_ => ValueTask.FromResult(new string('x', 250)),
|
||||
};
|
||||
|
||||
// ---- Finding 3: Start after DisposeAsync must not revive an orphan loop. ----
|
||||
|
||||
[Fact]
|
||||
public async Task Start_after_dispose_is_a_no_op()
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var runtime = NewRuntime(sim.Port, (_, _) => { });
|
||||
await runtime.DisposeAsync();
|
||||
runtime.Start();
|
||||
await Task.Delay(400, Ct);
|
||||
sim.Connections.ShouldBe(0);
|
||||
runtime.State.ShouldBe(HeidenhainConnectionState.Stopped);
|
||||
}
|
||||
|
||||
// ---- Finding 5: null sections must not NRE, and nothing may kill the loop silently. ----
|
||||
|
||||
[Fact]
|
||||
public async Task Null_option_sections_from_a_constructed_options_object_do_not_kill_the_loop()
|
||||
{
|
||||
int port;
|
||||
await using (var probe = Lsv2Simulator.WithScript((_, _) => Task.CompletedTask)) port = probe.Port;
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" }],
|
||||
RawTags = [TestTags.Raw("Status/ExecutionState")],
|
||||
Access = null!,
|
||||
Polling = null!,
|
||||
Reconnect = null!,
|
||||
TimeoutMs = 300,
|
||||
};
|
||||
var attempts = new ConcurrentQueue<DateTime>();
|
||||
await using var d = new HeidenhainDriver(options, "hh", null, new HeidenhainCredentials(null),
|
||||
(h, p, t) => { attempts.Enqueue(DateTime.UtcNow); return new Lsv2Client(h, p, t); }, null);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync([TestTags.Raw("Status/ExecutionState").RawPath], TimeSpan.FromSeconds(1), Ct);
|
||||
await Task.Delay(3500, Ct);
|
||||
attempts.Count.ShouldBeGreaterThanOrEqualTo(2); // the loop survived the first failure and retried
|
||||
d.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_option_sections_normalise_so_the_control_is_actually_read()
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tag = TestTags.Raw("Status/ExecutionState");
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = sim.Port, DeviceName = "dev1" }],
|
||||
RawTags = [tag],
|
||||
Access = null!,
|
||||
Polling = null!,
|
||||
Reconnect = null!,
|
||||
};
|
||||
await using var d = new HeidenhainDriver(options, "hh", null, new HeidenhainCredentials(null), null, null);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync([tag.RawPath], TimeSpan.FromSeconds(1), Ct);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while ((await d.ReadAsync([tag.RawPath], Ct))[0].StatusCode != HeidenhainStatus.Good && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(50, Ct);
|
||||
(await d.ReadAsync([tag.RawPath], Ct))[0].Value.ShouldBe(4);
|
||||
}
|
||||
|
||||
// ---- Finding 7: backoff resets only after real progress (a published poll batch). ----
|
||||
|
||||
[Fact]
|
||||
public async Task A_control_that_accepts_login_then_drops_is_backed_off_not_hammered()
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = SimReply.Drop; // login + version succeed, the first poll read drops
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var runtime = NewRuntime(sim.Port, (_, _) => { });
|
||||
await using var _ = runtime;
|
||||
runtime.Start();
|
||||
await Task.Delay(2000, Ct);
|
||||
// initial 100 ms doubling to 800 ms: ~100+200+400+800 ⇒ ≤ 6 connects in 2 s; pre-fix it reset to
|
||||
// 100 ms after every login and dialled ~15 times.
|
||||
sim.Connections.ShouldBeInRange(2, 6);
|
||||
runtime.ConsecutiveFailures.ShouldBeGreaterThanOrEqualTo(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backoff_resets_after_a_session_that_published()
|
||||
{
|
||||
var reads = 0;
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => Interlocked.Increment(ref reads) % 3 == 0 ? SimReply.Drop() : SimulatedControl.U16(4);
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var runtime = NewRuntime(sim.Port, (_, _) => { });
|
||||
await using var _ = runtime;
|
||||
runtime.Start();
|
||||
await Task.Delay(1500, Ct);
|
||||
runtime.ConsecutiveFailures.ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
// ---- Finding 4: the initial subscription push can never deliver an older value after a newer one. ----
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_push_and_publish_are_ordered()
|
||||
{
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = 1, DeviceName = "dev1" }],
|
||||
RawTags = [TestTags.Raw("Status/ExecutionState")],
|
||||
};
|
||||
await using var d = new HeidenhainDriver(options, "hh", null, new HeidenhainCredentials(null), null, null);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
var raw = options.RawTags[0].RawPath;
|
||||
var perSub = new ConcurrentDictionary<ISubscriptionHandle, List<int>>();
|
||||
d.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.Snapshot.Value is int v) perSub.GetOrAdd(e.SubscriptionHandle, _ => []).Add(v);
|
||||
};
|
||||
using var stop = new CancellationTokenSource();
|
||||
var publisher = Task.Run(() =>
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
for (var i = 1; i < 20_000 && !stop.IsCancellationRequested; i++)
|
||||
d.Publish(raw, new DataValueSnapshot(i, HeidenhainStatus.Good, now, now));
|
||||
}, Ct);
|
||||
for (var i = 0; i < 200; i++)
|
||||
await d.SubscribeAsync([raw], TimeSpan.FromSeconds(1), Ct);
|
||||
await publisher;
|
||||
foreach (var (_, values) in perSub)
|
||||
{
|
||||
List<int> copy;
|
||||
lock (values) copy = values.ToList();
|
||||
for (var i = 1; i < copy.Count; i++)
|
||||
copy[i].ShouldBeGreaterThan(copy[i - 1], "a subscriber saw an older value after a newer one");
|
||||
}
|
||||
}
|
||||
|
||||
private static HeidenhainDeviceRuntime NewRuntime(int port, Action<string, DataValueSnapshot> publish)
|
||||
{
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
TimeoutMs = 500,
|
||||
Polling = new HeidenhainPollingOptions { StatusMs = 100, CustomMs = 100, SlowMs = 100 },
|
||||
Reconnect = new HeidenhainReconnectOptions { InitialDelayMs = 100, MaxDelayMs = 800 },
|
||||
};
|
||||
var device = new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" };
|
||||
return new HeidenhainDeviceRuntime(device, options, [TestTags.Def("Status/ExecutionState")],
|
||||
(_, _) => ValueTask.FromResult(""), publish, (_, _, _) => { },
|
||||
(h, p, t) => new Lsv2Client(h, p, t), TimeProvider.System, NullLogger.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>Session read-policy regression ported from the reference <c>HeidenhainSessionTest</c>.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainSessionTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private sealed class Harness : IAsyncDisposable
|
||||
{
|
||||
public required Lsv2Simulator Sim { get; init; }
|
||||
public required SimulatedControl Control { get; init; }
|
||||
public required HeidenhainSession Session { get; init; }
|
||||
public required IReadOnlyList<HeidenhainTagDefinition> Tags { get; init; }
|
||||
|
||||
public async Task<Dictionary<string, HeidenhainSample>> ReadAsync(params string[] addresses)
|
||||
{
|
||||
var tags = addresses.Select(a => Tags.First(t => t.Address.Text == a || t.RawPath.EndsWith("/" + a, StringComparison.Ordinal))).ToList();
|
||||
var samples = await Session.ReadBatchAsync(tags, Ct);
|
||||
var map = new Dictionary<string, HeidenhainSample>();
|
||||
for (var i = 0; i < tags.Count; i++) map[addresses[i]] = samples[i];
|
||||
return map;
|
||||
}
|
||||
|
||||
public int RuntimeRequests(int? selector = null) =>
|
||||
Sim.Requests.Count(r => r.Command == "R_RI" && (selector is null || r.Selector == selector));
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Session.DisposeAsync();
|
||||
Control.Violations.ShouldBeEmpty();
|
||||
await Sim.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Harness> OpenAsync(
|
||||
SimulatedControl control, IReadOnlyList<HeidenhainTagDefinition> tags,
|
||||
bool telemetry = true, bool dnc = true, bool data = false, int maxAlarms = 64, bool timers = false,
|
||||
HeidenhainSession.PasswordSource? passwords = null)
|
||||
{
|
||||
var sim = Lsv2Simulator.WithControl(control);
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
var settings = new HeidenhainSessionSettings(telemetry, dnc, data, maxAlarms, timers,
|
||||
tags.Any(t => t.Address.Kind == HeidenhainAddressKind.Plc), tags.Any(t => t.Address.Kind == HeidenhainAddressKind.DataPath));
|
||||
var session = new HeidenhainSession(client, settings, passwords ?? ((_, _) => ValueTask.FromResult("")));
|
||||
await client.OpenAsync(Ct);
|
||||
await session.InitializeAsync(Ct);
|
||||
return new Harness { Sim = sim, Control = control, Session = session, Tags = tags };
|
||||
}
|
||||
|
||||
private static List<HeidenhainTagDefinition> Standard(params string[] addresses) => addresses.Select(a => TestTags.Def(a)).ToList();
|
||||
|
||||
private static SimReply DenyDnc(string role, string _) => role == "DNC" ? SimReply.Refuse(23) : SimReply.Ok("T_OK", []);
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_shares_reads_and_preserves_unknown_native_state()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
var program = new byte[8];
|
||||
BinaryPrimitives.WriteInt32BigEndian(program, 5);
|
||||
"A\0B\0"u8.ToArray().CopyTo(program, 4);
|
||||
c.Runtime[24] = () => SimReply.Ok("S_RI", program);
|
||||
c.Runtime[25] = () => SimReply.Ok("S_RI", new byte[12]);
|
||||
c.Runtime[23] = () => SimReply.Ok("S_RI", [1, 44]);
|
||||
await using var h = await OpenAsync(c, Standard("Program/Main", "Program/Current", "Program/Line", "Overrides/Feed", "Overrides/Spindle", "Status/ExecutionState"));
|
||||
var v = await h.ReadAsync("Program/Main", "Program/Current", "Program/Line", "Overrides/Feed", "Overrides/Spindle", "Status/ExecutionState");
|
||||
v.Values.ShouldAllBe(s => s.Status == HeidenhainStatus.Good);
|
||||
h.RuntimeRequests(24).ShouldBe(1);
|
||||
h.RuntimeRequests(25).ShouldBe(1);
|
||||
v["Status/ExecutionState"].Value.ShouldBe(300); // raw enum preserved, not mapped
|
||||
v["Program/Line"].Value.ShouldBe(5L);
|
||||
v["Program/Current"].Value.ShouldBe("B");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(57, true)]
|
||||
[InlineData(23, false)]
|
||||
[InlineData(24, false)]
|
||||
[InlineData(25, false)]
|
||||
public async Task Only_refusal_57_proves_an_empty_alarm_list(int code, bool empty)
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[27] = () => SimReply.Refuse(code);
|
||||
await using var h = await OpenAsync(c, Standard("Alarms/Count", "Alarms/Messages"));
|
||||
var v = await h.ReadAsync("Alarms/Count", "Alarms/Messages");
|
||||
if (empty)
|
||||
{
|
||||
v["Alarms/Count"].Value.ShouldBe(0);
|
||||
v["Alarms/Messages"].Value.ShouldBe(Array.Empty<string>());
|
||||
v["Alarms/Count"].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
}
|
||||
else
|
||||
{
|
||||
v.Values.ShouldAllBe(s => s.Status != HeidenhainStatus.Good && s.Value == null);
|
||||
}
|
||||
}
|
||||
|
||||
private static SimReply AlarmRecord()
|
||||
{
|
||||
var b = new byte[10];
|
||||
BinaryPrimitives.WriteInt16BigEndian(b, 3);
|
||||
BinaryPrimitives.WriteInt16BigEndian(b.AsSpan(2), 4);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(4), -1);
|
||||
b[8] = 65;
|
||||
return SimReply.Ok("S_RI", b);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task Alarm_cursor_end_at_the_limit_is_good(int limit)
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[27] = AlarmRecord;
|
||||
c.Runtime[28] = () => SimReply.Refuse(57);
|
||||
await using var h = await OpenAsync(c, Standard("Alarms/Count", "Alarms/Numbers", "Alarms/Classes"), maxAlarms: limit);
|
||||
var v = await h.ReadAsync("Alarms/Count", "Alarms/Numbers", "Alarms/Classes");
|
||||
v["Alarms/Count"].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
v["Alarms/Count"].Value.ShouldBe(1);
|
||||
v["Alarms/Numbers"].Value.ShouldBe(new[] { -1 });
|
||||
v["Alarms/Classes"].Value.ShouldBe(new[] { 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Alarm_limit_exhaustion_is_out_of_range_never_truncated()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[27] = AlarmRecord;
|
||||
c.Runtime[28] = AlarmRecord;
|
||||
await using var h = await OpenAsync(c, Standard("Alarms/Count", "Alarms/Messages"), maxAlarms: 1);
|
||||
var v = await h.ReadAsync("Alarms/Count", "Alarms/Messages");
|
||||
v.Values.ShouldAllBe(s => s.Status == HeidenhainStatus.BadOutOfRange && s.Value == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Malformed_alarm_record_is_a_decoding_error_not_an_empty_list()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[27] = () => SimReply.Ok("S_RI", [0, 0, 0, 0, 0, 0, 0, 0, 65]); // unterminated text
|
||||
await using var h = await OpenAsync(c, Standard("Alarms/Count", "Capabilities/Alarms"));
|
||||
var v = await h.ReadAsync("Alarms/Count", "Capabilities/Alarms");
|
||||
v["Alarms/Count"].Status.ShouldBe(HeidenhainStatus.BadDecodingError);
|
||||
v["Capabilities/Alarms"].Value.ShouldBe("InvalidData");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Denied_dnc_leaves_inspect_telemetry_available_and_honours_each_read_refusal()
|
||||
{
|
||||
var c = new SimulatedControl { Login = DenyDnc };
|
||||
c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]);
|
||||
c.Runtime[25] = () => SimReply.Refuse(23);
|
||||
await using var h = await OpenAsync(c, Standard("Controller/Model", "Status/ExecutionState", "Overrides/Feed", "Capabilities/DncLogin", "Capabilities/Overrides"));
|
||||
var v = await h.ReadAsync("Controller/Model", "Status/ExecutionState", "Overrides/Feed", "Capabilities/DncLogin", "Capabilities/Overrides");
|
||||
v["Controller/Model"].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
v["Status/ExecutionState"].Value.ShouldBe(4);
|
||||
v["Overrides/Feed"].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
v["Capabilities/DncLogin"].Value.ShouldBe("AccessDenied");
|
||||
v["Capabilities/Overrides"].Value.ShouldBe("AccessDenied");
|
||||
h.RuntimeRequests().ShouldBe(2);
|
||||
// a stable denial is suppressed for the rest of the session
|
||||
(await h.ReadAsync("Overrides/Feed"))["Overrides/Feed"].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
h.RuntimeRequests().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Telemetry_opt_out_prevents_every_runtime_request()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]);
|
||||
await using var h = await OpenAsync(c, Standard("Status/ExecutionState", "Capabilities/Execution", "Capabilities/DncLogin"), telemetry: false);
|
||||
var v = await h.ReadAsync("Status/ExecutionState", "Capabilities/Execution", "Capabilities/DncLogin");
|
||||
v["Status/ExecutionState"].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
v["Capabilities/Execution"].Value.ShouldBe("Disabled");
|
||||
v["Capabilities/DncLogin"].Value.ShouldBe("Disabled");
|
||||
h.RuntimeRequests().ShouldBe(0);
|
||||
h.Control.Logins.Select(l => l.Role).ShouldBe(new[] { "INSPECT" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Experimental_timers_are_disabled_by_default_and_never_requested()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[30] = () => SimReply.Ok("S_RI", [0, 0, 0, 9]);
|
||||
await using var h = await OpenAsync(c, Standard("Timers/NcUptime", "Capabilities/Timer_NcUptime"));
|
||||
var v = await h.ReadAsync("Timers/NcUptime", "Capabilities/Timer_NcUptime");
|
||||
v["Timers/NcUptime"].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
v["Capabilities/Timer_NcUptime"].Value.ShouldBe("Disabled");
|
||||
h.RuntimeRequests(30).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabled_timers_read_raw_uint32()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[34] = () => SimReply.Ok("S_RI", [0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
await using var h = await OpenAsync(c, Standard("Timers/Spindle1Runtime"), timers: true);
|
||||
(await h.ReadAsync("Timers/Spindle1Runtime"))["Timers/Spindle1Runtime"].Value.ShouldBe(4294967295L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tool_without_geometry_is_not_supported_not_zero()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[51] = () => SimReply.Ok("S_RI", [0, 0, 0, 7, 0, 1, 0, 2]);
|
||||
await using var h = await OpenAsync(c, Standard("Tool/Number", "Tool/Index", "Tool/AxisCode", "Tool/Length", "Tool/Radius"));
|
||||
var v = await h.ReadAsync("Tool/Number", "Tool/Index", "Tool/AxisCode", "Tool/Length", "Tool/Radius");
|
||||
v["Tool/Number"].Value.ShouldBe(7L);
|
||||
v["Tool/Index"].Value.ShouldBe(1);
|
||||
v["Tool/AxisCode"].Value.ShouldBe(2);
|
||||
v["Tool/Length"].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
v["Tool/Length"].Value.ShouldBeNull();
|
||||
v["Tool/Radius"].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle49")]
|
||||
[InlineData("hermle59")]
|
||||
public async Task Captured_inspect_only_replies_publish_typed_telemetry_after_dnc_denial(string machine)
|
||||
{
|
||||
var f = Fixtures.Load("itnc530-inspect.properties");
|
||||
var c = new SimulatedControl { Login = DenyDnc };
|
||||
foreach (var s in new[] { 22, 23, 24, 25, 26, 27, 28, 51 })
|
||||
{
|
||||
var reply = SimReply.FromFixture(f[$"{machine}.{s}"]);
|
||||
c.Runtime[s] = () => reply;
|
||||
}
|
||||
|
||||
string[] addresses = ["Status/ExecutionState", "Status/ProgramState", "Program/Main", "Axes/Names", "Axes/Positions",
|
||||
"Alarms/Count", "Alarms/Messages", "Alarms/Classes", "Overrides/Feed", "Tool/Number"];
|
||||
await using var h = await OpenAsync(c, Standard(addresses));
|
||||
var v = await h.ReadAsync(addresses);
|
||||
v["Status/ExecutionState"].Value.ShouldBe(4);
|
||||
v["Status/ProgramState"].Value.ShouldBe(7);
|
||||
((string)v["Program/Main"].Value!).ShouldStartWith("TNC:");
|
||||
v["Axes/Names"].Value.ShouldBe(new[] { "X", "Y", "Z", "C", "a", "A" });
|
||||
((double[])v["Axes/Positions"].Value!).Length.ShouldBe(6);
|
||||
v["Alarms/Count"].Value.ShouldBe(1);
|
||||
v["Alarms/Messages"].Value.ShouldBe(new[] { "Run test of cutout channels" });
|
||||
v["Alarms/Classes"].Value.ShouldBe(new[] { 7 });
|
||||
v["Overrides/Feed"].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
v["Tool/Number"].Status.ShouldBe(machine == "hermle49" ? HeidenhainStatus.BadNotSupported : HeidenhainStatus.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Data_path_type_mismatch_is_bad_and_aliases_share_one_read()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
|
||||
var tags = new List<HeidenhainTagDefinition>
|
||||
{
|
||||
TestTags.Def("DATA:/x", "Int64", "Plant/H/dev1/A"),
|
||||
TestTags.Def("DATA:/x", "String", "Plant/H/dev1/B"),
|
||||
};
|
||||
await using var h = await OpenAsync(c, tags, telemetry: false, data: true);
|
||||
var samples = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
samples[0].Value.ShouldBe(42L);
|
||||
samples[1].Status.ShouldBe(HeidenhainStatus.BadTypeMismatch);
|
||||
h.Sim.Requests.Count(r => r.Command == "R_DP").ShouldBe(1);
|
||||
h.Control.Logins.Select(l => l.Role).ShouldBe(new[] { "INSPECT", "DATA" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Data_bindings_stay_disabled_until_data_access_is_enabled()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
|
||||
var tags = new List<HeidenhainTagDefinition> { TestTags.Def("DATA:/x", "Int64"), TestTags.Def("Capabilities/DataLogin") };
|
||||
await using var h = await OpenAsync(c, tags, data: false);
|
||||
var s = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
s[1].Value.ShouldBe("Disabled");
|
||||
h.Sim.Requests.ShouldNotContain(r => r.Command == "R_DP");
|
||||
h.Control.Logins.ShouldNotContain(l => l.Role == "DATA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transport_failure_invalidates_the_batch_and_close_prevents_good_reads()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[23] = SimReply.Drop;
|
||||
var tags = Standard("Controller/Model", "Status/ExecutionState", "Capabilities/Identity");
|
||||
await using var h = await OpenAsync(c, tags);
|
||||
var v = await h.ReadAsync("Controller/Model", "Status/ExecutionState", "Capabilities/Identity");
|
||||
h.Session.CommunicationLost.ShouldBeTrue();
|
||||
v["Controller/Model"].Status.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
v["Status/ExecutionState"].Status.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
v["Capabilities/Identity"].Value.ShouldBe("CommunicationLost");
|
||||
await h.Session.DisposeAsync();
|
||||
(await h.ReadAsync("Controller/Model"))["Controller/Model"].Status.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DNC")]
|
||||
[InlineData("DATA")]
|
||||
public async Task Temporary_optional_login_refusal_retries_with_a_fresh_credential(string role)
|
||||
{
|
||||
var attempts = 0;
|
||||
var c = new SimulatedControl
|
||||
{
|
||||
Login = (r, pw) => r == role && Interlocked.Increment(ref attempts) == 1 ? SimReply.Refuse(25) : SimReply.Ok("T_OK", []),
|
||||
};
|
||||
c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 3]);
|
||||
c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
|
||||
var resolved = 0;
|
||||
var tags = role == "DATA" ? [TestTags.Def("DATA:/x", "Int64")] : Standard("Status/ExecutionState");
|
||||
await using var h = await OpenAsync(c, tags, data: true,
|
||||
passwords: (r, _) => { if (r == role) Interlocked.Increment(ref resolved); return ValueTask.FromResult("fresh"); });
|
||||
var s = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
attempts.ShouldBe(2);
|
||||
resolved.ShouldBe(2); // resolved on demand for each login — never cached
|
||||
h.Control.Logins.Where(l => l.Role == role).ShouldAllBe(l => l.Password == "fresh");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Temporary_or_malformed_layout_recovers_and_aliases_read_once_per_batch(bool malformed)
|
||||
{
|
||||
var layouts = 0;
|
||||
var c = new SimulatedControl
|
||||
{
|
||||
Intercept = r => r.Command == "R_PR" && Interlocked.Increment(ref layouts) == 1
|
||||
? (malformed ? SimReply.Ok("S_PR", new byte[1]) : SimReply.Refuse(25))
|
||||
: null,
|
||||
Memory = (_, n) => SimReply.Ok("S_MB", [42, 0, 0, 0]),
|
||||
};
|
||||
var tags = new List<HeidenhainTagDefinition>
|
||||
{
|
||||
TestTags.Def("PLC:DWORD:0", "Int64", "Plant/H/dev1/A"),
|
||||
TestTags.Def("PLC:DWORD:0", "Int32", "Plant/H/dev1/B"),
|
||||
};
|
||||
await using var h = await OpenAsync(c, tags, telemetry: false);
|
||||
var s = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
s.ShouldAllBe(x => x.Status == HeidenhainStatus.Good);
|
||||
s[0].Value.ShouldBe(42L);
|
||||
s[1].Value.ShouldBe(42);
|
||||
layouts.ShouldBe(2);
|
||||
h.Sim.Requests.Count(r => r.Command == "R_MB").ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plc_binding_outside_the_advertised_region_is_out_of_range_without_a_wire_read()
|
||||
{
|
||||
var c = new SimulatedControl { Memory = (_, _) => SimReply.Ok("S_MB", [0, 0]) };
|
||||
var tags = new List<HeidenhainTagDefinition> { TestTags.Def("PLC:WORD:5", "Int32"), TestTags.Def("PLC:WORD:4", "Int32") };
|
||||
await using var h = await OpenAsync(c, tags, telemetry: false);
|
||||
var s = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.BadOutOfRange);
|
||||
s[1].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
var mb = h.Sim.Requests.Single(r => r.Command == "R_MB");
|
||||
BinaryPrimitives.ReadUInt32BigEndian(mb.Payload).ShouldBe(1008u);
|
||||
mb.Payload[4].ShouldBe((byte)2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plc_memory_denial_is_access_denied_and_suppressed()
|
||||
{
|
||||
var c = new SimulatedControl(); // R_MB → T_ER 23, as on all four tested iTNC 530 controls
|
||||
var tags = new List<HeidenhainTagDefinition> { TestTags.Def("PLC:WORD:1", "Int32") };
|
||||
await using var h = await OpenAsync(c, tags, telemetry: false);
|
||||
(await h.Session.ReadBatchAsync(tags, Ct))[0].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
(await h.Session.ReadBatchAsync(tags, Ct))[0].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
h.Sim.Requests.Count(r => r.Command == "R_MB").ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_transient_dnc_failures_cannot_permanently_suppress_telemetry()
|
||||
{
|
||||
var logins = 0;
|
||||
var c = new SimulatedControl
|
||||
{
|
||||
Login = (r, _) => r == "DNC" && Interlocked.Increment(ref logins) <= 2 ? SimReply.Refuse(25) : SimReply.Ok("T_OK", []),
|
||||
};
|
||||
c.Runtime[23] = () => logins < 3 ? SimReply.Refuse(23) : SimReply.Ok("S_RI", [0, 4]);
|
||||
c.Runtime[26] = () => logins < 3 ? SimReply.Refuse(23) : SimReply.Ok("S_RI", [0, 7]);
|
||||
await using var h = await OpenAsync(c, Standard("Status/ExecutionState", "Status/ProgramState"));
|
||||
(await h.ReadAsync("Status/ExecutionState", "Status/ProgramState")).Values
|
||||
.ShouldAllBe(s => s.Status == HeidenhainStatus.BadUserAccessDenied);
|
||||
var recovered = await h.ReadAsync("Status/ExecutionState", "Status/ProgramState");
|
||||
recovered["Status/ExecutionState"].Value.ShouldBe(4);
|
||||
recovered["Status/ProgramState"].Value.ShouldBe(7);
|
||||
logins.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_request_the_session_emits_is_allowlisted()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
foreach (var s in new[] { 22, 23, 24, 25, 26, 51 }) c.Runtime[s] = () => SimReply.Refuse(24);
|
||||
c.Runtime[27] = () => SimReply.Refuse(57);
|
||||
var tags = HeidenhainCatalog.Entries.Select(e => TestTags.Def(e.Address)).ToList();
|
||||
tags.Add(TestTags.Def("PLC:WORD:1", "Int32"));
|
||||
await using var h = await OpenAsync(c, tags, timers: true);
|
||||
await h.Session.ReadBatchAsync(tags, Ct);
|
||||
await h.Session.ReadBatchAsync(tags, Ct);
|
||||
h.Sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB" });
|
||||
h.Sim.Requests.Where(r => r.Command == "A_LG").Select(r => Encoding.Latin1.GetString(r.Payload).Split('\0')[0])
|
||||
.ShouldBeSubsetOf(new[] { "INSPECT", "DNC", "DATA" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>Decoder regression ported from the reference <c>Lsv2DataTest</c> + <c>HeidenhainBindingsTest</c>.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Lsv2DataTests
|
||||
{
|
||||
private static byte[] T(string s) => Encoding.Latin1.GetBytes(s);
|
||||
|
||||
[Fact]
|
||||
public void Version_and_program_preserve_native_values()
|
||||
{
|
||||
Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0OPT\0")).Model.ShouldBe("iTNC 530");
|
||||
Lsv2Data.Versions(T("TNC\0NC\0PLC\0SPLC\0OPT\0")).Options.ShouldBe("OPT");
|
||||
var program = new byte[10];
|
||||
BinaryPrimitives.WriteInt32BigEndian(program, -1);
|
||||
T("A\0B\0\0\0").CopyTo(program, 4);
|
||||
var p = Lsv2Data.Program(program);
|
||||
p.Line.ShouldBe(4294967295L);
|
||||
p.Current.ShouldBe("B");
|
||||
Lsv2Data.U16([0xFF, 0xFE]).ShouldBe(65534);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_strings_and_lengths_are_rejected()
|
||||
{
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Versions(T("TNC\0NC\0PLC\0OPT")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Versions(T("TNC\0NC\0")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Program(new byte[4]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Overrides(new byte[11]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Layout(new byte[119]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.U16(new byte[3]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Alarm(new byte[8]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_version_options_do_not_become_the_optional_splc_field()
|
||||
{
|
||||
Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0SPLC\0\0")).Options.ShouldBe("");
|
||||
Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0\0")).Options.ShouldBe("");
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0SPLC\0OPT\0\0")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_are_unsigned_hundredths_and_tool_is_mixed_endian()
|
||||
{
|
||||
var o = new byte[12];
|
||||
BinaryPrimitives.WriteInt32BigEndian(o, 12345);
|
||||
BinaryPrimitives.WriteInt32BigEndian(o.AsSpan(4), 8000);
|
||||
Lsv2Data.Overrides(o).ShouldBe(new[] { 123.45, 80, 0 });
|
||||
var t = new byte[24];
|
||||
BinaryPrimitives.WriteInt32BigEndian(t, -1);
|
||||
BinaryPrimitives.WriteInt16BigEndian(t.AsSpan(4), 4);
|
||||
BinaryPrimitives.WriteInt16BigEndian(t.AsSpan(6), 2);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(8), 123.5);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(16), 4.25);
|
||||
var tool = Lsv2Data.Tool(t);
|
||||
tool.Number.ShouldBe(4294967295L);
|
||||
tool.Length.ShouldBe(123.5);
|
||||
tool.Radius.ShouldBe(4.25);
|
||||
Lsv2Data.Tool(new byte[8]).Length.ShouldBeNull();
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Tool(new byte[9]));
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(8), double.NaN);
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Tool(t));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Axes_are_aligned_with_no_guessed_units()
|
||||
{
|
||||
var a = Lsv2Data.Axes(T("\0\u0002-1.25\u00002.5\0X\0C\0"));
|
||||
a.Names.ShouldBe(new[] { "X", "C" });
|
||||
a.Positions.ShouldBe(new[] { -1.25, 2.5 });
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Axes(T("\0\u0002NaN\u00002\0X\0Y\0")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Axes(T("\0\u00021\u00002\0X\0X\0")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Axes(T("\0\u00021\u00002\0X\0")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Axes(T("\0\u0002abc\u00002\0X\0Y\0")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_number_is_signed_and_data_path_types_are_strict()
|
||||
{
|
||||
var e = new byte[12];
|
||||
BinaryPrimitives.WriteInt16BigEndian(e, 3);
|
||||
BinaryPrimitives.WriteInt16BigEndian(e.AsSpan(2), 4);
|
||||
BinaryPrimitives.WriteInt32BigEndian(e.AsSpan(4), -10);
|
||||
T("Err\0").CopyTo(e, 8);
|
||||
Lsv2Data.Alarm(e).Number.ShouldBe(-10);
|
||||
Lsv2Data.DataPath([0, 0, 0, 2, 0xFF, 0xFE]).ShouldBe(-2);
|
||||
Lsv2Data.DataPath([0, 0, 0, 17, 0xFF]).ShouldBe(255);
|
||||
Lsv2Data.DataPath([0, 0, 0, 16, 0xFF]).ShouldBe(-1);
|
||||
Lsv2Data.DataPath([0, 0, 0, 11, 1]).ShouldBe(true);
|
||||
Lsv2Data.DataPath([0, 0, 0, 8, 65, 66, 0]).ShouldBe("AB");
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.DataPath([0, 0, 0, 5]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.DataPath([0, 0, 0, 99, 1]));
|
||||
var inf = new byte[12];
|
||||
BinaryPrimitives.WriteInt32BigEndian(inf, 5);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(inf.AsSpan(4), double.PositiveInfinity);
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.DataPath(inf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_path_boolean_accepts_the_real_controls_ff_true_encoding()
|
||||
{
|
||||
Lsv2Data.DataPath([0, 0, 0, 11, 0xFF]).ShouldBe(true);
|
||||
Lsv2Data.DataPath([0, 0, 0, 11, 0]).ShouldBe(false);
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.DataPath([0, 0, 0, 11, 2]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Numeric_counts_are_bytes_even_when_no_adjacent_area_caps_them()
|
||||
{
|
||||
foreach (var length in new[] { 120, 124 })
|
||||
{
|
||||
var bytes = new byte[length];
|
||||
SimulatedControl.LayoutBytes(10).CopyTo(bytes, 0);
|
||||
var layout = Lsv2Data.Layout(bytes);
|
||||
foreach (var area in new[] { HeidenhainPlcArea.Byte, HeidenhainPlcArea.Word, HeidenhainPlcArea.Dword, HeidenhainPlcArea.InputWord, HeidenhainPlcArea.OutputWord })
|
||||
layout.Region(area)!.Bytes.ShouldBe(10, area.ToString());
|
||||
layout.Region(HeidenhainPlcArea.String)!.Bytes.ShouldBe(160);
|
||||
layout.Region(HeidenhainPlcArea.Word)!.Base.ShouldBe(1000);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Memory_range_uses_byte_capacity_and_element_index()
|
||||
{
|
||||
var layout = Lsv2Data.Layout(SimulatedControl.LayoutBytes(10));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Dword, 1, layout).ShouldBe(new HeidenhainMemoryRange(1004, 4));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Dword, 2, layout));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 9, layout).Address.ShouldBe(1009);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 10, layout));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 4, layout).Address.ShouldBe(1008);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 5, layout));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 4, layout).Address.ShouldBe(3008);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 5, layout));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputWord, 4, layout).Address.ShouldBe(4008);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputWord, 5, layout));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 0, null));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 0xFFFF_FFFFL, layout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Memory_and_runtime_coercion_cannot_truncate_or_wrap()
|
||||
{
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Dword, [0xFE, 0xFF, 0xFF, 0xFF], DriverDataType.Int64).ShouldBe(-2L);
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputWord, [0xFF, 0xFF], DriverDataType.Int32).ShouldBe(65535);
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Word, [0xFF, 0xFF], DriverDataType.Int16).ShouldBe((short)-1);
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [1], DriverDataType.Boolean).ShouldBe(true);
|
||||
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [0xFF], DriverDataType.Boolean));
|
||||
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Dword, new byte[3], DriverDataType.Int64));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(1.5, DriverDataType.Int32));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(32768, DriverDataType.Int16));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(1, DriverDataType.Boolean));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(1, DriverDataType.String));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(double.NaN, DriverDataType.Float64));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce("x", DriverDataType.Int64));
|
||||
HeidenhainBindingCodec.Coerce(2.0, DriverDataType.Int64).ShouldBe(2L);
|
||||
HeidenhainBindingCodec.Coerce(7, DriverDataType.Float64).ShouldBe(7.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle49")]
|
||||
[InlineData("hermle59")]
|
||||
public void Captured_layouts_reject_numeric_reads_at_the_next_advertised_area(string machine)
|
||||
{
|
||||
var f = Fixtures.Load("itnc530-inspect.properties");
|
||||
var layout = Lsv2Data.Layout(Convert.FromHexString(f[machine + ".layout"]));
|
||||
foreach (var (area, index) in new[] { (HeidenhainPlcArea.Byte, 10000L), (HeidenhainPlcArea.Word, 5000L), (HeidenhainPlcArea.Dword, 2500L), (HeidenhainPlcArea.InputWord, 500L), (HeidenhainPlcArea.OutputWord, 500L) })
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(area, index, layout), $"{machine} {area}:{index}");
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 9999, layout).Address.ShouldBe(10063);
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 4999, layout).Address.ShouldBe(10062);
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Dword, 2499, layout).Address.ShouldBe(10060);
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 499, layout).Address.ShouldBe(11062);
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputWord, 499, layout).Address.ShouldBe(12062);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The payload-aware outbound allowlist (ported from the reference <c>rejectsEveryWriteAndElevatedRole</c> /
|
||||
/// <c>validatesSelectorsMemoryAndPathsBeforeTransmission</c>), plus proof that a refused request never puts a
|
||||
/// single byte on the wire and that the driver has no write surface at all.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Lsv2ReadOnlyPolicyTests
|
||||
{
|
||||
public static TheoryData<string> WriteAndControlCommands() =>
|
||||
[
|
||||
// file / PLC / parameter / buffer writes and transfers, logout, file reads, key & scope operations,
|
||||
// and response mnemonics that must never be sent as requests.
|
||||
"C_CC", "C_FL", "C_MB", "C_DC", "C_DD", "C_FD", "C_FC", "C_PR", "C_EK", "C_LK", "C_MC", "C_TP",
|
||||
"A_LO", "R_MC", "R_FL", "R_DI", "R_FI", "R_DR", "R_ST", "R_TP", "R_OC", "T_OK", "T_ER", "S_RI", "XXXX",
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WriteAndControlCommands))]
|
||||
public void Every_non_allowlisted_command_is_refused(string command)
|
||||
{
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check(command, []));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check(command, [0, 22]));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2Codec.Frame(command, []));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PLCDEBUG")]
|
||||
[InlineData("MONITOR")]
|
||||
[InlineData("FILE")]
|
||||
[InlineData("DIAGNOSTICS")]
|
||||
[InlineData("DNC\0PLCDEBUG")]
|
||||
[InlineData("inspect")]
|
||||
[InlineData("")]
|
||||
public void Elevated_or_unknown_roles_are_refused(string role) =>
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.LoginPayload(role, ""));
|
||||
|
||||
[Fact]
|
||||
public void Login_payloads_are_checked_for_embedded_nul_and_role_smuggling()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => Lsv2ReadOnlyPolicy.LoginPayload("DNC", "x\0y"));
|
||||
// a hand-built payload that names an elevated role after a permitted one
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("A_LG", "PLCDEBUG\0"u8.ToArray()));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("A_LG", "INSPECT"u8.ToArray()));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("A_LG", "INSPECT\0a\0b\0"u8.ToArray()));
|
||||
Lsv2ReadOnlyPolicy.LoginPayload("INSPECT", "").ShouldBe("INSPECT\0"u8.ToArray());
|
||||
Lsv2ReadOnlyPolicy.LoginPayload("DATA", "pw").ShouldBe("DATA\0pw\0"u8.ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(21)]
|
||||
[InlineData(29)]
|
||||
[InlineData(35)]
|
||||
[InlineData(50)]
|
||||
[InlineData(52)]
|
||||
[InlineData(0xFFFF)]
|
||||
public void Runtime_selectors_outside_the_allowlist_are_refused(int selector) =>
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.RuntimePayload(selector));
|
||||
|
||||
[Fact]
|
||||
public void Allowlisted_selectors_pass_and_payload_shape_is_enforced()
|
||||
{
|
||||
foreach (var s in new[] { 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 51 })
|
||||
Lsv2ReadOnlyPolicy.RuntimePayload(s).Length.ShouldBe(2);
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_RI", [0, 23, 0]));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_VR", [0]));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_PR", [1, 2]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Memory_reads_are_bounded_and_cannot_wrap()
|
||||
{
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_MB", new byte[5])); // zero count
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.MemoryPayload(0, 248));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.MemoryPayload(-1, 1));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.MemoryPayload(0xFFFF_FFFF, 2)); // wraps
|
||||
Lsv2ReadOnlyPolicy.MemoryPayload(0xFFFF_FFFF, 1).ShouldBe(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 1 });
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_MB", new byte[6]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_paths_are_validated_before_transmission()
|
||||
{
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.DataPathPayload("/a\0/b"));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.DataPathPayload("/a/€"));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.DataPathPayload(new string('/', 244)));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.DataPathPayload(" "));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_DP", [0, 0, 0, 1, 65, 0])); // nonzero prefix
|
||||
Lsv2ReadOnlyPolicy.DataPathPayload("/PLC/memory/W/2").ShouldBe("\0\0\0\0\\PLC\\memory\\W\\2\0"u8.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_request_never_reaches_the_socket()
|
||||
{
|
||||
var received = 0;
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
while (await c.ReadByteAsync(ct) >= 0) Interlocked.Increment(ref received);
|
||||
});
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
await client.OpenAsync(TestContext.Current.CancellationToken);
|
||||
foreach (var cmd in new[] { "C_CC", "C_MB", "A_LO", "R_FL" })
|
||||
await Should.ThrowAsync<Lsv2PolicyException>(() => client.ExchangeAsync(cmd, [], "T_OK", TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<Lsv2PolicyException>(() => client.ExchangeAsync("R_RI", [0, 29], "S_RI", TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<Lsv2PolicyException>(() => client.ExchangeAsync("A_LG", "PLCDEBUG\0"u8.ToArray(), "T_OK", TestContext.Current.CancellationToken));
|
||||
client.RequestsSent.ShouldBe(0);
|
||||
client.IsClosed.ShouldBeFalse(); // a policy refusal is not a transport failure
|
||||
client.Close();
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
Volatile.Read(ref received).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_public_surface_has_no_raw_command_api()
|
||||
{
|
||||
var methods = typeof(Lsv2Client).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(m => !m.IsSpecialName).Select(m => m.Name).OrderBy(n => n).ToArray();
|
||||
methods.ShouldBe(new[]
|
||||
{
|
||||
"Close", "DisposeAsync", "LoginAsync", "OpenAsync", "ReadDataPathAsync", "ReadMemoryAsync",
|
||||
"ReadParametersAsync", "ReadRuntimeAsync", "ReadVersionAsync",
|
||||
});
|
||||
typeof(Lsv2Client).GetMethod("ExchangeAsync", BindingFlags.Public | BindingFlags.Instance).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_has_no_write_capability()
|
||||
{
|
||||
typeof(IWritable).IsAssignableFrom(typeof(HeidenhainDriver)).ShouldBeFalse();
|
||||
typeof(HeidenhainDriver).GetMethods().ShouldNotContain(m => m.Name.Contains("Write", StringComparison.Ordinal));
|
||||
Lsv2ReadOnlyPolicy.Commands.ShouldBe(new[] { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB", "R_DP" }, ignoreOrder: true);
|
||||
Lsv2ReadOnlyPolicy.Roles.ShouldBe(new[] { "INSPECT", "DNC", "DATA" }, ignoreOrder: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>Framing/transport regression ported from the reference <c>Lsv2TransportTest</c> (literal frames, independent of the encoder).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Lsv2TransportTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static async Task<Lsv2Client> OpenAsync(Lsv2Simulator sim, int timeoutMs = 1000)
|
||||
{
|
||||
var c = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromMilliseconds(timeoutMs));
|
||||
await c.OpenAsync(Ct);
|
||||
return c;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Literal_runtime_request_and_byte_fragmented_response()
|
||||
{
|
||||
byte[]? seen = null;
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
var req = await c.ReadRequestAsync(ct);
|
||||
seen = SimConnection.Frame(req!.Command, req.Payload);
|
||||
await c.WriteRawAsync(Convert.FromHexString("00000002535f52490003"), byteByByte: true, ct);
|
||||
});
|
||||
await using var client = await OpenAsync(sim);
|
||||
(await client.ReadRuntimeAsync(23, Ct)).ShouldBe(new byte[] { 0, 3 });
|
||||
seen.ShouldBe(Convert.FromHexString("00000002525f52490017"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_refusal_preserves_code_and_stream_sync()
|
||||
{
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
await c.ReadRequestAsync(ct);
|
||||
await c.ReplyAsync("T_ER", [1, 23], ct);
|
||||
await c.ReadRequestAsync(ct);
|
||||
await c.ReplyAsync("S_VR", [65, 0], ct);
|
||||
});
|
||||
await using var client = await OpenAsync(sim);
|
||||
var refusal = await Should.ThrowAsync<Lsv2RefusalException>(() => client.ReadRuntimeAsync(23, Ct));
|
||||
refusal.Code.ShouldBe(23);
|
||||
client.IsClosed.ShouldBeFalse();
|
||||
(await client.ReadVersionAsync(Ct)).ShouldBe(new byte[] { 65, 0 });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("00010000535f5249")] // oversized length: rejected before allocation
|
||||
[InlineData("00000000535f5652")] // unexpected response type (S_VR for an R_RI)
|
||||
[InlineData("00000002535f524900")] // partial body then EOF
|
||||
[InlineData("00000003545f455201")] // malformed refusal (3-byte T_ER)
|
||||
[InlineData("00000001545f4f4b00")] // T_OK with a body
|
||||
public async Task Bad_frames_close_the_client_and_later_requests_fail(string hex)
|
||||
{
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
await c.ReadRequestAsync(ct);
|
||||
await c.WriteRawAsync(Convert.FromHexString(hex), byteByByte: false, ct);
|
||||
if (hex.EndsWith("00", StringComparison.Ordinal) && hex.Length == 18) c.Close();
|
||||
await Task.Delay(2000, ct);
|
||||
});
|
||||
await using var client = await OpenAsync(sim);
|
||||
var req = hex.Contains("545f4f4b", StringComparison.Ordinal)
|
||||
? client.LoginAsync("INSPECT", "", Ct)
|
||||
: client.ReadRuntimeAsync(23, Ct);
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => req);
|
||||
client.IsClosed.ShouldBeTrue();
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => client.ReadVersionAsync(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_interrupts_a_blocked_read_without_waiting_for_the_gate()
|
||||
{
|
||||
var requested = new TaskCompletionSource();
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
await c.ReadRequestAsync(ct);
|
||||
requested.TrySetResult();
|
||||
await c.ReadByteAsync(ct); // never answers
|
||||
});
|
||||
await using var client = await OpenAsync(sim, timeoutMs: 10_000);
|
||||
var pending = client.ReadRuntimeAsync(23, Ct);
|
||||
await requested.Task.WaitAsync(TimeSpan.FromSeconds(2), Ct);
|
||||
var sw = Stopwatch.StartNew();
|
||||
client.Close();
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => pending.WaitAsync(TimeSpan.FromSeconds(2), Ct));
|
||||
sw.ElapsedMilliseconds.ShouldBeLessThan(2000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Trickle_cannot_reset_the_transaction_deadline()
|
||||
{
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
await c.ReadRequestAsync(ct);
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
await c.WriteRawAsync([0], byteByByte: false, ct);
|
||||
await Task.Delay(40, ct);
|
||||
}
|
||||
});
|
||||
await using var client = await OpenAsync(sim, timeoutMs: 120);
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => client.ReadRuntimeAsync(23, Ct));
|
||||
sw.ElapsedMilliseconds.ShouldBeLessThan(1000);
|
||||
client.IsClosed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transactions_are_serialized_one_frame_at_a_time()
|
||||
{
|
||||
var concurrent = 0;
|
||||
var maxConcurrent = 0;
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
while (await c.ReadRequestAsync(ct) is not null)
|
||||
{
|
||||
var now = Interlocked.Increment(ref concurrent);
|
||||
maxConcurrent = Math.Max(maxConcurrent, now);
|
||||
await Task.Delay(10, ct);
|
||||
Interlocked.Decrement(ref concurrent);
|
||||
await c.ReplyAsync("S_RI", [0, 1], ct);
|
||||
}
|
||||
});
|
||||
await using var client = await OpenAsync(sim);
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => client.ReadRuntimeAsync(23, Ct)));
|
||||
maxConcurrent.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_to_a_closed_port_is_a_transport_failure()
|
||||
{
|
||||
int port;
|
||||
await using (var sim = Lsv2Simulator.WithScript((_, _) => Task.CompletedTask)) port = sim.Port;
|
||||
var client = new Lsv2Client("127.0.0.1", port, TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => client.OpenAsync(Ct));
|
||||
client.IsClosed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
/// <summary>One request frame as the simulated control received it.</summary>
|
||||
public sealed record Lsv2Request(string Command, byte[] Payload)
|
||||
{
|
||||
/// <summary>The payload decoded as Latin-1 (for login/path assertions).</summary>
|
||||
public string Text => Encoding.Latin1.GetString(Payload);
|
||||
|
||||
/// <summary>The R_RI selector.</summary>
|
||||
public int Selector => BinaryPrimitives.ReadUInt16BigEndian(Payload);
|
||||
}
|
||||
|
||||
/// <summary>A scripted connection to the simulator.</summary>
|
||||
public sealed class SimConnection(TcpClient client)
|
||||
{
|
||||
private readonly NetworkStream _stream = client.GetStream();
|
||||
|
||||
/// <summary>Reads one complete request frame, or null on EOF.</summary>
|
||||
public async Task<Lsv2Request?> ReadRequestAsync(CancellationToken ct)
|
||||
{
|
||||
var header = new byte[8];
|
||||
var read = 0;
|
||||
while (read < 8)
|
||||
{
|
||||
var n = await _stream.ReadAsync(header.AsMemory(read), ct).ConfigureAwait(false);
|
||||
if (n == 0) return read == 0 ? null : throw new EndOfStreamException();
|
||||
read += n;
|
||||
}
|
||||
|
||||
var length = (int)BinaryPrimitives.ReadUInt32BigEndian(header);
|
||||
var payload = new byte[length];
|
||||
await _stream.ReadExactlyAsync(payload, ct).ConfigureAwait(false);
|
||||
return new Lsv2Request(Encoding.ASCII.GetString(header, 4, 4), payload);
|
||||
}
|
||||
|
||||
/// <summary>Writes one well-formed reply frame.</summary>
|
||||
public Task ReplyAsync(string command, byte[] payload, CancellationToken ct = default) =>
|
||||
WriteRawAsync(Frame(command, payload), byteByByte: false, ct);
|
||||
|
||||
/// <summary>Writes raw bytes, optionally one byte per write (TCP fragmentation).</summary>
|
||||
public async Task WriteRawAsync(byte[] bytes, bool byteByByte, CancellationToken ct = default)
|
||||
{
|
||||
if (!byteByByte)
|
||||
{
|
||||
await _stream.WriteAsync(bytes, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var b in bytes)
|
||||
{
|
||||
await _stream.WriteAsync(new[] { b }, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
await Task.Delay(1, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads one raw byte (blocks until the peer sends or closes).</summary>
|
||||
public async Task<int> ReadByteAsync(CancellationToken ct)
|
||||
{
|
||||
var b = new byte[1];
|
||||
return await _stream.ReadAsync(b, ct).ConfigureAwait(false) == 0 ? -1 : b[0];
|
||||
}
|
||||
|
||||
/// <summary>Closes the connection.</summary>
|
||||
public void Close() => client.Dispose();
|
||||
|
||||
/// <summary>Builds a frame independently of the driver's encoder.</summary>
|
||||
public static byte[] Frame(string command, byte[] payload)
|
||||
{
|
||||
var f = new byte[8 + payload.Length];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(f, (uint)payload.Length);
|
||||
Encoding.ASCII.GetBytes(command, f.AsSpan(4, 4));
|
||||
payload.CopyTo(f, 8);
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-process LSV2 peer on a loopback port. Either runs a per-connection <see cref="Script"/> (framing tests)
|
||||
/// or serves a <see cref="SimulatedControl"/> model. Records every request it receives on any connection,
|
||||
/// so a test can prove that only allowlisted telegrams ever left the driver.
|
||||
/// </summary>
|
||||
public sealed class Lsv2Simulator : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Func<SimConnection, CancellationToken, Task> _script;
|
||||
private readonly ConcurrentQueue<Lsv2Request> _requests = new();
|
||||
private readonly ConcurrentBag<TcpClient> _clients = new();
|
||||
private readonly Task _accept;
|
||||
private readonly ConcurrentQueue<Exception> _faults = new();
|
||||
private volatile bool _offline;
|
||||
private int _connections;
|
||||
|
||||
private Lsv2Simulator(Func<SimConnection, CancellationToken, Task> script)
|
||||
{
|
||||
_script = script;
|
||||
_listener.Start();
|
||||
_accept = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
/// <summary>A simulator running a raw per-connection script.</summary>
|
||||
public static Lsv2Simulator WithScript(Func<SimConnection, CancellationToken, Task> script) => new(script);
|
||||
|
||||
/// <summary>A simulator serving a control model (one model shared by all connections).</summary>
|
||||
public static Lsv2Simulator WithControl(SimulatedControl control)
|
||||
{
|
||||
Lsv2Simulator? sim = null;
|
||||
sim = new Lsv2Simulator(async (c, ct) =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var req = await c.ReadRequestAsync(ct).ConfigureAwait(false);
|
||||
if (req is null) return;
|
||||
sim!._requests.Enqueue(req);
|
||||
var reply = control.Handle(req);
|
||||
switch (reply.Kind)
|
||||
{
|
||||
case SimReplyKind.Frame:
|
||||
await c.ReplyAsync(reply.Command!, reply.Payload!, ct).ConfigureAwait(false);
|
||||
break;
|
||||
case SimReplyKind.Drop:
|
||||
c.Close();
|
||||
return;
|
||||
case SimReplyKind.Stall:
|
||||
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return sim;
|
||||
}
|
||||
|
||||
/// <summary>The loopback port.</summary>
|
||||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>Every request received (model mode), in arrival order.</summary>
|
||||
public IReadOnlyList<Lsv2Request> Requests => _requests.ToArray();
|
||||
|
||||
/// <summary>Accepted connection count.</summary>
|
||||
public int Connections => Volatile.Read(ref _connections);
|
||||
|
||||
/// <summary>Exceptions a script threw (tests assert this is empty).</summary>
|
||||
public IReadOnlyList<Exception> Faults => _faults.ToArray();
|
||||
|
||||
/// <summary>Simulates a network outage: drops every live connection and refuses (closes) new ones.</summary>
|
||||
public void GoOffline()
|
||||
{
|
||||
_offline = true;
|
||||
foreach (var c in _clients) c.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Ends the outage.</summary>
|
||||
public void GoOnline() => _offline = false;
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try { client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false); }
|
||||
catch { return; }
|
||||
if (_offline)
|
||||
{
|
||||
client.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _connections);
|
||||
_clients.Add(client);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await _script(new SimConnection(client), _cts.Token).ConfigureAwait(false); }
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException or SocketException or EndOfStreamException) { }
|
||||
catch (Exception ex) { _faults.Enqueue(ex); }
|
||||
finally { client.Dispose(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
_listener.Stop();
|
||||
foreach (var c in _clients) c.Dispose();
|
||||
try { await _accept.ConfigureAwait(false); } catch { /* stopped */ }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
public enum SimReplyKind { Frame, Drop, Stall }
|
||||
|
||||
/// <summary>What the simulated control does with one request.</summary>
|
||||
public sealed record SimReply(SimReplyKind Kind, string? Command = null, byte[]? Payload = null)
|
||||
{
|
||||
public static SimReply Ok(string command, byte[] payload) => new(SimReplyKind.Frame, command, payload);
|
||||
public static SimReply Refuse(int code, int type = 1) => new(SimReplyKind.Frame, "T_ER", [(byte)type, (byte)code]);
|
||||
public static SimReply Drop() => new(SimReplyKind.Drop);
|
||||
public static SimReply Stall() => new(SimReplyKind.Stall);
|
||||
|
||||
/// <summary>Parses a reference fixture value <c>S_RI:0004</c> / <c>T_ER:0117</c>.</summary>
|
||||
public static SimReply FromFixture(string value)
|
||||
{
|
||||
var parts = value.Split(':', 2);
|
||||
return Ok(parts[0], Convert.FromHexString(parts[1]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A small, mutable model of an iTNC 530 speaking the six allowlisted LSV2 requests. Anything else is
|
||||
/// recorded as a <see cref="Violations">violation</see> and refused — the driver must never produce one.
|
||||
/// </summary>
|
||||
public sealed class SimulatedControl
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _violations = new();
|
||||
|
||||
public byte[] Version { get; set; } = Encoding.Latin1.GetBytes("iTNC 530\0NC 340490 04\0PLC\0OPT\0");
|
||||
public byte[]? Layout { get; set; } = LayoutBytes(10);
|
||||
public Func<string, string, SimReply?>? Login { get; set; }
|
||||
public ConcurrentDictionary<int, Func<SimReply>> Runtime { get; } = new();
|
||||
public ConcurrentDictionary<string, SimReply> DataPaths { get; } = new(StringComparer.Ordinal);
|
||||
public Func<long, int, SimReply>? Memory { get; set; }
|
||||
public List<(string Role, string Password)> Logins { get; } = [];
|
||||
|
||||
/// <summary>Requests the control refused as not-allowlisted (must stay empty).</summary>
|
||||
public IReadOnlyList<string> Violations => _violations.ToArray();
|
||||
|
||||
/// <summary>Optional hook to override any reply (fault injection).</summary>
|
||||
public Func<Lsv2Request, SimReply?>? Intercept { get; set; }
|
||||
|
||||
public SimReply Handle(Lsv2Request req)
|
||||
{
|
||||
if (Intercept?.Invoke(req) is { } forced) return forced;
|
||||
switch (req.Command)
|
||||
{
|
||||
case "A_LG":
|
||||
{
|
||||
var fields = req.Text.Split('\0');
|
||||
var role = fields[0];
|
||||
var password = fields.Length > 2 ? fields[1] : "";
|
||||
lock (Logins) Logins.Add((role, password));
|
||||
return Login?.Invoke(role, password) ?? SimReply.Ok("T_OK", []);
|
||||
}
|
||||
case "R_VR" when req.Payload.Length == 0:
|
||||
return SimReply.Ok("S_VR", Version);
|
||||
case "R_PR" when req.Payload.Length == 0:
|
||||
return Layout is null ? SimReply.Refuse(24) : SimReply.Ok("S_PR", Layout);
|
||||
case "R_RI" when req.Payload.Length == 2:
|
||||
return Runtime.TryGetValue(req.Selector, out var f) ? f() : SimReply.Refuse(24);
|
||||
case "R_MB" when req.Payload.Length == 5:
|
||||
var address = BinaryPrimitives.ReadUInt32BigEndian(req.Payload);
|
||||
return Memory?.Invoke(address, req.Payload[4]) ?? SimReply.Refuse(23);
|
||||
case "R_DP" when req.Payload.Length > 5:
|
||||
{
|
||||
var path = Encoding.Latin1.GetString(req.Payload, 4, req.Payload.Length - 5).Replace('\\', '/');
|
||||
return DataPaths.TryGetValue(path, out var r) ? r : SimReply.Refuse(24);
|
||||
}
|
||||
default:
|
||||
_violations.Enqueue(req.Command);
|
||||
return SimReply.Refuse(22);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The reference test layout: words at 1000 with the given byte count, strings at 2000 (width 16), IW 3000, OW 4000.</summary>
|
||||
public static byte[] LayoutBytes(int wordBytes)
|
||||
{
|
||||
var b = new byte[120];
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(40), 1000);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(44), wordBytes);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(48), 2000);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(52), 10);
|
||||
b[56] = 16;
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(64), 3000);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(68), 10);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(72), 4000);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(76), 10);
|
||||
return b;
|
||||
}
|
||||
|
||||
/// <summary>Two-byte uint16be runtime reply.</summary>
|
||||
public static SimReply U16(int v)
|
||||
{
|
||||
var p = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(p, (ushort)v);
|
||||
return SimReply.Ok("S_RI", p);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Loads the ported Java <c>.properties</c> golden vectors.</summary>
|
||||
public static class Fixtures
|
||||
{
|
||||
public static IReadOnlyDictionary<string, string> Load(string name)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
|
||||
var map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var line in File.ReadAllLines(path))
|
||||
{
|
||||
if (line.Length == 0 || line[0] == '#') continue;
|
||||
var eq = line.IndexOf('=');
|
||||
map[line[..eq]] = line[(eq + 1)..];
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
internal static class TestTags
|
||||
{
|
||||
/// <summary>A TagConfig blob for an address (array flag and dataType filled in the way the editor writes them).</summary>
|
||||
public static string Config(string address, string? dataType = null)
|
||||
{
|
||||
var o = new Dictionary<string, object> { ["address"] = address };
|
||||
if (dataType is not null) o["dataType"] = dataType;
|
||||
if (HeidenhainCatalog.TryGet(address, out var e) && e.IsArray) o["isArray"] = true;
|
||||
return JsonSerializer.Serialize(o);
|
||||
}
|
||||
|
||||
public static HeidenhainTagDefinition Def(string address, string? dataType = null, string? rawPath = null)
|
||||
{
|
||||
if (!HeidenhainTagDefinitionFactory.TryFromTagConfig(rawPath ?? "Plant/H/dev1/" + address, "dev1", Config(address, dataType), out var d, out var error))
|
||||
throw new InvalidOperationException(error);
|
||||
return d;
|
||||
}
|
||||
|
||||
public static RawTagEntry Raw(string address, string? dataType = null, string device = "dev1") =>
|
||||
new($"Plant/H/{device}/{address}", Config(address, dataType), false, device);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- OTOPCUA0001: this suite invokes driver capability methods DIRECTLY to exercise wire-level behaviour
|
||||
against the in-process LSV2 simulator — the analyzer's documented intentional case. -->
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Golden vectors ported verbatim from the ignitionoee v0.20.3 Java test resources (real iTNC 530
|
||||
replies captured 2026-11-03 — reference evidence, not proof of this port). -->
|
||||
<ItemGroup>
|
||||
<Content Include="Fixtures\**\*.properties">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -41,6 +41,9 @@ public sealed class DriverPageJsonConverterTests
|
||||
new Dictionary<Type, JsonSerializerOptions>
|
||||
{
|
||||
[typeof(MqttDriverForm)] = MqttJson.Options,
|
||||
// HeidenhainDriverForm (#803) is a shell over HeidenhainDriverFormModel, which writes through its own
|
||||
// JsonOptions (a JsonObject bag that preserves the Devices collection) — the same pattern as MQTT.
|
||||
[typeof(HeidenhainDriverForm)] = HeidenhainDriverFormModel.JsonOptions,
|
||||
};
|
||||
|
||||
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly.</summary>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#803: the Heidenhain authoring surfaces (driver form, device form, tag editor, CSV, browse commit)
|
||||
/// write exactly what the driver factory and the deploy gate accept — the "registered but unauthorable" and
|
||||
/// "numerically-serialized enum" defect classes.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainAuthoringTests
|
||||
{
|
||||
[Fact]
|
||||
public void Every_authoring_seam_is_registered()
|
||||
{
|
||||
DriverConfigFormMap.Resolve(DriverTypeNames.Heidenhain).ShouldBe(typeof(HeidenhainDriverForm));
|
||||
DeviceFormMap.Resolve(DriverTypeNames.Heidenhain).ShouldBe(typeof(HeidenhainDeviceForm));
|
||||
TagConfigEditorMap.Resolve(DriverTypeNames.Heidenhain).ShouldBe(typeof(Components.Shared.Uns.TagEditors.HeidenhainTagConfigEditor));
|
||||
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, "{}").ShouldNotBeNull();
|
||||
CsvColumnMap.Resolve(DriverTypeNames.Heidenhain).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_default_driver_form_save_is_accepted_by_the_factory_and_preserves_devices()
|
||||
{
|
||||
var authored = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"m1"}],"Custom":42}""";
|
||||
var m = HeidenhainDriverFormModel.FromJson(authored);
|
||||
m.Validate().ShouldBeNull();
|
||||
m.InspectPassword = "secret:heidenhain/inspect";
|
||||
m.TimersEnabled = true;
|
||||
var json = m.ToJson();
|
||||
var o = HeidenhainOptionsParser.Parse(json);
|
||||
HeidenhainOptionsParser.Validate(o).ShouldBeEmpty(); // the factory's ParseOptions gate
|
||||
o.Devices.Single().Host.ShouldBe("10.0.0.5");
|
||||
o.Access.InspectPassword.ShouldBe("secret:heidenhain/inspect");
|
||||
o.Polling.TimersEnabled.ShouldBeTrue();
|
||||
JsonNode.Parse(json)!["Custom"]!.GetValue<int>().ShouldBe(42);
|
||||
// no field is ever written as a number where the parser expects a name — there are no enums at all
|
||||
HeidenhainDriverFormModel.FromJson(json).ToJson().ShouldBe(json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_form_refuses_literal_passwords_and_out_of_range_values()
|
||||
{
|
||||
var m = HeidenhainDriverFormModel.FromJson("{}");
|
||||
m.DncPassword = "hunter2";
|
||||
m.Validate()!.ShouldNotContain("hunter2");
|
||||
m.DncPassword = null;
|
||||
m.StatusMs = 10;
|
||||
m.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_form_round_trips_and_validates_with_the_driver_rule()
|
||||
{
|
||||
var d = HeidenhainDeviceModel.FromJson("""{"Host":"cnc-7","Note":"keep"}""");
|
||||
d.Port.ShouldBe(19000);
|
||||
d.Validate().ShouldBeNull();
|
||||
d.DataPassword = "plain";
|
||||
d.Validate().ShouldNotBeNull();
|
||||
d.DataPassword = "env:OTOPCUA_HEIDENHAIN_DATA";
|
||||
var json = d.ToJson();
|
||||
JsonNode.Parse(json)!["Note"]!.GetValue<string>().ShouldBe("keep");
|
||||
var opts = HeidenhainOptionsParser.Parse($$"""{"Devices":[{{json}}]}""");
|
||||
opts.Devices.Single().DataPassword.ShouldBe("env:OTOPCUA_HEIDENHAIN_DATA");
|
||||
HeidenhainDeviceModel.FromJson("{}").Validate().ShouldNotBeNull(); // host required
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Axes/Positions")]
|
||||
[InlineData("Alarms/Messages")]
|
||||
[InlineData("Status/ExecutionState")]
|
||||
[InlineData("Capabilities/DncLogin")]
|
||||
public void Catalog_addresses_derive_type_and_rank_so_the_driver_accepts_them(string address)
|
||||
{
|
||||
var json = new HeidenhainTagConfigModel { Address = address }.ToJson();
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out var def, out var error).ShouldBeTrue(error);
|
||||
HeidenhainCatalog.TryGet(address, out var e).ShouldBeTrue();
|
||||
def!.DataType.ShouldBe(e!.DataType);
|
||||
def.IsArray.ShouldBe(e.IsArray);
|
||||
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editor_writes_the_data_type_as_a_name_and_drops_a_stray_writable_flag()
|
||||
{
|
||||
var m = HeidenhainTagConfigModel.FromJson("""{"address":"PLC:WORD:2","dataType":"Int64","writable":true,"isHistorized":true}""");
|
||||
m.Validate().ShouldNotBeNull(); // as loaded: the driver refuses writable:true
|
||||
var json = m.ToJson();
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["dataType"]!.GetValue<string>().ShouldBe("Int64");
|
||||
o.ContainsKey("writable").ShouldBeFalse();
|
||||
o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
|
||||
HeidenhainTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editor_refuses_a_narrowing_binding()
|
||||
{
|
||||
var m = new HeidenhainTagConfigModel { Address = "PLC:DWORD:0", DataType = DriverDataType.Int16 };
|
||||
m.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Status/ExecutionState")]
|
||||
[InlineData("Axes/Names")]
|
||||
public void Browse_committed_leaves_open_in_the_editor_and_pass_the_driver_gate(string address)
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Heidenhain, address);
|
||||
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull();
|
||||
HeidenhainTagConfigModel.FromJson(json).Address.ShouldBe(address);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// #803 review findings 1 + 8 at the SAVE path (RawTreeService), not just the editor models: a literal or
|
||||
/// out-of-namespace Heidenhain password never reaches the config DB, and a Heidenhain tag row is refused when it
|
||||
/// is ReadWrite or carries a data type the driver does not publish — through every authoring entry point
|
||||
/// (modal create/update, manual entry = CreateTagAsync, CSV/browse = ImportTagsAsync).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainSavePathTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static async Task<(RawTreeService Svc, string Db, string DriverId, string DeviceId)> HeidenhainAsync()
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, "{}", Ct);
|
||||
created.Ok.ShouldBeTrue(created.Error);
|
||||
using var db = RawTreeTestDb.CreateNamed(name);
|
||||
var device = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId);
|
||||
return (svc, name, created.CreatedId!, device.DeviceId);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ finding 1: credentials never persisted
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"Access":{"InspectPassword":"hunter2"}}""")]
|
||||
[InlineData("""{"access":{"dncpassword":"hunter2"}}""")]
|
||||
[InlineData("""{"Access":{"DataPassword":"env:ConnectionStrings__ConfigDb"}}""")]
|
||||
[InlineData("""{"Devices":[{"Host":"h","InspectPassword":"secret:galaxy/inst1/apikey"}]}""")]
|
||||
public async Task CreateDriver_refuses_a_literal_or_out_of_namespace_password_and_persists_nothing(string config)
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, config, Ct);
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldNotContain("hunter2");
|
||||
using var db = RawTreeTestDb.CreateNamed(name);
|
||||
db.DriverInstances.ShouldNotContain(d => d.Name == "hh1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDriver_refuses_a_literal_password_and_keeps_the_stored_config()
|
||||
{
|
||||
var (svc, dbName, driverId, _) = await HeidenhainAsync();
|
||||
byte[] rv;
|
||||
using (var db = RawTreeTestDb.CreateNamed(dbName)) rv = db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
|
||||
var result = await svc.UpdateDriverAsync(driverId, "hh1", """{"Access":{"InspectPassword":"hunter2"}}""", null, rv, Ct);
|
||||
result.Ok.ShouldBeFalse();
|
||||
using var check = RawTreeTestDb.CreateNamed(dbName);
|
||||
check.DriverInstances.Single(d => d.DriverInstanceId == driverId).DriverConfig.ShouldNotContain("hunter2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDevice_and_UpdateDevice_refuse_a_literal_password()
|
||||
{
|
||||
var (svc, dbName, driverId, deviceId) = await HeidenhainAsync();
|
||||
(await svc.CreateDeviceAsync(driverId, "cnc2", """{"Host":"10.0.0.5","DncPassword":"hunter2"}""", Ct)).Ok.ShouldBeFalse();
|
||||
byte[] rv;
|
||||
using (var db = RawTreeTestDb.CreateNamed(dbName)) rv = db.Devices.Single(d => d.DeviceId == deviceId).RowVersion;
|
||||
(await svc.UpdateDeviceAsync(deviceId, "Device1x", """{"Host":"10.0.0.5","DataPassword":"hunter2"}""", true, rv, Ct)).Ok.ShouldBeFalse();
|
||||
using var check = RawTreeTestDb.CreateNamed(dbName);
|
||||
check.Devices.Where(d => d.DriverInstanceId == driverId).ShouldAllBe(d => !d.DeviceConfig.Contains("hunter2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Namespaced_references_save_and_other_drivers_are_unaffected()
|
||||
{
|
||||
var (svc, _, driverId, _) = await HeidenhainAsync();
|
||||
(await svc.CreateDeviceAsync(driverId, "cnc2",
|
||||
"""{"Host":"10.0.0.5","InspectPassword":"secret:heidenhain/cnc2/inspect","DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC"}""", Ct)).Ok.ShouldBeTrue();
|
||||
// A Modbus driver's config is none of this gate's business.
|
||||
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "mb9", DriverTypeNames.Modbus, """{"InspectPassword":"x"}""", Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_modal_save_gate_is_the_same_rule()
|
||||
{
|
||||
DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"hunter2"}}""").ShouldNotBeNull();
|
||||
DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:heidenhain/a"}}""").ShouldBeNull();
|
||||
DriverConfigSaveGate.Validate(DriverTypeNames.Modbus, """{"Access":{"InspectPassword":"hunter2"}}""").ShouldBeNull();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ finding 8: tag rows
|
||||
|
||||
private static RawTagInput Row(string name, string dataType, TagAccessLevel access, string config) =>
|
||||
new(name, dataType, access, WriteIdempotent: false, PollGroupId: null, config);
|
||||
|
||||
private const string Exec = """{"address":"Status/ExecutionState"}""";
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTag_refuses_ReadWrite_and_a_mismatched_data_type()
|
||||
{
|
||||
var (svc, _, _, deviceId) = await HeidenhainAsync();
|
||||
(await svc.CreateTagAsync(deviceId, null, Row("a", "Int32", TagAccessLevel.ReadWrite, Exec), Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.CreateTagAsync(deviceId, null, Row("b", "Float", TagAccessLevel.Read, Exec), Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.CreateTagAsync(deviceId, null, Row("c", "Int32", TagAccessLevel.Read, Exec), Ct)).Ok.ShouldBeTrue();
|
||||
(await svc.CreateTagAsync(deviceId, null,
|
||||
Row("d", "Double", TagAccessLevel.Read, """{"address":"Axes/Positions","isArray":true}"""), Ct)).Ok.ShouldBeTrue();
|
||||
(await svc.CreateTagAsync(deviceId, null,
|
||||
Row("e", "Int64", TagAccessLevel.Read, """{"address":"PLC:DWORD:0","dataType":"Int64"}"""), Ct)).Ok.ShouldBeTrue();
|
||||
(await svc.CreateTagAsync(deviceId, null,
|
||||
Row("f", "Int32", TagAccessLevel.Read, """{"address":"PLC:DWORD:0","dataType":"Int64"}"""), Ct)).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTag_refuses_ReadWrite()
|
||||
{
|
||||
var (svc, dbName, _, deviceId) = await HeidenhainAsync();
|
||||
var created = await svc.CreateTagAsync(deviceId, null, Row("a", "Int32", TagAccessLevel.Read, Exec), Ct);
|
||||
byte[] rv;
|
||||
using (var db = RawTreeTestDb.CreateNamed(dbName)) rv = db.Tags.Single(t => t.TagId == created.CreatedId).RowVersion;
|
||||
(await svc.UpdateTagAsync(created.CreatedId!, Row("a", "Int32", TagAccessLevel.ReadWrite, Exec), rv, Ct)).Ok.ShouldBeFalse();
|
||||
using var check = RawTreeTestDb.CreateNamed(dbName);
|
||||
check.Tags.Single(t => t.TagId == created.CreatedId).AccessLevel.ShouldBe(TagAccessLevel.Read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportTags_names_the_ReadWrite_and_mismatched_rows_and_inserts_nothing()
|
||||
{
|
||||
var (svc, dbName, _, deviceId) = await HeidenhainAsync();
|
||||
var outcome = await svc.ImportTagsAsync(deviceId,
|
||||
[
|
||||
new RawTagImportRow(null, Row("ok", "Int32", TagAccessLevel.Read, Exec)),
|
||||
new RawTagImportRow(null, Row("rw", "Int32", TagAccessLevel.ReadWrite, Exec)),
|
||||
new RawTagImportRow(null, Row("dt", "String", TagAccessLevel.Read, Exec)),
|
||||
], Ct);
|
||||
outcome.Errors.Count.ShouldBe(2);
|
||||
using var db = RawTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.ShouldNotContain(t => t.DeviceId == deviceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Row_defaults_force_Read_and_derive_the_data_type()
|
||||
{
|
||||
var access = TagAccessLevel.ReadWrite;
|
||||
var dataType = "Float";
|
||||
RawTagRowDefaults.Apply(DriverTypeNames.Heidenhain, """{"address":"Overrides/Feed"}""", a => access = a, d => dataType = d);
|
||||
access.ShouldBe(TagAccessLevel.Read);
|
||||
dataType.ShouldBe("Double");
|
||||
RawTagRowDefaults.Apply(DriverTypeNames.Modbus, Exec, a => access = TagAccessLevel.ReadWrite, d => dataType = "x");
|
||||
dataType.ShouldBe("Double"); // untouched for other drivers
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ public sealed class TagConfigNonEnumParityTests
|
||||
[DriverTypeNames.MTConnect] = j => MTConnectTagDefinitionFactory.TryFromTagConfig(j, out _, out _),
|
||||
[DriverTypeNames.Calculation] = j => CalculationTagDefinitionFactory.TryReadTagConfig(j, out _),
|
||||
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagDefinitionFactory.TryReadNodeId(j, out _),
|
||||
[DriverTypeNames.Heidenhain] = j => ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.HeidenhainTagDefinitionFactory.TryFromTagConfig(P, "", j, out _, out _),
|
||||
};
|
||||
|
||||
private static bool RuntimeAccepts(string driverType, string blob) => RuntimeParses[driverType](blob);
|
||||
@@ -91,6 +92,7 @@ public sealed class TagConfigNonEnumParityTests
|
||||
DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.AbCip, DriverTypeNames.AbLegacy,
|
||||
DriverTypeNames.TwinCAT, DriverTypeNames.FOCAS, DriverTypeNames.Mqtt, DriverTypeNames.Sql,
|
||||
DriverTypeNames.MTConnect, DriverTypeNames.Calculation, DriverTypeNames.OpcUaClient,
|
||||
DriverTypeNames.Heidenhain,
|
||||
];
|
||||
|
||||
foreach (var driverType in mapped)
|
||||
@@ -425,6 +427,20 @@ public sealed class TagConfigNonEnumParityTests
|
||||
(DriverTypeNames.MTConnect, """{"dataItemId":"Xact"}"""),
|
||||
(DriverTypeNames.MTConnect, """{"fullName":"Xact","driverDataType":"Strng"}"""),
|
||||
|
||||
(DriverTypeNames.Heidenhain, "{}"),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState","dataType":"Int32"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState","dataType":"String"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Axes/Names","isArray":true}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Axes/Names"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"PLC:WORD:4","dataType":"Int32"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"PLC:WORD:4","dataType":"8"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"PLC:WORD:4"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"PLC:DWORD:0","dataType":"Int16"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"DATA:/PLC/memory/W/8","dataType":"Int64"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState","writable":true}"""),
|
||||
(DriverTypeNames.Heidenhain, Pad + """{"address":"Overrides/Feed"}"""),
|
||||
|
||||
(DriverTypeNames.Calculation, "{}"),
|
||||
(DriverTypeNames.Calculation, """{"scriptId":"S1"}"""),
|
||||
(DriverTypeNames.Calculation, """{"scriptId":"S1","changeTriggered":true,"timerIntervalMs":5000}"""),
|
||||
@@ -602,6 +618,9 @@ public sealed class TagConfigNonEnumParityTests
|
||||
new MTConnectTagConfigModel { FullName = "Xact", DataType = DriverDataType.Float64 }.ToJson());
|
||||
data.Add(DriverTypeNames.Mqtt,
|
||||
new MqttTagConfigModel { Mode = MqttMode.Plain, Topic = "otopcua/fixture/a" }.ToJson());
|
||||
data.Add(DriverTypeNames.Heidenhain, new HeidenhainTagConfigModel { Address = "Axes/Positions" }.ToJson());
|
||||
data.Add(DriverTypeNames.Heidenhain,
|
||||
new HeidenhainTagConfigModel { Address = "PLC:INPUT_WORD:3", DataType = DriverDataType.Int32 }.ToJson());
|
||||
data.Add(DriverTypeNames.Mqtt, new MqttTagConfigModel
|
||||
{
|
||||
Mode = MqttMode.SparkplugB,
|
||||
|
||||
@@ -18,6 +18,7 @@ internal static class TagConfigRoundTrip
|
||||
DriverTypeNames.Sql => SqlTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.Mqtt => MqttTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.MTConnect => MTConnectTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.Heidenhain => HeidenhainTagConfigModel.FromJson(json).ToJson(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(driverType)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#803 end-to-end through the REAL <see cref="DriverInstanceActor"/>: the real Heidenhain driver
|
||||
/// (built by its factory from a merged config) against the in-process LSV2 simulator — subscribe, OPC write
|
||||
/// refusal, controller-side outbound restriction, outage invalidation and recovery.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainDriverEndToEndTests : RuntimeActorTestBase
|
||||
{
|
||||
private const string Exec = "Plant/H/dev1/Status/ExecutionState";
|
||||
private const string Model = "Plant/H/dev1/Controller/Model";
|
||||
|
||||
private static string Config(int port) => JsonSerializer.Serialize(new
|
||||
{
|
||||
Devices = new[] { new { Host = "127.0.0.1", Port = port, DeviceName = "dev1" } },
|
||||
RawTags = new[]
|
||||
{
|
||||
new { RawPath = Exec, TagConfig = """{"address":"Status/ExecutionState"}""", WriteIdempotent = false, DeviceName = "dev1" },
|
||||
new { RawPath = Model, TagConfig = """{"address":"Controller/Model"}""", WriteIdempotent = false, DeviceName = "dev1" },
|
||||
},
|
||||
TimeoutMs = 500,
|
||||
Polling = new { StatusMs = 100, CustomMs = 100, SlowMs = 200 },
|
||||
Reconnect = new { InitialDelayMs = 100, MaxDelayMs = 200 },
|
||||
});
|
||||
|
||||
private DriverInstanceActor.AttributeValueItem? AwaitItem(Akka.TestKit.TestProbe parent, string reference, Func<DriverInstanceActor.AttributeValueItem, bool> match)
|
||||
{
|
||||
DriverInstanceActor.AttributeValueItem? found = null;
|
||||
parent.FishForMessage<DriverInstanceActor.AttributeValueBatchPublished>(b =>
|
||||
{
|
||||
foreach (var i in b.Items)
|
||||
if (i.FullReference == reference && match(i)) { found = i; return true; }
|
||||
return false;
|
||||
}, TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken);
|
||||
return found;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_write_refusal_outage_and_recovery_through_the_real_actor()
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
control.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var driver = HeidenhainDriverFactoryExtensions.CreateInstance("hh-e2e", Config(sim.Port));
|
||||
var parent = CreateTestProbe();
|
||||
parent.IgnoreMessages(m => m is not DriverInstanceActor.AttributeValueBatchPublished);
|
||||
var actor = parent.ChildActorOf(
|
||||
DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(100),
|
||||
healthPollInterval: TimeSpan.FromMilliseconds(100), commsLossTimeout: TimeSpan.FromSeconds(1)),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions([Exec, Model], TimeSpan.FromMilliseconds(100)));
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested(Config(sim.Port)));
|
||||
|
||||
AwaitItem(parent, Exec, i => i.StatusCode == OpcUaStatus.Good)!.Value.Value.ShouldBe(4);
|
||||
|
||||
// OPC write: refused by the runtime (the driver implements no IWritable) — nothing reaches the control.
|
||||
var write = await actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
||||
new DriverInstanceActor.WriteAttribute(Exec, 9), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||
write.Success.ShouldBeFalse();
|
||||
|
||||
// Outage: every remote value goes BadNoCommunication.
|
||||
sim.GoOffline();
|
||||
AwaitItem(parent, Exec, i => i.StatusCode == HeidenhainStatus.BadNoCommunication).ShouldNotBeNull();
|
||||
|
||||
// Recovery: the control comes back holding the SAME value, and it re-publishes Good.
|
||||
sim.GoOnline();
|
||||
AwaitItem(parent, Exec, i => i.StatusCode == OpcUaStatus.Good)!.Value.Value.ShouldBe(4);
|
||||
|
||||
control.Violations.ShouldBeEmpty();
|
||||
sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_RI" });
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,15 @@
|
||||
<!-- Lane S7 review I-1: the real S7 driver (over a scripted IS7Plc), so the comms-loss watchdog test proves an
|
||||
access-denied (PUT/GET disabled) CPU is NOT reconnected every 90 s, against the real actor. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<!-- lmxopcua#803: the real Heidenhain LSV2 driver over the in-process LSV2 simulator (linked below), so the
|
||||
end-to-end test proves subscribe / write refusal / comms-loss watchdog recovery through the real actor. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Shared bind-race retry helper for tests that boot a real SDK server on a probed port. -->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests\OpcUaHostStart.cs" Link="OpcUaHostStart.cs"/>
|
||||
<Compile Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests\Simulator\*.cs" LinkBase="HeidenhainSimulator"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user