diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md index c1651178..41cc3b71 100644 --- a/docs/DriverLifecycle.md +++ b/docs/DriverLifecycle.md @@ -425,6 +425,15 @@ re-entered this path; the watchdog makes it automatic and unbounded, so a device have leaked ~360 transports at the 10 s retry interval. The shutdown is best-effort: a driver that throws on it still gets its initialise attempt. +**`InitializeAsync` itself releases the previous device/connection state first (#630).** The retry above is not +the only way in: `DriverInstanceActor.AdoptConfigDuringInit` calls `InitializeAsync` on a live driver with *no* +teardown, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans +the previous state's probe / recycle / fixed-tree loops and its client. FOCAS, AbLegacy, AbCip, TwinCAT, S7 and +OpcUaClient now cancel, bounded-join (5 s) and dispose that state at the top of `InitializeAsync` +(`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT. +It is deliberately the device-state half of `ShutdownAsync` only: the poll engine and subscriptions are *not* +dropped: the release is scoped to device state so it cannot take state the shutdown path owns. The actor re-subscribes values and alarms after every successful init, so driver-side subscriptions kept across a no-teardown re-init are retained leftovers, not needed for data flow. A new driver must do the same. + #### The fan-out that watchdog triggers: indexed per driver, and one batch `DisconnectObserved` raises `ConnectivityChanged`, and `DriverHostActor` answers it by pushing diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index 9d088d87..09b46101 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -67,6 +67,9 @@ pieces for driver-side routing (e.g. deciding a bit needs read-modify-write): | `ST9:0` | String file (82-byte fixed-length) | | `T4:0.ACC` / `C5:0.PRE` | Timer / counter sub-element | | `I:0/0` / `O:1/2` / `S:1` | Input / output / status system files (no file number) | +| `I:017/07` | **PLC-5 only:** `I:`/`O:` word and bit indices are **octal** (#244); a digit 8/9 is a parse error | +| `RTC:0.HR` / `HSC:0.ACC` | **MicroLogix only:** function files RTC, HSC, DLS, MMI, PTO, PWM, STI, EII, IOS, BHI (#245) | +| `N7:[N7:0]` / `N[N7:0]:5` | Indirect (index word read from the PLC) — parsed, **not supported at runtime** (#247) | `AbLegacyDataType` covers the corresponding PCCC types: `Bit`, `Int` (N), `Long` (L), `Float` (F), `AnalogInt` (A), `String` (ST), and the `TimerElement` / @@ -85,6 +88,42 @@ this is an inherent limitation of any software-level RMW. Input-image (`I`) bit writes are forwarded to the PLC as-is; the device drives the input image from physical inputs and may reject the write. +### Family-specific address forms (#244 / #245 / #247) + +`AbLegacyAddress.TryParse(text, family)` is family-aware; the driver passes the device's `PlcFamily` +(the one-argument overload is the family-neutral grammar: decimal, no function files). + +- **PLC-5 octal I/O (#244).** RSLogix 5 shows `I:`/`O:` word and bit indices in octal, so `I:017/07` is word 15, + bit 7 and `I:017/17` is bit 15. Only `I`/`O` on PLC-5 (`OctalIoAddressing` on the family profile) — SLC, + MicroLogix and Logix-PCCC stay decimal, and PLC-5 `N`/`B`/... files stay decimal. The record stores decimal and + `ToLibplctagName()` **emits decimal**, so the libplctag `name=` string and the string the native transport + re-parses carry identical numbers. This is not a double conversion: libplctag's `pccc.c` + (`parse_pccc_elem_num` / `parse_pccc_bit_num`) parses decimal numerals only (a FIXME there says octal is + unsupported), so the driver must convert. Pinned by `Plc5_io_indices_are_octal` (`I:017/17` -> `I:15/15`). + The connectivity-probe address is normalised the same way. + - ⚠️ **Behaviour change / migration.** Before this change a PLC-5 `I:`/`O:` address was passed to libplctag as + written, i.e. read as **decimal**. An address copied from RSLogix in octal (e.g. `I:017`) therefore read the + wrong word, and an authored decimal such as `I:1/12` now means bit 10 (octal 12). A previously "working" + address containing the digits 8 or 9 (`I:018`, `O:1/9`) is no longer valid on PLC-5. Re-author any PLC-5 + I/O tags in RSLogix octal. Such a tag is refused (see below), never silently misread. + - **A malformed / family-invalid address** (also: a function-file letter off MicroLogix) reads and writes + `BadNodeIdUnknown` for that tag only, creates no runtime, leaves driver health alone, and logs **one** + Warning per tag. The same check (`AbLegacyAddress.Validate`) runs in the AdminUI tag editor (family-independent: + rejects only what *no* family accepts) and the deploy gate (`EquipmentTagConfigInspector`, using the device's + `DeviceConfig.PlcFamily`, absent = SLC 500, unrecognised = unknown) — so `I:018` on a PLC-5 device warns at + deploy (an error under `TagConfigValidationMode.Error`), and the editor is never stricter than the gate. +- **MicroLogix function files (#245).** `RTC`, `HSC`, `DLS`, `MMI`, `PTO`, `PWM`, `STI`, `EII`, `IOS`, `BHI` + (single fixed instance — no file number — with a `.SUBELEMENT`; no bit index) parse on MicroLogix devices only. + `AbLegacyFunctionFile.SubElementType` carries the sub-element catalogue (flags → Boolean, words → Int32; unknown + members default to Int32). ⚠️ **Wire-unverified on both transports:** there is no capture and no simulator + coverage; the libplctag path passes the name through and the native transport has no file-type code for them, so + it refuses the tag as `Unsupported` (`BadNotSupported` to the client; see Wire evidence). +- **Indirect addressing (#247).** `N7:[N7:0]` (word from another address) and `N[N7:0]:5` (file from another + address) parse, one level deep (the inner address must be direct, and carries the family's octal rule). + **Runtime resolution is not implemented** (it needs a pre-read of the index word and per-resolved-address + runtimes): reads and writes of an indirect tag answer `BadNotSupported` before any runtime is created, on both + transports. + ## Configuration `AbLegacyDriverOptions` (`Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`) @@ -172,6 +211,7 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h | N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) | | F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) | | S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table | +| MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O, indirect addressing | **none** — parsed only; native refuses function files and indirect names as Unsupported | | T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** | ### Native known gaps diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md index 3df703f6..f2442d1b 100644 --- a/docs/drivers/FOCAS.md +++ b/docs/drivers/FOCAS.md @@ -91,6 +91,10 @@ on. | `MACRO:500` | Custom macro variable | System / user macro variable number | | `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only | +PMC letters are series-gated by `FocasCapabilityMatrix.PmcLetters`; the 16i accepts `X Y F G R D` (F/G are +the CNC-to-PMC / PMC-to-CNC handshake groups its ladders use, #265 — reads only, FOCAS stays read-only), while +`M C E A K T` remain 0i-F / 30i-family. + The first three name a location the driver asks the CNC for on demand. `FIXED:` does not: it names a field of the per-device fixed-tree snapshot the driver's own poll loop already maintains, so a `FIXED:` read issues no wire diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 1068984b..d19186f7 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -230,10 +230,37 @@ Other status codes an observation can surface: |---|---| | `BadTypeMismatch` (`0x80740000`) | The Agent's text isn't a value of the tag's coerced type at all | | `BadOutOfRange` (`0x803C0000`) | The Agent reported a number the coerced type can't represent | -| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a `TIME_SERIES` vector, or a structured `DATA_SET`/`TABLE` observation (deferred to P1.5; see [Known limitations](#known-limitations)) | +| `BadDecodingError` (`0x80070000`) | A `TIME_SERIES` vector whose element count disagrees with the observation's own `sampleCount` (or the tag's fixed `ArrayDim`) — see [TIME_SERIES](#time_series-arrays-627) | +| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a structured `DATA_SET`/`TABLE` observation (decided: stays unsupported, #627), or an array tag declared as anything other than Float64 | | `BadWaitingForInitialData` | Tag is authored but the Agent hasn't reported it yet | | `BadNodeIdUnknown` | DataItem id is neither authored nor ever observed | +### TIME_SERIES arrays (#627) + +A `SAMPLE` DataItem with `representation="TIME_SERIES"` is served as a +**variable-length `double[]`** (`ValueRank = 1`, no fixed `ArrayDimensions` — +a live Agent never declares `sampleCount` on the DataItem, see Known +limitations 4; a probe that does declare one yields a fixed `ArrayDim`). +Parsing: the observation text is split on any whitespace, each token parsed +invariant-culture. The result is **all-or-nothing** — one non-numeric / +non-finite token is `BadTypeMismatch`, a literal `UNAVAILABLE` token or an +empty body is `BadNoCommunication`, and a count that disagrees with the +observation's own `sampleCount` attribute (or a fixed `ArrayDim`) is +`BadDecodingError`. A partial vector is never served as Good. Only a +**Float64** array tag is served; any other declared element type stays +`BadNotSupported`. An authored tag needs `"isArray": true` and +`"dataType": "Float64"` in its TagConfig (the AdminUI editor shows an advisory +for any other array type; a browse-commit does not stamp `isArray`, so set it +in the Tag modal's array section). + +**Sample rate is not exposed on the served node.** The observation's +`sampleRate` and `sampleCount` attributes are parsed (`MTConnectObservation`) +but the OPC UA node has no description/property slot through `DriverAttributeInfo`, +and a sibling property node would need a new address-space mechanism. Decision: +document it — the rate is a property of the Agent's DataItem (`sampleRate` in +`/probe`), read it there. **`DATA_SET` / `TABLE` stay `BadNotSupported`** (decision, #627): they are +key/value maps with no scalar or array OPC UA shape that preserves the keys. + ### Subscribe — the `/sample` pump One shared `/sample` long-poll stream per driver instance (the Agent streams @@ -383,14 +410,30 @@ for anything beyond values-and-conditions. `GetHostStatuses()` fresh, and the result rides `DriverHealthChanged` to the AdminUI `/hosts` page (no SQL; the `DriverHostStatus` table was dropped). See `docs/Telemetry.md`. -2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for - routing.** One driver instance owns exactly one Agent client scoped by - `MTConnectDriverOptions.DeviceName` (the top-level config key); the - per-tag `RawTagEntry.DeviceName` field the v3 raw-tag identity carries is - never read by the driver. Two `/raw` Devices authored under one MTConnect - driver instance both resolve to the same one Agent connection. Real - per-device routing (a client per device) would be a design change, not a - bug fix. +2. **One MTConnect driver instance = one Agent client = at most one `/raw` + Device (#628, decision (b)).** The driver never reads + `RawTagEntry.DeviceName`; every raw tag binds through the single Agent client + scoped by `MTConnectDriverOptions.DeviceName`. The v3 raw tree always has a + Device level (a new driver auto-creates `Device1`), and that single-Device + layout is the normal, valid shape — its `DeviceConfig` is merged up into the + driver config by `DriverDeviceConfigMerger` (this is how the endpoint lands). + With **two or more** Devices under one MTConnect driver there is no merge-up + and no routing, so which Device supplies the endpoint is ambiguous. That is + now a **deploy-time `DraftValidator` error** (`MTConnectMultipleDevices`), + and the AdminUI refuses to *create* the second Device + (`RawTreeService.CreateDeviceAsync`, same message): "MTConnect binds all tags + through one agent client per driver instance; author one MTConnect driver per + agent device." Option (a), a client per Device, was rejected as a design + change with no demand. A multi-device Agent is served by several driver + instances, each with its own `DeviceName` scope. The driver's init-time + Warning for differing `RawTagEntry.DeviceName` values stays as defence in + depth for configs that predate the rule. + + ⚠️ **Upgrade note:** an MTConnect driver that *already* has two or more Device + rows fails its next deploy with `MTConnectMultipleDevices` (the rule counts every + Device row, enabled or not, because nothing at runtime reads `Device.Enabled`). + Before upgrading, move each extra Device's tags under its own MTConnect driver + and delete the extra Device rows. **The write path is the one place a device name is resolved per DataItem**, and it does not use `RawTagEntry.DeviceName` either: it reads the owning @@ -399,18 +442,13 @@ for anything beyond values-and-conditions. So `MTConnectDriverOptions.DeviceName` does double duty — request scope for reads/browse, fallback write target for writes. - **It is no longer silent (#613).** When the instance's raw tags carry two or - more distinct non-blank `RawTagEntry.DeviceName` values, driver init logs one - Warning naming them and the driver-level scope they collapse into. A single - value is not reported (it is just the `/raw` Device's own name). Real - per-device routing remains deferred. 3. **Authoring only *warns* when a declared coercion type cannot hold the inferred one (#613).** The AdminUI tag editor (`MTConnectTagConfigModel.Warnings()`) compares the declared type with `MTConnectDataTypeInference` over the `/probe` metadata a browse-commit stamped (`mtCategory`/`mtType`), and shows an advisory line — it never blocks a save (the deploy gate does not refuse it) and is a no-op for hand-authored blobs with no stamped metadata. `representation` - is not on the model, so TIME_SERIES arrays are not covered. Beyond that, + is not on the model; TIME_SERIES arrays get their own `isArray` advisory (declare Float64). Beyond that, nothing validates a raw tag's declared OPC UA `DataType` against the blob's coercion type. The `/probe`-sourced inference (`MTConnectDataTypeInference`) narrows how often an author picks a wrong @@ -427,9 +465,11 @@ for anything beyond values-and-conditions. declare a `sampleCount`, is unrealistic on this point and exists only to pin the parsing rule itself. 5. **CONDITION is a plain `String`, not a native Part 9 alarm** (see above). -6. **`TIME_SERIES` arrays and structured `DATA_SET`/`TABLE` observations - surface as `BadNotSupported`**, not an approximation — see the Quality - mapping table. +6. **Structured `DATA_SET`/`TABLE` observations surface as + `BadNotSupported`**, not an approximation — see the Quality mapping table. + `TIME_SERIES` is served as `double[]` (#627, see + [TIME_SERIES arrays](#time_series-arrays-627)); the sample rate is not + surfaced on the node. ## Deferred (not in this build) @@ -447,8 +487,7 @@ for anything beyond values-and-conditions. - **P1.5 fast-follow** (design [§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): CONDITION → native OPC UA Part 9 alarms via `IAlarmSource` (the Galaxy - native-alarm pattern); `TIME_SERIES` SAMPLE arrays materialized as real - OPC UA arrays; EVENT controlled-vocabulary values → OPC UA enumerations. + native-alarm pattern); EVENT controlled-vocabulary values → OPC UA enumerations. - **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"` switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses auto-discovery (no device model without an Agent in front, so diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md index 824d8e80..23ce7de8 100644 --- a/docs/drivers/S7.md +++ b/docs/drivers/S7.md @@ -65,6 +65,30 @@ hardware. A wrong slot causes a connection refusal during the handshake. See `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs` for the per-field defaults. +### TSAP / connection-type override + +By default S7.Net derives both ISO-TSAPs from `CpuType` / `Rack` / `Slot` (local `01.00`; remote +`03.` for the S7-300/400/1200/1500 families). Three optional keys override that +for CPUs and gateways that hand out connection resources by TSAP (S7-400H, CP343, LOGO!): + +| Key | Form | Effect | +|---|---|---| +| `localTsap` | `"01.00"` (also `"0100"`, `"0x0100"`, or a 16-bit number) | replaces the local TSAP | +| `remoteTsap` | `"03.01"` (same forms) | replaces the whole remote TSAP | +| `connectionType` | `"PG"` \| `"OP"` \| `"S7Basic"` (string) | writes `1` / `2` / `3` into the **first** remote-TSAP byte, keeping `rack×0x20+slot` as the second | + +`remoteTsap` and `connectionType` are mutually exclusive (a config carrying both fails to load); a +malformed TSAP fails to load rather than silently falling back to the slot-derived default. With none of +the three set the connection is built exactly as before. The override applies to the driver, the AdminUI +Test Connect probe and the AdminUI S7 driver form (Connection type / Local TSAP / Remote TSAP fields). + +### Test Connect and PUT/GET + +After the handshake, Test Connect reads 2 bytes from `MB0`. A CPU with PUT/GET disabled (the S7-1200/1500 +default) completes the handshake but denies every read, so the probe fails with an actionable "enable +PUT/GET communication" message instead of a false green tick. A CPU that merely rejects that address (no +`MB0`) still passes — the connection is fine; any other read fault fails the probe with its message. + ## Address forms Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by @@ -102,19 +126,52 @@ extra PLC round-trip. | `UInt64` | ULINT/LWORD | 8 bytes | yes | yes | | `Float64` | LREAL | 8 bytes | yes | yes | | `String` | STRING | `StringLength + 2` bytes | yes | yes | -| `DateTime` | DATE_AND_TIME | 8 bytes | yes | yes | +| `WString` | WSTRING | `4 + 2 × StringLength` bytes | yes | yes | +| `Char` | CHAR | 1 byte | yes | yes | +| `WChar` | WCHAR | 2 bytes | yes | yes | +| `DateTime` | DATE_AND_TIME (DT) | 8 bytes (BCD) | yes | yes | +| `Dtl` | DTL | 12 bytes | yes | yes | +| `Date` | DATE | 2 bytes | yes | yes | +| `Tod` | TIME_OF_DAY | 4 bytes | yes | yes | +| `Time` | TIME | 4 bytes | yes | yes | +| `S5Time` | S5TIME | 2 bytes | yes | yes | | Timer (`T{n}`) | TIME | — | yes | **read-only** | | Counter (`C{n}`) | COUNTER | — | yes | **read-only** | -Wide types (`Int64`, `UInt64`, `Float64`, `String`, `DateTime`) and Timer/Counter require -byte-anchored addressing — see the section below. +Wide types (`Int64`, `UInt64`, `Float64`, `String`, `DateTime`, and the string / date-time +types below) and Timer/Counter require byte-anchored addressing — see the section below. + +### String, character and date/time/duration types + +These add no new OPC UA data types — they map onto existing ones: + +| DataType | OPC UA type | Representation | Write refuses (`BadOutOfRange`, nothing sent) | +|---|---|---|---| +| `String` | String | S7 `[maxLen][curLen][chars]`, ASCII | over `StringLength` (S7.Net behaviour) | +| `WString` | String | `[maxLen UInt16][curLen UInt16][UTF-16BE]`; `stringLength` is the declared max in characters, **0–16382** (default 254) | more UTF-16 code units than `StringLength` | +| `Char` | String (1 char) | 1 byte, Latin-1 | a character above U+00FF | +| `WChar` | String (1 char) | UTF-16BE code unit | — (a string whose length is not 1 is `BadTypeMismatch`) | +| `DateTime` | DateTime | DT, 8-byte BCD (S7.Net codec; 1990–2089) | out-of-range year | +| `Dtl` | DateTime | year UInt16 + month/day/weekday/h/m/s + UInt32 nanoseconds; 100 ns resolution | year outside 1970–2262 | +| `Date` | DateTime (midnight) | UInt16 days since 1990-01-01 (max 2169-06-06) | before 1990-01-01 or after 2169-06-06 | +| `Tod` | **Int32 ms** since midnight | UInt32 ms | outside 0–86 399 999 | +| `Time` | **Int32 ms** (signed duration) | Int32 ms | outside the Int32 range | +| `S5Time` | **Int32 ms** | 2-bit time base (10 ms / 100 ms / 1 s / 10 s) + 3 BCD digits | above 9 990 000 ms, negative, or not exactly representable (resolution is 10 ms up to 9 990 ms, then coarser) — **never rounded** | + +A fractional millisecond `Double` is rounded to the nearest whole millisecond (banker's rounding), exactly +as the driver does for every other Int32 tag write; only values off the S5TIME time-base grid are refused. +A write of a `TimeSpan` is also accepted for `Tod` / `Time` / `S5Time`. A PLC buffer the codec cannot +decode — an uninitialised (all-zero) `Dtl`, a non-BCD `S5Time` digit, a `WString` whose current length +exceeds its reserved length — reads `BadOutOfRange` for that tag only, without degrading driver health +or condemning the connection. `S5Time` writes pick the finest time base that holds the value. The +`Timer` area (`T{n}`) keeps its own S5TIME-to-**seconds** (`Float64`) decode. ## Wide types & Timer/Counter ### Byte-anchored addressing for wide / structured types -Wide types (`Int64`, `UInt64`, `Float64`/LReal, `String`, `DateTime`) are -**byte-anchored**: the address must use the `B` suffix pointing at the **first +Wide types (`Int64`, `UInt64`, `Float64`/LReal, `String`, `DateTime`, `WString`, `Char`, +`WChar`, `Dtl`, `Date`, `Tod`, `Time`, `S5Time`) are **byte-anchored**: the address must use the `B` suffix pointing at the **first byte** of the value; the driver reads the correct number of contiguous bytes based on the DataType. @@ -125,6 +182,8 @@ based on the DataType. | `Float64` | same | 8 | `DB1.DBB8` → LREAL at bytes 8–15 | | `DateTime` | same | 8 | `DB3.DBB0` → DATE_AND_TIME at bytes 0–7 | | `String` | same | `StringLength + 2` | `DB4.DBB0`, StringLength=40 → 42 bytes | +| `WString` | same | `4 + 2 × StringLength` | `DB5.DBB0`, StringLength=10 → 24 bytes | +| `Dtl` | same | 12 | `DB6.DBB0` → DTL at bytes 0–11 | Using a `W` or `D` suffix with a wide DataType is a config error caught at `InitializeAsync`. For array tags (`isArray: true`), wide-type element arrays @@ -155,11 +214,18 @@ The following are explicitly deferred and will produce a config error or `BadNotSupported` if attempted: - **Wide-type arrays** — array tags (`isArray: true`) with element types `Int64`, - `UInt64`, `Float64`, `String`, or `DateTime`. -- **`S7WString`** (2-byte character strings; distinct from classic `STRING`). -- **`DTL` / `DateTimeLong`** (12-byte Siemens date-and-time-long). + `UInt64`, `Float64`, `String`, `DateTime`, or any of the string / date-time types above. - **Timer / Counter writes** — surfaced as `BadNotWritable` until write support lands. +## Negotiated PDU size + +The PDU size the CPU negotiates during the connection handshake (typically 240 bytes; 480 / 960 with +extended PDU) is snapshotted at every (re)connect and exposed as `S7Driver.NegotiatedPduSize` / +`S7Driver.GetDiagnostics()["S7.NegotiatedPduSize"]` (0 before the first connect and after shutdown). It is +also written to the `S7Driver connected` / `reconnected` log lines and to the AdminUI Test Connect +message (`S7 connected (CPU S71500, PDU 240 bytes)`). `DriverHealth` has no diagnostics bag in this +codebase, so the value is not yet on the `/hosts` health snapshot — adding one is a Core change. + ## Capability surface `S7Driver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe` diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md index 3a883607..a878f170 100644 --- a/docs/drivers/TwinCAT.md +++ b/docs/drivers/TwinCAT.md @@ -135,6 +135,29 @@ found against a real TwinCAT 2 PLC: registers, and the probe-tick retry re-checks it after a PLC program change. Reads go through the Beckhoff symbol cache, which converts numeric values by the PLC's own type, so only the string-kind rule applies there. +- **IEC temporal types publish native values (#306, behaviour change).** `TIME` and + `TOD` (TIME_OF_DAY) publish **`Double` milliseconds** (`DriverDataType.Float64`); + `DATE` and `DT` (DATE_AND_TIME) publish a **UTC `DateTime`** decoded from the + UDINT seconds-since-1970 counter (`DriverDataType.DateTime`). They used to + publish the raw counter as `UInt32`. Reads, notifications, array elements and + browse/discovery all agree (`TwinCATTemporalCodec`). No new `DriverDataType` + member was added (`Duration` would touch every switch in Core, the node + manager, historian mapper and editors); a duration is a Double ms, which is + what OPC UA `Duration` is on the wire. `LTIME`/`LDT` are not in the driver's + type surface and are still unsupported. **Writes** accept a `Double` ms / + `TimeSpan` (TIME, TOD) or a `DateTime` (DATE — truncated to a whole day — and + DT); negative, NaN, pre-1970 or > UDINT values are refused `BadOutOfRange`, and + a duration written to a date (or vice versa) `BadTypeMismatch`. + **Existing tags:** a tag authored with `dataType: "Time"` (etc.) now surfaces + the decoded type — re-browse/redeploy to pick up the new node type. A tag + authored `UDInt` against a TIME/DATE symbol is unchanged: it still reads and + writes the raw 32-bit counter (the guard tolerates the same-width divergence). + **Historian:** a historized tag authored `Date`/`DateTime` now publishes + `DriverDataType.DateTime`, which continuous historization skips as non-numeric + (it was historized as `UInt32` before) — such tags stop recording. Author them + `UDInt` to keep historizing the raw counter. `Time`/`TimeOfDay` are now + `Float64` and still historize. A `TimeOfDay` write of 86 400 000 ms or more + is refused `BadOutOfRange`. - **Sizes come from the resolved data type, not the symbol.** On a TwinCAT 2 target the Beckhoff library reports a dotted *member* symbol's `ByteSize` as if the PLC's byte count were a bit count (`.OPC_MACHINEDATA.PROCESSSTATUS` @@ -315,7 +338,7 @@ works: | `.ARRFAULTCODE` | `ARRAY [0..12] OF UINT` | UInt × 13 | `UInt16[13]` (element 3 = 32, matching `.ARRFAULTCODE[3]` scalar) | | `.ARRUSPOWER` | `ARRAY [1..3, 1..2] OF REAL` / `[1..2, 1..2]` | Real × 6 / 4 | `Single[6]` / `Single[4]` — changing (~1060 W on Z33733); **23/23** notifications identical to an immediate poll | | `P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE` | `ARRAY [1..20] OF REAL` (member, symbol 10 B) | Real × 20, and × 5 (sub-range) | `Single[20]` changing, **14/14** identical; `Single[5]` = its first 5 | -| `.THROTTLE_MODE_DEFAULT` | `ARRAY [0..12] OF TIME` | Time × 13 | `UInt32[13]` `[0, 10, 20, 40, …]` ms | +| `.THROTTLE_MODE_DEFAULT` | `ARRAY [0..12] OF TIME` | Time × 13 | `Double[13]` `[0, 10, 20, 40, …]` ms (was `UInt32[13]` before #306; declare `UDInt` for the raw counter) | | `.ARGTYPE_IS_BIT_TYPE` | `ARRAY [0..18] OF BOOL` | Bool × 19 | `Boolean[19]` | | `.FORMAT_DEFAULT_PRECISION` / `.DWORD_DECADE_DIVIDER` / `.DIXTIMEALARM` | `INT` / `DWORD` / `UDINT` arrays | Int / UDInt | `Int16[12]` / `UInt32[10]` / `UInt32[101]` | | `.IMQN_TABLE` / `.AMSNETID` / `.ARRMODULENO` | `LREAL` / `USINT` / `BYTE` arrays | LReal / USInt / SInt | `Double[32]` / `Byte[6]` / `SByte[13]` (changing on Z33856) | diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index 3543bce2..3f5bebab 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -41,6 +41,7 @@ public static class DraftValidator ValidateEquipReferenceResolution(draft, errors); ValidateCalculationTags(draft, errors); ValidateSqlConnectionStringNotPersisted(draft, errors); + ValidateMTConnectSingleDevice(draft, errors); ValidateNodeAclScopes(draft, errors); return errors; } @@ -343,6 +344,42 @@ public static class DraftValidator } } + /// Error code for an MTConnect driver instance that has more than one /raw Device (#628). + public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices"; + + /// + /// The one message both the deploy gate and the authoring-time guard (RawTreeService.CreateDeviceAsync) + /// use, so the two can never describe different rules. + /// + /// The driver instance id or name to cite. + public static string MTConnectMultipleDevicesMessage(string driverLabel) => + $"MTConnect driver '{driverLabel}' has more than one Device. MTConnect binds all tags through one agent " + + "client per driver instance and does not route by /raw Device, and with several Devices the Device's " + + "DeviceConfig (agent URI / device name) is no longer merged into the driver config, so which Device would " + + "supply the endpoint is ambiguous. Author one MTConnect driver per agent device, each with a single Device."; + + /// + /// #628 (decision (b)): an MTConnect driver instance owns exactly one agent client, so a second /raw + /// Device under it is a genuine ambiguity — the driver ignores RawTagEntry.DeviceName, and + /// DriverDeviceConfigMerger only merges a Device's DeviceConfig up to the driver config when + /// there is exactly ONE Device. The normal single-Device layout (every driver gets a default + /// Device1) stays valid. + /// + private static void ValidateMTConnectSingleDevice(DraftSnapshot draft, List errors) + { + foreach (var driver in draft.DriverInstances) + { + if (!string.Equals(driver.DriverType, Core.Abstractions.DriverTypeNames.MTConnect, StringComparison.Ordinal)) + continue; + var count = draft.Devices.Count(d => + string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal)); + if (count <= 1) continue; + errors.Add(new(MTConnectMultipleDevicesCode, + MTConnectMultipleDevicesMessage(driver.Name) + $" ({count} Devices found.)", + driver.DriverInstanceId)); + } + } + /// WP7 Calculation-driver deploy gates. For every tag bound to a Calculation driver: /// /// scriptId existence — the tag's TagConfig.scriptId must be present and resolve to diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs index 4c577290..4294c1f7 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs @@ -88,7 +88,7 @@ public sealed class WriteCommand : S7CommandBase internal static object ParseValue(string raw, S7DataType type) { if (type == S7DataType.Bool) return ParseBool(raw); - if (type == S7DataType.String) return raw; + if (type is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar) return raw; try { return type switch @@ -102,7 +102,11 @@ public sealed class WriteCommand : S7CommandBase S7DataType.UInt64 => (object)ulong.Parse(raw, CultureInfo.InvariantCulture), S7DataType.Float32 => (object)float.Parse(raw, CultureInfo.InvariantCulture), S7DataType.Float64 => (object)double.Parse(raw, CultureInfo.InvariantCulture), - S7DataType.DateTime => (object)DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + S7DataType.DateTime or S7DataType.Dtl or S7DataType.Date + => (object)DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + // TOD / TIME / S5TIME are Int32 milliseconds on the OPC UA side. + S7DataType.Tod or S7DataType.Time or S7DataType.S5Time + => (object)long.Parse(raw, CultureInfo.InvariantCulture), _ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."), }; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 9157f8ba..a80a86e0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -106,10 +106,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly Dictionary _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase); // v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive // ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper. - private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal); + private volatile Dictionary _tagsByRawPath = new(StringComparer.Ordinal); // Top-level authored definitions (parents + flat tags, NOT synthesised members), in author order — // the source DiscoverAsync groups by device. Members are synthesised inside the discovery/read paths. - private readonly List _declaredTags = new(); + private volatile List _declaredTags = new(); // Resolves a read/write/subscribe fullReference (always a RawPath in v3) to a tag definition by a // single hit on the authored _tagsByRawPath table; a miss is a miss (the pre-v3 blob-parse fallback @@ -335,8 +335,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, public string DriverType => "AbCip"; /// - public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { + // #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit + // runs it with no teardown first). Release the previous device states' probe loops + handles before + // the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions. + await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false); _health = new DriverHealth(DriverState.Initializing, null, null); _logger.LogInformation( "AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}", @@ -373,6 +377,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is // skipped (logged), never thrown — a bad tag must not fail the whole driver init. _skippedTags.BeginRebuild(_options.RawTags.Count); + var builtTagsByRawPath = new Dictionary(StringComparer.Ordinal); + var builtDeclaredTags = new List(); foreach (var entry in _options.RawTags) { if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped)) @@ -387,13 +393,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Duplicate-key check: a collision means two authored tags share the same RawPath. // Fail fast at init time with a diagnostic rather than silently clobbering. - if (_tagsByRawPath.TryGetValue(tag.Name, out var existingTag)) + if (builtTagsByRawPath.TryGetValue(tag.Name, out var existingTag)) throw new InvalidOperationException( $"AbCip tag name collision: '{tag.Name}' is declared more than once. " + $"Existing entry DeviceHostAddress='{existingTag.DeviceHostAddress}', " + $"TagPath='{existingTag.TagPath}'. Rename or remove the duplicate."); - _tagsByRawPath[tag.Name] = tag; - _declaredTags.Add(tag); + builtTagsByRawPath[tag.Name] = tag; + builtDeclaredTags.Add(tag); if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 }) { @@ -416,15 +422,18 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Member fan-out duplicate check: a member-path collision means two // configured structure tags produce the same member path, or a member // name collides with an independently-declared tag. - if (_tagsByRawPath.TryGetValue(memberTag.Name, out var existingMember)) + if (builtTagsByRawPath.TryGetValue(memberTag.Name, out var existingMember)) throw new InvalidOperationException( $"AbCip tag name collision: '{memberTag.Name}' is produced by both " + $"'{tag.Name}.{member.Name}' (member fan-out) and an existing tag " + $"'{existingMember.Name}'. Rename one of the configured tags to resolve."); - _tagsByRawPath[memberTag.Name] = memberTag; + builtTagsByRawPath[memberTag.Name] = memberTag; } } } + // Publish the finished tables by reference swap — readers see the old or the new, never a half-built one. + _tagsByRawPath = builtTagsByRawPath; + _declaredTags = builtDeclaredTags; _skippedTags.Commit(); // Probe loops — one per device when enabled + a ProbeTagPath is configured. @@ -459,7 +468,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, _logger.LogError(ex, "AbCip driver {DriverInstanceId} failed to initialize", _driverInstanceId); throw; } - return Task.CompletedTask; + } + + /// + /// Cancels, joins (bounded) and disposes every existing device state's probe loop + handles, then + /// clears the device/route tables. Device part of only. + /// + private async Task ReleaseDeviceStatesAsync(CancellationToken cancellationToken) + { + // The projection is replaced (not reused) when a config body is adopted; dispose the old one so its + // poll loop does not outlive it and raise duplicate alarm events through this driver. + await _alarmProjection.DisposeAsync().ConfigureAwait(false); + foreach (var state in _devices.Values) + { + try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { } + } + foreach (var state in _devices.Values) + { + var probeTask = state.ProbeTask; + if (probeTask is null) continue; + // Own bounded timeout, independent of the caller token: cancelling init must not abort the join + // and dispose handles under a probe loop that is still running. + try { await probeTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); } + catch (TimeoutException) { } + catch (OperationCanceledException) { } + } + foreach (var state in _devices.Values) + { + state.ProbeCts?.Dispose(); + state.ProbeCts = null; + state.ProbeTask = null; + state.DisposeHandles(); + } + _devices.Clear(); + _devicesByRoutingKey.Clear(); + // Rebuilt wholesale by Initialize; stale entries would trip its member fan-out collision check. + _tagsByRawPath = new(StringComparer.Ordinal); // swap, never Clear(): the retained poll engine / discovery may be enumerating + _declaredTags = new(); } /// @@ -506,8 +551,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } _devices.Clear(); _devicesByRoutingKey.Clear(); - _tagsByRawPath.Clear(); - _declaredTags.Clear(); + _tagsByRawPath = new(StringComparer.Ordinal); + _declaredTags = new(); _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyAddress.cs new file mode 100644 index 00000000..aac67c41 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyAddress.cs @@ -0,0 +1,311 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Parsed PCCC file-based address: file letter + file number + word number, optionally a +/// sub-element (.ACC on a timer) or bit index (/0 on a bit file). +/// +/// +/// Logix symbolic tags are parsed elsewhere ( is for SLC / PLC-5 / +/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number). +/// +/// N7:0 — integer file 7, word 0 (signed 16-bit). +/// N7:5 — integer file 7, word 5. +/// F8:0 — float file 8, word 0 (32-bit IEEE754). +/// B3:0/0 — bit file 3, word 0, bit 0. +/// ST9:0 — string file 9, string 0 (82-byte fixed-length + length word). +/// T4:0.ACC — timer file 4, timer 0, accumulator sub-element. +/// C5:0.PRE — counter file 5, counter 0, preset sub-element. +/// I:0/0 — input file, slot 0, bit 0 (no file-number for I/O). +/// O:1/2 — output file, slot 1, bit 2. +/// S:1 — status file, word 1. +/// L9:0 — long-integer file (SLC 5/05+, 32-bit). +/// I:017/07 — PLC-5 only: I/O word + bit indices are octal (#244); stored decimal. +/// RTC:0.HR — MicroLogix only: function file (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI, #245). +/// N7:[N7:0], N[N7:0]:5 — indirect addressing (#247): parsed, not resolved at runtime. +/// +/// The libplctag transport takes as its name=... attribute and the +/// native transport re-parses the same string, so both are handed identical (decimal) numbers. This parser +/// validates the shape + surfaces the structural pieces for driver-side routing (e.g. deciding whether a tag +/// needs bit-level read-modify-write). +/// +public sealed record AbLegacyAddress( + string FileLetter, + int? FileNumber, + int WordNumber, + int? BitIndex, + string? SubElement, + AbLegacyAddress? IndirectFileSource = null, + AbLegacyAddress? IndirectWordSource = null) +{ + /// + /// True when the file number or the word number is sourced from another PCCC address evaluated + /// at runtime (N7:[N7:0] or N[N7:0]:5). Neither transport can encode the bracket + /// form, and resolving it needs a pre-read of the index word, which is not implemented — the + /// driver answers an indirect tag BadNotSupported. + /// + public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null; + + /// + /// Converts the address to the libplctag library address format. Numeric indices are always + /// emitted decimal (a PLC-5 octal I/O address is normalised at parse time). This is correct, not a + /// double conversion: libplctag's pccc.c (parse_pccc_elem_num / parse_pccc_bit_num) parses + /// decimal numerals only (a FIXME there notes octal is unsupported), so handing it the RSLogix octal text would + /// read the wrong word. An indirect + /// address re-emits its bracket form, which neither transport accepts — check + /// first. + /// + /// The libplctag name=... attribute value for this address. + public string ToLibplctagName() + { + string file; + if (IndirectFileSource is not null) file = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]"; + else file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}"; + var word = IndirectWordSource is not null ? $"[{IndirectWordSource.ToLibplctagName()}]" : WordNumber.ToString(); + var wordPart = $"{file}:{word}"; + if (SubElement is not null) wordPart += $".{SubElement}"; + if (BitIndex is not null) wordPart += $"/{BitIndex}"; + return wordPart; + } + + /// Attempts to parse a string into an AB legacy address (family-neutral: decimal indices, no function files). + /// The address string to parse. + /// The parsed address, or when is not a valid PCCC address. + public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null); + + /// + /// Family-aware parser. PLC-5 reads I:/O: word and bit indices as octal + /// (I:017/07 = word 15, bit 7; a digit 8 or 9 is a parse error); MicroLogix additionally + /// accepts the function-file letters. The record always stores decimal values. + /// + /// The address string to parse. + /// The device's PLC family, or for the family-neutral grammar. + /// The parsed address, or when the string is not valid for the family. + public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value); + return ParseCore(value.Trim(), profile, allowIndirect: true); + } + + /// + /// The single authoring / deploy-gate / runtime address check. With a known the + /// address must parse for that family; with an unknown one () it must parse for at + /// least one family, so a caller that cannot see the device never rejects what the runtime would accept. + /// + /// The authored address text. + /// The device's PLC family, or when it is not known. + /// An error message, or when the address is acceptable. + public static string? Validate(string? address, AbLegacyPlcFamily? family) + { + if (string.IsNullOrWhiteSpace(address)) return "Address is required."; + if (family is { } f) + { + if (TryParse(address, f) is not null) return null; + var hint = f == AbLegacyPlcFamily.Plc5 && TryParse(address, AbLegacyPlcFamily.Slc500) is not null + ? " (PLC-5 I:/O: word and bit indices are octal - 8 and 9 are invalid)" + : IsFunctionLetterPrefix(address) ? " (function files are MicroLogix-only)" : ""; + return $"'{address}' is not a valid address for a {f} device{hint}."; + } + foreach (var candidate in Enum.GetValues()) + if (TryParse(address, candidate) is not null) return null; + return $"'{address}' is not a valid PCCC file address for any PLC family."; + } + + private static bool IsFunctionLetterPrefix(string address) + { + var colon = address.IndexOf(':'); + return colon > 0 && IsFunctionFileLetter(address[..colon].Trim().ToUpperInvariant()); + } + + private static AbLegacyAddress? ParseCore(string src, AbLegacyPlcFamilyProfile? profile, bool allowIndirect) + { + // BitIndex: trailing /N outside any bracketed inner address. The numeric parse is deferred until the + // file letter is known (PLC-5 I/O bits are octal); the valid range depends on the parent word width. + string? bitText = null; + var slashIdx = src.LastIndexOf('/'); + if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']')) + { + bitText = src[(slashIdx + 1)..]; + src = src[..slashIdx]; + } + + // SubElement: trailing .NAME (ACC / PRE / EN / DN / HR ...) — top level only, since the inner + // address of an indirect form may carry its own dot (N[T4:0.ACC]:5). + string? subElement = null; + var dotIdx = LastIndexOfTopLevel(src, '.'); + if (dotIdx >= 0) + { + var candidate = src[(dotIdx + 1)..]; + if (candidate.Length > 0 && candidate.All(char.IsLetter)) + { + subElement = candidate.ToUpperInvariant(); + src = src[..dotIdx]; + } + } + + var colonIdx = IndexOfTopLevel(src, ':'); + if (colonIdx <= 0) return null; + var filePart = src[..colonIdx]; + var wordPart = src[(colonIdx + 1)..]; + + // File letter + optional file number (decimal digits, or a bracketed indirect address). + if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null; + var letterEnd = 1; + while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++; + + var letter = filePart[..letterEnd].ToUpperInvariant(); + int? fileNumber = null; + AbLegacyAddress? indirectFile = null; + if (letterEnd < filePart.Length) + { + var fileTail = filePart[letterEnd..]; + if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']') + { + if (!allowIndirect) return null; + indirectFile = ParseInner(fileTail[1..^1], profile); + if (indirectFile is null) return null; + } + else + { + if (!int.TryParse(fileTail, out var fn) || fn < 0) return null; + fileNumber = fn; + } + } + + // Reject unknown file letters. Function-file letters are MicroLogix-only. + var functionFile = IsFunctionFileLetter(letter); + if (!IsKnownFileLetter(letter) && !(functionFile && profile?.SupportsFunctionFiles == true)) return null; + + // I/O/S are single-letter system files and each function file is a single fixed instance: none carries + // a file number. Accepting I3:0 would pass a malformed address straight to the transport; reject early. + if ((fileNumber is not null || indirectFile is not null) && (IsNoFileNumberLetter(letter) || functionFile)) return null; + + // Word part: a numeric literal (octal for PLC-5 I:/O:) or a bracketed indirect address. + var octal = profile?.OctalIoAddressing == true && letter is "I" or "O"; + var word = 0; + AbLegacyAddress? indirectWord = null; + if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']') + { + if (!allowIndirect) return null; + indirectWord = ParseInner(wordPart[1..^1], profile); + if (indirectWord is null) return null; + } + else if (!TryParseIndex(wordPart, octal, out word) || word < 0) return null; + + int? bitIndex = null; + if (bitText is not null) + { + if (!TryParseIndex(bitText, octal, out var bit) || bit < 0) return null; + bitIndex = bit; + + // Range-check against the file's word width: 16-bit N/B/I/O/S/A → 0..15, 32-bit L → 0..31. + // F-files are floats and function files are structured — neither is bit-addressable. + var maxBit = MaxBitIndexFor(letter); + if (maxBit < 0 || bit > maxBit) return null; + } + + // A PCCC address cannot have both a sub-element and a bit index. + if (subElement is not null && bitIndex is not null) return null; + + // Sub-elements only exist on Timer / Counter / Control files and the function files — accepting + // B3:0.DN or N7:0.FOO would produce an address the transport silently misinterprets. + if (subElement is not null && !IsSubElementFileLetter(letter)) return null; + + return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord); + } + + /// Parses a bracketed inner address. Depth is capped at one: the inner address must be direct. + private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile) + { + var src = inner.Trim(); + if (src.Length == 0 || src.IndexOfAny(['[', ']']) >= 0) return null; + return ParseCore(src, profile, allowIndirect: false); + } + + private static int IndexOfTopLevel(string s, char c) + { + var depth = 0; + for (var i = 0; i < s.Length; i++) + { + if (s[i] == '[') depth++; + else if (s[i] == ']') depth--; + else if (depth == 0 && s[i] == c) return i; + } + return -1; + } + + private static int LastIndexOfTopLevel(string s, char c) + { + var depth = 0; + var last = -1; + for (var i = 0; i < s.Length; i++) + { + if (s[i] == '[') depth++; + else if (s[i] == ']') depth--; + else if (depth == 0 && s[i] == c) last = i; + } + return last; + } + + private static bool TryParseIndex(string text, bool octal, out int value) + { + if (!octal) return int.TryParse(text, out value); + + // Octal accepts only the digits 0-7 (no sign); 8 and 9 are a parse error rather than a silent misread. + value = 0; + if (text.Length == 0) return false; + var acc = 0; + foreach (var c in text) + { + if (c < '0' || c > '7') return false; + acc = (acc * 8) + (c - '0'); + if (acc > ushort.MaxValue) return false; + } + value = acc; + return true; + } + + /// + /// Highest valid bit index for a file letter, or -1 if the file type is not + /// bit-addressable. 16-bit element files (N/B/I/O/S/A) permit bits 0..15; the 32-bit + /// L-file permits 0..31. + /// + private static int MaxBitIndexFor(string letter) => letter switch + { + "L" => 31, + "N" or "B" or "I" or "O" or "S" or "A" => 15, + _ => -1, + }; + + private static bool IsKnownFileLetter(string letter) => letter switch + { + "N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true, + _ => false, + }; + + /// + /// MicroLogix 1100/1400 function-file prefixes — each a single fixed instance with a sub-element + /// catalogue (AbLegacyFunctionFile). + /// + public static bool IsFunctionFileLetter(string letter) => letter switch + { + "RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => true, + _ => false, + }; + + /// + /// Returns for file letters that carry no explicit file number in the + /// PCCC spec. I (input), O (output), and S (status) are single-letter + /// system files; a digit after the letter (e.g. I3) is a malformed address. + /// + private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S"; + + /// + /// Returns for file letters that may carry a sub-element suffix + /// (.ACC, .PRE, etc.): Timer (T), Counter (C), Control (R) and the + /// MicroLogix function files. + /// + private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R" || IsFunctionFileLetter(letter); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs index 5d533d33..b45e65b2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs @@ -15,7 +15,14 @@ public sealed record AbLegacyPlcFamilyProfile( /// int MaxTagBytes, bool SupportsStringFile, - bool SupportsLongFile) + bool SupportsLongFile, + /// + /// PLC-5 only: the word and bit indices on I:/O: references are octal + /// (I:017/07), as RSLogix 5 displays them. SLC / MicroLogix / Logix-PCCC stay decimal. + /// + bool OctalIoAddressing = false, + /// MicroLogix only: RTC / HSC / DLS / MMI / PTO / PWM / STI / EII / IOS / BHI function files. + bool SupportsFunctionFiles = false) { /// Gets the profile for the specified PLC family. /// The PLC family. @@ -41,21 +48,23 @@ public sealed record AbLegacyPlcFamilyProfile( DefaultCipPath: "1,0", MaxTagBytes: 240, // SLC 5/05 PCCC max packet data SupportsStringFile: true, // ST file available SLC 5/04+ - SupportsLongFile: true); // L file available SLC 5/05+ + SupportsLongFile: true); // L file available SLC 5/05+ (decimal I:/O: indices, RSLogix 500) public static readonly AbLegacyPlcFamilyProfile MicroLogix = new( LibplctagPlcAttribute: "micrologix", DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path MaxTagBytes: 232, SupportsStringFile: true, - SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files + SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files + SupportsFunctionFiles: true); // ML 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI public static readonly AbLegacyPlcFamilyProfile Plc5 = new( LibplctagPlcAttribute: "plc5", DefaultCipPath: "1,0", MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower SupportsStringFile: true, - SupportsLongFile: false); // PLC-5 predates L files + SupportsLongFile: false, // PLC-5 predates L files + OctalIoAddressing: true); // RSLogix 5 displays I:/O: word + bit indices as octal (#244) /// /// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs index 79b48a1b..1bbfa9af 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; using System.Text.Json; using System.Text.Json.Nodes; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -122,6 +123,12 @@ public static class AbLegacyTagDefinitionFactory /// The RawPath handed to during inspection — the mapper only copies it /// onto and never validates it. /// + private static bool IsIndirectFor(string address, AbLegacyPlcFamily? family) + { + if (family is { } f) return AbLegacyAddress.TryParse(address, f)?.IsIndirect == true; + return Enum.GetValues().Any(c => AbLegacyAddress.TryParse(address, c)?.IsIndirect == true); + } + private const string InspectionRawPath = "(inspect)"; /// @@ -145,7 +152,18 @@ public static class AbLegacyTagDefinitionFactory /// /// The equipment tag's TagConfig JSON. /// The warnings; empty when clean. - public static IReadOnlyList Inspect(string reference) + public static IReadOnlyList Inspect(string reference) => Inspect(reference, family: null); + + /// + /// plus the family-aware address check (, + /// the same authority the driver runtime uses). is the tag's device's PLC family, or + /// when unknown (then only an address no family accepts is flagged). An indirect + /// address parses but the runtime always answers it BadNotSupported, so it is warned about too. + /// + /// The equipment tag's TagConfig JSON. + /// The owning device's PLC family, when known. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference, AbLegacyPlcFamily? family) { var warnings = new List(); // Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to @@ -157,7 +175,14 @@ public static class AbLegacyTagDefinitionFactory // The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the // mapper — the driver reads the stored blob, not a trimmed copy of it. - if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings; + if (FromTagConfig(reference, InspectionRawPath, out var accepted)) + { + if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError) + warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown."); + else if (IsIndirectFor(accepted.Address, family)) + warnings.Add($"AbLegacy address '{accepted.Address}' is indirect; indirect addressing is not supported and the tag reads BadNotSupported."); + return warnings; + } try { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs deleted file mode 100644 index 4ff25df0..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs +++ /dev/null @@ -1,157 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; - -/// -/// Parsed PCCC file-based address: file letter + file number + word number, optionally a -/// sub-element (.ACC on a timer) or bit index (/0 on a bit file). -/// -/// -/// Logix symbolic tags are parsed elsewhere ( is for SLC / PLC-5 / -/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number). -/// -/// N7:0 — integer file 7, word 0 (signed 16-bit). -/// N7:5 — integer file 7, word 5. -/// F8:0 — float file 8, word 0 (32-bit IEEE754). -/// B3:0/0 — bit file 3, word 0, bit 0. -/// ST9:0 — string file 9, string 0 (82-byte fixed-length + length word). -/// T4:0.ACC — timer file 4, timer 0, accumulator sub-element. -/// C5:0.PRE — counter file 5, counter 0, preset sub-element. -/// I:0/0 — input file, slot 0, bit 0 (no file-number for I/O). -/// O:1/2 — output file, slot 1, bit 2. -/// S:1 — status file, word 1. -/// L9:0 — long-integer file (SLC 5/05+, 32-bit). -/// -/// Pass the original string straight through to libplctag's name=... attribute — -/// the PLC-side decoder handles the format. This parser only validates the shape + surfaces -/// the structural pieces for driver-side routing (e.g. deciding whether a tag needs -/// bit-level read-modify-write). -/// -public sealed record AbLegacyAddress( - string FileLetter, - int? FileNumber, - int WordNumber, - int? BitIndex, - string? SubElement) -{ - /// Converts the address to the libplctag library address format. - /// The libplctag name=... attribute value for this address. - public string ToLibplctagName() - { - var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}"; - var wordPart = $"{file}:{WordNumber}"; - if (SubElement is not null) wordPart += $".{SubElement}"; - if (BitIndex is not null) wordPart += $"/{BitIndex}"; - return wordPart; - } - - /// Attempts to parse a string into an AB legacy address. - /// The address string to parse. - /// The parsed address, or when is not a valid PCCC address. - public static AbLegacyAddress? TryParse(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return null; - var src = value.Trim(); - - // BitIndex: trailing /N. The valid range depends on the parent word width, which is - // determined by the file letter (16-bit N/B/I/O/S/A → 0..15, 32-bit L → 0..31). Capture - // the raw value here and range-check it once the file letter is known (see below). - int? bitIndex = null; - var slashIdx = src.IndexOf('/'); - if (slashIdx >= 0) - { - if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0) return null; - bitIndex = bit; - src = src[..slashIdx]; - } - - // SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.) - string? subElement = null; - var dotIdx = src.LastIndexOf('.'); - if (dotIdx >= 0) - { - var candidate = src[(dotIdx + 1)..]; - if (candidate.Length > 0 && candidate.All(char.IsLetter)) - { - subElement = candidate.ToUpperInvariant(); - src = src[..dotIdx]; - } - } - - var colonIdx = src.IndexOf(':'); - if (colonIdx <= 0) return null; - var filePart = src[..colonIdx]; - var wordPart = src[(colonIdx + 1)..]; - if (!int.TryParse(wordPart, out var word) || word < 0) return null; - - // File letter + optional file number (single letter for I/O/S, letter+number otherwise). - if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null; - var letterEnd = 1; - while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++; - - var letter = filePart[..letterEnd].ToUpperInvariant(); - int? fileNumber = null; - if (letterEnd < filePart.Length) - { - if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null; - fileNumber = fn; - } - - // Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families. - if (!IsKnownFileLetter(letter)) return null; - - // Range-check the bit index against the file's word width. A PCCC N/B/I/O/S/A word is a - // 16-bit element, so valid bit indices are 0..15; an L-file element is 32-bit (0..31). - // F-files are 32-bit IEEE-754 floats and are not bit-addressable at all. - if (bitIndex is int b) - { - var maxBit = MaxBitIndexFor(letter); - if (maxBit < 0 || b > maxBit) return null; - } - - // I/O/S are single-letter system files — they carry no file number in the PCCC spec. - // Accepting I3:0 or S2:1 would pass a malformed address straight to libplctag; reject early. - if (fileNumber is not null && IsNoFileNumberLetter(letter)) return null; - - // A PCCC address cannot have both a sub-element and a bit index: the word is either - // structured (T4:0.ACC) or bit-addressed (N7:0/3), never both. - if (subElement is not null && bitIndex is not null) return null; - - // Sub-elements are only meaningful on Timer (T), Counter (C), and Control (R) files — - // those are the only structured-element file types in the PCCC spec. Accepting B3:0.DN - // or N7:0.FOO would produce an address libplctag silently misinterprets. - if (subElement is not null && !IsSubElementFileLetter(letter)) return null; - - return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement); - } - - /// - /// Highest valid bit index for a file letter, or -1 if the file type is not - /// bit-addressable. 16-bit element files (N/B/I/O/S/A) permit bits 0..15; the 32-bit - /// L-file permits 0..31. - /// - private static int MaxBitIndexFor(string letter) => letter switch - { - "L" => 31, - "N" or "B" or "I" or "O" or "S" or "A" => 15, - _ => -1, - }; - - private static bool IsKnownFileLetter(string letter) => letter switch - { - "N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true, - _ => false, - }; - - /// - /// Returns for file letters that carry no explicit file number in the - /// PCCC spec. I (input), O (output), and S (status) are single-letter - /// system files; a digit after the letter (e.g. I3) is a malformed address. - /// - private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S"; - - /// - /// Returns for file letters that may carry a sub-element suffix - /// (.ACC, .PRE, etc.). Only Timer (T), Counter (C), and - /// Control (R) files have structured elements in the PCCC spec. - /// - private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R"; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 982df163..f1ea75e7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -143,8 +143,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover } /// - public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { + // #630: InitializeAsync can run on a live driver (DriverInstanceActor.AdoptConfigDuringInit calls it + // without a teardown first). Release the previous device states' probe loops + runtimes BEFORE the + // overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT + // ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init). + await ReleaseDeviceStatesAsync().ConfigureAwait(false); _health = new DriverHealth(DriverState.Initializing, null, null); _logger.LogInformation( "AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}", @@ -255,7 +260,35 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses throw; } - return Task.CompletedTask; + } + + /// + /// Cancels, bounded-joins and disposes every existing device state, then clears the table. + /// Device-state part of only (no poll / subscription teardown). + /// + private async Task ReleaseDeviceStatesAsync() + { + if (_devices.Count == 0) return; + foreach (var state in _devices.Values) + try { state.ProbeCts?.Cancel(); } catch { } + var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList(); + if (loops.Count > 0) + { + var all = Task.WhenAll(loops); + var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); + if (finished != all) + _logger.LogWarning( + "AbLegacy re-initialise: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}", + loops.Count, LoopJoinTimeout, _driverInstanceId); + } + foreach (var state in _devices.Values) + { + state.ProbeCts?.Dispose(); + state.ProbeCts = null; + state.ProbeTask = null; + state.DisposeRuntimes(); + } + _devices.Clear(); } /// @@ -355,6 +388,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover try { + // Indirect addresses (N7:[N7:0]) parse but are not resolved at runtime (#247) — answer the + // refusal before a runtime is created for a name neither transport can encode. + var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); + if (preParsed is null) + { + WarnMalformedOnce(device, def); + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + if (preParsed.IsIndirect) + { + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); + continue; + } + var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); int status; @@ -368,7 +416,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { await runtime.ReadAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); - var parsed = AbLegacyAddress.TryParse(def.Address); + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); if (status != 0) value = null; else if (IsArrayTag(def)) @@ -455,7 +503,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover try { - var parsed = AbLegacyAddress.TryParse(def.Address); + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); + + // A malformed address is a config error, not a comms fault (BadNodeIdUnknown, warned once per tag). + if (parsed is null) + { + WarnMalformedOnce(device, def); + results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); + continue; + } + + // Indirect addressing is parse-only (#247): refuse before any runtime or RMW read is created. + if (parsed.IsIndirect) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); + continue; + } // A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN, // or any PLC-5 write) is answered BEFORE a runtime is created. Creating one first costs an init @@ -610,7 +673,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover Port: state.ParsedAddress.Port, CipPath: state.EffectiveCipPath, LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, - TagName: _options.Probe.ProbeAddress!, + // Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through + // so the transport reports the failure as it always did. + TagName: AbLegacyAddress.TryParse(_options.Probe.ProbeAddress, state.Options.PlcFamily)?.ToLibplctagName() + ?? _options.Probe.ProbeAddress!, Timeout: _options.Probe.Timeout); IAbLegacyTagRuntime? probeRuntime = null; @@ -858,6 +924,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover keep(); } + /// + /// A tag whose address does not parse for its device's PLC family (e.g. a PLC-5 octal I/O digit 8/9, a + /// function-file letter off MicroLogix) is a configuration error: answered BadNodeIdUnknown with the + /// handle/health untouched, and warned once per tag so a polled tag does not flood the log. + /// + private void WarnMalformedOnce(DeviceState device, AbLegacyTagDefinition def) + { + if (!device.MalformedAddressLogged.TryAdd(def.Name, 0)) return; + _logger.LogWarning( + "AbLegacy tag address is not valid for the device's PLC family. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Family={Family}", + _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, device.Options.PlcFamily); + } + /// The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so /// the refusal is asked about exactly the runtime a write would otherwise create. private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) => @@ -883,9 +962,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing; - var parsed = AbLegacyAddress.TryParse(def.Address) + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily) ?? throw new InvalidOperationException( - $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); + $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}."); var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed)); await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct) @@ -1090,6 +1169,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public bool FirstNonZeroStatusLogged { get; set; } + /// Tags already warned about for a malformed address (once-per-tag log). + public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new(); + /// /// Disposes all cached tag runtimes. /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs new file mode 100644 index 00000000..948c6dad --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs @@ -0,0 +1,51 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// MicroLogix 1100/1400 function-file sub-element catalogue (#245): the driver-surface type of +/// RTC:0.HR, HSC:0.ACC, ... Covers the commonly-addressed members per file — not exhaustive +/// (Rockwell defines 30+ on RTC alone). Unverified on the wire: the layouts are transcribed from the +/// MicroLogix instruction-set reference; neither transport has been captured against these files and the +/// native transport has no encoding for them at all. +/// +public static class AbLegacyFunctionFile +{ + /// + /// Driver-surface type for .; an unrecognised + /// sub-element falls back to so the driver never refuses a member the + /// catalogue happens to omit. + /// + /// The function-file letter (RTC, HSC, ...). + /// The sub-element mnemonic, or null. + /// The mapped driver data type. + public static DriverDataType SubElementType(string fileLetter, string? subElement) + { + if (subElement is null) return DriverDataType.Int32; + return (fileLetter.ToUpperInvariant(), subElement.ToUpperInvariant()) switch + { + // Real-time clock: time fields are 16-bit words; DS (disabled) / BL (battery low) are flags. + ("RTC", "DS") or ("RTC", "BL") or ("RTC", "EN") => DriverDataType.Boolean, + + // High-speed counter: accumulator/preset/overflow/underflow are numeric, the rest status bits. + ("HSC", "EN") or ("HSC", "UF") or ("HSC", "IF") or ("HSC", "IN") or ("HSC", "IH") or + ("HSC", "IL") or ("HSC", "DN") or ("HSC", "CD") or ("HSC", "CU") => DriverDataType.Boolean, + + // Daylight saving + memory-module info. + ("DLS", "EN") => DriverDataType.Boolean, + ("MMI", "MP") or ("MMI", "MCP") => DriverDataType.Boolean, + + // Pulse-train / PWM outputs. + ("PTO", "EN") or ("PTO", "DN") or ("PTO", "EH") or ("PTO", "ED") or ("PTO", "RP") or ("PTO", "OUT") => DriverDataType.Boolean, + ("PWM", "EN") or ("PWM", "DN") or ("PWM", "EH") or ("PWM", "ED") or ("PWM", "RP") or ("PWM", "OUT") => DriverDataType.Boolean, + + // Selectable timed interrupt + event input interrupt. + ("STI", "EN") or ("STI", "TIE") or ("STI", "DN") or ("STI", "PS") or ("STI", "ED") => DriverDataType.Boolean, + ("EII", "EN") or ("EII", "TIE") or ("EII", "PE") or ("EII", "ES") or ("EII", "ED") => DriverDataType.Boolean, + + // Everything else (HR/MIN/SEC/MON/DAY/YR/DOW, ACC/PRE/OVF/UNF, STR/STD, FT/LBN, OF/IDA/ODA/PE/PD, + // SPM/ER/PFN, ID/TYP, OS/FRN/BSN/CC, ...) is a numeric word. + _ => DriverDataType.Int32, + }; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs index 6ba2c2e1..4e2fa43b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs @@ -154,10 +154,23 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime } } + private static bool IsFunctionFileName(string tagName) + { + var colon = tagName.IndexOf(':'); + return colon > 0 && AbLegacyAddress.IsFunctionFileLetter(tagName[..colon].ToUpperInvariant()); + } + private static PcccAddress ToPcccAddress(string tagName) { + // Family-neutral parse is correct here: the driver hands the runtime AbLegacyAddress.ToLibplctagName(), which is + // already decimal-normalised (PLC-5 octal I/O included), so no family is needed to read it back. var a = AbLegacyAddress.TryParse(tagName) - ?? throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is not a valid PCCC address."); + ?? throw new AbTransportException(AbStatus.Unsupported, + IsFunctionFileName(tagName) + ? $"'{tagName}' is a MicroLogix function file; the native transport has no encoding for it (unverified on the wire)." + : $"'{tagName}' is not a valid PCCC address."); + if (a.IsIndirect) + throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is an indirect address; indirect addressing is not supported."); return a.SubElement is not null ? PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, a.SubElement) : new PcccAddress(a.FileLetter, a.FileNumber, a.WordNumber, null, a.BitIndex); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs index b8037e54..0e861e92 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs @@ -84,7 +84,7 @@ public static class FocasCapabilityMatrix /// A set of accepted PMC letter strings. internal static IReadOnlySet PmcLetters(FocasCncSeries series) => series switch { - FocasCncSeries.Sixteen_i => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" }, + FocasCncSeries.Sixteen_i => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D" }, FocasCncSeries.Zero_i_D => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" }, FocasCncSeries.Zero_i_F or FocasCncSeries.Zero_i_MF or diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 07fc913c..7152be71 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -178,8 +178,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// The driver configuration JSON (see remarks — not re-parsed). /// Cancellation token for the operation. /// A completed task. - public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { + // #630: release the previous device states' loops + clients BEFORE they are overwritten (the actor's + // AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll + // and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init). + await ReleaseDeviceStatesAsync().ConfigureAwait(false); Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null)); try { @@ -316,7 +320,56 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, Volatile.Write(ref _health, new DriverHealth(DriverState.Faulted, null, ex.Message)); throw; } - return Task.CompletedTask; + } + + /// + /// Cancels, bounded-joins and disposes every existing device state (probe / recycle / fixed-tree + /// loops + client) and the alarm projection, then clears the table. Device part of + /// only (no poll / subscription teardown). + /// + private async Task ReleaseDeviceStatesAsync() + { + if (_alarmProjection is { } proj) + { + await proj.DisposeAsync().ConfigureAwait(false); + _alarmProjection = null; + } + if (_devices.Count == 0) return; + var loops = new List(); + foreach (var state in _devices.Values) + { + try { state.ProbeCts?.Cancel(); } + catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); } + try { state.RecycleCts?.Cancel(); } + catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); } + try { state.FixedTreeCts?.Cancel(); } + catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); } + if (state.ProbeTask is { } pt) loops.Add(pt); + if (state.RecycleTask is { } rt) loops.Add(rt); + if (state.FixedTreeTask is { } ft) loops.Add(ft); + } + if (loops.Count > 0) + { + var all = Task.WhenAll(loops); + var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); + if (finished != all) + _logger.LogWarning("FOCAS re-initialise: {Count} background loop(s) did not stop within {Timeout}; proceeding", + loops.Count, LoopJoinTimeout); + } + foreach (var state in _devices.Values) + { + state.ProbeCts?.Dispose(); + state.ProbeCts = null; + state.ProbeTask = null; + state.RecycleCts?.Dispose(); + state.RecycleCts = null; + state.RecycleTask = null; + state.FixedTreeCts?.Dispose(); + state.FixedTreeCts = null; + state.FixedTreeTask = null; + state.DisposeClient(); + } + _devices.Clear(); } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs index 740a2d71..0158722b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs @@ -158,11 +158,22 @@ public sealed record MTConnectStreamsResult( /// false, the shape of every ordinary scalar observation. /// /// +/// +/// The observation's own sampleCount attribute — the number of values a TIME_SERIES +/// vector claims to carry (#627). The index checks the parsed vector against it. null when absent. +/// +/// +/// The observation's own sampleRate attribute (samples per second) on a TIME_SERIES +/// observation. Carried for the parser contract; not yet surfaced on the served node (see +/// docs/drivers/MTConnect.md). null when absent. +/// public sealed record MTConnectObservation( string DataItemId, string Value, DateTime TimestampUtc, - bool IsStructured = false); + bool IsStructured = false, + int? SampleCount = null, + double? SampleRate = null); /// /// Recursive helpers over the device/component tree. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs index c68a870c..7e95659d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs @@ -49,8 +49,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// /// /// Shapes this build does not represent are coded BadNotSupported with a null -/// value rather than approximated: a TIME_SERIES vector (array materialization is -/// deferred to P1.5) and a DATA_SET / TABLE observation. The latter carries its +/// value rather than approximated: a non-Float64 array tag and a DATA_SET / TABLE +/// observation (a Float64 TIME_SERIES vector is served as double[], #627). The latter carries its /// real content in <Entry key=…> child elements, which reach a text-valued /// observation concatenated with the keys discarded — a two-entry set reads as the single /// token "12" — and because the type inference correctly demotes those @@ -85,9 +85,12 @@ internal sealed class MTConnectObservationIndex /// The Agent reported a number the authored type cannot represent. private const uint BadOutOfRange = 0x803C0000u; - /// The observation's shape is real data this build cannot represent (TIME_SERIES vectors). + /// The observation's shape is real data this build cannot represent (a DATA_SET/TABLE, or a non-Float64 array tag). private const uint BadNotSupported = 0x803D0000u; + /// A TIME_SERIES vector whose element count disagrees with its declared sampleCount. + private const uint BadDecodingError = 0x80070000u; + /// The Agent reported text that is not a value of the authored type at all. private const uint BadTypeMismatch = 0x80740000u; @@ -268,13 +271,11 @@ internal sealed class MTConnectObservationIndex _tags.TryGetValue(observation.DataItemId, out var tag); - // TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA - // arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a - // type mismatch, and emphatically not the first element parsed out and served as Good. - // Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms. + // TIME_SERIES vectors arrive as one whitespace-separated string (#627). Checked AFTER the + // sentinel so an unavailable array tag still reports the truthful no-comms. if (tag is { IsArray: true }) { - return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp); + return BuildArraySnapshot(observation, tag, sourceTimestamp, serverTimestamp); } // An unauthored observation has no declared target type; String is the only coercion that @@ -286,6 +287,40 @@ internal sealed class MTConnectObservationIndex : new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp); } + /// + /// Parses a TIME_SERIES vector into a double[]. Only + /// arrays are served (the MTConnect TIME_SERIES element type is a floating-point sample); any other + /// authored array type is BadNotSupported rather than an approximation. All-or-nothing: one + /// unparseable or non-finite token, or a count that disagrees with the observation's own + /// sampleCount (or the tag's fixed ArrayDim), makes the whole vector Bad — a partial + /// vector served as Good would be a silently wrong waveform. + /// + private static DataValueSnapshot BuildArraySnapshot( + MTConnectObservation observation, MTConnectTagDefinition tag, DateTime sourceTimestamp, DateTime serverTimestamp) + { + DataValueSnapshot Bad(uint status) => new(null, status, sourceTimestamp, serverTimestamp); + + if (tag.DriverDataType != DriverDataType.Float64) return Bad(BadNotSupported); + + var tokens = observation.Value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + var values = new double[tokens.Length]; + for (var i = 0; i < tokens.Length; i++) + { + if (string.Equals(tokens[i], UnavailableSentinel, StringComparison.Ordinal)) return Bad(BadNoCommunication); + + if (!double.TryParse(tokens[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i]) || + !double.IsFinite(values[i])) + { + return Bad(BadTypeMismatch); + } + } + + if (observation.SampleCount is { } declared && declared != values.Length) return Bad(BadDecodingError); + if (tag.ArrayDim > 0 && tag.ArrayDim != values.Length) return Bad(BadDecodingError); + + return new DataValueSnapshot(values, Good, sourceTimestamp, serverTimestamp); + } + /// /// Converts the Agent's text to the tag's authored type. Every parse is /// : MTConnect is an invariant-format wire, and a diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs index b0d6bdff..06a9a5cf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs @@ -377,6 +377,8 @@ internal static class MTConnectStreamsParser string? dataItemId = null; var timestampUtc = default(DateTime); string? conditionState = null; + int? sampleCount = null; + double? sampleRate = null; if (isObservation) { @@ -385,6 +387,24 @@ internal static class MTConnectStreamsParser timestampUtc = ReadTimestampUtc(reader, dataItemId); conditionState = isCondition ? ConditionState(name) : null; + // TIME_SERIES metadata rides the observation element itself. Read here, before the subtree + // walk moves the reader off this element's attributes. Lenient: a vendor's unparseable + // attribute is dropped rather than failing the whole document (the vector is checked + // against the declared sampleCount only when one parses). + if (!isCondition) + { + sampleCount = int.TryParse( + MTConnectXml.OptionalAttribute(reader, "sampleCount"), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var count) && count >= 0 ? count : null; + sampleRate = double.TryParse( + MTConnectXml.OptionalAttribute(reader, "sampleRate"), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var rate) && double.IsFinite(rate) ? rate : null; + } + // The slot is reserved BEFORE the subtree is read, and filled after: an observation is // only complete once its text and its child-element flag are known, but appending it // then would report a nested observation ahead of the one enclosing it. The tree walk @@ -430,7 +450,9 @@ internal static class MTConnectStreamsParser // MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from // the element NAME, so child content cannot corrupt it, and flagging one would throw // away a perfectly well-determined state. - IsStructured: !isCondition && hasElements); + IsStructured: !isCondition && hasElements, + SampleCount: sampleCount, + SampleRate: sampleRate); } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 559d9177..87c17144 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -211,6 +211,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { + // #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs + // it with no teardown first); `Session = session` below would otherwise orphan the previous session, + // its keep-alive handler and any reconnect handler. + await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false); _health = new DriverHealth(DriverState.Initializing, null, null); try { @@ -775,32 +779,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } - /// - public async Task ShutdownAsync(CancellationToken cancellationToken) + /// + /// Cancels any in-flight reconnect, detaches the keep-alive handler and closes + disposes the current + /// session. The session part of ; also run at the top of + /// so a re-initialise on a live driver (#630) does not orphan the + /// previous session and its reconnect handler. Subscriptions are NOT touched here. + /// + private async Task ReleaseSessionAsync(CancellationToken cancellationToken) { - // Tear down remote subscriptions first — otherwise Session.Close will try and may fail - // with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared - // whether or not the wire-side delete succeeds since the local handles are useless - // after close anyway. Before deleting each subscription we detach the Notification - // handlers we attached at subscribe time so the SDK's invocation list no longer - // holds the driver instance through the closure. - foreach (var rs in _subscriptions.Values) - { - DetachNotificationHandlers(rs.ItemHandlers); - try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } - catch { /* best-effort */ } - } - _subscriptions.Clear(); - - foreach (var ras in _alarmSubscriptions.Values) - { - try { ras.EventItem.Notification -= ras.Handler; } - catch { /* best-effort */ } - try { await ras.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } - catch { /* best-effort */ } - } - _alarmSubscriptions.Clear(); - // Abort any in-flight reconnect attempts before touching the session — BeginReconnect's // retry loop holds a reference to the current session and would fight Session.CloseAsync // if left spinning. Take the handler under _probeLock so a keep-alive callback racing @@ -835,6 +821,35 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit try { if (sessionToClose is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); } catch { /* best-effort */ } try { sessionToClose?.Dispose(); } catch { } + } + + /// + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + // Tear down remote subscriptions first — otherwise Session.Close will try and may fail + // with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared + // whether or not the wire-side delete succeeds since the local handles are useless + // after close anyway. Before deleting each subscription we detach the Notification + // handlers we attached at subscribe time so the SDK's invocation list no longer + // holds the driver instance through the closure. + foreach (var rs in _subscriptions.Values) + { + DetachNotificationHandlers(rs.ItemHandlers); + try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } + catch { /* best-effort */ } + } + _subscriptions.Clear(); + + foreach (var ras in _alarmSubscriptions.Values) + { + try { ras.EventItem.Notification -= ras.Handler; } + catch { /* best-effort */ } + try { await ras.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } + catch { /* best-effort */ } + } + _alarmSubscriptions.Clear(); + + await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false); _namespaceMap = null; _connectedEndpointUrl = null; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7CpuType.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7CpuType.cs index 2c739c89..a17db3bb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7CpuType.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7CpuType.cs @@ -16,3 +16,20 @@ public enum S7CpuType S71200 = 30, S71500 = 40, } + +/// +/// ISO-TSAP connection resource requested during the handshake — the FIRST byte of the remote TSAP +/// (the second byte is rack × 0x20 + slot). Values are the on-wire bytes. Leave unset to keep +/// S7.Net's per-CPU default ( for the S7-300/400/1200/1500 families). +/// +public enum S7ConnectionType +{ + /// Programming-device (PG) connection resource. + PG = 1, + + /// Operator-panel (OP/HMI) connection resource. + OP = 2, + + /// "S7 basic" / other-communication connection resource — S7.Net's default. + S7Basic = 3, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs index a5771e2e..d94968c3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs @@ -51,6 +51,34 @@ public sealed class S7DriverOptions /// public short Slot { get; init; } = 0; + /// + /// Optional override of the LOCAL ISO-TSAP, as two hex bytes "01.00" (also accepted: + /// "0100", "0x0100"). Null/blank keeps S7.Net's per-CPU default (01.00). + /// Needed for CPUs / gateways that reserve connection resources by TSAP (S7-400H, CP343, LOGO!). + /// + [System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))] + public string? LocalTsap { get; init; } + + /// + /// Optional override of the REMOTE ISO-TSAP, same text form as + /// (e.g. "03.01" = S7-basic resource, rack 0 slot 1). Null/blank keeps the slot-derived default. + /// Mutually exclusive with . + /// + [System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))] + public string? RemoteTsap { get; init; } + + /// + /// Optional connection resource (PG / OP / S7Basic) written into the first byte of the slot-derived + /// remote TSAP, keeping rack × 0x20 + slot as the second byte. Null keeps the default. + /// Mutually exclusive with . + /// + public S7ConnectionType? ConnectionType { get; init; } + + /// The authoring/load error for setting both and ; shared by the factory and the AdminUI form so they never disagree. + public const string TsapConflictDetail = + "sets both RemoteTsap and ConnectionType; ConnectionType only fills the first byte of the " + + "slot-derived remote TSAP, so choose one."; + /// Connect + per-operation timeout. public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5); @@ -139,6 +167,31 @@ public sealed class S7DriverOptions public const int DefaultSpanReadMaxSpanBytes = 220; } +/// +/// Reads a TSAP as either hex text ("03.01") or a JSON number (769, the form the driver +/// factory also accepts) and always writes the hex text. Without it a numeric TSAP would fail to bind to +/// the string property, and a typed consumer (the AdminUI form, the Test Connect probe) would fall back +/// to defaults — a form Save would then silently erase the authored config. +/// +public sealed class S7TsapJsonConverter : System.Text.Json.Serialization.JsonConverter +{ + /// + public override string? Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) + { + if (reader.TokenType == System.Text.Json.JsonTokenType.Null) return null; + if (reader.TokenType == System.Text.Json.JsonTokenType.Number && reader.TryGetUInt16(out var n)) + return $"{n >> 8:X2}.{n & 0xFF:X2}"; + if (reader.TokenType == System.Text.Json.JsonTokenType.String) return reader.GetString(); + throw new System.Text.Json.JsonException("S7 TSAP must be hex text such as \"03.01\" or a 16-bit number."); + } + + /// + public override void Write(System.Text.Json.Utf8JsonWriter writer, string? value, System.Text.Json.JsonSerializerOptions options) + { + if (value is null) writer.WriteNullValue(); else writer.WriteStringValue(value); + } +} + public sealed class S7ProbeOptions { /// Gets or sets a value indicating whether probing is enabled. @@ -197,5 +250,22 @@ public enum S7DataType Float32, Float64, String, + /// S7 DATE_AND_TIME (DT, 8-byte BCD) → DateTime. The other date/time members below were appended after it. DateTime, + /// S7 WSTRING (UTF-16BE, 4-byte header) → String. StringLength is the declared max in characters (≤ 16382). + WString, + /// S7 CHAR (1 byte, Latin-1) → one-character String. + Char, + /// S7 WCHAR (UTF-16BE code unit) → one-character String. + WChar, + /// S7 DTL (12 bytes, ns resolution) → DateTime. + Dtl, + /// S7 DATE (UInt16 days since 1990-01-01) → DateTime. + Date, + /// S7 TIME_OF_DAY (UInt32 ms since midnight) → Int32 milliseconds. + Tod, + /// S7 TIME (Int32 ms, signed duration) → Int32 milliseconds. + Time, + /// S7 S5TIME (BCD + time base) → Int32 milliseconds. + S5Time, } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs index f89fa18a..e4368a25 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs @@ -20,6 +20,20 @@ public static class S7TagDefinitionFactory /// S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max). private const int MaxStringLength = 254; + /// S7 WSTRING max length in characters (S7-1500; the 4-byte header's UInt16 is capped at 16382 so the field fits a DB). + private const int MaxWStringLength = 16382; + + /// + /// The declared-length ceiling for a type that carries a stringLength, or null for a type that + /// does not (every non-string type ignores the key, so a copy-paste remnant never blocks parsing). + /// + private static int? MaxLengthFor(S7DataType t) => t switch + { + S7DataType.String => MaxStringLength, + S7DataType.WString => MaxWStringLength, + _ => null, + }; + /// /// Maps an authored TagConfig object to a typed definition keyed by . /// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3). @@ -55,7 +69,7 @@ public static class S7TagDefinitionFactory // Range-guard applies only to String tags: an S7 string can't exceed 254 chars, and a // negative length is meaningless. For non-String types stringLength is irrelevant and any // authored value (including an out-of-range remnant from copy-paste) must not block parsing. - if (dataType == S7DataType.String && (stringLength < 0 || stringLength > MaxStringLength)) return false; + if (MaxLengthFor(dataType) is { } maxLen && (stringLength < 0 || stringLength > maxLen)) return false; // Array intent: the canonical sink-side parse (DeploymentArtifact.ExtractTagArray) // honours arrayLength ONLY when isArray is true AND the prop is a JSON number — mirror // that here so the driver's def agrees byte-for-byte with the materialised OPC UA node's @@ -230,14 +244,14 @@ public static class S7TagDefinitionFactory if (TagConfigJson.DescribeInvalidEnum(root, DataTypeKey) is { } w) warnings.Add(w); if (TagConfigJson.TryReadEnumStrict(root, DataTypeKey, S7DataType.Int16, out var dataType) - && dataType == S7DataType.String) + && MaxLengthFor(dataType) is { } maxLen) { var stringLength = ReadInt(root, "stringLength"); - if (stringLength < 0 || stringLength > MaxStringLength) + if (stringLength < 0 || stringLength > maxLen) { warnings.Add( - $"S7 String tag has a 'stringLength' of {stringLength} — an S7 string is 0–" - + $"{MaxStringLength} characters, so the tag is rejected (BadNodeIdUnknown)."); + $"S7 {dataType} tag has a 'stringLength' of {stringLength} — an S7 {dataType} is 0–" + + $"{maxLen} characters, so the tag is rejected (BadNodeIdUnknown)."); } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs index 9e01f6f0..83966fc8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs @@ -29,6 +29,13 @@ public interface IS7Plc : IDisposable /// Closes the S7 connection. Best-effort and idempotent. void Close(); + /// + /// PDU size negotiated with the CPU during , in bytes (typically 240 on S7-300/400 + /// and S7-1200/1500 defaults, 480/960 with extended PDU); 0 when unknown / not yet connected. Default + /// interface member so a test fake that predates it keeps compiling. + /// + int MaxPduSize => 0; + /// Reads a scalar value by S7 address string (e.g. DB1.DBW0); the boxed type follows the size suffix. /// The S7 address string. /// Cancellation token for the read. @@ -94,6 +101,16 @@ public interface IS7PlcFactory /// Connect + per-operation timeout, applied to the underlying read/write timeouts. /// A new, not-yet-opened . IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout); + + /// + /// Creates a connection from the full driver options, so an optional TSAP / connection-type override + /// reaches the handshake. The default forwards to the six-argument overload (no override); the + /// real factory honours and friends. + /// + /// The driver options. + /// A new, not-yet-opened . + IS7Plc Create(S7DriverOptions options) => + Create(options.CpuType, options.Host, options.Port, options.Rack, options.Slot, options.Timeout); } /// @@ -114,8 +131,23 @@ internal sealed class S7PlcAdapter : IS7Plc /// CPU slot. /// Connect + per-operation timeout. public S7PlcAdapter(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) + : this(new Plc(S7CpuTypeMap.ToS7Net(cpuType), host, port, rack, slot), timeout) { - _plc = new Plc(S7CpuTypeMap.ToS7Net(cpuType), host, port, rack, slot); + } + + /// Initializes a new instance with an explicit TSAP pair (LocalTsap / RemoteTsap / ConnectionType override). + /// PLC IP address or hostname. + /// TCP port. + /// The effective TSAP pair. + /// Connect + per-operation timeout. + public S7PlcAdapter(string host, int port, global::S7.Net.Protocol.TsapPair tsap, TimeSpan timeout) + : this(new Plc(host, port, tsap), timeout) + { + } + + private S7PlcAdapter(Plc plc, TimeSpan timeout) + { + _plc = plc; _operationTimeout = timeout; // S7netplus writes timeouts into the underlying TcpClient via ReadTimeout / WriteTimeout // (milliseconds). Set before OpenAsync so the handshake itself honours the bound — matches @@ -132,6 +164,9 @@ internal sealed class S7PlcAdapter : IS7Plc /// public bool IsConnected => _plc.IsConnected; + /// + public int MaxPduSize => _plc.MaxPDUSize; + /// public Task OpenAsync(CancellationToken cancellationToken) => _plc.OpenAsync(cancellationToken); @@ -187,4 +222,10 @@ internal sealed class S7PlcFactory : IS7PlcFactory /// public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => new S7PlcAdapter(cpuType, host, port, rack, slot, timeout); + + /// + public IS7Plc Create(S7DriverOptions options) => + S7Tsap.Resolve(options) is { } tsap + ? new S7PlcAdapter(options.Host, options.Port, tsap, options.Timeout) + : new S7PlcAdapter(options.CpuType, options.Host, options.Port, options.Rack, options.Slot, options.Timeout); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index e0110870..43a5dc08 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -117,6 +117,8 @@ public sealed class S7Driver private const uint StatusBadNotSupported = 0x803D0000u; /// OPC UA StatusCode used when a written value has the wrong shape / length / element type. private const uint StatusBadTypeMismatch = 0x80740000u; + /// OPC UA StatusCode used when a value is unrepresentable in the S7 type (write) or a PLC buffer does not decode (read). + private const uint StatusBadOutOfRange = 0x803C0000u; /// OPC UA StatusCode used when the tag is declared read-only. private const uint StatusBadNotWritable = 0x803B0000u; /// OPC UA StatusCode used when write fails validation (e.g. out-of-range value). @@ -223,6 +225,11 @@ public sealed class S7Driver /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { + // #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit + // runs it with no teardown first). Stop the previous probe loop and drop the previous connection + // before they are overwritten below. Deliberately NOT ShutdownAsync: that also drops _poll and the + // subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init). + await ReleaseConnectionAsync().ConfigureAwait(false); Health = new DriverHealth(DriverState.Initializing, null, null); try { @@ -269,8 +276,7 @@ public sealed class S7Driver // Build the connection through the factory (the same seam EnsureConnectedAsync's reopen // uses) so the read/write timeouts are baked in identically on connect and reconnect. - var plc = _plcFactory.Create( - _options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout); + var plc = _plcFactory.Create(_options); try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -298,6 +304,7 @@ public sealed class S7Driver Plc = plc; _plcDead = false; _connectBackoff.RecordSuccess(); + Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize); // Parse every tag's address once at init so config typos fail fast here instead // of surfacing as BadInternalError on every Read against the bad tag. The parser @@ -310,8 +317,8 @@ public sealed class S7Driver _initialized = true; Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); - _logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}", - _driverInstanceId, _options.Host, _options.CpuType, _tagsByRawPath.Count); + _logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}", + _driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize, _tagsByRawPath.Count); // Kick off the probe loop once the connection is up. Initial HostState stays // Unknown until the first probe tick succeeds — avoids broadcasting a premature @@ -332,6 +339,7 @@ public sealed class S7Driver // the TcpClient. Dispose is best-effort and idempotent. try { Plc?.Dispose(); } catch { } Plc = null; + Volatile.Write(ref _negotiatedPduSize, 0); _initialized = false; _plcDead = false; Health = new DriverHealth(DriverState.Faulted, null, ex.Message); @@ -360,7 +368,18 @@ public sealed class S7Driver // disposing its CTS (drain-before-dispose, STAB-6-S7), so no poll iteration touches _gate // after this returns. await _poll.DisposeAsync().ConfigureAwait(false); + await ReleaseConnectionAsync().ConfigureAwait(false); + _writeFault = null; + _connectBackoff.RecordSuccess(); + Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null); + } + /// + /// Cancels + bounded-joins the probe loop, then disposes its CTS and the PLC connection. The + /// connection part of (everything but the poll engine and health). + /// + private async Task ReleaseConnectionAsync() + { var probeCts = _probeCts; var probeTask = _probeTask; try { probeCts?.Cancel(); } catch { } @@ -379,13 +398,27 @@ public sealed class S7Driver _probeCts = null; _probeTask = null; - _initialized = false; - _plcDead = false; - _writeFault = null; - _connectBackoff.RecordSuccess(); - try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ } - Plc = null; - Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null); + // Swap the connection out under _gate so a Read/Write/poll pass that already holds the gate (they + // re-read Plc inside it) can never see a disposed Plc mid-swap. Bounded: a wedged holder must not hang + // teardown, in which case we proceed exactly as the un-gated path always did. + var gated = false; + try { gated = await _gate.WaitAsync(DrainTimeout).ConfigureAwait(false); } catch { } + if (!gated) + _logger.LogWarning( + "S7 driver {DriverInstanceId}: disposing the S7 connection while an operation still holds the gate (waited {Timeout})", + _driverInstanceId, DrainTimeout); + try + { + _initialized = false; + _plcDead = false; + try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ } + Plc = null; + Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online" + } + finally + { + if (gated) { try { _gate.Release(); } catch (ObjectDisposedException) { } } + } } /// @@ -411,6 +444,25 @@ public sealed class S7Driver /// authored against a non-byte suffix (DBW/DBD/…) is a config error — see /// . /// + /// + /// PDU size the CPU negotiated on the most recent successful connect, in bytes; 0 before the first + /// connect and after shutdown. Snapshotted at connect so it stays readable while the handle is + /// being replaced. Surfaced through and the connect log line. + /// + public int NegotiatedPduSize => Volatile.Read(ref _negotiatedPduSize); + + private int _negotiatedPduSize; + + /// + /// Driver-specific diagnostics key/value pairs (currently S7.NegotiatedPduSize). The core + /// record carries no diagnostics bag, so this is the surface until one exists. + /// + /// A snapshot of the diagnostics. + public IReadOnlyDictionary GetDiagnostics() => new Dictionary + { + ["S7.NegotiatedPduSize"] = NegotiatedPduSize, + }; + private static readonly HashSet ByteAnchoredDataTypes = new() { S7DataType.Int64, @@ -418,6 +470,14 @@ public sealed class S7Driver S7DataType.Float64, S7DataType.String, S7DataType.DateTime, + S7DataType.WString, + S7DataType.Char, + S7DataType.WChar, + S7DataType.Dtl, + S7DataType.Date, + S7DataType.Tod, + S7DataType.Time, + S7DataType.S5Time, }; /// @@ -1199,6 +1259,11 @@ public sealed class S7Driver case NotSupportedException: return StatusBadNotSupported; + case S7ValueRangeException: + // A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data + // problem, not a transport fault — no health change, no connection teardown. + return StatusBadOutOfRange; + case PlcException pex when IsAccessDenied(pex): // PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration // fault, NOT a transient one. Blind retry is wasted effort, so map it to @@ -1312,8 +1377,7 @@ public sealed class S7Driver /// true if the tag routes through the byte-buffer codec. internal static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) => addr.Area is S7Area.Timer or S7Area.Counter - || tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 - or S7DataType.String or S7DataType.DateTime; + || ByteAnchoredDataTypes.Contains(tag.DataType); /// /// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's @@ -1363,6 +1427,11 @@ public sealed class S7Driver S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8, S7DataType.DateTime => 8, // 8 = S7 DATE_AND_TIME/DT BCD (S7.Net.Types.DateTime; the 8-byte DT, not 12-byte DTL). S7DataType.String => tag.StringLength + 2, + S7DataType.WString => S7ValueCodecs.WStringWidth(tag.StringLength), + S7DataType.Char => 1, + S7DataType.WChar or S7DataType.Date or S7DataType.S5Time => 2, + S7DataType.Tod or S7DataType.Time => 4, + S7DataType.Dtl => 12, _ => throw new InvalidOperationException( $"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"), }; @@ -1419,6 +1488,17 @@ public sealed class S7Driver // System.DateTime explicitly (the cast pins the box type, consistent with the numeric arms). S7DataType.DateTime => (object)global::S7.Net.Types.DateTime.FromByteArray(block), + // The codecs below live in S7ValueCodecs (pure, big-endian); a malformed PLC buffer throws + // S7ValueRangeException → BadOutOfRange on the read. TOD/TIME/S5TIME surface as Int32 ms. + S7DataType.WString => S7ValueCodecs.DecodeWString(block), + S7DataType.Char => S7ValueCodecs.DecodeChar(block), + S7DataType.WChar => S7ValueCodecs.DecodeWChar(block), + S7DataType.Dtl => (object)S7ValueCodecs.DecodeDtl(block), + S7DataType.Date => (object)S7ValueCodecs.DecodeDate(block), + S7DataType.Tod => (object)S7ValueCodecs.DecodeTod(block), + S7DataType.Time => (object)S7ValueCodecs.DecodeTime(block), + S7DataType.S5Time => (object)S7ValueCodecs.DecodeS5Time(block), + _ => throw new System.IO.InvalidDataException( $"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " + $"'{tag.Address}' parsed as Size={addr.Size}"), @@ -1484,6 +1564,16 @@ public sealed class S7Driver // System.DateTime or a parseable timestamp string). return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value)); + // Writes refuse (S7ValueRangeException → BadOutOfRange) rather than truncate or clamp. + case S7DataType.WString: return S7ValueCodecs.EncodeWString(value, tag.StringLength); + case S7DataType.Char: return S7ValueCodecs.EncodeChar(value); + case S7DataType.WChar: return S7ValueCodecs.EncodeWChar(value); + case S7DataType.Dtl: return S7ValueCodecs.EncodeDtl(value); + case S7DataType.Date: return S7ValueCodecs.EncodeDate(value); + case S7DataType.Tod: return S7ValueCodecs.EncodeTod(value); + case S7DataType.Time: return S7ValueCodecs.EncodeTime(value); + case S7DataType.S5Time: return S7ValueCodecs.EncodeS5Time(value); + default: throw new InvalidOperationException( $"S7 EncodeScalarBlock called for non-buffer type {tag.DataType} (tag '{tag.Name}')"); @@ -1596,6 +1686,14 @@ public sealed class S7Driver case (S7DataType.Float64, _): case (S7DataType.String, _): case (S7DataType.DateTime, _): + case (S7DataType.WString, _): + case (S7DataType.Char, _): + case (S7DataType.WChar, _): + case (S7DataType.Dtl, _): + case (S7DataType.Date, _): + case (S7DataType.Tod, _): + case (S7DataType.Time, _): + case (S7DataType.S5Time, _): throw new NotSupportedException( $"S7 array reads of {tag.DataType} land in a follow-up PR"); @@ -1811,6 +1909,12 @@ public sealed class S7Driver { results[i] = new WriteResult(StatusBadNotSupported); } + catch (S7ValueRangeException) + { + // Value unrepresentable in the S7 type (string over its declared max, DATE < 1990…): + // refused with nothing sent — a client error, no health impact. + results[i] = new WriteResult(StatusBadOutOfRange); + } catch (S7WriteValueException) { // Client-supplied value of the wrong shape — not a device or comms fault. @@ -1989,6 +2093,14 @@ public sealed class S7Driver case (S7DataType.Float64, _): case (S7DataType.String, _): case (S7DataType.DateTime, _): + case (S7DataType.WString, _): + case (S7DataType.Char, _): + case (S7DataType.WChar, _): + case (S7DataType.Dtl, _): + case (S7DataType.Date, _): + case (S7DataType.Tod, _): + case (S7DataType.Time, _): + case (S7DataType.S5Time, _): throw new NotSupportedException($"S7 array writes of {tag.DataType} are not supported (tag '{tag.Name}')."); case (S7DataType.Byte, S7Size.Byte): case (S7DataType.UInt16, S7Size.Word): @@ -2089,6 +2201,9 @@ public sealed class S7Driver S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"), S7DataType.String => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"), S7DataType.DateTime => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"), + S7DataType.WString or S7DataType.Char or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date + or S7DataType.Tod or S7DataType.Time or S7DataType.S5Time + => throw new NotSupportedException($"S7 {dataType} is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"), _ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"), }; @@ -2141,8 +2256,7 @@ public sealed class S7Driver throw new InvalidOperationException( $"S7 reconnect to {_options.Host}:{_options.Port} suppressed — backing off after a failed connect attempt."); - var plc = _plcFactory.Create( - _options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout); + var plc = _plcFactory.Create(_options); try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); @@ -2172,8 +2286,9 @@ public sealed class S7Driver _connectBackoff.RecordSuccess(); Plc = plc; - _logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType}", - _driverInstanceId, _options.Host, _options.CpuType); + Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize); + _logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize}", + _driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize); return plc; } @@ -2249,7 +2364,7 @@ public sealed class S7Driver /// no typed error code for it, so the inner message is the only discriminator /// S7.Net exposes. /// - private static bool IsAccessDenied(PlcException pex) + internal static bool IsAccessDenied(PlcException pex) { for (Exception? e = pex; e is not null; e = e.InnerException) { @@ -2306,8 +2421,10 @@ public sealed class S7Driver S7DataType.UInt64 => DriverDataType.UInt64, S7DataType.Float32 => DriverDataType.Float32, S7DataType.Float64 => DriverDataType.Float64, - S7DataType.String => DriverDataType.String, - S7DataType.DateTime => DriverDataType.DateTime, + S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar => DriverDataType.String, + S7DataType.DateTime or S7DataType.Dtl or S7DataType.Date => DriverDataType.DateTime, + // TOD / TIME / S5TIME surface as Int32 milliseconds (the default arm; named for the reader). + S7DataType.Tod or S7DataType.Time or S7DataType.S5Time => DriverDataType.Int32, _ => DriverDataType.Int32, }; @@ -2492,6 +2609,7 @@ public sealed class S7Driver _connectBackoff.RecordSuccess(); try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ } Plc = null; + Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online" Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs index 8fa880aa..e769dba8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs @@ -87,9 +87,23 @@ public static class S7DriverFactoryExtensions $"S7 driver config for '{driverInstanceId}' has SpanReadMaxGapBytes {spanReadMaxGapBytes}; " + "must be >= -1 — -1 disables span grouping and 0 merges only strictly adjacent tags."); + // Optional TSAP / connection-type override. Validated here (the single choke point) so a typo'd + // TSAP fails the deploy rather than silently connecting with the slot-derived default. + var localTsap = NormalizeTsap(dto.LocalTsap, driverInstanceId, "LocalTsap"); + var remoteTsap = NormalizeTsap(dto.RemoteTsap, driverInstanceId, "RemoteTsap"); + S7ConnectionType? connectionType = string.IsNullOrWhiteSpace(dto.ConnectionType) + ? null + : ParseEnum(dto.ConnectionType, driverInstanceId, "ConnectionType"); + if (remoteTsap is not null && connectionType is not null) + throw new InvalidOperationException( + $"S7 driver config for '{driverInstanceId}' {S7DriverOptions.TsapConflictDetail}"); + return new S7DriverOptions { Host = dto.Host!, + LocalTsap = localTsap, + RemoteTsap = remoteTsap, + ConnectionType = connectionType, Port = dto.Port ?? 102, CpuType = ParseEnum(dto.CpuType, driverInstanceId, "CpuType", fallback: S7CpuType.S71500), @@ -115,6 +129,23 @@ public static class S7DriverFactoryExtensions }; } + private static string? NormalizeTsap(JsonElement? raw, string driverInstanceId, string field) + { + if (raw is not { } el || el.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return null; + string? text = el.ValueKind switch + { + JsonValueKind.String => el.GetString(), + JsonValueKind.Number when el.TryGetUInt16(out var n) => S7Tsap.Format(n), + _ => "?", + }; + if (string.IsNullOrWhiteSpace(text)) return null; + if (!S7Tsap.TryParse(text, out var tsap)) + throw new InvalidOperationException( + $"S7 driver config for '{driverInstanceId}' has {field} '{text}'; expected two hex bytes " + + "such as \"01.00\" or \"03.01\" (or a 16-bit number)."); + return S7Tsap.Format(tsap); + } + private static T ParseEnum(string? raw, string driverInstanceId, string field, string? tagName = null, T? fallback = null) where T : struct, Enum { @@ -151,6 +182,12 @@ public static class S7DriverFactoryExtensions public short? Rack { get; init; } /// Gets the slot number. public short? Slot { get; init; } + /// Gets the local TSAP override — hex text ("01.00") or a 16-bit number. + public JsonElement? LocalTsap { get; init; } + /// Gets the remote TSAP override — hex text ("03.01") or a 16-bit number. + public JsonElement? RemoteTsap { get; init; } + /// Gets the connection-type name (PG / OP / S7Basic); a string so AdminUI-authored configs bind. + public string? ConnectionType { get; init; } /// Gets the connection timeout in milliseconds. public int? TimeoutMs { get; init; } /// Gets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the deploy diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs index 9d0b0380..d936b98a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs @@ -66,7 +66,11 @@ public sealed class S7DriverProbe : IDriverProbe // The Plc is opened and immediately closed — no reads or writes are performed. // Use a linked CTS so we can distinguish a real caller cancellation from // S7netplus's internal socket-read timeout (which also surfaces as OCE). - var plc = new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot); + // An authored TSAP / connection-type override applies to Test Connect too — otherwise the probe + // would succeed (or fail) on a different handshake than the driver will use. + var plc = S7Tsap.Resolve(opts) is { } tsap + ? new Plc(host, port, tsap) + : new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot); plc.ReadTimeout = (int)timeout.TotalMilliseconds; using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); handshakeCts.CancelAfter(timeout); @@ -76,7 +80,15 @@ public sealed class S7DriverProbe : IDriverProbe sw.Stop(); if (plc.IsConnected) - return new(true, $"S7 connected (CPU {opts.CpuType})", sw.Elapsed); + { + // A handshake alone passes on a CPU with PUT/GET disabled (S7-1200/1500 default) — every + // later read would fail. One minimal read proves data access. + if (await CheckDataAccessAsync( + t => plc.ReadBytesAsync(global::S7.Net.DataType.Memory, 0, 0, 2, t), handshakeCts.Token) + is { } denied) + return denied; + return new(true, $"S7 connected (CPU {opts.CpuType}, PDU {plc.MaxPDUSize} bytes)", sw.Elapsed); + } return new(false, $"Reachable at {host}:{port} but S7 handshake failed: not connected", null); } @@ -100,6 +112,38 @@ public sealed class S7DriverProbe : IDriverProbe } } + /// + /// Runs one minimal read after the handshake. Returns null when data access works — including when the + /// CPU merely rejects that particular address (MB0 not present / out of range: the connection is fine). + /// PUT/GET-disabled yields the actionable message; any other fault is a probe failure with its message. + /// + /// The minimal read. + /// Cancellation token. + /// A failure result, or null on success. + internal static async Task CheckDataAccessAsync(Func> read, CancellationToken ct) + { + try + { + await read(ct).ConfigureAwait(false); + return null; + } + catch (OperationCanceledException) { throw; } + catch (PlcException pex) when (S7Driver.IsAccessDenied(pex)) + { + return new(false, + "S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " + + $"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null); + } + catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData) + { + return null; // address not available on this CPU — not a connection problem + } + catch (Exception ex) + { + return new(false, $"S7 connected but a test read failed: {ex.Message}", null); + } + } + private static (string host, int port) ExtractTarget(S7DriverOptions opts) => (opts.Host, opts.Port); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Tsap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Tsap.cs new file mode 100644 index 00000000..59466735 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Tsap.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using S7.Net.Protocol; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// ISO-TSAP helpers for the optional LocalTsap / RemoteTsap / ConnectionType +/// config: parsing the hex text form and resolving the effective handed to +/// S7.Net. Pure — no I/O — so the resolution rules are unit-testable without a PLC. +/// +internal static class S7Tsap +{ + /// + /// Parses a TSAP written as two hex bytes — "01.00", "03:01" (1–2 digits per byte) — or + /// as one 16-bit hex value, "0100" / "0x0301". High byte first. + /// + /// The configured text. + /// The 16-bit TSAP (first << 8 | second). + /// True when is a valid TSAP. + internal static bool TryParse(string? text, out ushort tsap) + { + tsap = 0; + if (string.IsNullOrWhiteSpace(text)) return false; + var t = text.Trim(); + var sep = t.IndexOfAny(['.', ':', '-']); + if (sep >= 0) + { + if (!TryHexByte(t[..sep], out var hi) || !TryHexByte(t[(sep + 1)..], out var lo)) return false; + tsap = (ushort)((hi << 8) | lo); + return true; + } + if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..]; + if (t.Length is < 1 or > 4) return false; + return ushort.TryParse(t, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out tsap); + } + + private static bool TryHexByte(string s, out byte b) + { + b = 0; + s = s.Trim(); + return s.Length is >= 1 and <= 2 + && byte.TryParse(s, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out b); + } + + /// Formats a 16-bit TSAP as canonical "XX.YY" (upper-case hex). + /// The 16-bit TSAP. + /// The canonical text. + internal static string Format(ushort tsap) => $"{tsap >> 8:X2}.{tsap & 0xFF:X2}"; + + /// + /// Resolves the TSAP pair to connect with, or null when the options carry no override (the + /// caller then keeps the plain Plc(cpu, host, port, rack, slot) construction, unchanged). + /// Starts from S7.Net's own per-CPU default for 's rack/slot, then applies, + /// in order: LocalTsap replaces the local TSAP; RemoteTsap replaces the remote TSAP; + /// ConnectionType replaces only the remote TSAP's first byte. + /// + /// The driver options. + /// The effective pair, or null for "no override". + internal static TsapPair? Resolve(S7DriverOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var hasLocal = !string.IsNullOrWhiteSpace(options.LocalTsap); + var hasRemote = !string.IsNullOrWhiteSpace(options.RemoteTsap); + if (!hasLocal && !hasRemote && options.ConnectionType is null) return null; + + var def = TsapPair.GetDefaultTsapPair(S7CpuTypeMap.ToS7Net(options.CpuType), options.Rack, options.Slot); + var local = def.Local; + var remote = def.Remote; + + if (hasLocal) + { + if (!TryParse(options.LocalTsap, out var l)) + throw new FormatException($"S7 LocalTsap '{options.LocalTsap}' is not a valid TSAP (expected e.g. \"01.00\")."); + local = new Tsap((byte)(l >> 8), (byte)(l & 0xFF)); + } + if (hasRemote) + { + if (!TryParse(options.RemoteTsap, out var r)) + throw new FormatException($"S7 RemoteTsap '{options.RemoteTsap}' is not a valid TSAP (expected e.g. \"03.01\")."); + remote = new Tsap((byte)(r >> 8), (byte)(r & 0xFF)); + } + else if (options.ConnectionType is { } ct) + { + remote = new Tsap((byte)ct, remote.SecondByte); + } + return new TsapPair(local, remote); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs new file mode 100644 index 00000000..8017987b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs @@ -0,0 +1,300 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// A value the S7 codecs refuse because it is outside what the S7 type can represent — +/// on the WRITE side a caller value that overflows the declared field (a string longer than its +/// declared max length, a DATE before 1990, a TOD past midnight), on the READ side a PLC buffer +/// that does not decode (uninitialised DTL, a non-BCD S5TIME digit, a string whose current length +/// exceeds its reserved length). Maps to BadOutOfRange and, unlike a transport fault, does +/// not degrade driver health or condemn the connection. +/// +internal sealed class S7ValueRangeException(string message) : Exception(message); + +/// +/// Pure byte-level codecs for the S7 string, character and date/time/duration types the narrow +/// (1/2/4-byte) read path cannot express: WSTRING, CHAR, WCHAR, DTL, +/// DATE, TOD, TIME and S5TIME. Every method works on a raw big-endian S7 +/// block, so each wire format is locked by known-byte-vector unit tests without a PLC. Classic +/// STRING and DATE_AND_TIME (DT) stay on S7.Net's own codecs. +/// +/// +/// +/// OPC UA mapping (no new DriverDataType): WString/Char/WChar → String; +/// DTL/Date → DateTime; TOD, TIME, S5TIME → Int32 milliseconds (TOD = ms since midnight, +/// TIME = signed duration, S5TIME = duration). All values fit Int32. +/// +/// +/// Writes refuse rather than clamp or truncate: an unrepresentable value throws +/// (→ BadOutOfRange) with nothing sent to the PLC; a value +/// of the wrong shape throws (→ BadTypeMismatch). +/// +/// +internal static class S7ValueCodecs +{ + /// Largest declared WSTRING character count (S7-1500 limit; header is UInt16 but the PDU-safe ceiling is 16382). + internal const int MaxWStringLength = 16382; + + /// First day of the S7 DATE epoch. + private static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); + + private const int MillisecondsPerDay = 86_400_000; + + // ---- CHAR / WCHAR ---- + + /// Decodes a 1-byte S7 CHAR (Latin-1) to a one-character string. + internal static string DecodeChar(ReadOnlySpan block) + { + RequireLength(block, 1, "CHAR"); + return ((char)block[0]).ToString(); + } + + /// Encodes a one-character string to a 1-byte S7 CHAR. Characters above U+00FF are out of range. + internal static byte[] EncodeChar(object? value) + { + var c = SingleChar(value, "CHAR"); + if (c > 0xFF) + throw new S7ValueRangeException($"S7 CHAR holds one 8-bit character; U+{(int)c:X4} is out of range."); + return [(byte)c]; + } + + /// Decodes a 2-byte S7 WCHAR (UTF-16 big-endian code unit) to a one-character string. + internal static string DecodeWChar(ReadOnlySpan block) + { + RequireLength(block, 2, "WCHAR"); + return ((char)BinaryPrimitives.ReadUInt16BigEndian(block)).ToString(); + } + + /// Encodes a one-character string to a 2-byte S7 WCHAR (UTF-16 big-endian). + internal static byte[] EncodeWChar(object? value) + { + var b = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(b, SingleChar(value, "WCHAR")); + return b; + } + + private static char SingleChar(object? value, string type) + { + if (value is char ch) return ch; + var s = value as string ?? throw new S7Driver.S7WriteValueException($"S7 {type} write needs a one-character string."); + if (s.Length != 1) + throw new S7Driver.S7WriteValueException( + $"S7 {type} write needs exactly one character; got {s.Length}."); + return s[0]; + } + + // ---- WSTRING ---- + + /// Byte width of a WSTRING reserving characters: 4-byte header + 2 bytes per character. + internal static int WStringWidth(int maxLength) => 4 + (2 * maxLength); + + /// + /// Decodes [maxLen UInt16 BE][curLen UInt16 BE][curLen × UTF-16BE]. Characters past + /// curLen (the reserved padding) are ignored. + /// + internal static string DecodeWString(ReadOnlySpan block) + { + if (block.Length < 4) + throw new S7ValueRangeException($"S7 WSTRING block is {block.Length} bytes; the header alone is 4."); + var maxLen = BinaryPrimitives.ReadUInt16BigEndian(block); + var curLen = BinaryPrimitives.ReadUInt16BigEndian(block[2..]); + if (curLen > maxLen) + throw new S7ValueRangeException($"S7 WSTRING current length {curLen} exceeds its reserved length {maxLen}."); + if (4 + (2 * curLen) > block.Length) + throw new S7ValueRangeException( + $"S7 WSTRING current length {curLen} runs past the {block.Length}-byte block."); + return Encoding.BigEndianUnicode.GetString(block.Slice(4, 2 * curLen)); + } + + /// + /// Encodes a string into the full reserved WSTRING field ( bytes, zero + /// padded). A value with more UTF-16 code units than is refused, not truncated. + /// + internal static byte[] EncodeWString(object? value, int maxLength) + { + var s = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? ""; + if (s.Length > maxLength) + throw new S7ValueRangeException( + $"S7 WSTRING value is {s.Length} characters; the declared maximum is {maxLength}."); + var b = new byte[WStringWidth(maxLength)]; + BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)maxLength); + BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(2), (ushort)s.Length); + Encoding.BigEndianUnicode.GetBytes(s, b.AsSpan(4)); + return b; + } + + // ---- DTL (12 bytes) ---- + + /// + /// Decodes DTL: year UInt16 BE, month, day, weekday, hour, minute, second, nanoseconds UInt32 BE. + /// The weekday byte is ignored. An all-zero (uninitialised) buffer is refused. + /// + internal static DateTime DecodeDtl(ReadOnlySpan block) + { + RequireLength(block, 12, "DTL"); + var year = BinaryPrimitives.ReadUInt16BigEndian(block); + var nanos = BinaryPrimitives.ReadUInt32BigEndian(block[8..]); + if (year == 0 || block[2] == 0 || block[3] == 0) + throw new S7ValueRangeException("S7 DTL buffer is uninitialised (year/month/day is zero)."); + if (nanos > 999_999_999) + throw new S7ValueRangeException($"S7 DTL nanoseconds {nanos} out of range."); + try + { + return new DateTime(year, block[2], block[3], block[5], block[6], block[7], DateTimeKind.Unspecified) + .AddTicks(nanos / 100); + } + catch (ArgumentOutOfRangeException ex) + { + throw new S7ValueRangeException($"S7 DTL buffer is not a valid date/time: {ex.Message}"); + } + } + + /// Encodes a DateTime to DTL. Years outside the PLC's 1970–2262 range are refused; sub-100 ns precision is not representable in a DateTime so nanoseconds are a multiple of 100. + internal static byte[] EncodeDtl(object? value) + { + var dt = ToDateTime(value, "DTL"); + if (dt.Year is < 1970 or > 2262) + throw new S7ValueRangeException($"S7 DTL year {dt.Year} is outside 1970–2262."); + var b = new byte[12]; + BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)dt.Year); + b[2] = (byte)dt.Month; + b[3] = (byte)dt.Day; + b[4] = (byte)((int)dt.DayOfWeek + 1); // S7: 1 = Sunday … 7 = Saturday + b[5] = (byte)dt.Hour; + b[6] = (byte)dt.Minute; + b[7] = (byte)dt.Second; + BinaryPrimitives.WriteUInt32BigEndian(b.AsSpan(8), (uint)((dt.Ticks % TimeSpan.TicksPerSecond) * 100)); + return b; + } + + // ---- DATE (UInt16 days since 1990-01-01) ---- + + /// Decodes DATE (days since 1990-01-01, UInt16 BE) to a midnight DateTime. + internal static DateTime DecodeDate(ReadOnlySpan block) + { + RequireLength(block, 2, "DATE"); + return DateEpoch.AddDays(BinaryPrimitives.ReadUInt16BigEndian(block)); + } + + /// Encodes the DATE part of a DateTime; dates before 1990-01-01 or past 2169-06-06 (the UInt16 ceiling) are refused. The time-of-day is dropped. + internal static byte[] EncodeDate(object? value) + { + var days = (ToDateTime(value, "DATE").Date - DateEpoch).Days; + if (days is < 0 or > ushort.MaxValue) + throw new S7ValueRangeException("S7 DATE must be between 1990-01-01 and 2169-06-06 (UInt16 days since 1990-01-01)."); + var b = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)days); + return b; + } + + // ---- TOD (UInt32 ms since midnight) ---- + + /// Decodes TIME_OF_DAY to Int32 milliseconds since midnight (0–86 399 999). + internal static int DecodeTod(ReadOnlySpan block) + { + RequireLength(block, 4, "TOD"); + var ms = BinaryPrimitives.ReadUInt32BigEndian(block); + if (ms >= MillisecondsPerDay) + throw new S7ValueRangeException($"S7 TOD value {ms} ms is past midnight (max 86399999)."); + return (int)ms; + } + + /// Encodes milliseconds since midnight (or a ) to TOD; outside 0–86 399 999 is refused. + internal static byte[] EncodeTod(object? value) + { + var ms = ToMilliseconds(value, "TOD"); + if (ms is < 0 or >= MillisecondsPerDay) + throw new S7ValueRangeException($"S7 TOD value {ms} ms is outside 0–86399999."); + var b = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(b, (uint)ms); + return b; + } + + // ---- TIME (Int32 ms, signed) ---- + + /// Decodes IEC TIME to a signed Int32 millisecond duration. + internal static int DecodeTime(ReadOnlySpan block) + { + RequireLength(block, 4, "TIME"); + return BinaryPrimitives.ReadInt32BigEndian(block); + } + + /// Encodes a signed millisecond duration (or ) to TIME; outside the Int32 range is refused. + internal static byte[] EncodeTime(object? value) + { + var ms = ToMilliseconds(value, "TIME"); + if (ms is < int.MinValue or > int.MaxValue) + throw new S7ValueRangeException($"S7 TIME value {ms} ms is outside the Int32 range."); + var b = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(b, (int)ms); + return b; + } + + // ---- S5TIME (2 bytes) ---- + + private static readonly int[] S5BaseMs = [10, 100, 1000, 10_000]; + + /// + /// Decodes S5TIME — bits 13–12 select the time base (00 = 10 ms, 01 = 100 ms, 10 = 1 s, 11 = 10 s), + /// bits 11–0 are three BCD digits — to Int32 milliseconds. Bits 15–14 are ignored. A non-BCD nibble is refused. + /// + internal static int DecodeS5Time(ReadOnlySpan block) + { + RequireLength(block, 2, "S5TIME"); + int d2 = block[0] & 0x0F, d1 = block[1] >> 4, d0 = block[1] & 0x0F; + if (d2 > 9 || d1 > 9 || d0 > 9) + throw new S7ValueRangeException("S7 S5TIME holds a non-BCD digit."); + var value = (d2 * 100) + (d1 * 10) + d0; + return value * S5BaseMs[(block[0] >> 4) & 0x03]; + } + + /// + /// Encodes a millisecond duration to S5TIME using the finest time base that holds it (value ≤ 999). + /// Refused when it exceeds 9 990 000 ms or is not an exact multiple of a base that fits — no rounding. + /// + internal static byte[] EncodeS5Time(object? value) + { + var ms = ToMilliseconds(value, "S5TIME"); + if (ms < 0 || ms > 999L * 10_000) + throw new S7ValueRangeException($"S7 S5TIME value {ms} ms is outside 0–9990000."); + for (var baseCode = 0; baseCode < S5BaseMs.Length; baseCode++) + { + var unit = S5BaseMs[baseCode]; + if (ms % unit != 0 || ms / unit > 999) continue; + var v = (int)(ms / unit); + return [(byte)((baseCode << 4) | (v / 100)), (byte)(((v / 10 % 10) << 4) | (v % 10))]; + } + throw new S7ValueRangeException( + $"S7 S5TIME cannot represent {ms} ms exactly (resolution is 10 ms up to 9990 ms, coarser above)."); + } + + // ---- helpers ---- + + private static void RequireLength(ReadOnlySpan block, int expected, string type) + { + if (block.Length < expected) + throw new S7ValueRangeException($"S7 {type} block is {block.Length} bytes; expected {expected}."); + } + + private static DateTime ToDateTime(object? value, string type) + { + try { return value is DateTimeOffset dto ? dto.DateTime : Convert.ToDateTime(value, System.Globalization.CultureInfo.InvariantCulture); } + catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) + { + throw new S7Driver.S7WriteValueException($"S7 {type} write needs a date/time value ({ex.Message})."); + } + } + + // Fractional input is rounded (Convert.ToInt64, banker's), matching the driver's existing Int32 write + // path (BoxValueForWrite → Convert.ToInt32). "Refused, not rounded" applies to S5TIME's time-base grid. + private static long ToMilliseconds(object? value, string type) + { + try { return value is TimeSpan ts ? (long)ts.TotalMilliseconds : Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture); } + catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) + { + throw new S7Driver.S7WriteValueException($"S7 {type} write needs a millisecond count ({ex.Message})."); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDataType.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDataType.cs index 31c79615..a84ce5fb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDataType.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDataType.cs @@ -20,10 +20,9 @@ public enum TwinCATDataType String, // ASCII string WString,// UTF-16 string // IEC 61131-3 / TwinCAT temporal types — all stored on the wire as 32-bit unsigned (UDINT) - // raw counters. The driver surfaces them as DriverDataType.UInt32, so - // operators see the raw counter, not a decoded date/duration. Proper decoding to - // DriverDataType.DateTime is a future enhancement; until then comments accurately describe - // the on-wire encoding so the next implementer doesn't re-derive it wrong. + // raw counters. The driver decodes them (#306): TIME / TOD publish Double milliseconds + // (DriverDataType.Float64), DATE / DT publish a UTC DateTime (DriverDataType.DateTime). A tag that + // wants the raw counter declares UDInt against the same symbol. Time, // TIME — duration in milliseconds, stored as UDINT (32-bit unsigned) Date, // DATE — seconds since 1970-01-01 truncated to a day boundary, stored as UDINT DateTime, // DT (DATE_AND_TIME) — seconds since 1970-01-01, stored as UDINT diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index bc10d2c4..396db079 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -155,7 +155,10 @@ internal sealed class AdsTwinCATClient : ITwinCATClient if (result.ErrorCode != AdsErrorCode.NoError) return (null, MapAndSignal((uint)result.ErrorCode)); - return (result.Value, TwinCATStatusMapper.Good); + return (TwinCATTemporalCodec.IsTemporal(type) && result.Value is uint rawTemporal + ? TwinCATTemporalCodec.Decode(type, rawTemporal) + : result.Value, + TwinCATStatusMapper.Good); } catch (AdsErrorException ex) { @@ -412,6 +415,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient } else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString) value = DecodeString(reg.Type, raw, reg.StringsUtf8); + else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal) + value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal); _notificationQueue.Writer.TryWrite(new PendingNotification(reg, value)); } @@ -696,7 +701,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient _ => typeof(int), }; - private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch + internal static object ConvertForWrite(TwinCATDataType type, object? value) => type switch { TwinCATDataType.Bool => Convert.ToBoolean(value), TwinCATDataType.SInt => Convert.ToSByte(value), @@ -711,7 +716,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient TwinCATDataType.LReal => Convert.ToDouble(value), TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty, TwinCATDataType.Time or TwinCATDataType.Date - or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value), + or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => TwinCATTemporalCodec.Encode(type, value), _ => throw new NotSupportedException($"TwinCATDataType {type} not writable."), }; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs index 7d471bd1..a6248931 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs @@ -73,15 +73,28 @@ internal static class TwinCATArrayCodec for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32LittleEndian(bytes[(i * stride)..]); return a; } - case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.Date - or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay: + case TwinCATDataType.UDInt: { - // TIME / DATE / DT / TOD publish their raw 32-bit count — the same CLR shape (uint) a - // scalar of those types publishes. var a = new uint[n]; for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]); return a; } + case TwinCATDataType.Time or TwinCATDataType.TimeOfDay: + { + // TIME / TOD publish milliseconds as double — the same CLR shape a scalar publishes (#306). + var a = new double[n]; + for (var i = 0; i < n; i++) + a[i] = (double)TwinCATTemporalCodec.Decode(declared, BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..])); + return a; + } + case TwinCATDataType.Date or TwinCATDataType.DateTime: + { + // DATE / DT publish a UTC DateTime, as a scalar does (#306). + var a = new DateTime[n]; + for (var i = 0; i < n; i++) + a[i] = (DateTime)TwinCATTemporalCodec.Decode(declared, BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..])); + return a; + } case TwinCATDataType.LInt: { var a = new long[n]; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataTypeExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataTypeExtensions.cs index bd070be0..114fd1f5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataTypeExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataTypeExtensions.cs @@ -22,8 +22,10 @@ public static class TwinCATDataTypeExtensions TwinCATDataType.Real => DriverDataType.Float32, TwinCATDataType.LReal => DriverDataType.Float64, TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String, - TwinCATDataType.Time or TwinCATDataType.Date - or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.UInt32, + // TIME / TOD → Double milliseconds, DATE / DT → UTC DateTime (#306); the driver decodes the UDINT + // wire counter in AdsTwinCATClient. A tag that wants the raw counter declares UDInt instead. + TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DriverDataType.Float64, + TwinCATDataType.Date or TwinCATDataType.DateTime => DriverDataType.DateTime, TwinCATDataType.Structure => DriverDataType.String, _ => DriverDataType.Int32, }; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 86d7831e..5782300a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -110,8 +110,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery public string DriverType => "TwinCAT"; /// - public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { + // #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit + // runs it with no teardown first). Release the previous device states' probe loops + clients before + // the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions. + await ReleaseDeviceStatesAsync().ConfigureAwait(false); _health = new DriverHealth(DriverState.Initializing, null, null); try { @@ -175,7 +179,30 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery _health = new DriverHealth(DriverState.Faulted, null, ex.Message); throw; } - return Task.CompletedTask; + } + + /// + /// Cancels, awaits and disposes every existing device state's probe loop, client and gate, then + /// clears the device table. Device part of only. + /// + private async Task ReleaseDeviceStatesAsync() + { + if (_devices.IsEmpty) return; + foreach (var state in _devices.Values) + { + try { state.ProbeCts?.Cancel(); } catch { } + if (state.ProbeTask is Task pt) + { + try { await pt.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); } + catch { /* cancelled, faulted or wedged — teardown continues */ } + } + state.ProbeCts?.Dispose(); + state.ProbeCts = null; + state.ProbeTask = null; + state.DisposeClient(); + state.DisposeGate(); + } + _devices.Clear(); } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs new file mode 100644 index 00000000..065c515d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs @@ -0,0 +1,76 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Converts the IEC 61131-3 temporal types between their 32-bit unsigned (UDINT) wire counters and the +/// values the driver publishes (#637 / #306). TIME and TOD surface as a of +/// milliseconds (OPC UA Double; no new DriverDataType member — see +/// docs/drivers/TwinCAT.md); DATE and DT surface as a UTC . +/// +internal static class TwinCATTemporalCodec +{ + private static readonly DateTime Epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// True for TIME / DATE / DT / TOD. + public static bool IsTemporal(TwinCATDataType type) => + type is TwinCATDataType.Time or TwinCATDataType.Date + or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay; + + /// Decodes a raw UDINT counter into the published value for . + public static object Decode(TwinCATDataType type, uint raw) => type switch + { + TwinCATDataType.Time or TwinCATDataType.TimeOfDay => (double)raw, + TwinCATDataType.Date or TwinCATDataType.DateTime => Epoch.AddSeconds(raw), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "not a temporal type"), + }; + + /// + /// Encodes a published/written value into the UDINT counter. Accepts the native shape + /// ( ms / / ) and any other + /// numeric as the raw counter, so a client writing an integer keeps working. + /// + /// Out of the UDINT range, negative, or NaN/infinite. + /// A value of no accepted shape. + public static uint Encode(TwinCATDataType type, object? value) + { + switch (type) + { + case TwinCATDataType.Time or TwinCATDataType.TimeOfDay: + var ms = value switch + { + TimeSpan ts => ToUInt(ts.TotalMilliseconds), + DateTime => throw new InvalidCastException($"{type} takes a duration in milliseconds, not a DateTime"), + double d => ToUInt(d), + float f => ToUInt(f), + _ => Convert.ToUInt32(value), + }; + // TOD is a time of day: a full day or more is not representable. + if (type == TwinCATDataType.TimeOfDay && ms >= 86_400_000u) + throw new OverflowException($"{ms} ms is not a time of day (< 86 400 000)"); + return ms; + case TwinCATDataType.Date or TwinCATDataType.DateTime: + if (value is DateTime dt) + { + var utc = dt.Kind == DateTimeKind.Local ? dt.ToUniversalTime() : dt; + var secs = ToUInt(Math.Floor((utc - Epoch).TotalSeconds)); + // DATE is a whole-day counter. + return type == TwinCATDataType.Date ? secs - secs % 86400u : secs; + } + if (value is TimeSpan) throw new InvalidCastException($"{type} takes a DateTime, not a duration"); + return value switch + { + double d => ToUInt(d), + float f => ToUInt(f), + _ => Convert.ToUInt32(value), + }; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, "not a temporal type"); + } + } + + private static uint ToUInt(double v) + { + if (double.IsNaN(v) || double.IsInfinity(v) || v < 0 || v > uint.MaxValue) + throw new OverflowException($"{v} is outside the 32-bit unsigned range of an IEC temporal value"); + return (uint)Math.Round(v, MidpointRounding.AwayFromZero); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor index ec88539f..bbf436e8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor @@ -142,6 +142,13 @@ try { _keys = await Keys.ListAsync(default); + // The one-time banner is only meaningful while its key exists; the credential is + // otua__, so drop it once no listed key matches (deleted here or elsewhere). + if (_created is not null + && !_keys.Any(k => _created.StartsWith($"otua_{k.ApiKeyId}_", StringComparison.Ordinal))) + { + _created = null; + } } catch (Exception ex) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor index da936e66..b68cf28d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor @@ -30,6 +30,36 @@
Controls ISO-TSAP slot byte during handshake.
+
+
+ + + + @foreach (var v in Enum.GetValues()) + { + + } + +
First byte of the remote TSAP. Leave on default unless the CPU needs a PG/OP resource.
+
+
+ + +
Optional hex override, e.g. 01.00.
+
+
+ + +
Optional hex override, e.g. 03.01. Not combinable with Connection type.
+
+
+ @if (_form.TsapConflictError() is { } tsapError) + { + + } @@ -141,6 +171,10 @@ public S7CpuType CpuType { get; set; } = S7CpuType.S71500; public short Rack { get; set; } = 0; public short Slot { get; set; } = 0; + public string? LocalTsap { get; set; } + public string? RemoteTsap { get; set; } + // Bound to a