Merge branch 'master' into b626-opcuaclient

This commit is contained in:
Joseph Doherty
2026-10-08 14:47:49 -04:00
72 changed files with 3775 additions and 343 deletions
+9
View File
@@ -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
+40
View File
@@ -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
+4
View File
@@ -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
+59 -20
View File
@@ -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
+74 -8
View File
@@ -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.<rack×0x20+slot>` 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`
+24 -1
View File
@@ -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) |
@@ -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
}
}
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device (#628).</summary>
public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices";
/// <summary>
/// The one message both the deploy gate and the authoring-time guard (<c>RawTreeService.CreateDeviceAsync</c>)
/// use, so the two can never describe different rules.
/// </summary>
/// <param name="driverLabel">The driver instance id or name to cite.</param>
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.";
/// <summary>
/// #628 (decision (b)): an MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
/// Device under it is a genuine ambiguity — the driver ignores <c>RawTagEntry.DeviceName</c>, and
/// <c>DriverDeviceConfigMerger</c> only merges a Device's <c>DeviceConfig</c> up to the driver config when
/// there is exactly ONE Device. The normal single-Device layout (every driver gets a default
/// <c>Device1</c>) stays valid.
/// </summary>
private static void ValidateMTConnectSingleDevice(DraftSnapshot draft, List<ValidationError> 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));
}
}
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
/// <list type="number">
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
@@ -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."),
};
}
@@ -106,10 +106,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly Dictionary<string, DeviceState> _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<string, AbCipTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
private volatile Dictionary<string, AbCipTagDefinition> _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<AbCipTagDefinition> _declaredTags = new();
private volatile List<AbCipTagDefinition> _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";
/// <inheritdoc />
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<string, AbCipTagDefinition>(StringComparer.Ordinal);
var builtDeclaredTags = new List<AbCipTagDefinition>();
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;
}
/// <summary>
/// Cancels, joins (bounded) and disposes every existing device state's probe loop + handles, then
/// clears the device/route tables. Device part of <see cref="ShutdownAsync"/> only.
/// </summary>
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();
}
/// <inheritdoc />
@@ -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);
}
@@ -0,0 +1,311 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Parsed PCCC file-based address: file letter + file number + word number, optionally a
/// sub-element (<c>.ACC</c> on a timer) or bit index (<c>/0</c> on a bit file).
/// </summary>
/// <remarks>
/// <para>Logix symbolic tags are parsed elsewhere (<see cref="AbLegacy"/> is for SLC / PLC-5 /
/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number).</para>
/// <list type="bullet">
/// <item><c>N7:0</c> — integer file 7, word 0 (signed 16-bit).</item>
/// <item><c>N7:5</c> — integer file 7, word 5.</item>
/// <item><c>F8:0</c> — float file 8, word 0 (32-bit IEEE754).</item>
/// <item><c>B3:0/0</c> — bit file 3, word 0, bit 0.</item>
/// <item><c>ST9:0</c> — string file 9, string 0 (82-byte fixed-length + length word).</item>
/// <item><c>T4:0.ACC</c> — timer file 4, timer 0, accumulator sub-element.</item>
/// <item><c>C5:0.PRE</c> — counter file 5, counter 0, preset sub-element.</item>
/// <item><c>I:0/0</c> — input file, slot 0, bit 0 (no file-number for I/O).</item>
/// <item><c>O:1/2</c> — output file, slot 1, bit 2.</item>
/// <item><c>S:1</c> — status file, word 1.</item>
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
/// <item><c>I:017/07</c> — PLC-5 only: I/O word + bit indices are <b>octal</b> (#244); stored decimal.</item>
/// <item><c>RTC:0.HR</c> — MicroLogix only: function file (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI, #245).</item>
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (#247): parsed, not resolved at runtime.</item>
/// </list>
/// <para>The libplctag transport takes <see cref="ToLibplctagName"/> as its <c>name=...</c> 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).</para>
/// </remarks>
public sealed record AbLegacyAddress(
string FileLetter,
int? FileNumber,
int WordNumber,
int? BitIndex,
string? SubElement,
AbLegacyAddress? IndirectFileSource = null,
AbLegacyAddress? IndirectWordSource = null)
{
/// <summary>
/// True when the file number or the word number is sourced from another PCCC address evaluated
/// at runtime (<c>N7:[N7:0]</c> or <c>N[N7:0]:5</c>). 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 <c>BadNotSupported</c>.
/// </summary>
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
/// <summary>
/// Converts the address to the libplctag library address format. Numeric indices are always
/// emitted <b>decimal</b> (a PLC-5 octal I/O address is normalised at parse time). This is correct, not a
/// double conversion: libplctag's <c>pccc.c</c> (<c>parse_pccc_elem_num</c> / <c>parse_pccc_bit_num</c>) 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
/// <see cref="IsIndirect"/> first.
/// </summary>
/// <returns>The libplctag <c>name=...</c> attribute value for this address.</returns>
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;
}
/// <summary>Attempts to parse a string into an AB legacy address (family-neutral: decimal indices, no function files).</summary>
/// <param name="value">The address string to parse.</param>
/// <returns>The parsed address, or <see langword="null"/> when <paramref name="value"/> is not a valid PCCC address.</returns>
public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
/// <summary>
/// Family-aware parser. PLC-5 reads <c>I:</c>/<c>O:</c> word and bit indices as octal
/// (<c>I:017/07</c> = 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.
/// </summary>
/// <param name="value">The address string to parse.</param>
/// <param name="family">The device's PLC family, or <see langword="null"/> for the family-neutral grammar.</param>
/// <returns>The parsed address, or <see langword="null"/> when the string is not valid for the family.</returns>
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);
}
/// <summary>
/// The single authoring / deploy-gate / runtime address check. With a known <paramref name="family"/> the
/// address must parse for that family; with an unknown one (<see langword="null"/>) it must parse for at
/// least one family, so a caller that cannot see the device never rejects what the runtime would accept.
/// </summary>
/// <param name="address">The authored address text.</param>
/// <param name="family">The device's PLC family, or <see langword="null"/> when it is not known.</param>
/// <returns>An error message, or <see langword="null"/> when the address is acceptable.</returns>
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<AbLegacyPlcFamily>())
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);
}
/// <summary>Parses a bracketed inner address. Depth is capped at one: the inner address must be direct.</summary>
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;
}
/// <summary>
/// Highest valid bit index for a file letter, or <c>-1</c> 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.
/// </summary>
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,
};
/// <summary>
/// MicroLogix 1100/1400 function-file prefixes — each a single fixed instance with a sub-element
/// catalogue (<c>AbLegacyFunctionFile</c>).
/// </summary>
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,
};
/// <summary>
/// Returns <see langword="true"/> for file letters that carry no explicit file number in the
/// PCCC spec. <c>I</c> (input), <c>O</c> (output), and <c>S</c> (status) are single-letter
/// system files; a digit after the letter (e.g. <c>I3</c>) is a malformed address.
/// </summary>
private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S";
/// <summary>
/// Returns <see langword="true"/> for file letters that may carry a sub-element suffix
/// (<c>.ACC</c>, <c>.PRE</c>, etc.): Timer (<c>T</c>), Counter (<c>C</c>), Control (<c>R</c>) and the
/// MicroLogix function files.
/// </summary>
private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R" || IsFunctionFileLetter(letter);
}
@@ -15,7 +15,14 @@ public sealed record AbLegacyPlcFamilyProfile(
/// </summary>
int MaxTagBytes,
bool SupportsStringFile,
bool SupportsLongFile)
bool SupportsLongFile,
/// <summary>
/// PLC-5 only: the word and bit indices on <c>I:</c>/<c>O:</c> references are octal
/// (<c>I:017/07</c>), as RSLogix 5 displays them. SLC / MicroLogix / Logix-PCCC stay decimal.
/// </summary>
bool OctalIoAddressing = false,
/// <summary>MicroLogix only: RTC / HSC / DLS / MMI / PTO / PWM / STI / EII / IOS / BHI function files.</summary>
bool SupportsFunctionFiles = false)
{
/// <summary>Gets the profile for the specified PLC family.</summary>
/// <param name="family">The PLC family.</param>
@@ -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)
/// <summary>
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
@@ -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 <see cref="FromTagConfig"/> during inspection — the mapper only copies it
/// onto <see cref="AbLegacyTagDefinition.Name"/> and never validates it.
/// </summary>
private static bool IsIndirectFor(string address, AbLegacyPlcFamily? family)
{
if (family is { } f) return AbLegacyAddress.TryParse(address, f)?.IsIndirect == true;
return Enum.GetValues<AbLegacyPlcFamily>().Any(c => AbLegacyAddress.TryParse(address, c)?.IsIndirect == true);
}
private const string InspectionRawPath = "(inspect)";
/// <summary>
@@ -145,7 +152,18 @@ public static class AbLegacyTagDefinitionFactory
/// </remarks>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
public static IReadOnlyList<string> Inspect(string reference) => Inspect(reference, family: null);
/// <summary>
/// <see cref="Inspect(string)"/> plus the family-aware address check (<see cref="AbLegacyAddress.Validate"/>,
/// the same authority the driver runtime uses). <paramref name="family"/> is the tag's device's PLC family, or
/// <see langword="null"/> when unknown (then only an address no family accepts is flagged). An indirect
/// address parses but the runtime always answers it <c>BadNotSupported</c>, so it is warned about too.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <param name="family">The owning device's PLC family, when known.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference, AbLegacyPlcFamily? family)
{
var warnings = new List<string>();
// 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
{
@@ -1,157 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Parsed PCCC file-based address: file letter + file number + word number, optionally a
/// sub-element (<c>.ACC</c> on a timer) or bit index (<c>/0</c> on a bit file).
/// </summary>
/// <remarks>
/// <para>Logix symbolic tags are parsed elsewhere (<see cref="AbLegacy"/> is for SLC / PLC-5 /
/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number).</para>
/// <list type="bullet">
/// <item><c>N7:0</c> — integer file 7, word 0 (signed 16-bit).</item>
/// <item><c>N7:5</c> — integer file 7, word 5.</item>
/// <item><c>F8:0</c> — float file 8, word 0 (32-bit IEEE754).</item>
/// <item><c>B3:0/0</c> — bit file 3, word 0, bit 0.</item>
/// <item><c>ST9:0</c> — string file 9, string 0 (82-byte fixed-length + length word).</item>
/// <item><c>T4:0.ACC</c> — timer file 4, timer 0, accumulator sub-element.</item>
/// <item><c>C5:0.PRE</c> — counter file 5, counter 0, preset sub-element.</item>
/// <item><c>I:0/0</c> — input file, slot 0, bit 0 (no file-number for I/O).</item>
/// <item><c>O:1/2</c> — output file, slot 1, bit 2.</item>
/// <item><c>S:1</c> — status file, word 1.</item>
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
/// </list>
/// <para>Pass the original string straight through to libplctag's <c>name=...</c> 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).</para>
/// </remarks>
public sealed record AbLegacyAddress(
string FileLetter,
int? FileNumber,
int WordNumber,
int? BitIndex,
string? SubElement)
{
/// <summary>Converts the address to the libplctag library address format.</summary>
/// <returns>The libplctag <c>name=...</c> attribute value for this address.</returns>
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;
}
/// <summary>Attempts to parse a string into an AB legacy address.</summary>
/// <param name="value">The address string to parse.</param>
/// <returns>The parsed address, or <see langword="null"/> when <paramref name="value"/> is not a valid PCCC address.</returns>
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);
}
/// <summary>
/// Highest valid bit index for a file letter, or <c>-1</c> 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.
/// </summary>
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,
};
/// <summary>
/// Returns <see langword="true"/> for file letters that carry no explicit file number in the
/// PCCC spec. <c>I</c> (input), <c>O</c> (output), and <c>S</c> (status) are single-letter
/// system files; a digit after the letter (e.g. <c>I3</c>) is a malformed address.
/// </summary>
private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S";
/// <summary>
/// Returns <see langword="true"/> for file letters that may carry a sub-element suffix
/// (<c>.ACC</c>, <c>.PRE</c>, etc.). Only Timer (<c>T</c>), Counter (<c>C</c>), and
/// Control (<c>R</c>) files have structured elements in the PCCC spec.
/// </summary>
private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R";
}
@@ -143,8 +143,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
/// <inheritdoc />
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;
}
/// <summary>
/// Cancels, bounded-joins and disposes every existing device state, then clears the table.
/// Device-state part of <see cref="ShutdownAsync"/> only (no poll / subscription teardown).
/// </summary>
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<Task>().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();
}
/// <inheritdoc />
@@ -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();
}
/// <summary>
/// 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 <c>BadNodeIdUnknown</c> with the
/// handle/health untouched, and warned once per tag so a polled tag does not flood the log.
/// </summary>
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);
}
/// <summary>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.</summary>
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
/// </summary>
public bool FirstNonZeroStatusLogged { get; set; }
/// <summary>Tags already warned about for a malformed address (once-per-tag log).</summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, byte> MalformedAddressLogged { get; } = new();
/// <summary>
/// Disposes all cached tag runtimes.
/// </summary>
@@ -0,0 +1,51 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// MicroLogix 1100/1400 function-file sub-element catalogue (#245): the driver-surface type of
/// <c>RTC:0.HR</c>, <c>HSC:0.ACC</c>, ... Covers the commonly-addressed members per file — not exhaustive
/// (Rockwell defines 30+ on RTC alone). <b>Unverified on the wire:</b> 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.
/// </summary>
public static class AbLegacyFunctionFile
{
/// <summary>
/// Driver-surface type for <paramref name="fileLetter"/>.<paramref name="subElement"/>; an unrecognised
/// sub-element falls back to <see cref="DriverDataType.Int32"/> so the driver never refuses a member the
/// catalogue happens to omit.
/// </summary>
/// <param name="fileLetter">The function-file letter (RTC, HSC, ...).</param>
/// <param name="subElement">The sub-element mnemonic, or null.</param>
/// <returns>The mapped driver data type.</returns>
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,
};
}
}
@@ -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);
@@ -84,7 +84,7 @@ public static class FocasCapabilityMatrix
/// <returns>A set of accepted PMC letter strings.</returns>
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
{
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D" },
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
@@ -178,8 +178,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
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;
}
/// <summary>
/// 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
/// <see cref="ShutdownAsync"/> only (no poll / subscription teardown).
/// </summary>
private async Task ReleaseDeviceStatesAsync()
{
if (_alarmProjection is { } proj)
{
await proj.DisposeAsync().ConfigureAwait(false);
_alarmProjection = null;
}
if (_devices.Count == 0) return;
var loops = new List<Task>();
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();
}
/// <inheritdoc />
@@ -158,11 +158,22 @@ public sealed record MTConnectStreamsResult(
/// <c>false</c>, the shape of every ordinary scalar observation.
/// </para>
/// </param>
/// <param name="SampleCount">
/// The observation's own <c>sampleCount</c> attribute — the number of values a <c>TIME_SERIES</c>
/// vector claims to carry (#627). The index checks the parsed vector against it. <c>null</c> when absent.
/// </param>
/// <param name="SampleRate">
/// The observation's own <c>sampleRate</c> attribute (samples per second) on a <c>TIME_SERIES</c>
/// observation. Carried for the parser contract; not yet surfaced on the served node (see
/// docs/drivers/MTConnect.md). <c>null</c> when absent.
/// </param>
public sealed record MTConnectObservation(
string DataItemId,
string Value,
DateTime TimestampUtc,
bool IsStructured = false);
bool IsStructured = false,
int? SampleCount = null,
double? SampleRate = null);
/// <summary>
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
@@ -49,8 +49,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </para>
/// <para>
/// <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
/// value rather than approximated: a <c>TIME_SERIES</c> vector (array materialization is
/// deferred to P1.5) and a <c>DATA_SET</c> / <c>TABLE</c> observation. The latter carries its
/// value rather than approximated: a non-Float64 array tag and a <c>DATA_SET</c> / <c>TABLE</c>
/// observation (a Float64 <c>TIME_SERIES</c> vector is served as <c>double[]</c>, #627). The latter carries its
/// real content in <c>&lt;Entry key=…&gt;</c> child elements, which reach a text-valued
/// observation concatenated with the keys discarded — a two-entry set reads as the single
/// token <c>"12"</c> — and because the type inference correctly demotes those
@@ -85,9 +85,12 @@ internal sealed class MTConnectObservationIndex
/// <summary>The Agent reported a number the authored type cannot represent.</summary>
private const uint BadOutOfRange = 0x803C0000u;
/// <summary>The observation's shape is real data this build cannot represent (TIME_SERIES vectors).</summary>
/// <summary>The observation's shape is real data this build cannot represent (a DATA_SET/TABLE, or a non-Float64 array tag).</summary>
private const uint BadNotSupported = 0x803D0000u;
/// <summary>A TIME_SERIES vector whose element count disagrees with its declared <c>sampleCount</c>.</summary>
private const uint BadDecodingError = 0x80070000u;
/// <summary>The Agent reported text that is not a value of the authored type at all.</summary>
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);
}
/// <summary>
/// Parses a <c>TIME_SERIES</c> vector into a <c>double[]</c>. Only <see cref="DriverDataType.Float64"/>
/// arrays are served (the MTConnect TIME_SERIES element type is a floating-point sample); any other
/// authored array type is <c>BadNotSupported</c> rather than an approximation. All-or-nothing: one
/// unparseable or non-finite token, or a count that disagrees with the observation's own
/// <c>sampleCount</c> (or the tag's fixed <c>ArrayDim</c>), makes the whole vector Bad — a partial
/// vector served as Good would be a silently wrong waveform.
/// </summary>
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);
}
/// <summary>
/// Converts the Agent's text to the tag's authored type. Every parse is
/// <see cref="CultureInfo.InvariantCulture"/>: MTConnect is an invariant-format wire, and a
@@ -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);
}
/// <summary>
@@ -211,6 +211,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <inheritdoc />
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);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
/// <summary>
/// Cancels any in-flight reconnect, detaches the keep-alive handler and closes + disposes the current
/// session. The session part of <see cref="ShutdownAsync"/>; also run at the top of
/// <see cref="InitializeAsync"/> so a re-initialise on a live driver (#630) does not orphan the
/// previous session and its reconnect handler. Subscriptions are NOT touched here.
/// </summary>
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 { }
}
/// <inheritdoc />
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;
@@ -16,3 +16,20 @@ public enum S7CpuType
S71200 = 30,
S71500 = 40,
}
/// <summary>
/// ISO-TSAP connection resource requested during the handshake — the FIRST byte of the remote TSAP
/// (the second byte is <c>rack × 0x20 + slot</c>). Values are the on-wire bytes. Leave unset to keep
/// S7.Net's per-CPU default (<see cref="S7Basic"/> for the S7-300/400/1200/1500 families).
/// </summary>
public enum S7ConnectionType
{
/// <summary>Programming-device (PG) connection resource.</summary>
PG = 1,
/// <summary>Operator-panel (OP/HMI) connection resource.</summary>
OP = 2,
/// <summary>"S7 basic" / other-communication connection resource — S7.Net's default.</summary>
S7Basic = 3,
}
@@ -51,6 +51,34 @@ public sealed class S7DriverOptions
/// </summary>
public short Slot { get; init; } = 0;
/// <summary>
/// Optional override of the LOCAL ISO-TSAP, as two hex bytes <c>"01.00"</c> (also accepted:
/// <c>"0100"</c>, <c>"0x0100"</c>). Null/blank keeps S7.Net's per-CPU default (<c>01.00</c>).
/// Needed for CPUs / gateways that reserve connection resources by TSAP (S7-400H, CP343, LOGO!).
/// </summary>
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
public string? LocalTsap { get; init; }
/// <summary>
/// Optional override of the REMOTE ISO-TSAP, same text form as <see cref="LocalTsap"/>
/// (e.g. <c>"03.01"</c> = S7-basic resource, rack 0 slot 1). Null/blank keeps the slot-derived default.
/// Mutually exclusive with <see cref="ConnectionType"/>.
/// </summary>
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
public string? RemoteTsap { get; init; }
/// <summary>
/// Optional connection resource (PG / OP / S7Basic) written into the first byte of the slot-derived
/// remote TSAP, keeping <c>rack × 0x20 + slot</c> as the second byte. Null keeps the default.
/// Mutually exclusive with <see cref="RemoteTsap"/>.
/// </summary>
public S7ConnectionType? ConnectionType { get; init; }
/// <summary>The authoring/load error for setting both <see cref="RemoteTsap"/> and <see cref="ConnectionType"/>; shared by the factory and the AdminUI form so they never disagree.</summary>
public const string TsapConflictDetail =
"sets both RemoteTsap and ConnectionType; ConnectionType only fills the first byte of the " +
"slot-derived remote TSAP, so choose one.";
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
@@ -139,6 +167,31 @@ public sealed class S7DriverOptions
public const int DefaultSpanReadMaxSpanBytes = 220;
}
/// <summary>
/// Reads a TSAP as either hex text (<c>"03.01"</c>) or a JSON number (<c>769</c>, 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.
/// </summary>
public sealed class S7TsapJsonConverter : System.Text.Json.Serialization.JsonConverter<string?>
{
/// <inheritdoc />
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.");
}
/// <inheritdoc />
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
{
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
@@ -197,5 +250,22 @@ public enum S7DataType
Float32,
Float64,
String,
/// <summary>S7 DATE_AND_TIME (DT, 8-byte BCD) → DateTime. The other date/time members below were appended after it.</summary>
DateTime,
/// <summary>S7 WSTRING (UTF-16BE, 4-byte header) → String. <c>StringLength</c> is the declared max in characters (≤ 16382).</summary>
WString,
/// <summary>S7 CHAR (1 byte, Latin-1) → one-character String.</summary>
Char,
/// <summary>S7 WCHAR (UTF-16BE code unit) → one-character String.</summary>
WChar,
/// <summary>S7 DTL (12 bytes, ns resolution) → DateTime.</summary>
Dtl,
/// <summary>S7 DATE (UInt16 days since 1990-01-01) → DateTime.</summary>
Date,
/// <summary>S7 TIME_OF_DAY (UInt32 ms since midnight) → Int32 milliseconds.</summary>
Tod,
/// <summary>S7 TIME (Int32 ms, signed duration) → Int32 milliseconds.</summary>
Time,
/// <summary>S7 S5TIME (BCD + time base) → Int32 milliseconds.</summary>
S5Time,
}
@@ -20,6 +20,20 @@ public static class S7TagDefinitionFactory
/// <summary>S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max).</summary>
private const int MaxStringLength = 254;
/// <summary>S7 WSTRING max length in characters (S7-1500; the 4-byte header's UInt16 is capped at 16382 so the field fits a DB).</summary>
private const int MaxWStringLength = 16382;
/// <summary>
/// The declared-length ceiling for a type that carries a <c>stringLength</c>, or null for a type that
/// does not (every non-string type ignores the key, so a copy-paste remnant never blocks parsing).
/// </summary>
private static int? MaxLengthFor(S7DataType t) => t switch
{
S7DataType.String => MaxStringLength,
S7DataType.WString => MaxWStringLength,
_ => null,
};
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// 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<S7DataType>(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).");
}
}
}
@@ -29,6 +29,13 @@ public interface IS7Plc : IDisposable
/// <summary>Closes the S7 connection. Best-effort and idempotent.</summary>
void Close();
/// <summary>
/// PDU size negotiated with the CPU during <see cref="OpenAsync"/>, 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.
/// </summary>
int MaxPduSize => 0;
/// <summary>Reads a scalar value by S7 address string (e.g. <c>DB1.DBW0</c>); the boxed type follows the size suffix.</summary>
/// <param name="address">The S7 address string.</param>
/// <param name="cancellationToken">Cancellation token for the read.</param>
@@ -94,6 +101,16 @@ public interface IS7PlcFactory
/// <param name="timeout">Connect + per-operation timeout, applied to the underlying read/write timeouts.</param>
/// <returns>A new, not-yet-opened <see cref="IS7Plc"/>.</returns>
IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout);
/// <summary>
/// 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 <see cref="S7DriverOptions.LocalTsap"/> and friends.
/// </summary>
/// <param name="options">The driver options.</param>
/// <returns>A new, not-yet-opened <see cref="IS7Plc"/>.</returns>
IS7Plc Create(S7DriverOptions options) =>
Create(options.CpuType, options.Host, options.Port, options.Rack, options.Slot, options.Timeout);
}
/// <summary>
@@ -114,8 +131,23 @@ internal sealed class S7PlcAdapter : IS7Plc
/// <param name="slot">CPU slot.</param>
/// <param name="timeout">Connect + per-operation timeout.</param>
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);
}
/// <summary>Initializes a new instance with an explicit TSAP pair (<c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c> override).</summary>
/// <param name="host">PLC IP address or hostname.</param>
/// <param name="port">TCP port.</param>
/// <param name="tsap">The effective TSAP pair.</param>
/// <param name="timeout">Connect + per-operation timeout.</param>
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
/// <inheritdoc />
public bool IsConnected => _plc.IsConnected;
/// <inheritdoc />
public int MaxPduSize => _plc.MaxPDUSize;
/// <inheritdoc />
public Task OpenAsync(CancellationToken cancellationToken) => _plc.OpenAsync(cancellationToken);
@@ -187,4 +222,10 @@ internal sealed class S7PlcFactory : IS7PlcFactory
/// <inheritdoc />
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
new S7PlcAdapter(cpuType, host, port, rack, slot, timeout);
/// <inheritdoc />
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);
}
@@ -117,6 +117,8 @@ public sealed class S7Driver
private const uint StatusBadNotSupported = 0x803D0000u;
/// <summary>OPC UA StatusCode used when a written value has the wrong shape / length / element type.</summary>
private const uint StatusBadTypeMismatch = 0x80740000u;
/// <summary>OPC UA StatusCode used when a value is unrepresentable in the S7 type (write) or a PLC buffer does not decode (read).</summary>
private const uint StatusBadOutOfRange = 0x803C0000u;
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
private const uint StatusBadNotWritable = 0x803B0000u;
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
@@ -223,6 +225,11 @@ public sealed class S7Driver
/// <inheritdoc />
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);
}
/// <summary>
/// Cancels + bounded-joins the probe loop, then disposes its CTS and the PLC connection. The
/// connection part of <see cref="ShutdownAsync"/> (everything but the poll engine and health).
/// </summary>
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) { } }
}
}
/// <inheritdoc />
@@ -411,6 +444,25 @@ public sealed class S7Driver
/// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
/// <see cref="RejectUnsupportedTagConfigs"/>.
/// </summary>
/// <summary>
/// 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 <see cref="GetDiagnostics"/> and the connect log line.
/// </summary>
public int NegotiatedPduSize => Volatile.Read(ref _negotiatedPduSize);
private int _negotiatedPduSize;
/// <summary>
/// Driver-specific diagnostics key/value pairs (currently <c>S7.NegotiatedPduSize</c>). The core
/// <see cref="DriverHealth"/> record carries no diagnostics bag, so this is the surface until one exists.
/// </summary>
/// <returns>A snapshot of the diagnostics.</returns>
public IReadOnlyDictionary<string, object> GetDiagnostics() => new Dictionary<string, object>
{
["S7.NegotiatedPduSize"] = NegotiatedPduSize,
};
private static readonly HashSet<S7DataType> 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,
};
/// <summary>
@@ -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
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
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);
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -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<S7ConnectionType>(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<S7CpuType>(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<T>(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; }
/// <summary>Gets the slot number.</summary>
public short? Slot { get; init; }
/// <summary>Gets the local TSAP override — hex text (<c>"01.00"</c>) or a 16-bit number.</summary>
public JsonElement? LocalTsap { get; init; }
/// <summary>Gets the remote TSAP override — hex text (<c>"03.01"</c>) or a 16-bit number.</summary>
public JsonElement? RemoteTsap { get; init; }
/// <summary>Gets the connection-type name (PG / OP / S7Basic); a string so AdminUI-authored configs bind.</summary>
public string? ConnectionType { get; init; }
/// <summary>Gets the connection timeout in milliseconds.</summary>
public int? TimeoutMs { get; init; }
/// <summary>Gets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the deploy
@@ -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
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="read">The minimal read.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A failure result, or null on success.</returns>
internal static async Task<DriverProbeResult?> CheckDataAccessAsync(Func<CancellationToken, Task<byte[]>> 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);
}
@@ -0,0 +1,88 @@
using System.Globalization;
using S7.Net.Protocol;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// ISO-TSAP helpers for the optional <c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c>
/// config: parsing the hex text form and resolving the effective <see cref="TsapPair"/> handed to
/// S7.Net. Pure — no I/O — so the resolution rules are unit-testable without a PLC.
/// </summary>
internal static class S7Tsap
{
/// <summary>
/// Parses a TSAP written as two hex bytes — <c>"01.00"</c>, <c>"03:01"</c> (1–2 digits per byte) — or
/// as one 16-bit hex value, <c>"0100"</c> / <c>"0x0301"</c>. High byte first.
/// </summary>
/// <param name="text">The configured text.</param>
/// <param name="tsap">The 16-bit TSAP (<c>first &lt;&lt; 8 | second</c>).</param>
/// <returns>True when <paramref name="text"/> is a valid TSAP.</returns>
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);
}
/// <summary>Formats a 16-bit TSAP as canonical <c>"XX.YY"</c> (upper-case hex).</summary>
/// <param name="tsap">The 16-bit TSAP.</param>
/// <returns>The canonical text.</returns>
internal static string Format(ushort tsap) => $"{tsap >> 8:X2}.{tsap & 0xFF:X2}";
/// <summary>
/// Resolves the TSAP pair to connect with, or <c>null</c> when the options carry no override (the
/// caller then keeps the plain <c>Plc(cpu, host, port, rack, slot)</c> construction, unchanged).
/// Starts from S7.Net's own per-CPU default for <paramref name="options"/>'s rack/slot, then applies,
/// in order: <c>LocalTsap</c> replaces the local TSAP; <c>RemoteTsap</c> replaces the remote TSAP;
/// <c>ConnectionType</c> replaces only the remote TSAP's first byte.
/// </summary>
/// <param name="options">The driver options.</param>
/// <returns>The effective pair, or null for "no override".</returns>
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);
}
}
@@ -0,0 +1,300 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// 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 <c>BadOutOfRange</c> and, unlike a transport fault, does
/// not degrade driver health or condemn the connection.
/// </summary>
internal sealed class S7ValueRangeException(string message) : Exception(message);
/// <summary>
/// Pure byte-level codecs for the S7 string, character and date/time/duration types the narrow
/// (1/2/4-byte) read path cannot express: <c>WSTRING</c>, <c>CHAR</c>, <c>WCHAR</c>, <c>DTL</c>,
/// <c>DATE</c>, <c>TOD</c>, <c>TIME</c> and <c>S5TIME</c>. 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
/// <c>STRING</c> and <c>DATE_AND_TIME</c> (DT) stay on S7.Net's own codecs.
/// </summary>
/// <remarks>
/// <para>
/// <b>OPC UA mapping</b> (no new <c>DriverDataType</c>): WString/Char/WChar → String;
/// DTL/Date → DateTime; TOD, TIME, S5TIME → Int32 <b>milliseconds</b> (TOD = ms since midnight,
/// TIME = signed duration, S5TIME = duration). All values fit Int32.
/// </para>
/// <para>
/// <b>Writes refuse rather than clamp or truncate</b>: an unrepresentable value throws
/// <see cref="S7ValueRangeException"/> (→ <c>BadOutOfRange</c>) with nothing sent to the PLC; a value
/// of the wrong shape throws <see cref="S7Driver.S7WriteValueException"/> (→ <c>BadTypeMismatch</c>).
/// </para>
/// </remarks>
internal static class S7ValueCodecs
{
/// <summary>Largest declared WSTRING character count (S7-1500 limit; header is UInt16 but the PDU-safe ceiling is 16382).</summary>
internal const int MaxWStringLength = 16382;
/// <summary>First day of the S7 DATE epoch.</summary>
private static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
private const int MillisecondsPerDay = 86_400_000;
// ---- CHAR / WCHAR ----
/// <summary>Decodes a 1-byte S7 CHAR (Latin-1) to a one-character string.</summary>
internal static string DecodeChar(ReadOnlySpan<byte> block)
{
RequireLength(block, 1, "CHAR");
return ((char)block[0]).ToString();
}
/// <summary>Encodes a one-character string to a 1-byte S7 CHAR. Characters above U+00FF are out of range.</summary>
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];
}
/// <summary>Decodes a 2-byte S7 WCHAR (UTF-16 big-endian code unit) to a one-character string.</summary>
internal static string DecodeWChar(ReadOnlySpan<byte> block)
{
RequireLength(block, 2, "WCHAR");
return ((char)BinaryPrimitives.ReadUInt16BigEndian(block)).ToString();
}
/// <summary>Encodes a one-character string to a 2-byte S7 WCHAR (UTF-16 big-endian).</summary>
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 ----
/// <summary>Byte width of a WSTRING reserving <paramref name="maxLength"/> characters: 4-byte header + 2 bytes per character.</summary>
internal static int WStringWidth(int maxLength) => 4 + (2 * maxLength);
/// <summary>
/// Decodes <c>[maxLen UInt16 BE][curLen UInt16 BE][curLen × UTF-16BE]</c>. Characters past
/// <c>curLen</c> (the reserved padding) are ignored.
/// </summary>
internal static string DecodeWString(ReadOnlySpan<byte> 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));
}
/// <summary>
/// Encodes a string into the full reserved WSTRING field (<see cref="WStringWidth"/> bytes, zero
/// padded). A value with more UTF-16 code units than <paramref name="maxLength"/> is refused, not truncated.
/// </summary>
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) ----
/// <summary>
/// Decodes DTL: <c>year UInt16 BE, month, day, weekday, hour, minute, second, nanoseconds UInt32 BE</c>.
/// The weekday byte is ignored. An all-zero (uninitialised) buffer is refused.
/// </summary>
internal static DateTime DecodeDtl(ReadOnlySpan<byte> 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}");
}
}
/// <summary>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.</summary>
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) ----
/// <summary>Decodes DATE (days since 1990-01-01, UInt16 BE) to a midnight DateTime.</summary>
internal static DateTime DecodeDate(ReadOnlySpan<byte> block)
{
RequireLength(block, 2, "DATE");
return DateEpoch.AddDays(BinaryPrimitives.ReadUInt16BigEndian(block));
}
/// <summary>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.</summary>
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) ----
/// <summary>Decodes TIME_OF_DAY to Int32 milliseconds since midnight (0–86 399 999).</summary>
internal static int DecodeTod(ReadOnlySpan<byte> 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;
}
/// <summary>Encodes milliseconds since midnight (or a <see cref="TimeSpan"/>) to TOD; outside 0–86 399 999 is refused.</summary>
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) ----
/// <summary>Decodes IEC TIME to a signed Int32 millisecond duration.</summary>
internal static int DecodeTime(ReadOnlySpan<byte> block)
{
RequireLength(block, 4, "TIME");
return BinaryPrimitives.ReadInt32BigEndian(block);
}
/// <summary>Encodes a signed millisecond duration (or <see cref="TimeSpan"/>) to TIME; outside the Int32 range is refused.</summary>
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];
/// <summary>
/// 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.
/// </summary>
internal static int DecodeS5Time(ReadOnlySpan<byte> 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];
}
/// <summary>
/// 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.
/// </summary>
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<byte> 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}).");
}
}
}
@@ -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
@@ -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."),
};
@@ -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];
@@ -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,
};
@@ -110,8 +110,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public string DriverType => "TwinCAT";
/// <inheritdoc />
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;
}
/// <summary>
/// Cancels, awaits and disposes every existing device state's probe loop, client and gate, then
/// clears the device table. Device part of <see cref="ShutdownAsync"/> only.
/// </summary>
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();
}
/// <inheritdoc />
@@ -0,0 +1,76 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// 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 <see cref="double"/> of
/// milliseconds (OPC UA <c>Double</c>; no new <c>DriverDataType</c> member — see
/// <c>docs/drivers/TwinCAT.md</c>); DATE and DT surface as a UTC <see cref="DateTime"/>.
/// </summary>
internal static class TwinCATTemporalCodec
{
private static readonly DateTime Epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>True for TIME / DATE / DT / TOD.</summary>
public static bool IsTemporal(TwinCATDataType type) =>
type is TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay;
/// <summary>Decodes a raw UDINT counter into the published value for <paramref name="type"/>.</summary>
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"),
};
/// <summary>
/// Encodes a published/written value into the UDINT counter. Accepts the native shape
/// (<see cref="double"/> ms / <see cref="DateTime"/> / <see cref="TimeSpan"/>) and any other
/// numeric as the raw counter, so a client writing an integer keeps working.
/// </summary>
/// <exception cref="OverflowException">Out of the UDINT range, negative, or NaN/infinite.</exception>
/// <exception cref="InvalidCastException">A value of no accepted shape.</exception>
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);
}
}
@@ -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_<keyId>_<secret>, 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)
{
@@ -30,6 +30,36 @@
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="s7ConnType">Connection type</label>
<InputSelect id="s7ConnType" @bind-Value="_form.ConnectionTypeText" @bind-Value:after="EmitAsync"
class="form-select form-select-sm">
<option value="">Default (slot-derived)</option>
@foreach (var v in Enum.GetValues<S7ConnectionType>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">First byte of the remote TSAP. Leave on default unless the CPU needs a PG/OP resource.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="s7LocalTsap">Local TSAP</label>
<InputText id="s7LocalTsap" @bind-Value="_form.LocalTsap" @bind-Value:after="EmitAsync"
class="form-control form-control-sm mono" placeholder="01.00" />
<div class="form-text">Optional hex override, e.g. <code>01.00</code>.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="s7RemoteTsap">Remote TSAP</label>
<InputText id="s7RemoteTsap" @bind-Value="_form.RemoteTsap" @bind-Value:after="EmitAsync"
class="form-control form-control-sm mono" placeholder="03.01" />
<div class="form-text">Optional hex override, e.g. <code>03.01</code>. Not combinable with Connection type.</div>
</div>
</div>
@if (_form.TsapConflictError() is { } tsapError)
{
<div class="text-danger small" role="alert">@tsapError</div>
}
</div>
</section>
@@ -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 <select> as text ("" = default) so the nullable enum round-trips through InputSelect.
public string ConnectionTypeText { get; set; } = "";
public int TimeoutSeconds { get; set; } = 5;
// Probe
@@ -153,6 +187,12 @@
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
/// <summary>The same error the driver factory raises for Remote TSAP + Connection type together; null when fine.</summary>
public string? TsapConflictError() =>
!string.IsNullOrWhiteSpace(RemoteTsap) && !string.IsNullOrWhiteSpace(ConnectionTypeText)
? "S7 driver config " + S7DriverOptions.TsapConflictDetail
: null;
public static FormModel FromOptions(S7DriverOptions o) => new()
{
Host = o.Host,
@@ -160,6 +200,9 @@
CpuType = o.CpuType,
Rack = o.Rack,
Slot = o.Slot,
LocalTsap = o.LocalTsap,
RemoteTsap = o.RemoteTsap,
ConnectionTypeText = o.ConnectionType?.ToString() ?? "",
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
@@ -174,6 +217,9 @@
CpuType = CpuType,
Rack = Rack,
Slot = Slot,
LocalTsap = string.IsNullOrWhiteSpace(LocalTsap) ? null : LocalTsap.Trim(),
RemoteTsap = string.IsNullOrWhiteSpace(RemoteTsap) ? null : RemoteTsap.Trim(),
ConnectionType = Enum.TryParse<S7ConnectionType>(ConnectionTypeText, out var ct) ? ct : null,
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new S7ProbeOptions
{
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -615,9 +616,16 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (!await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == driverInstanceId, ct))
var owner = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
if (owner is null)
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
// #628: MTConnect owns one agent client per driver — mirror the deploy gate
// (DraftValidator.ValidateMTConnectSingleDevice) so the second Device is refused where it is authored.
if (string.Equals(owner.DriverType, DriverTypeNames.MTConnect, StringComparison.Ordinal)
&& await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId, ct))
return new UnsMutationResult(false, DraftValidator.MTConnectMultipleDevicesMessage(owner.Name));
if (await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId && dev.Name == name, ct))
return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver.");
@@ -68,8 +68,11 @@ public sealed class AbLegacyTagConfigModel
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(_bag, "dataType") is { } dtError)
return dtError;
if (string.IsNullOrWhiteSpace(Address))
return "Address is required.";
// The editor cannot see the device's PLC family, so it checks only what no family accepts (the same
// AbLegacyAddress authority the deploy gate and runtime use) — never stricter than the gate, which adds the
// family-specific verdict (PLC-5 octal I/O, MicroLogix-only function files).
if (AbLegacyAddress.Validate(Address, family: null) is { } addressError)
return addressError;
// DELIBERATELY STRICTER THAN THE RUNTIME, and re-confirmed as such by the Gitea #554 survey — do not
// "align" it. PCCC data-file elements are limited to AbLegacyArray.MaxElements (256). The reader
@@ -229,10 +229,25 @@ public sealed class MTConnectTagConfigModel
/// passed as null; it can only DEMOTE an item to String (DATA_SET/TABLE), which cannot create a false
/// warning because a numeric declaration on a String-inferred item is warned either way, and a SAMPLE
/// inferred Float64 is only warned about for a non-numeric, non-String declaration or an integer one.
/// TIME_SERIES arrays are not detectable without it and are not covered.
/// TIME_SERIES arrays are covered separately by the isArray rule in <see cref="Warnings"/>.
/// </remarks>
/// <returns>Zero or more human-readable warnings.</returns>
public IReadOnlyList<string> Warnings()
{
// #627: an array (TIME_SERIES) tag is served as double[] only. Independent of the probe metadata —
// the array flag is authored on the tag, and any other declared element type reads BadNotSupported.
var arrayWarning = _bag.TryGetPropertyValue("isArray", out var arr)
&& arr is JsonValue av && av.TryGetValue<bool>(out var isArray) && isArray
&& DataType != DriverDataType.Float64
? $"Data type {DataType} on an array tag: MTConnect TIME_SERIES arrays are served as Float64 (double[]) only; "
+ "declare Float64 or the tag reads BadNotSupported. Saving is still allowed."
: null;
var scalar = ScalarWarnings();
return arrayWarning is null ? scalar : [arrayWarning, .. scalar];
}
private IReadOnlyList<string> ScalarWarnings()
{
if (string.IsNullOrWhiteSpace(MtCategory)) { return []; }
@@ -120,7 +120,9 @@ public sealed class S7TagConfigModel
{
var dt = DataType;
if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
or S7DataType.String or S7DataType.DateTime)
or S7DataType.String or S7DataType.DateTime or S7DataType.WString or S7DataType.Char
or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date or S7DataType.Tod
or S7DataType.Time or S7DataType.S5Time)
{
return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, "
+ "UInt32 or Float32 for an array tag, or a scalar tag.";
@@ -48,3 +48,14 @@
flex: 0 0 18px;
width: 18px;
}
/* Modal backdrop overran the viewport on /raw (#631). The theme's `.rise` entrance animation runs with
`animation-fill-mode: both`, which keeps its final keyframe's transform applied for good — and ANY
transform (even the identity it leaves) makes that section the containing block for position:fixed
descendants. A modal rendered inside a `.panel.rise` therefore had its fixed backdrop offset by the
panel's left edge, so it ran 240px past the viewport and the page grew a horizontal scrollbar.
`backwards` keeps the hidden start state during the stagger delay but drops the transform once the
animation ends. (Upstream fix belongs in ZB.MOM.WW.Theme's theme.css.) */
.rise {
animation-fill-mode: backwards;
}
@@ -262,12 +262,16 @@ public sealed class AdminOperationsActor : ReceiveActor
var driverByDevice = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal);
var deviceConfigById = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DeviceConfig, StringComparer.Ordinal);
var tagConfigWarnings = new List<(string TagId, string Text)>();
foreach (var t in draft.Tags)
{
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue;
foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig))
foreach (var w in EquipmentTagConfigInspector.Inspect(
driverType, t.TagConfig, deviceConfigById.GetValueOrDefault(t.DeviceId)))
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
}
if (tagConfigWarnings.Count > 0)
@@ -1,6 +1,7 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -78,6 +79,47 @@ public static class EquipmentTagConfigInspector
return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty<string>();
}
/// <summary>
/// <see cref="Inspect(string?, string?)"/> with the owning device's <c>DeviceConfig</c>, so a driver whose
/// address grammar depends on the device (AbLegacy: the <c>PlcFamily</c>) is checked against the family the
/// runtime will use. Other drivers ignore the device config. A missing family key is the runtime default
/// (SLC 500); an unrecognised value is "unknown" and only the family-independent check applies — the gate
/// is never stricter than the runtime.
/// </summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <param name="tagConfig">The equipment tag's <c>TagConfig</c> JSON.</param>
/// <param name="deviceConfig">The owning device's <c>DeviceConfig</c> JSON (may be null).</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string? driverType, string? tagConfig, string? deviceConfig)
{
if (string.IsNullOrEmpty(driverType) || tagConfig is null) return Array.Empty<string>();
if (string.Equals(driverType, DriverTypeNames.AbLegacy, StringComparison.OrdinalIgnoreCase))
return AbLegacyTagDefinitionFactory.Inspect(tagConfig, ReadAbLegacyFamily(deviceConfig));
return Inspect(driverType, tagConfig);
}
private static AbLegacyPlcFamily? ReadAbLegacyFamily(string? deviceConfig)
{
if (string.IsNullOrWhiteSpace(deviceConfig)) return AbLegacyPlcFamily.Slc500;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(deviceConfig);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return AbLegacyPlcFamily.Slc500;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "PlcFamily", StringComparison.OrdinalIgnoreCase)) continue;
var raw = prop.Value.ValueKind == System.Text.Json.JsonValueKind.String ? prop.Value.GetString() : null;
if (string.IsNullOrWhiteSpace(raw)) return AbLegacyPlcFamily.Slc500;
return Enum.TryParse<AbLegacyPlcFamily>(raw, ignoreCase: true, out var f) && Enum.IsDefined(f) ? f : null;
}
return AbLegacyPlcFamily.Slc500;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
/// <summary>Whether the given driver type has a mapped inspector.</summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> when an inspector is registered for the driver type.</returns>
@@ -0,0 +1,61 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Gitea #628 (decision b): an MTConnect driver instance binds every tag through one agent client, so a
/// second <c>/raw</c> Device under it is a deploy error — while the normal single-Device layout stays valid.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorMTConnectDeviceTests
{
private const string Code = DraftValidator.MTConnectMultipleDevicesCode;
private static DriverInstance Driver(string id, string type) => new()
{
DriverInstanceId = id, ClusterId = "c", Name = id, DriverType = type, DriverConfig = "{}",
};
private static Device Dev(string id, string driverId) => new()
{
DeviceId = id, DriverInstanceId = driverId, Name = id, DeviceConfig = "{}",
};
private static DraftSnapshot Draft(DriverInstance[] drivers, params Device[] devices) => new()
{
GenerationId = 1, ClusterId = "c", DriverInstances = drivers, Devices = devices,
};
[Fact]
public void Two_devices_under_one_MTConnect_driver_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft([Driver("mt", "MTConnect")], Dev("d1", "mt"), Dev("d2", "mt")));
var e = errors.Where(x => x.Code == Code).ShouldHaveSingleItem();
e.Context.ShouldBe("mt");
e.Message.ShouldContain("one MTConnect driver per agent device");
}
[Fact]
public void The_normal_single_device_layout_stays_valid()
=> DraftValidator.Validate(Draft([Driver("mt", "MTConnect")], Dev("d1", "mt")))
.ShouldNotContain(x => x.Code == Code);
[Fact]
public void A_driver_with_no_device_is_not_this_rules_concern()
=> DraftValidator.Validate(Draft([Driver("mt", "MTConnect")]))
.ShouldNotContain(x => x.Code == Code);
[Fact]
public void Several_devices_under_a_non_MTConnect_driver_are_unaffected_and_devices_are_counted_per_driver()
{
var errors = DraftValidator.Validate(Draft(
[Driver("mb", "Modbus"), Driver("mt1", "MTConnect"), Driver("mt2", "MTConnect")],
Dev("a", "mb"), Dev("b", "mb"), Dev("c", "mt1"), Dev("d", "mt2")));
errors.ShouldNotContain(x => x.Code == Code);
}
}
@@ -145,4 +145,17 @@ public sealed class WriteCommandParseValueTests
{
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
}
/// <summary>The #288/#289 types parse to the CLR shape the codecs accept.</summary>
[Fact]
public void ParseValue_handles_the_string_and_date_time_codec_types()
{
WriteCommand.ParseValue("héllo", S7DataType.WString).ShouldBe("héllo");
WriteCommand.ParseValue("A", S7DataType.Char).ShouldBe("A");
WriteCommand.ParseValue("2008-12-16T20:30:20", S7DataType.Dtl).ShouldBeOfType<DateTime>();
WriteCommand.ParseValue("2009-12-01", S7DataType.Date).ShouldBeOfType<DateTime>();
WriteCommand.ParseValue("86399999", S7DataType.Tod).ShouldBe(86399999L);
WriteCommand.ParseValue("-1000", S7DataType.Time).ShouldBe(-1000L);
WriteCommand.ParseValue("127000", S7DataType.S5Time).ShouldBe(127000L);
}
}
@@ -229,6 +229,33 @@ public sealed class AbCipAlarmProjectionTests
}
/// <summary>Waits for <paramref name="more"/> further reads of the fake runtime (a processed-count wait).</summary>
/// <summary>
/// #630 — a re-Initialize on a live driver must dispose the alarm projection's poll loop rather
/// than leave it running (it would keep raising duplicate alarm events through this driver).
/// </summary>
[Fact]
public async Task Reinitialize_Stops_The_Previous_Projection_Poll_Loop()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
var tag = factory.Tags["HighTemp"];
// Positive control: the loop is demonstrably polling before the re-init.
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
await drv.InitializeAsync("{}", CancellationToken.None);
int Reads() => factory.Tags.Values.Sum(t => t.ReadCount); // the loop may re-create its runtime on the new device state
var frozen = Reads();
await Task.Delay(300); // ~15 poll intervals; a live loop would advance the count
Reads().ShouldBe(frozen);
await drv.ShutdownAsync(CancellationToken.None);
}
private static async Task WaitForMoreReadsAsync(FakeAbCipTagFactory factory, string tagName, int more)
{
var tag = factory.Tags[tagName];
@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// #630 (same pattern) — <c>InitializeAsync</c> on a live driver overwrote <c>_devices[host]</c> and
/// orphaned the previous state's probe loop + handles. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe handle is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsHandles()
{
const string host = "ab://10.0.0.5/1,0";
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(20),
Timeout = TimeSpan.FromMilliseconds(200),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and has initialised its runtime.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("@raw_cpu_type", out var t) && t.InitializeCount > 0))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["@raw_cpu_type"];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,320 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>Family-aware addressing: PLC-5 octal I/O (#244), MicroLogix function files (#245), indirect (#247).</summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyFamilyAddressingTests
{
// ---- #244 PLC-5 octal I/O ----
/// <summary>PLC-5 I/O word and bit indices parse as octal and are stored decimal.</summary>
[Theory]
[InlineData("I:017/07", "I", 15, 7, "I:15/7")]
[InlineData("I:017/17", "I", 15, 15, "I:15/15")]
[InlineData("O:010/10", "O", 8, 8, "O:8/8")]
[InlineData("I:000", "I", 0, null, "I:0")]
[InlineData("I:12", "I", 10, null, "I:10")]
public void Plc5_io_indices_are_octal(string text, string letter, int word, int? bit, string libplctag)
{
var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldNotBeNull();
a.FileLetter.ShouldBe(letter);
a.WordNumber.ShouldBe(word);
a.BitIndex.ShouldBe(bit);
a.ToLibplctagName().ShouldBe(libplctag);
}
/// <summary>The decimal-normalised name round-trips family-neutrally, so the native transport reads back the same numbers.</summary>
[Fact]
public void Plc5_octal_address_round_trips_through_the_family_neutral_parser()
{
var a = AbLegacyAddress.TryParse("I:017/07", AbLegacyPlcFamily.Plc5)!;
AbLegacyAddress.TryParse(a.ToLibplctagName()).ShouldBe(a);
}
/// <summary>A digit 8 or 9 is not octal: a clear parse failure, not a silent misread.</summary>
[Theory]
[InlineData("I:018")]
[InlineData("I:019/07")]
[InlineData("I:017/08")]
[InlineData("O:9/1")]
[InlineData("O:1/9")]
public void Plc5_io_rejects_8_and_9(string text) =>
AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldBeNull();
/// <summary>Control: the same text under SLC / MicroLogix / Logix-PCCC / no family is decimal.</summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500)]
[InlineData(AbLegacyPlcFamily.MicroLogix)]
[InlineData(AbLegacyPlcFamily.LogixPccc)]
public void Non_plc5_io_stays_decimal(AbLegacyPlcFamily family)
{
AbLegacyAddress.TryParse("I:12/9", family).ShouldNotBeNull().WordNumber.ShouldBe(12);
AbLegacyAddress.TryParse("I:12/9", family)!.BitIndex.ShouldBe(9);
AbLegacyAddress.TryParse("I:12/9").ShouldNotBeNull().WordNumber.ShouldBe(12);
}
/// <summary>PLC-5 N-files (and bit indices on them) stay decimal — only I/O is octal.</summary>
[Fact]
public void Plc5_non_io_files_stay_decimal()
{
var a = AbLegacyAddress.TryParse("N7:12/9", AbLegacyPlcFamily.Plc5).ShouldNotBeNull();
a.WordNumber.ShouldBe(12);
a.BitIndex.ShouldBe(9);
}
// ---- #245 MicroLogix function files ----
/// <summary>MicroLogix parses the function-file letters, with their sub-elements.</summary>
[Theory]
[InlineData("RTC:0.HR", "RTC", 0, "HR")]
[InlineData("HSC:0.ACC", "HSC", 0, "ACC")]
[InlineData("pto:0.EN", "PTO", 0, "EN")]
[InlineData("STI:0.SPM", "STI", 0, "SPM")]
[InlineData("BHI:0.FRN", "BHI", 0, "FRN")]
public void MicroLogix_parses_function_files(string text, string letter, int element, string sub)
{
var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
a.FileLetter.ShouldBe(letter);
a.FileNumber.ShouldBeNull();
a.WordNumber.ShouldBe(element);
a.SubElement.ShouldBe(sub);
}
/// <summary>Function files are MicroLogix-only; every other family and the neutral grammar reject them.</summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500)]
[InlineData(AbLegacyPlcFamily.Plc5)]
[InlineData(AbLegacyPlcFamily.LogixPccc)]
public void Other_families_reject_function_files(AbLegacyPlcFamily family)
{
AbLegacyAddress.TryParse("RTC:0.HR", family).ShouldBeNull();
AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull();
}
/// <summary>A function file is a fixed single instance and not bit-addressable.</summary>
[Theory]
[InlineData("RTC1:0.HR")]
[InlineData("RTC:0/3")]
[InlineData("HSC:0.ACC/2")]
public void MicroLogix_function_file_shape_is_validated(string text) =>
AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldBeNull();
/// <summary>The catalogue maps flags to Boolean, numeric members to Int32, and unknown members to Int32.</summary>
[Theory]
[InlineData("RTC", "HR", DriverDataType.Int32)]
[InlineData("RTC", "BL", DriverDataType.Boolean)]
[InlineData("HSC", "ACC", DriverDataType.Int32)]
[InlineData("HSC", "DN", DriverDataType.Boolean)]
[InlineData("PTO", "OUT", DriverDataType.Boolean)]
[InlineData("STI", "SPM", DriverDataType.Int32)]
[InlineData("RTC", "NOPE", DriverDataType.Int32)]
public void Function_file_catalogue(string file, string sub, DriverDataType expected) =>
AbLegacyFunctionFile.SubElementType(file, sub).ShouldBe(expected);
// ---- #247 indirect addressing ----
/// <summary>Both indirect forms parse, carry their inner address, and round-trip their text.</summary>
[Theory]
[InlineData("N7:[N7:0]", false, "N7:[N7:0]")]
[InlineData("N[N7:0]:5", true, "N[N7:0]:5")]
[InlineData("N7:[T4:1.ACC]", false, "N7:[T4:1.ACC]")]
[InlineData("B3:[N7:0]/4", false, "B3:[N7:0]/4")]
public void Indirect_forms_parse(string text, bool fileIndirect, string roundTrip)
{
var a = AbLegacyAddress.TryParse(text).ShouldNotBeNull();
a.IsIndirect.ShouldBeTrue();
(a.IndirectFileSource is not null).ShouldBe(fileIndirect);
(a.IndirectWordSource is not null).ShouldBe(!fileIndirect);
a.ToLibplctagName().ShouldBe(roundTrip);
}
/// <summary>Nested indirection, empty or invalid inner addresses, and unbalanced brackets are rejected.</summary>
[Theory]
[InlineData("N7:[N[N7:0]:1]")]
[InlineData("N7:[]")]
[InlineData("N7:[Q9:0]")]
[InlineData("N7:[N7:0")]
[InlineData("N7:N7:0]")]
[InlineData("N7:[N7:[N7:0]]")]
public void Malformed_indirect_is_rejected(string text) =>
AbLegacyAddress.TryParse(text).ShouldBeNull();
/// <summary>A direct address is not indirect.</summary>
[Fact]
public void Direct_address_is_not_indirect() =>
AbLegacyAddress.TryParse("N7:3/2")!.IsIndirect.ShouldBeFalse();
// ---- driver level ----
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
AbLegacyPlcFamily family, params AbLegacyTagDefinition[] tags)
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
RawTags = AbLegacyRawTags.Entries(tags),
}, "drv-1", factory);
return (drv, factory);
}
/// <summary>A PLC-5 octal I/O tag reaches the transport as the decimal name, and its bit decodes at the octal-parsed index.</summary>
[Fact]
public async Task Plc5_octal_io_tag_is_handed_to_the_transport_decimal()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Plc5,
new AbLegacyTagDefinition("In", "ab://10.0.0.5/1,0", "I:017/17", AbLegacyDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = true };
var snap = (await drv.ReadAsync(["In"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.ContainsKey("I:15/15").ShouldBeTrue();
factory.Tags["I:15/15"].ReadCount.ShouldBe(1);
}
/// <summary>An indirect tag is answered BadNotSupported on read and write, and no runtime is ever created for it.</summary>
[Fact]
public async Task Indirect_tag_is_BadNotSupported_and_creates_no_runtime()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Ind", "ab://10.0.0.5/1,0", "N7:[N7:0]", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Ok", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5 };
var read = await drv.ReadAsync(["Ind", "Ok"], CancellationToken.None);
var write = await drv.WriteAsync(
[new WriteRequest("Ind", 1), new WriteRequest("Ok", 1)], CancellationToken.None);
read[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
// positive control: the neighbouring direct tag in the same batch still works, so the refusal is per-tag
read[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
write[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.Keys.ShouldNotContain(k => k.Contains('['));
}
/// <summary>A MicroLogix function-file tag reaches the transport under its libplctag name.</summary>
[Fact]
public async Task MicroLogix_function_file_tag_reaches_the_transport()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.MicroLogix,
new AbLegacyTagDefinition("Hour", "ab://10.0.0.5/1,0", "RTC:0.HR", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 14 };
var snap = (await drv.ReadAsync(["Hour"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snap.Value.ShouldBe(14);
factory.Tags.ContainsKey("RTC:0.HR").ShouldBeTrue();
}
// ---- native transport ----
private sealed class NativeHarness : IAsyncDisposable
{
private readonly FakeCipChannel _channel = new();
private readonly RequestScheduler _scheduler;
public NativeAbLegacyTagRuntime Runtime { get; }
public NativeHarness(string plc, string tag)
{
_scheduler = new RequestScheduler(_channel, allowPacking: false);
Runtime = new NativeAbLegacyTagRuntime(
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(2), 1),
_scheduler, 0x1234, 0x01020304);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>The native transport refuses function files and indirect names as Unsupported rather than mis-encoding them.</summary>
[Theory]
[InlineData("RTC:0.HR", "function file")]
[InlineData("N7:[N7:0]", "indirect")]
public async Task Native_transport_refuses_function_file_and_indirect_as_Unsupported(string tag, string why)
{
await using var h = new NativeHarness("micrologix", tag);
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.Unsupported);
ex.Message.ShouldContain(why, Case.Insensitive);
}
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<AbLegacyDriver>
{
public readonly System.Collections.Concurrent.ConcurrentQueue<string> Warnings = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel level, Microsoft.Extensions.Logging.EventId id,
TState state, Exception? ex, Func<TState, Exception?, string> f)
{
if (level == Microsoft.Extensions.Logging.LogLevel.Warning) Warnings.Enqueue(f(state, ex));
}
}
/// <summary>
/// A PLC-5 address that no longer parses (the pre-octal-era decimal 8/9 digit) is a config error: BadNodeIdUnknown on
/// read and write, no runtime created, health untouched, and ONE warning per tag however often it is polled.
/// </summary>
[Fact]
public async Task Malformed_address_is_BadNodeIdUnknown_warned_once_and_never_throws()
{
var factory = new FakeAbLegacyTagFactory();
var logger = new CapturingLogger();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("Bad", "ab://10.0.0.5/1,0", "I:018", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Good", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
var first = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
var second = await drv.ReadAsync(["Bad"], CancellationToken.None);
var write = await drv.WriteAsync([new WriteRequest("Bad", 1)], CancellationToken.None);
first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
factory.Tags.Keys.ShouldNotContain(k => k.StartsWith("I:"));
logger.Warnings.Count(w => w.Contains("not valid for the device's PLC family") && w.Contains("I:018")).ShouldBe(1);
}
/// <summary>The connectivity-probe address is normalised like tag paths (PLC-5 octal I/O handed over decimal).</summary>
[Fact]
public async Task Probe_address_is_normalised_like_tag_paths()
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
Probe = new AbLegacyProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(200),
ProbeAddress = "I:017",
},
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!factory.Tags.ContainsKey("I:15") && DateTime.UtcNow < deadline) await Task.Delay(20);
await drv.ShutdownAsync(CancellationToken.None);
factory.Tags.ContainsKey("I:15").ShouldBeTrue();
factory.Tags.ContainsKey("I:017").ShouldBeFalse();
}
}
@@ -0,0 +1,50 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + runtime. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe runtime is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsRuntime()
{
const string host = "ab://10.0.0.5/1,0";
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is running and has created + initialised its runtime.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("S:0", out var t) && t.InitializeCount > 0))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["S:0"];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -69,8 +69,9 @@ public sealed class FocasCapabilityMatrixTests
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
[InlineData(FocasCncSeries.Sixteen_i, "F", true)] // #265: F/G handshakes are documented on 16i ladders
[InlineData(FocasCncSeries.Sixteen_i, "G", true)]
[InlineData(FocasCncSeries.Sixteen_i, "M", false)]
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
@@ -0,0 +1,58 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsClient()
{
const string host = "focas://10.0.0.5:8193";
var factory = new FakeFocasClientFactory();
var options = new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
};
var drv = new FocasDriver(options, "focas-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is demonstrably running and holds a live client.
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
var firstClient = factory.Clients[0];
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
await drv.ShutdownAsync(CancellationToken.None);
}
private static async Task WaitUntilAsync(Func<bool> cond)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!cond())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not met");
await Task.Delay(10);
}
}
}
@@ -30,6 +30,7 @@ public sealed class MTConnectObservationIndexTests
private const uint BadOutOfRange = 0x803C0000u;
private const uint BadNotSupported = 0x803D0000u;
private const uint BadTypeMismatch = 0x80740000u;
private const uint BadDecodingError = 0x80070000u;
/// <summary>Every dataItemId both /current fixtures report.</summary>
private static readonly string[] AllFixtureDataItemIds =
@@ -48,7 +49,7 @@ public sealed class MTConnectObservationIndexTests
/// <summary>Wraps one synthetic observation in the minimal legal streams result.</summary>
private static MTConnectStreamsResult Synthetic(
string dataItemId, string value, DateTime? timestampUtc = null, bool isStructured = false) =>
string dataItemId, string value, DateTime? timestampUtc = null, bool isStructured = false, int? sampleCount = null) =>
new(
InstanceId: 1L,
NextSequence: 2L,
@@ -57,7 +58,8 @@ public sealed class MTConnectObservationIndexTests
dataItemId,
value,
timestampUtc ?? new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc),
isStructured)]);
isStructured,
sampleCount)]);
private static MTConnectTagDefinition Tag(string id, DriverDataType type) => new(id, type);
@@ -412,13 +414,9 @@ public sealed class MTConnectObservationIndexTests
// -------------------------------------------------------------------------------- TIME_SERIES
/// <summary>
/// P1 defers TIME_SERIES array materialization to P1.5. An array-authored tag therefore
/// reports BadNotSupported rather than pretending: the vector is real data this build
/// cannot represent, which is neither a type mismatch nor a comms failure.
/// </summary>
/// <summary>#627: a Float64 array tag serves the whole vector as <c>double[]</c>, Good.</summary>
[Fact]
public void Time_series_authored_as_an_array_reports_BadNotSupported()
public void Time_series_authored_as_a_float64_array_parses_to_double_array()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
@@ -426,25 +424,93 @@ public sealed class MTConnectObservationIndexTests
var snap = idx.Get("dev1_vibration_ts");
snap.StatusCode.ShouldBe(BadNotSupported);
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<double[]>()
.ShouldBe([1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0]);
}
/// <summary>The variable-length shape (no declared ArrayDim) is served the same way.</summary>
[Fact]
public void Time_series_without_a_declared_length_is_served_variable_length()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", "1 2.5\n 3"));
var snap = idx.Get("ts");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<double[]>().ShouldBe([1.0, 2.5, 3.0]);
}
[Fact]
public void Time_series_count_mismatching_the_observations_sampleCount_is_Bad()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", "1 2 3", sampleCount: 4));
var snap = idx.Get("ts");
snap.StatusCode.ShouldBe(BadDecodingError);
snap.Value.ShouldBeNull();
}
/// <summary>
/// The falsifiable half of the deferral: the vector must never be silently reduced to its
/// first element. A Good 1.1 here would be a wrong value an operator would trust.
/// </summary>
[Fact]
public void Time_series_vector_is_never_silently_parsed_as_its_first_element()
public void Time_series_count_mismatching_a_fixed_ArrayDim_is_Bad()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true, ArrayDim: 5)]);
idx.Apply(Synthetic("ts", "1 2 3"));
idx.Get("ts").StatusCode.ShouldBe(BadDecodingError);
}
[Theory]
[InlineData("1 2 abc")]
[InlineData("1 NaN 3")]
[InlineData("1 UNAVAILABLE 3")]
public void Time_series_with_an_unusable_token_is_Bad_never_a_partial_vector(string text)
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", text));
var snap = idx.Get("ts");
snap.StatusCode.ShouldNotBe(Good);
snap.Value.ShouldBeNull();
}
[Fact]
public void Empty_time_series_reports_no_communication()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", " "));
idx.Get("ts").StatusCode.ShouldBe(BadNoCommunication);
}
[Fact]
public void A_non_float64_array_tag_stays_BadNotSupported()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Int32, IsArray: true)]);
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_vibration_ts");
idx.Get("dev1_vibration_ts").StatusCode.ShouldBe(BadNotSupported);
}
snap.Value.ShouldNotBe(1.1);
snap.StatusCode.ShouldNotBe(Good);
/// <summary>The parser carries the observation's own sampleCount / sampleRate attributes.</summary>
[Fact]
public void Parser_carries_time_series_sampleCount_and_sampleRate()
{
var obs = ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_vibration_ts");
obs.SampleCount.ShouldBe(10);
obs.SampleRate.ShouldBe(100.0);
ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_pos").SampleCount.ShouldBeNull();
}
/// <summary>
@@ -452,7 +518,7 @@ public sealed class MTConnectObservationIndexTests
/// representable state and must report it rather than the deferral code.
/// </summary>
[Fact]
public void Unavailable_wins_over_the_unsupported_array_shape()
public void Unavailable_array_tag_reports_no_communication()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
@@ -219,15 +219,16 @@ public sealed class MTConnectReadTests
res[0].SourceTimestampUtc.ShouldNotBeNull();
}
/// <summary>A TIME_SERIES vector is real data this build cannot represent (P1.5 deferral).</summary>
/// <summary>A probe-declared TIME_SERIES item is read end to end as a <c>double[]</c> (#627).</summary>
[Fact]
public async Task Read_of_a_time_series_ref_reports_BadNotSupported()
public async Task Read_of_a_time_series_ref_returns_the_double_vector()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["dev1_vibration_ts"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(BadNotSupported);
res[0].StatusCode.ShouldBe(0u);
res[0].Value.ShouldBeOfType<double[]>().Length.ShouldBe(10);
}
/// <summary>A blank reference is data, not a programming error — it codes Bad, it does not throw.</summary>
@@ -0,0 +1,30 @@
using Moq;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// #630 (same pattern) — <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> first) used to
/// overwrite <c>Session</c> and orphan the previous session + its keep-alive handler.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientReinitializeReleasesSessionTests
{
/// <summary>A second Initialize disposes the live session before attempting to build a new one.</summary>
[Fact]
public async Task Initialize_OnLiveDriver_DisposesThePreviousSession()
{
var options = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://127.0.0.1:1" };
using var drv = new OpcUaClientDriver(options, "opcua-reinit");
var old = new Mock<ISession>();
drv.SetSessionForTest(old.Object);
// The new connect is refused (nothing listens on port 1) — what matters is what happened to the old one.
await Should.ThrowAsync<Exception>(() => drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
old.Verify(s => s.Dispose(), Times.AtLeastOnce);
drv.Session.ShouldBeNull();
}
}
@@ -482,6 +482,37 @@ public sealed class S7DriverReconnectTests
(factory.Created.Count - atFirstAttempt).ShouldBeLessThanOrEqualTo(1);
}
/// <summary>
/// #630 — a second <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> between, as
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> does) must stop the first probe loop and
/// dispose the first connection rather than orphan them.
/// </summary>
[Fact]
public async Task Second_Initialize_stops_first_probe_loop_and_disposes_first_connection()
{
var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(ProbeEnabledOptions(), "s7-reinit", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var first = factory.Created[0];
// Positive control: the first probe loop is demonstrably running on the first connection.
await WaitUntilAsync(() => Volatile.Read(ref first.ReadStatusCount) >= 2, TimeSpan.FromSeconds(5));
Volatile.Read(ref first.ReadStatusCount).ShouldBeGreaterThanOrEqualTo(2);
first.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
first.Disposed.ShouldBeTrue();
var frozen = Volatile.Read(ref first.ReadStatusCount);
var second = factory.Created[^1];
second.ShouldNotBeSameAs(first);
// Positive control for the absence check: the NEW loop keeps ticking over the same window.
var secondBefore = Volatile.Read(ref second.ReadStatusCount);
await WaitUntilAsync(() => Volatile.Read(ref second.ReadStatusCount) >= secondBefore + 3, TimeSpan.FromSeconds(5));
Volatile.Read(ref second.ReadStatusCount).ShouldBeGreaterThanOrEqualTo(secondBefore + 3);
Volatile.Read(ref first.ReadStatusCount).ShouldBe(frozen);
}
private static S7DriverOptions ProbeEnabledOptions() => new()
{
Host = "192.0.2.1",
@@ -626,8 +657,12 @@ public sealed class S7DriverReconnectTests
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Number of probe <see cref="ReadStatusAsync"/> calls made on this connection.</summary>
public int ReadStatusCount;
public async Task ReadStatusAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref ReadStatusCount);
if (ReadStatusHangsUntilCancelled)
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false);
if (ReadStatusThrows is { } ex)
@@ -0,0 +1,233 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// TSAP / connection-type override (Gitea #295) and negotiated-PDU surfacing (Gitea #294).
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7TsapAndPduTests
{
// ---- TSAP text ----
[Theory]
[InlineData("01.00", 0x0100)]
[InlineData("03.01", 0x0301)]
[InlineData("3.1", 0x0301)]
[InlineData("0301", 0x0301)]
[InlineData("0x0301", 0x0301)]
[InlineData("03:21", 0x0321)]
[InlineData(" 4d.57 ", 0x4D57)]
public void Tsap_text_parses(string text, int expected)
{
S7Tsap.TryParse(text, out var v).ShouldBeTrue();
v.ShouldBe((ushort)expected);
}
[Theory]
[InlineData("")]
[InlineData("zz.00")]
[InlineData("100.00")]
[InlineData("01.00.00")]
[InlineData("12345")]
[InlineData("-1")]
public void Tsap_garbage_is_rejected(string text) => S7Tsap.TryParse(text, out _).ShouldBeFalse();
[Fact]
public void Tsap_formats_canonically() => S7Tsap.Format(0x0301).ShouldBe("03.01");
// ---- resolution ----
private static (byte, byte, byte, byte) Bytes(S7DriverOptions o)
{
var p = S7Tsap.Resolve(o).ShouldNotBeNull();
return (p.Local.FirstByte, p.Local.SecondByte, p.Remote.FirstByte, p.Remote.SecondByte);
}
[Fact]
public void No_override_resolves_to_null_so_the_plain_constructor_is_kept() =>
S7Tsap.Resolve(new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2 }).ShouldBeNull();
[Fact]
public void Local_override_replaces_only_the_local_tsap()
{
// S7-300 rack 0 slot 2 default remote = 03.02 (S7.Net slot-derived).
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, LocalTsap = "02.00" };
Bytes(o).ShouldBe(((byte)2, (byte)0, (byte)3, (byte)2));
}
[Fact]
public void Remote_override_replaces_the_whole_remote_tsap()
{
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, RemoteTsap = "03.01" };
Bytes(o).ShouldBe(((byte)1, (byte)0, (byte)3, (byte)1));
}
[Theory]
[InlineData(S7ConnectionType.PG, 1)]
[InlineData(S7ConnectionType.OP, 2)]
[InlineData(S7ConnectionType.S7Basic, 3)]
public void Connection_type_sets_only_the_first_remote_byte_and_keeps_rack_slot(S7ConnectionType ct, int first)
{
// rack 1 slot 3 → second byte 1*0x20 + 3 = 0x23.
var o = new S7DriverOptions { CpuType = S7CpuType.S7400, Rack = 1, Slot = 3, ConnectionType = ct };
var (_, _, rFirst, rSecond) = Bytes(o);
rFirst.ShouldBe((byte)first);
rSecond.ShouldBe((byte)0x23);
}
// ---- factory JSON ----
[Fact]
public void Json_accepts_string_number_and_enum_name_and_normalises()
{
var o = S7DriverFactoryExtensions.ParseOptions("d",
"""{"host":"h","localTsap":"1.0","remoteTsap":769,"cpuType":"S7300"}""");
o.LocalTsap.ShouldBe("01.00");
o.RemoteTsap.ShouldBe("03.01");
o.ConnectionType.ShouldBeNull();
var ct = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h","connectionType":"pg"}""");
ct.ConnectionType.ShouldBe(S7ConnectionType.PG);
ct.RemoteTsap.ShouldBeNull();
}
[Fact]
public void Json_without_the_keys_leaves_the_defaults()
{
var o = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h"}""");
o.LocalTsap.ShouldBeNull();
o.RemoteTsap.ShouldBeNull();
o.ConnectionType.ShouldBeNull();
}
[Theory]
[InlineData("""{"host":"h","localTsap":"nope"}""")]
[InlineData("""{"host":"h","remoteTsap":"01.00.00"}""")]
[InlineData("""{"host":"h","remoteTsap":99999999}""")]
[InlineData("""{"host":"h","connectionType":"Bogus"}""")]
[InlineData("""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}""")]
public void Json_rejects_bad_tsap_settings(string json) =>
Should.Throw<InvalidOperationException>(() => S7DriverFactoryExtensions.ParseOptions("d", json));
[Fact]
public async Task Driver_hands_the_full_options_to_the_factory()
{
var factory = new CapturingFactory();
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RemoteTsap = "03.01",
LocalTsap = "01.00",
};
using var drv = new S7Driver(opts, "s7-tsap", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
factory.Seen.ShouldNotBeNull().RemoteTsap.ShouldBe("03.01");
factory.Seen!.LocalTsap.ShouldBe("01.00");
}
// ---- PDU ----
[Fact]
public async Task Negotiated_pdu_size_is_surfaced_after_connect_and_cleared_on_shutdown()
{
var factory = new CapturingFactory { Pdu = 480 };
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
using var drv = new S7Driver(opts, "s7-pdu", factory);
drv.NegotiatedPduSize.ShouldBe(0);
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(0);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(480);
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(480);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(0);
}
[Fact]
public async Task A_plc_that_does_not_report_a_pdu_size_yields_zero_not_a_throw()
{
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
using var drv = new S7Driver(opts, "s7-pdu0", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(0);
}
private sealed class CapturingFactory : IS7PlcFactory
{
public S7DriverOptions? Seen { get; private set; }
public int Pdu { get; init; }
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
new Plc(Pdu);
public IS7Plc Create(S7DriverOptions options)
{
Seen = options;
return new Plc(Pdu);
}
private sealed class Plc(int pdu) : IS7Plc
{
public bool IsConnected { get; private set; }
public int MaxPduSize => IsConnected ? pdu : 0;
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string a, CancellationToken ct) => throw new NotSupportedException();
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int s, int c, CancellationToken ct) => throw new NotSupportedException();
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
List<global::S7.Net.Types.DataItem> i, CancellationToken ct) => throw new NotSupportedException();
public Task WriteAsync(string a, object v, CancellationToken ct) => throw new NotSupportedException();
public Task WriteBytesAsync(S7NetDataType area, int db, int s, byte[] v, CancellationToken ct) => throw new NotSupportedException();
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
}
[Fact]
public void Factory_conflict_message_matches_the_shared_detail()
{
var ex = Should.Throw<InvalidOperationException>(() => S7DriverFactoryExtensions.ParseOptions("d",
"""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}"""));
ex.Message.ShouldContain(S7DriverOptions.TsapConflictDetail);
}
// ---- Test Connect data-access check (PUT/GET) ----
[Fact]
public async Task Probe_data_access_succeeds_when_the_read_works() =>
(await S7DriverProbe.CheckDataAccessAsync(_ => Task.FromResult(new byte[2]), TestContext.Current.CancellationToken))
.ShouldBeNull();
[Fact]
public async Task Probe_fails_with_actionable_message_when_put_get_is_disabled()
{
var denied = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
new Exception("Accessing object not allowed"));
var r = await S7DriverProbe.CheckDataAccessAsync(_ => throw denied, TestContext.Current.CancellationToken);
r.ShouldNotBeNull().Ok.ShouldBeFalse();
r.Message!.ShouldContain("PUT/GET");
}
[Fact]
public async Task Probe_tolerates_an_address_not_available_read_error()
{
var missing = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, new Exception("Object does not exist"));
(await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull();
}
[Fact]
public async Task Probe_reports_any_other_read_fault_as_a_failure()
{
var r = await S7DriverProbe.CheckDataAccessAsync(
_ => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ConnectionError, "socket dropped"),
TestContext.Current.CancellationToken);
r.ShouldNotBeNull().Ok.ShouldBeFalse();
r.Message!.ShouldContain("socket dropped");
}
}
@@ -0,0 +1,438 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Known-byte-vector tests for the S7 string/char/date-time/duration codecs (Gitea #288, #289) —
/// the pure <see cref="S7ValueCodecs"/> plus the driver's dispatch
/// (<see cref="S7Driver.ScalarByteWidth"/> / <c>DecodeScalarBlock</c> / <c>EncodeScalarBlock</c>) and one
/// over-the-seam read/write pass through a memory-backed fake PLC proving status mapping.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ValueCodecTests
{
private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254, string address = "DB1.DBB0") =>
new("T", address, dt, StringLength: stringLength);
private static S7ParsedAddress Addr() =>
new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0);
private static object Decode(S7DataType dt, byte[] block, int stringLength = 254) =>
S7Driver.DecodeScalarBlock(Tag(dt, stringLength), Addr(), block);
private static byte[] Encode(S7DataType dt, object? value, int stringLength = 254) =>
S7Driver.EncodeScalarBlock(Tag(dt, stringLength), value);
// ---- widths ----
[Theory]
[InlineData(S7DataType.Char, 1)]
[InlineData(S7DataType.WChar, 2)]
[InlineData(S7DataType.Date, 2)]
[InlineData(S7DataType.S5Time, 2)]
[InlineData(S7DataType.Tod, 4)]
[InlineData(S7DataType.Time, 4)]
[InlineData(S7DataType.Dtl, 12)]
public void Fixed_widths(S7DataType dt, int width) =>
S7Driver.ScalarByteWidth(Tag(dt), Addr()).ShouldBe(width);
[Theory]
[InlineData(0, 4)]
[InlineData(1, 6)]
[InlineData(5, 14)]
[InlineData(16382, 32768)]
public void WString_width_is_header_plus_two_bytes_per_char(int maxLen, int width) =>
S7Driver.ScalarByteWidth(Tag(S7DataType.WString, maxLen), Addr()).ShouldBe(width);
// ---- WSTRING ----
[Fact]
public void WString_encode_known_vector()
{
// maxLen=5, curLen=2, "Hi" UTF-16BE, 3 zero-padded chars.
Encode(S7DataType.WString, "Hi", 5).ShouldBe(
[0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0, 0, 0, 0, 0, 0]);
}
[Fact]
public void WString_decode_ignores_padding_past_current_length()
{
byte[] block = [0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0x00, 0x58, 0x00, 0x58, 0, 0];
Decode(S7DataType.WString, block, 5).ShouldBe("Hi");
}
[Theory]
[InlineData("")]
[InlineData("A")]
[InlineData("Grüße Ω")]
[InlineData("12345678")] // exactly at the declared max
public void WString_round_trips_including_boundary_lengths(string s)
{
var bytes = Encode(S7DataType.WString, s, 8);
bytes.Length.ShouldBe(4 + 16);
Decode(S7DataType.WString, bytes, 8).ShouldBe(s);
}
[Fact]
public void WString_empty_with_zero_max_is_a_bare_header()
{
var bytes = Encode(S7DataType.WString, "", 0);
bytes.ShouldBe([0, 0, 0, 0]);
Decode(S7DataType.WString, bytes, 0).ShouldBe("");
}
[Fact]
public void WString_write_over_declared_max_is_refused_not_truncated() =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.WString, "123456789", 8));
[Fact]
public void WString_decode_rejects_curLen_above_maxLen() =>
Should.Throw<S7ValueRangeException>(() =>
Decode(S7DataType.WString, [0x00, 0x02, 0x00, 0x03, 0, 0x41, 0, 0x42, 0, 0x43], 2));
[Fact]
public void WString_decode_rejects_length_running_past_block() =>
Should.Throw<S7ValueRangeException>(() =>
Decode(S7DataType.WString, [0x00, 0x05, 0x00, 0x04, 0, 0x41], 5));
// ---- CHAR / WCHAR ----
[Fact]
public void Char_known_vectors()
{
Encode(S7DataType.Char, "A").ShouldBe([0x41]);
Encode(S7DataType.Char, "é").ShouldBe([0xE9]);
Decode(S7DataType.Char, [0x41]).ShouldBe("A");
Decode(S7DataType.Char, [0xE9]).ShouldBe("é");
}
[Fact]
public void Char_refuses_wide_character_and_wrong_length()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Char, "Ā")); // U+0100
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, "AB"));
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, ""));
}
[Fact]
public void WChar_known_vectors()
{
Encode(S7DataType.WChar, "Ω").ShouldBe([0x03, 0xA9]);
Encode(S7DataType.WChar, "A").ShouldBe([0x00, 0x41]);
Decode(S7DataType.WChar, [0x03, 0xA9]).ShouldBe("Ω");
}
// ---- DTL ----
// Siemens documentation example: DTL#2008-12-16-20:30:20.250 (a Tuesday → S7 weekday 3).
private static readonly byte[] DtlVector = [0x07, 0xD8, 0x0C, 0x10, 0x03, 0x14, 0x1E, 0x14, 0x0E, 0xE6, 0xB2, 0x80];
[Fact]
public void Dtl_known_vector_both_ways()
{
var expected = new DateTime(2008, 12, 16, 20, 30, 20, 250);
Decode(S7DataType.Dtl, DtlVector).ShouldBe(expected);
Encode(S7DataType.Dtl, expected).ShouldBe(DtlVector);
}
[Fact]
public void Dtl_keeps_100ns_resolution()
{
var dt = new DateTime(2030, 1, 2, 3, 4, 5).AddTicks(1234567);
Decode(S7DataType.Dtl, Encode(S7DataType.Dtl, dt)).ShouldBe(dt);
}
[Fact]
public void Dtl_uninitialised_zero_buffer_is_rejected_not_year_zero() =>
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, new byte[12]));
[Fact]
public void Dtl_invalid_calendar_fields_are_rejected()
{
var bad = (byte[])DtlVector.Clone();
bad[2] = 13; // month 13
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
bad = (byte[])DtlVector.Clone();
bad[8] = 0xFF; // nanoseconds > 999,999,999
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
}
[Theory]
[InlineData(1969)]
[InlineData(2263)]
public void Dtl_write_outside_plc_year_range_is_refused(int year) =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Dtl, new DateTime(year, 1, 1)));
// ---- DT (existing S7DataType.DateTime — regression vector) ----
[Fact]
public void Dt_still_routes_through_the_8_byte_bcd_codec()
{
var dt = new DateTime(2008, 12, 16, 20, 30, 20, 250);
var bytes = Encode(S7DataType.DateTime, dt);
bytes.Length.ShouldBe(8);
bytes.Take(7).ToArray().ShouldBe([0x08, 0x12, 0x16, 0x20, 0x30, 0x20, 0x25]);
Decode(S7DataType.DateTime, bytes).ShouldBe(dt);
}
// ---- DATE ----
[Theory]
[InlineData(1990, 1, 1, 0x0000)]
[InlineData(2000, 2, 29, 0x0E7F)]
[InlineData(2009, 12, 1, 0x1C6A)]
[InlineData(2168, 12, 31, 0xFF62)]
public void Date_known_vectors(int y, int m, int d, int days)
{
byte[] bytes = [(byte)(days >> 8), (byte)days];
Decode(S7DataType.Date, bytes).ShouldBe(new DateTime(y, m, d));
Encode(S7DataType.Date, new DateTime(y, m, d, 17, 5, 0)).ShouldBe(bytes); // time-of-day dropped
}
[Fact]
public void Date_write_outside_uint16_range_is_refused()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(1989, 12, 31)));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(2169, 6, 7)));
Encode(S7DataType.Date, new DateTime(2169, 6, 6)).ShouldBe([0xFF, 0xFF]);
}
// ---- TOD ----
[Theory]
[InlineData(0, new byte[] { 0, 0, 0, 0 })]
[InlineData(1000, new byte[] { 0, 0, 0x03, 0xE8 })]
[InlineData(86_399_999, new byte[] { 0x05, 0x26, 0x5B, 0xFF })] // TOD#23:59:59.999
public void Tod_known_vectors(int ms, byte[] bytes)
{
Decode(S7DataType.Tod, bytes).ShouldBe(ms);
Encode(S7DataType.Tod, ms).ShouldBe(bytes);
}
[Fact]
public void Tod_boundaries_are_refused_on_both_sides()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, 86_400_000));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, -1));
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Tod, [0x05, 0x26, 0x5C, 0x00]));
Encode(S7DataType.Tod, TimeSpan.FromHours(1)).ShouldBe([0, 0x36, 0xEE, 0x80]);
}
[Fact]
public void Fractional_millisecond_double_rounds_like_the_other_int32_writes()
{
// Pinned: matches BoxValueForWrite's Convert.ToInt32 behaviour (banker's), see docs.
Encode(S7DataType.Tod, 100.7).ShouldBe(Encode(S7DataType.Tod, 101));
Encode(S7DataType.Time, 100.7).ShouldBe(Encode(S7DataType.Time, 101));
Encode(S7DataType.S5Time, 99.7).ShouldBe(Encode(S7DataType.S5Time, 100));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 100.7)); // rounds to 101: off the grid
}
// ---- TIME ----
[Theory]
[InlineData(86_400_000, new byte[] { 0x05, 0x26, 0x5C, 0x00 })] // T#1d
[InlineData(-1000, new byte[] { 0xFF, 0xFF, 0xFC, 0x18 })] // T#-1s
[InlineData(int.MaxValue, new byte[] { 0x7F, 0xFF, 0xFF, 0xFF })] // T#24d20h31m23s647ms
[InlineData(int.MinValue, new byte[] { 0x80, 0, 0, 0 })]
public void Time_known_vectors(int ms, byte[] bytes)
{
Decode(S7DataType.Time, bytes).ShouldBe(ms);
Encode(S7DataType.Time, ms).ShouldBe(bytes);
}
[Fact]
public void Time_outside_int32_is_refused() =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Time, (long)int.MaxValue + 1));
// ---- S5TIME ----
[Theory]
[InlineData(0x0000, 0)]
[InlineData(0x0999, 9_990)] // 999 × 10 ms
[InlineData(0x1500, 50_000)] // 500 × 100 ms
[InlineData(0x2127, 127_000)] // W#16#2127 = 127 s (Siemens docs)
[InlineData(0x3999, 9_990_000)] // 999 × 10 s, the maximum
[InlineData(0xC127, 1_270)] // bits 15-14 are ignored (base 00, 127 × 10 ms)
public void S5Time_decode_known_vectors(int word, int ms) =>
Decode(S7DataType.S5Time, [(byte)(word >> 8), (byte)word]).ShouldBe(ms);
[Theory]
[InlineData(0, 0x0000)]
[InlineData(9_990, 0x0999)] // finest base that fits
[InlineData(10_000, 0x1100)] // 100 × 100 ms
[InlineData(127_000, 0x2127)]
[InlineData(9_990_000, 0x3999)]
public void S5Time_encode_picks_the_finest_base_that_fits(int ms, int word) =>
Encode(S7DataType.S5Time, ms).ShouldBe([(byte)(word >> 8), (byte)word]);
[Fact]
public void S5Time_non_bcd_nibble_is_rejected_on_read() =>
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.S5Time, [0x01, 0x2A]));
[Fact]
public void S5Time_unrepresentable_writes_are_refused()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 9_990_001)); // above max
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, -10));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 15)); // not a multiple of 10 ms
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 1_000_500)); // no base holds it exactly
}
[Fact]
public void S5Time_round_trips_every_representable_value_in_a_sample()
{
foreach (var ms in new[] { 10, 990, 9_990, 10_000, 99_900, 100_000, 999_000, 1_000_000, 9_990_000 })
Decode(S7DataType.S5Time, Encode(S7DataType.S5Time, ms)).ShouldBe(ms);
}
// ---- type mapping ----
[Theory]
[InlineData(S7DataType.WString, DriverDataType.String)]
[InlineData(S7DataType.Char, DriverDataType.String)]
[InlineData(S7DataType.WChar, DriverDataType.String)]
[InlineData(S7DataType.Dtl, DriverDataType.DateTime)]
[InlineData(S7DataType.Date, DriverDataType.DateTime)]
[InlineData(S7DataType.Tod, DriverDataType.Int32)]
[InlineData(S7DataType.Time, DriverDataType.Int32)]
[InlineData(S7DataType.S5Time, DriverDataType.Int32)]
public async Task Discovery_maps_new_types_onto_existing_driver_types(S7DataType dt, DriverDataType expected)
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(Tag(dt, 10) with { Name = "X" }),
};
using var drv = new S7Driver(opts, "s7-map", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single().Attr.DriverDataType.ShouldBe(expected);
}
[Fact]
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
{
S7DriverOptions With(S7TagDefinition t) => new()
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(t),
};
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
// ---- TagConfig ----
[Fact]
public void TagConfig_accepts_wstring_length_up_to_16382_but_string_stays_254()
{
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16382}""", "p", out var d).ShouldBeTrue();
d.DataType.ShouldBe(S7DataType.WString);
d.StringLength.ShouldBe(16382);
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16383}""", "p", out _).ShouldBeFalse();
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""", "p", out _).ShouldBeFalse();
S7TagDefinitionFactory.Inspect("""{"address":"DB1.DBB0","dataType":"WString","stringLength":99999}""")
.ShouldHaveSingleItem().ShouldContain("16382");
}
[Theory]
[InlineData("Char")]
[InlineData("WChar")]
[InlineData("Dtl")]
[InlineData("Date")]
[InlineData("Tod")]
[InlineData("Time")]
[InlineData("S5Time")]
public void TagConfig_round_trips_every_new_type_name(string name)
{
var dt = Enum.Parse<S7DataType>(name);
var cfg = S7TagDefinitionFactory.ToTagConfig(Tag(dt));
S7TagDefinitionFactory.FromTagConfig(cfg, "p", out var back).ShouldBeTrue();
back.DataType.ShouldBe(dt);
}
// ---- over the seam: status mapping ----
[Fact]
public async Task Driver_reads_writes_and_maps_range_errors_without_degrading_health()
{
var plc = new MemoryS7Plc();
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(
new S7TagDefinition("Ws", "DB1.DBB0", S7DataType.WString, StringLength: 4),
new S7TagDefinition("Dtl", "DB1.DBB100", S7DataType.Dtl),
new S7TagDefinition("S5", "DB1.DBB120", S7DataType.S5Time)),
};
using var drv = new S7Driver(opts, "s7-codec-e2e", new MemoryS7PlcFactory(plc));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var ct = TestContext.Current.CancellationToken;
// Good write then read-back.
(await drv.WriteAsync([new WriteRequest("Ws", "abcd")], ct))[0].StatusCode.ShouldBe(0u);
plc.Mem.Take(12).ToArray().ShouldBe([0, 4, 0, 4, 0, 0x61, 0, 0x62, 0, 0x63, 0, 0x64]);
var r = await drv.ReadAsync(["Ws"], ct);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe("abcd");
// Over-length write → BadOutOfRange, nothing written, health unharmed.
(await drv.WriteAsync([new WriteRequest("Ws", "abcde")], ct))[0].StatusCode.ShouldBe(0x803C0000u);
plc.Mem.Skip(5).Take(1).Single().ShouldBe((byte)0x61);
// Uninitialised DTL (all zero) reads BadOutOfRange, not a comms fault; the sibling stays Good.
var reads = await drv.ReadAsync(["Dtl", "Ws"], ct);
reads[0].StatusCode.ShouldBe(0x803C0000u);
reads[1].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// S5TIME value that fits no base → BadOutOfRange; wrong-shape value → BadTypeMismatch.
(await drv.WriteAsync([new WriteRequest("S5", 15)], ct))[0].StatusCode.ShouldBe(0x803C0000u);
(await drv.WriteAsync([new WriteRequest("S5", "soon")], ct))[0].StatusCode.ShouldBe(0x80740000u);
(await drv.WriteAsync([new WriteRequest("S5", 127_000)], ct))[0].StatusCode.ShouldBe(0u);
(await drv.ReadAsync(["S5"], ct))[0].Value.ShouldBe(127_000);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---- fakes ----
private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory
{
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => plc;
}
/// <summary>Single-DB memory-backed fake: block reads/writes address one flat byte array.</summary>
internal sealed class MemoryS7Plc : IS7Plc
{
public byte[] Mem { get; } = new byte[512];
public bool IsConnected { get; private set; }
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string address, CancellationToken ct) => throw new NotSupportedException();
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int start, int count, CancellationToken ct) =>
Task.FromResult(Mem.AsSpan(start, count).ToArray());
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
List<global::S7.Net.Types.DataItem> items, CancellationToken ct) => throw new NotSupportedException();
public Task WriteAsync(string address, object value, CancellationToken ct) => throw new NotSupportedException();
public Task WriteBytesAsync(S7NetDataType area, int db, int start, byte[] value, CancellationToken ct)
{
value.CopyTo(Mem, start);
return Task.CompletedTask;
}
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
}
@@ -194,18 +194,28 @@ public sealed class TwinCATArrayCodecTests
}
/// <summary>
/// TIME / DATE / DT / TOD elements publish as their raw 32-bit count (<c>uint[]</c>) — the same
/// CLR shape a scalar of those types publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c>
/// (<c>ARRAY [0..12] OF TIME</c>) begins <c>0, 10, 20</c> ms.
/// TIME / TOD elements publish as Double milliseconds and DATE / DT as UTC DateTime (#306) — the same
/// CLR shape a scalar publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c> (<c>ARRAY [0..12] OF TIME</c>)
/// begins <c>0, 10, 20</c> ms.
/// </summary>
[Theory]
[InlineData(TwinCATDataType.Time)]
[InlineData(TwinCATDataType.TimeOfDay)]
public void Decode_duration_elements_as_double_ms(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false)
.ShouldBe(new double[] { 0, 10, 20 });
/// <summary>DATE / DT elements decode to UTC DateTime from seconds since 1970.</summary>
[Theory]
[InlineData(TwinCATDataType.Date)]
[InlineData(TwinCATDataType.DateTime)]
[InlineData(TwinCATDataType.TimeOfDay)]
public void Decode_time_and_date_elements_as_uint(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false)
.ShouldBe(new uint[] { 0, 10, 20 });
public void Decode_date_elements_as_utc_datetime(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 60, 0, 0, 0], new TwinCATArrayLayout(2, 4), utf8: false)
.ShouldBe(new[]
{
new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(1970, 1, 1, 0, 1, 0, DateTimeKind.Utc),
});
/// <summary>A BOOL element is one byte; any non-zero byte is TRUE (live: <c>.ARGTYPE_IS_BIT_TYPE</c>).</summary>
[Fact]
@@ -93,8 +93,8 @@ public sealed class TwinCATDriverTests
TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String);
// IEC TIME/DATE/DT/TOD surface as their raw UDINT counter (Driver.TwinCAT-002).
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
// IEC TIME/TOD surface as Double ms, DATE/DT as DateTime (#306).
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Float64);
}
/// <summary>Verifies that status mapper covers known ADS error codes.</summary>
@@ -21,17 +21,16 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-004 — TIME/DATE/DT/TOD surface unchanged but comments corrected ----
/// <summary>Verifies that IEC time types map to uint32 raw counters.</summary>
/// <summary>IEC time types surface as native values (#306): TIME/TOD as Double ms, DATE/DT as DateTime.</summary>
[Fact]
public void Iec_time_types_map_to_uint32_raw_counter()
public void Iec_time_types_map_to_native_types()
{
// Documents the contract called out in the corrected comments: TIME / DATE / DT / TOD
// surface as their raw UDINT counter (32-bit unsigned), not as decoded DateTime/TimeSpan.
// The next implementer who wants to decode them needs to see this mapping is intentional.
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
// The raw counter stays reachable by declaring UDInt.
TwinCATDataType.UDInt.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
}
// ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ----
@@ -191,6 +191,35 @@ public sealed class TwinCATReadWriteTests
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
}
/// <summary>A refused temporal write (#306) surfaces a status through WriteAsync, never an exception.</summary>
[Theory]
[InlineData(TwinCATDataType.TimeOfDay, 86_400_000d, TwinCATStatusMapper.BadOutOfRange)]
[InlineData(TwinCATDataType.Time, -1d, TwinCATStatusMapper.BadOutOfRange)]
[InlineData(TwinCATDataType.DateTime, "TimeSpan", TwinCATStatusMapper.BadTypeMismatch)]
public async Task Refused_temporal_write_surfaces_status_not_exception(
TwinCATDataType type, object value, uint expected)
{
var (drv, factory) = NewDriver(new TwinCATTagDefinition("T", "ads://5.23.91.23.1.1:851", "MAIN.T", type));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new ConvertingFake();
if (value is "TimeSpan") value = TimeSpan.FromSeconds(1);
var results = await drv.WriteAsync([new WriteRequest("T", value)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(expected);
}
// Runs the production write conversion the fake otherwise skips.
private sealed class ConvertingFake : FakeTwinCATClient
{
public override Task<uint> WriteValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{
AdsTwinCATClient.ConvertForWrite(type, value);
return base.WriteValueAsync(symbolPath, type, bitIndex, value, ct);
}
}
/// <summary>Verifies that write exceptions surface as BadCommunicationError status.</summary>
[Fact]
public async Task Write_exception_surfaces_BadCommunicationError()
@@ -0,0 +1,48 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #630 (same pattern) — <c>InitializeAsync</c> on a live driver overwrote <c>_devices[host]</c> and
/// orphaned the previous state's probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsClient()
{
const string host = "ads://5.23.91.23.1.1:851";
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(host)],
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and holds a connected client.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Clients.Count > 0 && factory.Clients[0].IsConnected))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
await Task.Delay(10);
}
var firstClient = factory.Clients[0];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,83 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>#306 — TIME / DATE / DT / TOD decode and encode round trips on known values.</summary>
[Trait("Category", "Unit")]
public sealed class TwinCATTemporalCodecTests
{
private static readonly DateTime Dt = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Dt_decodes_seconds_since_1970_to_utc()
{
var raw = (uint)(Dt - DateTime.UnixEpoch).TotalSeconds;
var v = (DateTime)TwinCATTemporalCodec.Decode(TwinCATDataType.DateTime, raw);
v.ShouldBe(Dt);
v.Kind.ShouldBe(DateTimeKind.Utc);
TwinCATTemporalCodec.Encode(TwinCATDataType.DateTime, v).ShouldBe(raw);
}
[Fact]
public void Date_round_trips_and_truncates_to_day()
{
var day = new DateTime(2026, 10, 8, 0, 0, 0, DateTimeKind.Utc);
var raw = TwinCATTemporalCodec.Encode(TwinCATDataType.Date, day);
((DateTime)TwinCATTemporalCodec.Decode(TwinCATDataType.Date, raw)).ShouldBe(day);
TwinCATTemporalCodec.Encode(TwinCATDataType.Date, day.AddHours(13)).ShouldBe(raw);
}
[Fact]
public void Tod_round_trips_milliseconds()
{
const uint ms = (12 * 3600 + 30 * 60 + 15) * 1000 + 250; // TOD#12:30:15.250
TwinCATTemporalCodec.Decode(TwinCATDataType.TimeOfDay, ms).ShouldBe(45_015_250d);
TwinCATTemporalCodec.Encode(TwinCATDataType.TimeOfDay, 45_015_250d).ShouldBe(ms);
}
[Fact]
public void Time_round_trips_milliseconds_and_accepts_timespan()
{
const uint ms = ((1 * 60 + 2) * 60 + 3) * 1000 + 4; // T#1h2m3s4ms
TwinCATTemporalCodec.Decode(TwinCATDataType.Time, ms).ShouldBe(3_723_004d);
TwinCATTemporalCodec.Encode(TwinCATDataType.Time, 3_723_004d).ShouldBe(ms);
TwinCATTemporalCodec.Encode(TwinCATDataType.Time, new TimeSpan(0, 1, 2, 3, 4)).ShouldBe(ms);
TwinCATTemporalCodec.Encode(TwinCATDataType.Time, 5u).ShouldBe(5u); // raw integer still writes
}
[Theory]
[InlineData(-1d)]
[InlineData(double.NaN)]
[InlineData(5_000_000_000d)]
public void Time_encode_refuses_out_of_range(double value)
=> Should.Throw<OverflowException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.Time, value));
[Theory]
[InlineData(86_400_000d)]
[InlineData(-1d)]
public void Tod_encode_refuses_a_full_day_or_more(double value)
=> Should.Throw<OverflowException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.TimeOfDay, value));
[Fact]
public void Dt_before_1970_is_refused()
=> Should.Throw<OverflowException>(() =>
TwinCATTemporalCodec.Encode(TwinCATDataType.DateTime, new DateTime(1960, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
[Fact]
public void Wrong_shape_is_a_type_mismatch()
{
Should.Throw<InvalidCastException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.Time, Dt));
Should.Throw<InvalidCastException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.DateTime, TimeSpan.FromSeconds(1)));
}
[Fact]
public void Client_write_conversion_routes_temporal_types_through_the_codec()
{
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Time, 1000d).ShouldBe(1000u);
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.DateTime, Dt)
.ShouldBe((uint)(Dt - DateTime.UnixEpoch).TotalSeconds);
// Authored UDInt against a TIME symbol keeps the raw counter.
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.UDInt, 1000u).ShouldBe(1000u);
}
}
@@ -105,4 +105,61 @@ public sealed class S7DriverPageFormSerializationTests
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
roundTripped.ProbeTimeoutSeconds.ShouldBe(20);
}
[Fact]
public void FormModel_RoundTrip_PreservesTsapOverrides()
{
var opts = new S7DriverOptions
{
Host = "192.168.1.50",
LocalTsap = "01.00",
RemoteTsap = "03.01",
};
var back = S7DriverForm.FormModel.FromOptions(opts).ToOptions();
back.LocalTsap.ShouldBe("01.00");
back.RemoteTsap.ShouldBe("03.01");
back.ConnectionType.ShouldBeNull();
var withType = S7DriverForm.FormModel.FromOptions(new S7DriverOptions { ConnectionType = S7ConnectionType.OP }).ToOptions();
withType.ConnectionType.ShouldBe(S7ConnectionType.OP);
// Defaults stay absent: a blank form must not invent an override.
var blank = S7DriverForm.FormModel.FromOptions(new S7DriverOptions()).ToOptions();
blank.LocalTsap.ShouldBeNull();
blank.RemoteTsap.ShouldBeNull();
blank.ConnectionType.ShouldBeNull();
}
[Fact]
public void ConnectionType_serialises_as_a_string_the_factory_binds()
{
var json = JsonSerializer.Serialize(
new S7DriverOptions { ConnectionType = S7ConnectionType.PG },
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
json.ShouldContain("\"connectionType\":\"PG\"");
}
[Fact]
public void Numeric_tsap_loads_into_the_form_model_instead_of_falling_back_to_defaults()
{
// The factory accepts a JSON number; the form binds the same document the same way, so a Save
// cannot silently replace the authored config with defaults.
var opts = JsonSerializer.Deserialize<S7DriverOptions>(
"""{"remoteTsap":769,"localTsap":"01.00","cpuType":"S7300"}""",
new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } });
opts.ShouldNotBeNull();
var back = S7DriverForm.FormModel.FromOptions(opts).ToOptions();
back.RemoteTsap.ShouldBe("03.01");
back.LocalTsap.ShouldBe("01.00");
back.CpuType.ShouldBe(S7CpuType.S7300);
}
[Fact]
public void Form_reports_the_factory_conflict_error_for_remote_tsap_plus_connection_type()
{
var form = new S7DriverForm.FormModel { RemoteTsap = "03.01", ConnectionTypeText = "PG" };
form.TsapConflictError().ShouldNotBeNull().ShouldContain(S7DriverOptions.TsapConflictDetail);
new S7DriverForm.FormModel { RemoteTsap = "03.01" }.TsapConflictError().ShouldBeNull();
new S7DriverForm.FormModel { ConnectionTypeText = "PG" }.TsapConflictError().ShouldBeNull();
}
}
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>#244/#245/#247: one address authority (AbLegacyAddress.Validate) behind the editor and the deploy gate.</summary>
public sealed class AbLegacyFamilyAddressGateTests
{
private static string Cfg(string address) => "{\"address\":\"" + address + "\",\"dataType\":\"Int\"}";
[Theory]
[InlineData("{\"PlcFamily\":\"Plc5\"}", "I:018", true)] // octal 8 on PLC-5
[InlineData("{\"PlcFamily\":\"Plc5\"}", "I:017/07", false)]
[InlineData("{\"PlcFamily\":\"Slc500\"}", "I:018", false)] // control: decimal on SLC
[InlineData("{\"plcFamily\":\"plc5\"}", "I:018", true)] // key + value case-insensitive
[InlineData("{\"PlcFamily\":\"Slc500\"}", "RTC:0.HR", true)] // function file off MicroLogix
[InlineData("{\"PlcFamily\":\"MicroLogix\"}", "RTC:0.HR", false)]
[InlineData(null, "RTC:0.HR", true)] // absent family = runtime default SLC 500
[InlineData("{\"PlcFamily\":\"Bogus\"}", "I:018", false)] // unknown family: never stricter than the runtime
[InlineData("{\"PlcFamily\":\"Slc500\"}", "Q9:0", true)]
public void Gate_checks_the_address_against_the_device_family(string? deviceConfig, string address, bool warns)
{
var w = EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg(address), deviceConfig);
(w.Count > 0).ShouldBe(warns, string.Join("; ", w));
}
[Fact]
public void Gate_warns_about_indirect_addresses_which_never_read()
{
EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg("N7:[N7:0]"), "{\"PlcFamily\":\"Slc500\"}")
.ShouldContain(x => x.Contains("indirect"));
}
[Theory]
[InlineData("N7:0", null)]
[InlineData("I:018", null)] // family unknown to the editor: another family accepts it
[InlineData("RTC:0.HR", null)] // MicroLogix accepts it
[InlineData("Q9:0", "any PLC family")]
public void Editor_validates_only_the_family_independent_shape(string address, string? error)
{
var result = AbLegacyTagConfigModel.FromJson(Cfg(address)).Validate();
if (error is null) result.ShouldBeNull(); else result!.ShouldContain(error);
}
[Fact]
public void Editor_is_never_stricter_than_the_gate()
{
const string address = "Q9:0";
AbLegacyTagConfigModel.FromJson(Cfg(address)).Validate().ShouldNotBeNull();
EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg(address), null).ShouldNotBeEmpty();
}
}
@@ -328,4 +328,15 @@ public sealed class MTConnectTagConfigModelTests
public void Warnings_is_a_noop_without_probe_metadata()
=> MTConnectTagConfigModel.FromJson("{\"fullName\":\"x\",\"dataType\":\"Boolean\"}")
.Warnings().ShouldBeEmpty();
[Fact]
public void Warnings_accepts_a_float64_array_for_time_series_and_flags_other_array_types()
{
MTConnectTagConfigModel.FromJson("{\"fullName\":\"x\",\"dataType\":\"Float64\",\"isArray\":true}")
.Warnings().ShouldBeEmpty();
var m = MTConnectTagConfigModel.FromJson("{\"fullName\":\"x\",\"dataType\":\"Int32\",\"isArray\":true}");
m.Warnings().ShouldHaveSingleItem().ShouldContain("Float64");
m.Validate().ShouldBeNull(); // advisory only
}
}
@@ -346,7 +346,7 @@ public sealed class RawTagConfigGateTests
public void A_browse_committed_config_passes_the_gate(string driverType)
{
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
driverType, "SomeAddress", BrowseFieldsFor(driverType));
driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType));
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
$"browse-commit for {driverType} must not author a config the import refuses");
@@ -421,6 +421,25 @@ public sealed class RawTreeServiceWaveBTests
result.Error!.ShouldContain("already exists");
}
[Fact]
public async Task CreateDevice_refuses_a_second_device_under_an_MTConnect_driver_but_not_under_others()
{
var (service, dbName) = Seeded();
var mt = await service.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "mt-1", "MTConnect", "{}");
mt.Ok.ShouldBeTrue();
// CreateDriver auto-creates Device1, so the very next device is the ambiguous second one (#628).
var refused = await service.CreateDeviceAsync(mt.CreatedId!, "Device2", "{}");
refused.Ok.ShouldBeFalse();
refused.Error!.ShouldContain("one MTConnect driver per agent device");
// Positive control: the same call against a non-MTConnect driver still succeeds.
(await service.CreateDeviceAsync(RawTreeTestDb.FolderedDriverId, "Device9", "{}")).Ok.ShouldBeTrue();
using var db = RawTreeTestDb.CreateNamed(dbName);
db.Devices.Count(d => d.DriverInstanceId == mt.CreatedId).ShouldBe(1);
}
// ------------------------------------------------------------- Merged probe config (Test connect)
[Fact]
@@ -112,6 +112,14 @@ public sealed class S7TagConfigModelTests
[InlineData("Float64")]
[InlineData("String")]
[InlineData("DateTime")]
[InlineData("WString")]
[InlineData("Char")]
[InlineData("WChar")]
[InlineData("Dtl")]
[InlineData("Date")]
[InlineData("Tod")]
[InlineData("Time")]
[InlineData("S5Time")]
public void Validate_rejects_wide_type_arrays(string dataType)
{
var m = S7TagConfigModel.FromJson(