Merge branch 'master' into b626-opcuaclient
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
+13
-4
@@ -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.
|
||||
|
||||
+27
-2
@@ -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><Entry key=…></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 << 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)
|
||||
{
|
||||
|
||||
+46
@@ -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)
|
||||
|
||||
+42
@@ -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>
|
||||
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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];
|
||||
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
+320
@@ -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();
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-19
@@ -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>
|
||||
|
||||
+30
@@ -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>
|
||||
|
||||
+8
-9
@@ -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()
|
||||
|
||||
+48
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user