Compare commits
112 Commits
task-galax
...
auto/drive
| Author | SHA1 | Date | |
|---|---|---|---|
| eed5857aa9 | |||
|
|
7f9d6a778e | ||
| 1922b93bd5 | |||
|
|
eb5286148e | ||
| 69069aa3be | |||
|
|
c689ac58b1 | ||
| 05528bf71c | |||
|
|
01f4ee6b53 | ||
| 8a8dc1ee5a | |||
|
|
0c6a0d6e50 | ||
| 73ff10b595 | |||
|
|
f6c26db609 | ||
| 7cbddd4b4a | |||
|
|
4098d72bbb | ||
| 569001364f | |||
|
|
b67eb6c8d0 | ||
| 4a071b6d5a | |||
|
|
931049b5a7 | ||
| fa2fbb404d | |||
|
|
17faf76ea7 | ||
| 5432c49364 | |||
|
|
d7633fe36f | ||
| 69d9a6fbb5 | |||
|
|
07abee5f6d | ||
| 0f3abed4c7 | |||
|
|
cc21281cbb | ||
| 5e164dc965 | |||
|
|
02d1c85190 | ||
| 1d3e9a3237 | |||
|
|
0f509fbd3a | ||
| e879b3ae90 | |||
|
|
4d3ee47235 | ||
| 9ebe5bd523 | |||
|
|
63099115bf | ||
| 7042b11f34 | |||
|
|
2f3eeecd17 | ||
| 3b82f4f5fb | |||
|
|
451b37a632 | ||
| 6743d51db8 | |||
|
|
0044603902 | ||
| 2fc71d288e | |||
|
|
286ab3ba41 | ||
| 5ca2ad83cd | |||
|
|
e3c0750f7d | ||
| 177d75784b | |||
|
|
6e244e0c01 | ||
| 27878d0faf | |||
|
|
08d8a104bb | ||
| 7ee0cbc3f4 | |||
|
|
e5299cda5a | ||
| e5b192fcb3 | |||
|
|
cfcaf5c1d3 | ||
| 2731318c81 | |||
|
|
86407e6ca2 | ||
| 2266dd9ad5 | |||
|
|
0df14ab94a | ||
| 448a97d67f | |||
|
|
b699052324 | ||
| e6a55add20 | |||
|
|
fcf89618cd | ||
| f83c467647 | |||
|
|
80b2d7f8c3 | ||
| 8286255ae5 | |||
|
|
615ab25680 | ||
| 545cc74ec8 | |||
|
|
e5122c546b | ||
| 6737edbad2 | |||
|
|
ce98c2ada3 | ||
| 676eebd5e4 | |||
|
|
2b66cec582 | ||
| b751c1c096 | |||
|
|
316f820eff | ||
| 38eb909f69 | |||
|
|
d1699af609 | ||
| c6c694b69e | |||
|
|
4a3860ae92 | ||
| d57e24a7fa | |||
|
|
bb1ab47b68 | ||
| a04ba2af7a | |||
|
|
494fdf2358 | ||
| 9f1e033e83 | |||
|
|
fae00749ca | ||
| bf200e813e | |||
|
|
7209364c35 | ||
| 8314c273e7 | |||
|
|
1abf743a9f | ||
| 63a79791cd | |||
|
|
cc757855e6 | ||
| 84913638b1 | |||
|
|
9ec92a9082 | ||
| 49fc23adc6 | |||
|
|
3c2c4f29ea | ||
| ae7cc15178 | |||
|
|
3d9697b918 | ||
| 329e222aa2 | |||
|
|
551494d223 | ||
| 5b4925e61a | |||
|
|
4ff4cc5899 | ||
| b95eaacc05 | |||
|
|
c89f5bb3b9 | ||
| 07235d3b66 | |||
|
|
f2bc36349e | ||
| ccf2e3a9c0 | |||
|
|
8f7265186d | ||
| 651d6c005c | |||
|
|
36b2929780 | ||
| 345ac97c43 | |||
|
|
767ac4aec5 | ||
| 29edd835a3 | |||
|
|
d78a471e90 | ||
| 1d9e40236b | |||
|
|
2e6228a243 |
@@ -20,6 +20,7 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help
|
|||||||
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
||||||
| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix |
|
| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix |
|
||||||
| `--timeout-ms` | `5000` | Per-operation timeout |
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--addressing-mode` | `Auto` | `Auto` / `Symbolic` / `Logical` — see [AbCip-Performance §Addressing mode](drivers/AbCip-Performance.md#addressing-mode). `Logical` against Micro800 silently falls back to Symbolic with a warning. |
|
||||||
| `--verbose` | off | Serilog debug output |
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
Family ↔ CIP-path cheat sheet:
|
Family ↔ CIP-path cheat sheet:
|
||||||
@@ -81,3 +82,25 @@ otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i
|
|||||||
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
|
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
|
||||||
read the status code — safety tags surface `BadNotWritable` / CIP errors,
|
read the status code — safety tags surface `BadNotWritable` / CIP errors,
|
||||||
non-safety tags surface `Good`.
|
non-safety tags surface `Good`.
|
||||||
|
|
||||||
|
## Connection Size
|
||||||
|
|
||||||
|
PR abcip-3.1 introduced a per-device `ConnectionSize` override on the driver
|
||||||
|
side (`AbCipDeviceOptions.ConnectionSize`, range `500..4002`). The CLI does
|
||||||
|
not expose a flag for it — every CLI invocation uses the family-default
|
||||||
|
Connection Size (4002 / 504 / 488 depending on `--family`). When a Forward
|
||||||
|
Open is rejected with a CIP error like `0x01/0x113` ("connection request
|
||||||
|
size invalid"), the symptom is almost always a **mismatch between the chosen
|
||||||
|
family default and the controller firmware**:
|
||||||
|
|
||||||
|
- **v19-and-earlier ControlLogix** caps at 504 — pick `--family CompactLogix`
|
||||||
|
on the CLI to fall back to that narrower default.
|
||||||
|
- **5069-L1/L2/L3 CompactLogix** narrow-buffer parts also cap at 504, which
|
||||||
|
is the family default already.
|
||||||
|
- **FW20+ ControlLogix** accepts the full 4002.
|
||||||
|
|
||||||
|
For the warning *"AbCip device 'X' family 'Y' uses a narrow-buffer profile
|
||||||
|
(default ConnectionSize Z); the configured ConnectionSize N exceeds the
|
||||||
|
511-byte legacy-firmware cap..."* see
|
||||||
|
[`docs/drivers/AbCip-Performance.md`](drivers/AbCip-Performance.md) — that
|
||||||
|
warning is fired by the driver host, not the CLI.
|
||||||
|
|||||||
@@ -95,6 +95,62 @@ PLC-managed — use with caution.
|
|||||||
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Deadband
|
||||||
|
|
||||||
|
PR 8 — per-tag absolute / percent change filter on top of the polled subscription. The driver
|
||||||
|
caches the last *published* value per tag and suppresses `OnDataChange` notifications until the
|
||||||
|
new sample crosses the configured threshold.
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `--deadband-absolute <value>` | Suppress until `|new - prev| >= value`. |
|
||||||
|
| `--deadband-percent <value>` | Suppress until `|new - prev| >= |prev * value / 100|`. `prev == 0` always publishes (avoids div-by-zero). |
|
||||||
|
|
||||||
|
Booleans bypass the filter entirely (every transition publishes); strings + status changes
|
||||||
|
always publish; first-seen always publishes; both flags set → either passing triggers a
|
||||||
|
publish (Kepware-style logical OR).
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Float — drop sub-0.5 jitter from the noisy load-cell address.
|
||||||
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a F8:0 -t Float -i 500 `
|
||||||
|
--deadband-absolute 0.5
|
||||||
|
|
||||||
|
# Integer — only fire on >= 5% deviation from the last reported value.
|
||||||
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 `
|
||||||
|
--deadband-percent 5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Array reads
|
||||||
|
|
||||||
|
PR 7 — one PCCC frame can carry up to ~120 words. Address an array tag with either the
|
||||||
|
Rockwell-native `,N` suffix or the libplctag-native `[N]` suffix on the word number; both
|
||||||
|
forms canonicalise to `[N]` when the driver hands the tag to libplctag, and the parser
|
||||||
|
caps `N` at 120.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Rockwell `,N` form — "10 consecutive words starting at N7:0"
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0,10" -t Int
|
||||||
|
|
||||||
|
# libplctag `[N]` form — same wire result
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0[10]" -t Int
|
||||||
|
|
||||||
|
# Float / Long arrays — same suffix syntax, narrower frame ceiling on Float (~60 elements)
|
||||||
|
# and Long (~60 elements) because each element is 4 bytes vs Int's 2.
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "F8:0,4" -t Float
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "L19:0,4" -t Long
|
||||||
|
|
||||||
|
# --array-length override — pin the element count from config rather than the address
|
||||||
|
# suffix. Wins over the parsed `,N` / `[N]` value when both are set; useful for keeping the
|
||||||
|
# address string compact while bumping the element count from a tags config file.
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0" --array-length 10 -t Int
|
||||||
|
```
|
||||||
|
|
||||||
|
Array tags reject sub-element references (`T4:0,5.ACC`) and bit suffixes (`N7:0,10/3`) at
|
||||||
|
parse time — both combinations are semantically meaningless against a contiguous block.
|
||||||
|
|
||||||
|
For `B`-files the Rockwell convention is "one BOOL per word, not per bit": `B3:0,10`
|
||||||
|
returns `bool[10]` (one per word's non-zero state), not `bool[160]`.
|
||||||
|
|
||||||
## Known caveat — ab_server upstream gap
|
## Known caveat — ab_server upstream gap
|
||||||
|
|
||||||
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC
|
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "Recipe[3]" -t Real
|
|||||||
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.sMessage -t WString
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.sMessage -t WString
|
||||||
```
|
```
|
||||||
|
|
||||||
|
ADS variable handles for `read` / `write` symbols are cached transparently
|
||||||
|
inside the CLI's underlying `AdsTwinCATClient`. The first read of a symbol
|
||||||
|
resolves a handle; repeats reuse the cached handle for smaller AMS payloads
|
||||||
|
and skipped name resolution. The cache wipes on reconnect, on
|
||||||
|
`DeviceSymbolVersionInvalid` (with a one-shot retry), and on CLI exit. See
|
||||||
|
`docs/drivers/TwinCAT-Test-Fixture.md §Handle caching` for the full story
|
||||||
|
including the staleness caveat after an online change.
|
||||||
|
|
||||||
### `write`
|
### `write`
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -99,3 +107,7 @@ otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
|
|||||||
|
|
||||||
The subscribe banner announces which mechanism is in play — "ADS notification"
|
The subscribe banner announces which mechanism is in play — "ADS notification"
|
||||||
or "polling" — so it's obvious in screen-recorded bug reports.
|
or "polling" — so it's obvious in screen-recorded bug reports.
|
||||||
|
|
||||||
|
`--poll-only` polls go through the same cached-handle path as `read`, so
|
||||||
|
repeated polls of the same symbol carry only a 4-byte handle on the wire
|
||||||
|
rather than the full symbolic path.
|
||||||
|
|||||||
405
docs/drivers/AbCip-Performance.md
Normal file
405
docs/drivers/AbCip-Performance.md
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# AB CIP — Performance knobs
|
||||||
|
|
||||||
|
Phase 3 of the AB CIP driver plan introduces a small set of operator-tunable
|
||||||
|
performance knobs that change how the driver talks to the controller without
|
||||||
|
altering the address space or per-tag semantics. They consolidate decisions
|
||||||
|
that Kepware exposes as a slider / advanced page so deployments running into
|
||||||
|
high-latency PLCs, narrow-CPU CompactLogix parts, or legacy ControlLogix
|
||||||
|
firmware have an explicit lever to pull.
|
||||||
|
|
||||||
|
This document is the home for those knobs as PRs land. PR abcip-3.1 ships the
|
||||||
|
first knob: per-device **CIP Connection Size**.
|
||||||
|
|
||||||
|
## Connection Size
|
||||||
|
|
||||||
|
### What it is
|
||||||
|
|
||||||
|
CIP Connection Size — the byte ceiling on a single Forward Open response
|
||||||
|
fragment, set during the EtherNet/IP Forward Open handshake. Larger
|
||||||
|
connection sizes pack more tags into a single CIP RTT (higher request-packing
|
||||||
|
density, fewer round-trips for the same scan list); smaller connection sizes
|
||||||
|
stay compatible with legacy or narrow-buffer firmware that rejects oversized
|
||||||
|
Forward Open requests.
|
||||||
|
|
||||||
|
### Family defaults
|
||||||
|
|
||||||
|
The driver picks a Connection Size from the per-family profile when the
|
||||||
|
device-level override is unset:
|
||||||
|
|
||||||
|
| Family | Default | Rationale |
|
||||||
|
|---|---:|---|
|
||||||
|
| `ControlLogix` | `4002` | Large Forward Open — FW20+ |
|
||||||
|
| `GuardLogix` | `4002` | Same wire protocol as ControlLogix |
|
||||||
|
| `CompactLogix` | `504` | 5069-L1/L2/L3 narrow-buffer parts (5370 family) |
|
||||||
|
| `Micro800` | `488` | Hard cap on Micro800 firmware |
|
||||||
|
|
||||||
|
These map straight to libplctag's `connection_size` attribute and match the
|
||||||
|
defaults Kepware uses out of the box for the same families.
|
||||||
|
|
||||||
|
### Override knob
|
||||||
|
|
||||||
|
`AbCipDeviceOptions.ConnectionSize` (`int?`, default `null`) overrides the
|
||||||
|
family default for one device. Bind it through driver config JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"ConnectionSize": 504
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The override threads through every libplctag handle the driver creates for
|
||||||
|
that device — read tags, write tags, probe tags, UDT-template reads, the
|
||||||
|
`@tags` walker, and BOOL-in-DINT parent runtimes. There is no per-tag
|
||||||
|
override; one Connection Size applies to the whole controller (matches CIP
|
||||||
|
session semantics).
|
||||||
|
|
||||||
|
### Valid range
|
||||||
|
|
||||||
|
`[500..4002]` bytes. This matches the slider Kepware exposes for the same
|
||||||
|
family. Values outside the range fail driver `InitializeAsync` with an
|
||||||
|
`InvalidOperationException` — there's no silent clamp; misconfigured devices
|
||||||
|
fail loudly so operators see the problem at deploy time.
|
||||||
|
|
||||||
|
| Value | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `null` | Use family default (4002 / 504 / 488) |
|
||||||
|
| `499` or below | Driver init fault — out-of-range |
|
||||||
|
| `500..4002` | Threaded through to libplctag |
|
||||||
|
| `4003` or above | Driver init fault — out-of-range |
|
||||||
|
|
||||||
|
### Legacy-firmware caveat
|
||||||
|
|
||||||
|
ControlLogix firmware **v19 and earlier** caps the CIP buffer at **504
|
||||||
|
bytes** — Connection Sizes above that cause the controller to reject the
|
||||||
|
Forward Open with CIP error 0x01/0x113. The 5069-L1/L2/L3 CompactLogix narrow
|
||||||
|
parts are subject to the same cap.
|
||||||
|
|
||||||
|
The driver emits a warning via `AbCipDriverOptions.OnWarning` when the
|
||||||
|
configured Connection Size **exceeds 511** *and* the device's family profile
|
||||||
|
default is also at-or-below the legacy cap (i.e. CompactLogix with default
|
||||||
|
504, or Micro800 with default 488). Production hosting should wire
|
||||||
|
`OnWarning` to the application logger; the unit tests (`AbCipConnectionSizeTests`)
|
||||||
|
collect into a list to assert which warnings fired.
|
||||||
|
|
||||||
|
The warning fires once per device at `InitializeAsync`. It does not block
|
||||||
|
initialisation — operators may need the override anyway when running newer
|
||||||
|
CompactLogix firmware that does support the larger Forward Open. The
|
||||||
|
controller will reject the connection at runtime if it can't honour the size,
|
||||||
|
and that surfaces through the standard `IHostConnectivityProbe` channel.
|
||||||
|
|
||||||
|
### Performance trade-off
|
||||||
|
|
||||||
|
| Larger Connection Size | Smaller Connection Size |
|
||||||
|
|---|---|
|
||||||
|
| More tags per CIP RTT — higher throughput | Compatible with legacy / narrow firmware |
|
||||||
|
| Bigger buffers held by libplctag native (RSS impact) | Lower memory footprint |
|
||||||
|
| Forward Open rejected on FW19- ControlLogix | Always works (assuming ≥500) |
|
||||||
|
| Required for high-density scan lists | Forces more round-trips — higher latency |
|
||||||
|
|
||||||
|
For most FW20+ ControlLogix shops, the default `4002` is correct and the
|
||||||
|
override is unnecessary. The override is mainly useful when:
|
||||||
|
|
||||||
|
1. **Migrating off Kepware** with a controller-specific slider value already
|
||||||
|
tuned in production — set Connection Size to match.
|
||||||
|
2. **Mixed-firmware fleets** where some controllers are still on FW19 — set
|
||||||
|
the legacy controllers explicitly to `504`.
|
||||||
|
3. **CompactLogix L1/L2/L3** running newer firmware that supports a larger
|
||||||
|
Forward Open than the family-default 504 — bump the override up.
|
||||||
|
4. **Micro800** never goes above `488`; the override is for documentation /
|
||||||
|
discoverability rather than capability change.
|
||||||
|
|
||||||
|
### libplctag wrapper limitation
|
||||||
|
|
||||||
|
The libplctag .NET wrapper (1.5.x) does not expose `connection_size` as a
|
||||||
|
public `Tag` property. The driver propagates the value via reflection on the
|
||||||
|
wrapper's internal `NativeTagWrapper.SetIntAttribute("connection_size", N)`
|
||||||
|
after `InitializeAsync` — equivalent to libplctag's
|
||||||
|
`plc_tag_set_int_attribute`. Because libplctag native parses
|
||||||
|
`connection_size` only at create time, this is **best-effort** until either:
|
||||||
|
|
||||||
|
- the libplctag .NET wrapper exposes `ConnectionSize` directly (planned in
|
||||||
|
the upstream backlog), in which case the reflection no-ops cleanly, or
|
||||||
|
- libplctag native gains post-create hot-update for `connection_size`, in
|
||||||
|
which case the call lands as intended.
|
||||||
|
|
||||||
|
In the meantime the value is correctly stored on `DeviceState.ConnectionSize`
|
||||||
|
+ surfaces in every `AbCipTagCreateParams` the driver builds, so the override
|
||||||
|
is observable end-to-end through the public driver surface and unit tests
|
||||||
|
even if the underlying wrapper isn't yet honouring it on the wire.
|
||||||
|
|
||||||
|
Operators who need *guaranteed* Connection Size enforcement against FW19
|
||||||
|
controllers today can pin `libplctag` to a wrapper version that exposes
|
||||||
|
`ConnectionSize` once one is available, or run a libplctag native build
|
||||||
|
patched for runtime updates. Both paths are tracked in the AB CIP plan.
|
||||||
|
|
||||||
|
### See also
|
||||||
|
|
||||||
|
- [`docs/Driver.AbCip.Cli.md`](../Driver.AbCip.Cli.md) — AB CIP CLI uses the
|
||||||
|
family default ConnectionSize on each invocation; per-device overrides only
|
||||||
|
apply through the driver's device-config JSON, not the CLI's command-line.
|
||||||
|
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §5 —
|
||||||
|
ab_server simulator does not enforce the narrow CompactLogix cap, so
|
||||||
|
Connection Size correctness is verified by unit tests + Emulate-rig live
|
||||||
|
smokes only.
|
||||||
|
- [`PlcFamilies/AbCipPlcFamilyProfile.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
|
||||||
|
per-family default values.
|
||||||
|
- [`AbCipConnectionSize`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs) —
|
||||||
|
range bounds + legacy-firmware threshold constants.
|
||||||
|
|
||||||
|
## Addressing mode
|
||||||
|
|
||||||
|
### What it is
|
||||||
|
|
||||||
|
CIP exposes two equivalent ways to address a Logix tag on the wire:
|
||||||
|
|
||||||
|
1. **Symbolic** — the request carries the tag's ASCII name and the controller
|
||||||
|
parses + resolves the path on every read. This is the libplctag default
|
||||||
|
and what every previous driver build has used.
|
||||||
|
2. **Logical** — the request carries a CIP Symbol Object instance ID (a small
|
||||||
|
integer assigned by the controller when the project was downloaded). The
|
||||||
|
controller skips ASCII parsing entirely; the lookup is a single
|
||||||
|
instance-table dereference.
|
||||||
|
|
||||||
|
Logical addressing is faster on the controller side and produces smaller
|
||||||
|
request frames. The trade-off is that the driver has to learn the
|
||||||
|
name → instance-id mapping once, by reading the `@tags` pseudo-tag at
|
||||||
|
startup, and the resolution step has to repeat after a controller program
|
||||||
|
download (instance IDs are re-assigned).
|
||||||
|
|
||||||
|
### Enum values
|
||||||
|
|
||||||
|
`AbCipDeviceOptions.AddressingMode` (`AddressingMode` enum, default
|
||||||
|
`Auto`) takes one of three values:
|
||||||
|
|
||||||
|
| Value | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `Auto` | Driver picks. **Currently resolves to `Symbolic`** — a future PR will plumb a real auto-detection heuristic (firmware version + symbol-table size). |
|
||||||
|
| `Symbolic` | Force ASCII symbolic addressing on the wire. The historical default. |
|
||||||
|
| `Logical` | Use CIP logical-segment / instance-ID addressing. Triggers a one-time `@tags` walk at the first read; subsequent reads consult the cached map. |
|
||||||
|
|
||||||
|
`Auto` is documented as "Symbolic-for-now" so deployments setting `Auto`
|
||||||
|
explicitly today will silently flip to a real heuristic when one ships,
|
||||||
|
matching the spirit of the toggle. Operators who want to pin the wire
|
||||||
|
behaviour should set `Symbolic` or `Logical` directly.
|
||||||
|
|
||||||
|
### Family compatibility
|
||||||
|
|
||||||
|
Logical addressing depends on the controller implementing CIP Symbol Object
|
||||||
|
class 0x6B with stable instance IDs. Older AB families don't:
|
||||||
|
|
||||||
|
| Family | Logical addressing supported? | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `ControlLogix` | yes | Native class 0x6B support, FW10+ |
|
||||||
|
| `CompactLogix` | yes | Same wire protocol as ControlLogix |
|
||||||
|
| `GuardLogix` | yes | Same wire protocol; safety partition is tag-level, not addressing-level |
|
||||||
|
| `Micro800` | **no** | Firmware does not implement class 0x6B; instance-ID reads trip CIP "Path Segment Error" 0x04 |
|
||||||
|
| `SLC500` / `PLC5` | **no** | Pre-CIP families; PCCC bridging only — no Symbol Object at all |
|
||||||
|
|
||||||
|
When `AddressingMode = Logical` is set on an unsupported family, the driver
|
||||||
|
**falls back to Symbolic with a warning** (via `OnWarning`) instead of
|
||||||
|
faulting. This keeps mixed-firmware deployments working — operators can ship
|
||||||
|
a uniform "Logical" config across the fleet and let the driver downgrade
|
||||||
|
the families that can't honour it.
|
||||||
|
|
||||||
|
The driver-level decision is exposed via
|
||||||
|
`PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing` and resolved at
|
||||||
|
`AbCipDriver.InitializeAsync` time; the resolved mode is stored on
|
||||||
|
`DeviceState.AddressingMode` and threaded through every
|
||||||
|
`AbCipTagCreateParams` from then on.
|
||||||
|
|
||||||
|
### One-time symbol-table walk
|
||||||
|
|
||||||
|
The first read on a Logical-mode device triggers a one-time `@tags` walk via
|
||||||
|
`LibplctagTagEnumerator` (the same component used for opt-in controller
|
||||||
|
browse). The driver caches the resulting name → instance-id map on
|
||||||
|
`DeviceState.LogicalInstanceMap`; subsequent reads consult the cache without
|
||||||
|
issuing another walk. The walk is gated by a per-device `SemaphoreSlim` so
|
||||||
|
parallel first-reads serialise on a single dispatch.
|
||||||
|
|
||||||
|
The walk happens in `AbCipDriver.EnsureLogicalMappingsAsync` and runs only
|
||||||
|
for devices that have actually resolved to `Logical`. Symbolic-mode devices
|
||||||
|
skip the walk entirely. Walk failures are non-fatal: the
|
||||||
|
`LogicalWalkComplete` flag still flips to `true` so the driver does not
|
||||||
|
re-attempt indefinitely, and per-tag handles fall back to Symbolic addressing
|
||||||
|
on the wire (libplctag's default).
|
||||||
|
|
||||||
|
A controller program download invalidates the instance IDs. There is no
|
||||||
|
auto-invalidation today — operators trigger a fresh walk by either
|
||||||
|
restarting the driver or calling `RebrowseAsync` (the same surface that
|
||||||
|
clears the UDT template cache) with logic-mode plumbing extended in a
|
||||||
|
future PR. For now, restart-on-download is the recommended workflow.
|
||||||
|
|
||||||
|
### libplctag wrapper limitation
|
||||||
|
|
||||||
|
The libplctag .NET wrapper (1.5.x) does **not** expose a public knob for
|
||||||
|
instance-ID addressing. The driver translates Logical-mode params into
|
||||||
|
libplctag attributes via reflection on
|
||||||
|
`NativeTagWrapper.SetAttributeString("use_connected_msg", "1")` +
|
||||||
|
`SetAttributeString("cip_addr", "0x6B,N")` — same best-effort fallback
|
||||||
|
pattern as the Connection Size knob.
|
||||||
|
|
||||||
|
This means **Logical mode is observable end-to-end through the public
|
||||||
|
driver surface and unit tests today**, but the actual wire behaviour
|
||||||
|
remains Symbolic until either:
|
||||||
|
|
||||||
|
- the upstream libplctag .NET wrapper exposes the
|
||||||
|
`UseConnectedMessaging` + `CipAddr` properties on `Tag` directly
|
||||||
|
(planned in the upstream backlog), in which case the reflection no-ops
|
||||||
|
cleanly, or
|
||||||
|
- libplctag native gains post-create hot-update for `cip_addr`, in which
|
||||||
|
case the call lands as intended.
|
||||||
|
|
||||||
|
The driver-level bookkeeping (resolved mode, instance-id map, family
|
||||||
|
compatibility, fall-back warning) is fully wired so the upgrade path is
|
||||||
|
purely a wrapper-version bump.
|
||||||
|
|
||||||
|
### Performance trade-off
|
||||||
|
|
||||||
|
| Symbolic addressing | Logical addressing |
|
||||||
|
|---|---|
|
||||||
|
| Works everywhere | Requires Symbol Object class 0x6B |
|
||||||
|
| ASCII parse on every read (controller-side cost) | One-time walk; instance-id lookup thereafter |
|
||||||
|
| No first-read latency | First read on a device pays the `@tags` walk |
|
||||||
|
| Smaller code surface | Stale on program download — restart driver to re-walk |
|
||||||
|
| Best for small / sparse tag sets | Best for >500-tag scans with stable controller |
|
||||||
|
|
||||||
|
For scan lists in the **single-digit-tag** range, the per-poll ASCII parse
|
||||||
|
cost is invisible. For **medium** scan lists (~100 tags) the gain is real
|
||||||
|
but small — typically 5–10% per CIP RTT depending on tag-name length. The
|
||||||
|
break-even point is where the ASCII-parse overhead starts dominating,
|
||||||
|
roughly **>500 tags** in a tight scan loop, which is also where libplctag's
|
||||||
|
own request-packing benefits compound. Large MES / batch projects with
|
||||||
|
many UDT instances are the canonical case.
|
||||||
|
|
||||||
|
### Driver config JSON
|
||||||
|
|
||||||
|
Bind the toggle through the driver-config JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"AddressingMode": "Logical"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"Auto"`, `"Symbolic"`, and `"Logical"` parse case-insensitively. Omitting
|
||||||
|
the field defaults to `"Auto"`.
|
||||||
|
|
||||||
|
### See also
|
||||||
|
|
||||||
|
- [`AbCipDriverOptions.AddressingMode`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
|
||||||
|
enum definition + per-value docstrings.
|
||||||
|
- [`AbCipPlcFamilyProfile.SupportsLogicalAddressing`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
|
||||||
|
family compatibility table source-of-truth.
|
||||||
|
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §
|
||||||
|
"What it actually covers" — Logical-mode fixture coverage status.
|
||||||
|
- [`AbCipAddressingModeBenchTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs) —
|
||||||
|
scaffold for the wall-clock comparison; gated on `[AbServerFact]`.
|
||||||
|
|
||||||
|
## Read strategy (PR abcip-3.3)
|
||||||
|
|
||||||
|
A per-device toggle that controls how multi-member UDT batches are read.
|
||||||
|
The default `Auto` value matches every previous build's behaviour for dense
|
||||||
|
reads but switches to per-member bundling when only a handful of members of
|
||||||
|
a large UDT are subscribed — the canonical "5 of 50" sparse-subscription
|
||||||
|
case where reading the whole UDT buffer just to extract a few fields wastes
|
||||||
|
wire bandwidth.
|
||||||
|
|
||||||
|
### Three modes
|
||||||
|
|
||||||
|
| Mode | When to use |
|
||||||
|
|---|---|
|
||||||
|
| `WholeUdt` | Most members of every subscribed UDT are read together. One libplctag read per parent UDT, members decoded in-memory at their byte offsets. The task #194 default. |
|
||||||
|
| `MultiPacket` | A few members of a large UDT are subscribed at a time. One read per subscribed member, bundled per parent into one CIP Multi-Service Packet. |
|
||||||
|
| `Auto` (default) | Planner picks per-batch from the subscribed-member fraction (see *Sparsity threshold*). |
|
||||||
|
|
||||||
|
### Sparsity threshold
|
||||||
|
|
||||||
|
Auto mode divides `subscribedMembers / totalMembers` for each parent UDT and
|
||||||
|
picks `MultiPacket` when the fraction is **strictly less than** the
|
||||||
|
threshold, else `WholeUdt`. Default threshold `0.25` — a 1/4 subscription is
|
||||||
|
the rough break-even where the wire-cost of one whole-UDT read still beats
|
||||||
|
N member reads on a ControlLogix 4002-byte connection-size buffer; above
|
||||||
|
1/4, the per-member overhead dominates.
|
||||||
|
|
||||||
|
Tune via `AbCipDeviceOptions.MultiPacketSparsityThreshold` (clamped to
|
||||||
|
`[0..1]`). Threshold `0.0` = "never MultiPacket"; `1.0` = "always MultiPacket
|
||||||
|
when any member is subscribed."
|
||||||
|
|
||||||
|
### Family compatibility
|
||||||
|
|
||||||
|
`MultiPacket` requires CIP service `0x0A` (Multi-Service Packet) on the
|
||||||
|
controller. Source of truth is
|
||||||
|
[`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs):
|
||||||
|
|
||||||
|
| Family | `SupportsRequestPacking` |
|
||||||
|
|---|---|
|
||||||
|
| ControlLogix | yes |
|
||||||
|
| CompactLogix | yes |
|
||||||
|
| GuardLogix | yes (wire identical to ControlLogix) |
|
||||||
|
| Micro800 | **no** |
|
||||||
|
| SLC500 / PLC5 (when those profiles ship) | **no** |
|
||||||
|
|
||||||
|
User-forced `MultiPacket` against a non-packing family logs a warning at
|
||||||
|
device init and falls back to `WholeUdt`. `Auto` against a non-packing
|
||||||
|
family stays `Auto` at the device level — the per-batch heuristic caps the
|
||||||
|
strategy to `WholeUdt` so the wire never sees a Multi-Service-Packet against
|
||||||
|
a controller that can't decode it.
|
||||||
|
|
||||||
|
### libplctag wrapper limitation
|
||||||
|
|
||||||
|
The libplctag .NET wrapper (1.5.x) does not expose the `0x0A` service as a
|
||||||
|
public knob — same wrapper-version constraint that gates PR abcip-3.1's
|
||||||
|
`connection_size` and PR abcip-3.2's instance-ID addressing. Today's
|
||||||
|
MultiPacket runtime therefore issues N libplctag reads sequentially when
|
||||||
|
the planner picks the strategy; the wire-level bundling lands cleanly when
|
||||||
|
an upstream wrapper release exposes the primitive.
|
||||||
|
|
||||||
|
The driver-level bookkeeping (resolved strategy, per-batch heuristic,
|
||||||
|
family-compat fall-back, per-device dispatch counters) is fully wired so
|
||||||
|
the upgrade path is a wrapper-version bump only — the planner already
|
||||||
|
produces the right plan, and `AbCipMultiPacketReadPlanner.Build` is
|
||||||
|
covered by unit tests that pin the plan shape rather than wire bytes.
|
||||||
|
|
||||||
|
### Driver config JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"ReadStrategy": "Auto",
|
||||||
|
"MultiPacketSparsityThreshold": 0.25
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"Auto"`, `"WholeUdt"`, and `"MultiPacket"` parse case-insensitively.
|
||||||
|
Omitting the field defaults to `"Auto"`. Omitting
|
||||||
|
`MultiPacketSparsityThreshold` defaults to `0.25`.
|
||||||
|
|
||||||
|
### See also
|
||||||
|
|
||||||
|
- [`AbCipDriverOptions.ReadStrategy`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
|
||||||
|
enum definition + per-value docstrings.
|
||||||
|
- [`AbCipMultiPacketReadPlanner`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs) —
|
||||||
|
plan shape + Auto-mode heuristic.
|
||||||
|
- [`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
|
||||||
|
family compatibility table source-of-truth.
|
||||||
|
- [`AbCipReadStrategyTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadStrategyTests.cs) —
|
||||||
|
device-init resolution, heuristic edges, dispatch counters, DTO round-trip.
|
||||||
|
- [`AbCipEmulateMultiPacketReadTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs) —
|
||||||
|
golden-box-tier wire-level coverage scaffold; gated on `AB_SERVER_PROFILE=emulate`.
|
||||||
@@ -36,6 +36,12 @@ supplies a `FakeAbLegacyTag`.
|
|||||||
|
|
||||||
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
|
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
|
||||||
/ LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
|
/ LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
|
||||||
|
- `AbLegacyArrayTests` — PR 7 array contiguous-block addressing: parser
|
||||||
|
positives + rejects for `,N` / `[N]` suffixes, options-override
|
||||||
|
(`ArrayLength`), driver `IsArray` discovery, and array decoding for N / F /
|
||||||
|
L / B files (Rockwell convention: one BOOL per word for `B3:0,10`). Latency
|
||||||
|
benchmark against the Docker fixture is a perf-flagged integration case in
|
||||||
|
`AbLegacyArrayReadTests` — runs only when ab_server is reachable.
|
||||||
- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
|
- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
|
||||||
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
|
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
|
||||||
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
|
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
|
||||||
@@ -43,6 +49,12 @@ supplies a `FakeAbLegacyTag`.
|
|||||||
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
|
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
|
||||||
fake-returned statuses
|
fake-returned statuses
|
||||||
- `AbLegacyDriverTests` — `IDriver` lifecycle
|
- `AbLegacyDriverTests` — `IDriver` lifecycle
|
||||||
|
- `AbLegacyDeadbandTests` — PR 8 per-tag deadband / change filter:
|
||||||
|
absolute-only suppression sequence `[10.0, 10.5, 11.5, 11.6] -> [10.0, 11.5]`,
|
||||||
|
percent-only suppression with a zero-prev short-circuit, both-set logical-OR
|
||||||
|
semantics (Kepware), Boolean edge-only publish, string change-only publish,
|
||||||
|
status-change always-publish, first-seen always-publish, ReinitializeAsync
|
||||||
|
cache wipe, JSON DTO round-trip.
|
||||||
|
|
||||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
|
|||||||
- `--plc controllogix` and `--plc compactlogix` mode dispatch.
|
- `--plc controllogix` and `--plc compactlogix` mode dispatch.
|
||||||
- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
|
- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
|
||||||
clone without the simulator stays green.
|
clone without the simulator stays green.
|
||||||
|
- **Symbolic vs Logical addressing wall-clock** (PR abcip-3.2,
|
||||||
|
`AbCipAddressingModeBenchTests`) — both modes complete + emit timing.
|
||||||
|
**Emulate-tier only**: `ab_server` does not currently honour the CIP Symbol
|
||||||
|
Object class 0x6B `cip_addr` attribute that Logical mode sets, so on the
|
||||||
|
fixture the two modes measure the same wire path. The bench scaffold
|
||||||
|
asserts both complete + records timing for human inspection; the actual
|
||||||
|
Symbolic-vs-Logical perf comparison requires a real ControlLogix /
|
||||||
|
CompactLogix on the network. See
|
||||||
|
[`docs/drivers/AbCip-Performance.md`](AbCip-Performance.md) §"Addressing
|
||||||
|
mode" for the full caveat.
|
||||||
|
|
||||||
## What it does NOT cover
|
## What it does NOT cover
|
||||||
|
|
||||||
@@ -60,6 +70,19 @@ Unit coverage: `AbCipFetchUdtShapeTests`, `CipTemplateObjectDecoderTests`,
|
|||||||
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
|
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
|
||||||
+ offset-keyed `FakeAbCipTag` values.
|
+ offset-keyed `FakeAbCipTag` values.
|
||||||
|
|
||||||
|
PR abcip-3.3 layers a per-device **`ReadStrategy`** selector on top
|
||||||
|
(`WholeUdt` / `MultiPacket` / `Auto`, see
|
||||||
|
[`AbCip-Performance.md`](AbCip-Performance.md) §"Read strategy"). Strategy
|
||||||
|
switching is planner-side: the dispatcher picks between
|
||||||
|
`AbCipUdtReadPlanner` (whole-UDT) and `AbCipMultiPacketReadPlanner`
|
||||||
|
(per-member, bundled per parent) per batch. The selector + per-batch Auto
|
||||||
|
heuristic + family-compat fall-back + per-device dispatch counters are
|
||||||
|
**unit-tested only** in `AbCipReadStrategyTests` — `ab_server` cannot host
|
||||||
|
a 50-member UDT to exercise the sparse case the strategy is designed for,
|
||||||
|
and the libplctag .NET wrapper (1.5.x) does not expose explicit
|
||||||
|
Multi-Service-Packet bundling, so wire-level coverage stays Emulate-tier
|
||||||
|
in `AbCipEmulateMultiPacketReadTests` (gated on `AB_SERVER_PROFILE=emulate`).
|
||||||
|
|
||||||
### 2. ALMD / ALMA alarm projection (#177)
|
### 2. ALMD / ALMA alarm projection (#177)
|
||||||
|
|
||||||
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
|
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
|
||||||
@@ -96,6 +119,15 @@ value per PR 10, but `ab_server` accepts whatever the client asks for — the
|
|||||||
cap's correctness is trusted from its unit test, never stressed against a
|
cap's correctness is trusted from its unit test, never stressed against a
|
||||||
simulator that rejects oversized requests.
|
simulator that rejects oversized requests.
|
||||||
|
|
||||||
|
PR abcip-3.1 layers the **per-device `ConnectionSize` override** on top
|
||||||
|
(`AbCipDeviceOptions.ConnectionSize`, range `[500..4002]`, see
|
||||||
|
[`AbCip-Performance.md`](AbCip-Performance.md)). Same gap — `ab_server`
|
||||||
|
happily honours an oversized override against the CompactLogix profile, so
|
||||||
|
the legacy-firmware warning + Forward Open rejection that real 5069-L1/L2/L3
|
||||||
|
parts emit are unit-tested only. Live coverage stays Emulate / rig-only
|
||||||
|
(connect against a real CompactLogix L2 with `ConnectionSize=1500` to
|
||||||
|
confirm the Forward Open fails with CIP error 0x01/0x113).
|
||||||
|
|
||||||
### 6. BOOL-within-DINT read-modify-write (#181)
|
### 6. BOOL-within-DINT read-modify-write (#181)
|
||||||
|
|
||||||
The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`
|
The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`
|
||||||
|
|||||||
@@ -106,6 +106,42 @@ Tier-C pipeline end-to-end without any CNC.
|
|||||||
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
|
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
|
||||||
| "Do macro variables round-trip across power cycles?" | no | yes (required) |
|
| "Do macro variables round-trip across power cycles?" | no | yes (required) |
|
||||||
|
|
||||||
|
## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a
|
||||||
|
|
||||||
|
`FocasAlarmProjection` ships two modes:
|
||||||
|
|
||||||
|
- **`ActiveOnly`** (default) — surfaces only currently-active alarms.
|
||||||
|
No history poll. Same back-compat shape every prior FOCAS deployment used.
|
||||||
|
- **`ActivePlusHistory`** — additionally polls `cnc_rdalmhistry` on connect
|
||||||
|
+ on the configured cadence (`HistoryPollInterval`, default 5 min). Each
|
||||||
|
unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from
|
||||||
|
the CNC's reported timestamp, not Now.
|
||||||
|
|
||||||
|
Unit-test coverage in `FocasAlarmProjectionTests`:
|
||||||
|
|
||||||
|
- mode `ActiveOnly` — no `ReadAlarmHistoryAsync` call ever issued
|
||||||
|
- mode `ActivePlusHistory` — first poll fires on subscribe (== "on connect")
|
||||||
|
- dedup — same `(OccurrenceTime, AlarmNumber, AlarmType)` triple across two
|
||||||
|
polls only emits once
|
||||||
|
- distinct entries with different timestamps each emit separately
|
||||||
|
- same alarm number / different type still emits both (type is part of the
|
||||||
|
dedup key)
|
||||||
|
- `OccurrenceTime` is the wire timestamp (round-trips a year-old stamp
|
||||||
|
without bleeding into Now)
|
||||||
|
- `HistoryDepth` clamp — user-supplied 500 collapses to 250 on the wire;
|
||||||
|
zero / negative falls back to the 100 default
|
||||||
|
- `FocasAlarmHistoryDecoder` — round-trips through `Encode` / `Decode` and
|
||||||
|
pins the simulator command id at `0x0F1A`
|
||||||
|
|
||||||
|
Future integration coverage (not yet shipped — no FOCAS integration test
|
||||||
|
project exists):
|
||||||
|
|
||||||
|
- a focas-mock with a per-profile ring buffer and `mock_patch_alarmhistory`
|
||||||
|
admin endpoint will let `cnc_rdalmhistry` round-trip end-to-end through
|
||||||
|
the wire protocol
|
||||||
|
- `FocasSimFixture.SeedAlarmHistoryAsync` will let series tests prime canned
|
||||||
|
history without per-test JSON
|
||||||
|
|
||||||
## Follow-up candidates
|
## Follow-up candidates
|
||||||
|
|
||||||
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
|
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
|
||||||
|
|||||||
55
docs/drivers/FOCAS.md
Normal file
55
docs/drivers/FOCAS.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# FOCAS driver
|
||||||
|
|
||||||
|
Fanuc CNC driver for the FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / 35i /
|
||||||
|
Power Mate i families. Talks to the controller via the licensed
|
||||||
|
`Fwlib32.dll` (Tier C, process-isolated per
|
||||||
|
[`docs/v2/driver-stability.md`](../v2/driver-stability.md)).
|
||||||
|
|
||||||
|
For range-validation and per-series capability surface see
|
||||||
|
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md).
|
||||||
|
|
||||||
|
## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a
|
||||||
|
|
||||||
|
`FocasAlarmProjection` exposes two modes via `FocasDriverOptions.AlarmProjection`:
|
||||||
|
|
||||||
|
| Mode | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| `ActiveOnly` *(default)* | Subscribe / unsubscribe / acknowledge wire up so capability negotiation works, but no history poll runs. Back-compat with every pre-F3-a deployment. |
|
||||||
|
| `ActivePlusHistory` | On subscribe (== "on connect") and on every `HistoryPollInterval` tick, the projection issues `cnc_rdalmhistry` for the most recent `HistoryDepth` entries. Each previously-unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's reported timestamp — OPC UA dashboards see the real occurrence time, not the moment the projection polled. |
|
||||||
|
|
||||||
|
### Config knobs
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"AlarmProjection": {
|
||||||
|
"Mode": "ActivePlusHistory", // "ActiveOnly" (default) | "ActivePlusHistory"
|
||||||
|
"HistoryPollInterval": "00:05:00", // default 5 min
|
||||||
|
"HistoryDepth": 100 // default 100, capped at 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dedup key
|
||||||
|
|
||||||
|
`(OccurrenceTime, AlarmNumber, AlarmType)`. The same triple across two
|
||||||
|
polls only emits once. The dedup set is in-memory and **resets on
|
||||||
|
reconnect** — first poll after reconnect re-emits everything in the ring
|
||||||
|
buffer. OPC UA clients that need exactly-once semantics dedupe client-side
|
||||||
|
on the same triple (the timestamp + type + number tuple is stable across
|
||||||
|
the boundary).
|
||||||
|
|
||||||
|
### `HistoryDepth` cap
|
||||||
|
|
||||||
|
Capped at `FocasAlarmProjectionOptions.MaxHistoryDepth = 250` so an
|
||||||
|
operator who types `10000` by accident can't blast the wire session with a
|
||||||
|
giant request. Typical FANUC ring buffers cap at ~100 entries; the default
|
||||||
|
`HistoryDepth = 100` matches the most common ring-buffer size.
|
||||||
|
|
||||||
|
### Wire surface
|
||||||
|
|
||||||
|
- Wire-protocol command id: `0x0F1A` (see
|
||||||
|
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)).
|
||||||
|
- ODBALMHIS struct decoder: `Wire/FocasAlarmHistoryDecoder.cs`.
|
||||||
|
- Tier-C Fwlib32 backend short-circuits the packed-buffer decoder by
|
||||||
|
surfacing the FWLIB struct fields directly into
|
||||||
|
`FocasAlarmHistoryEntry`.
|
||||||
@@ -125,6 +125,69 @@ back an `IAlarmSource`, but shipping that is a separate feature.
|
|||||||
| "Do notifications coalesce under load?" | no | yes (required) |
|
| "Do notifications coalesce under load?" | no | yes (required) |
|
||||||
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
PR 2.1 (Sum-read / Sum-write, IndexGroup `0xF080..0xF084`) replaced the per-tag
|
||||||
|
`ReadValueAsync` loop in `TwinCATDriver.ReadAsync` / `WriteAsync` with a
|
||||||
|
bucketed bulk dispatch — N tags addressed against the same device flow through a
|
||||||
|
single ADS sum-command round-trip via `SumInstancePathAnyTypeRead` (read) and
|
||||||
|
`SumWriteBySymbolPath` (write). Whole-array tags + bit-extracted BOOL tags
|
||||||
|
remain on the per-tag fallback path because the sum surface only marshals
|
||||||
|
scalars and bit-RMW writes need the per-parent serialisation lock.
|
||||||
|
|
||||||
|
**Baseline → Sum-command delta** (dev box, 1000 × DINT, XAR VM over LAN):
|
||||||
|
|
||||||
|
| Path | Round-trips | Wall-clock |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Per-tag loop (pre-PR 2.1) | 1000 | ~5–8 s |
|
||||||
|
| Sum-command bulk (PR 2.1) | 1 | ~250–600 ms |
|
||||||
|
| Ratio | — | ≥ 10× typical, ≥ 5× CI floor |
|
||||||
|
|
||||||
|
The perf-tier test
|
||||||
|
`TwinCATSumCommandPerfTests.Driver_sum_read_1000_tags_beats_loop_baseline_by_5x`
|
||||||
|
asserts the ratio with a conservative 5× lower bound that survives noisy CI /
|
||||||
|
VM scheduling. It is gated behind both `TWINCAT_TARGET_NETID` (XAR reachable)
|
||||||
|
and `TWINCAT_PERF=1` (operator opt-in) — perf runs aren't part of the default
|
||||||
|
integration pass because they hit the wire heavily.
|
||||||
|
|
||||||
|
The required fixture state (1000-DINT GVL + churn POU) is documented in
|
||||||
|
`TwinCatProject/README.md §Performance scenarios`; XAE-form sources land at
|
||||||
|
`TwinCatProject/PLC/GVLs/GVL_Perf.TcGVL` + `TwinCatProject/PLC/POUs/FB_PerfChurn.TcPOU`.
|
||||||
|
|
||||||
|
### Handle caching (PR 2.2)
|
||||||
|
|
||||||
|
Per-tag reads / writes route through an in-process ADS variable-handle cache.
|
||||||
|
The first read of a symbol resolves a handle via `CreateVariableHandleAsync`;
|
||||||
|
subsequent reads / writes of the same symbol issue against the cached handle.
|
||||||
|
On the wire this trades a multi-byte symbolic path (`GVL_Perf.aTags[742]` =
|
||||||
|
20+ bytes) for a 4-byte handle, and the device server skips name resolution
|
||||||
|
on every subsequent op. Cache lifetime is process-scoped; entries are evicted
|
||||||
|
on `AdsErrorCode.DeviceSymbolVersionInvalid` (with one retry against a fresh
|
||||||
|
handle), wiped on reconnect (handles are per-AMS-session), and deleted
|
||||||
|
best-effort on driver disposal.
|
||||||
|
|
||||||
|
`TwinCATHandleCachePerfTests.Driver_handle_cache_avoids_repeat_symbol_resolution`
|
||||||
|
asserts the contract on real XAR by reading 50 symbols twice and verifying
|
||||||
|
the second pass issues zero new `CreateVariableHandleAsync` calls. It runs
|
||||||
|
under the standard `[TwinCATFact]` gate (XAR reachable; no `TWINCAT_PERF`
|
||||||
|
opt-in needed because 50 symbols is cheap).
|
||||||
|
|
||||||
|
**Self-invalidation (PR 2.3)**: handle cache is now self-invalidating on
|
||||||
|
TwinCAT online changes. `AdsTwinCATClient` registers an
|
||||||
|
`AdsSymbolVersionChanged` event listener (Beckhoff's high-level wrapper
|
||||||
|
around the SymbolVersion ADS notification, IndexGroup `0xF008`) on connect;
|
||||||
|
when the PLC's symbol-version counter increments — full re-init after a
|
||||||
|
download / activate-config — the listener fires and wipes the handle cache
|
||||||
|
proactively. Three-layered defence in depth: (1) proactive listener
|
||||||
|
preempts the next read entirely on full re-inits, (2) the
|
||||||
|
`DeviceSymbolVersionInvalid` evict-and-retry path from PR 2.2 catches the
|
||||||
|
narrower "symbol survives but its descriptor moved" race, and (3)
|
||||||
|
operators can still call `ITwinCATClient.FlushOptionalCachesAsync` manually
|
||||||
|
for the truly-paranoid case. The bulk Sum-read / Sum-write path remains
|
||||||
|
on symbolic paths in PR 2.2 (the bulk path's per-call symbol resolution
|
||||||
|
is already amortised across N tags; the perf delta vs. handle-batched
|
||||||
|
bulk is marginal — tracked as a follow-up for the Phase-2 perf sweep).
|
||||||
|
|
||||||
## Follow-up candidates
|
## Follow-up candidates
|
||||||
|
|
||||||
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
||||||
|
|||||||
45
docs/v2/focas-deployment.md
Normal file
45
docs/v2/focas-deployment.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# FOCAS deployment guide
|
||||||
|
|
||||||
|
Per-driver runbook for deploying the FANUC FOCAS driver. See
|
||||||
|
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) for the per-feature
|
||||||
|
reference and [`focas-version-matrix.md`](./focas-version-matrix.md) for
|
||||||
|
the per-CNC-series capability surface.
|
||||||
|
|
||||||
|
## Operator config-knob cheat sheet
|
||||||
|
|
||||||
|
| Knob | Where | Default | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `Devices[].HostAddress` | `FocasDriverOptions.Devices` | — | `focas://{ip}[:{port}]` |
|
||||||
|
| `Devices[].Series` | `FocasDriverOptions.Devices` | `Unknown` | Drives per-series range validation in `FocasCapabilityMatrix`. |
|
||||||
|
| `Devices[].OverrideParameters` | `FocasDriverOptions.Devices` | `null` | MTB-specific parameter numbers for Feed/Rapid/Spindle/Jog overrides. `null` suppresses the `Override/` subtree. |
|
||||||
|
| `Probe.Enabled` | `FocasDriverOptions.Probe` | `true` | Background reachability probe. |
|
||||||
|
| `Probe.Interval` | `FocasDriverOptions.Probe` | `00:00:05` | Probe cadence. |
|
||||||
|
| `FixedTree.ApplyFigureScaling` | `FocasDriverOptions.FixedTree` | `true` | Divide position values by 10^decimal-places (issue #262). |
|
||||||
|
| **`AlarmProjection.Mode`** | **`FocasDriverOptions.AlarmProjection`** | **`ActiveOnly`** | **`ActiveOnly` keeps today's behaviour. `ActivePlusHistory` polls `cnc_rdalmhistry` on connect + on `HistoryPollInterval` ticks (issue #267, plan PR F3-a).** |
|
||||||
|
| **`AlarmProjection.HistoryPollInterval`** | **`FocasDriverOptions.AlarmProjection`** | **`00:05:00`** | **Cadence of the history poll. Operator dashboards run the default; high-frequency rigs can drop to 30 s.** |
|
||||||
|
| **`AlarmProjection.HistoryDepth`** | **`FocasDriverOptions.AlarmProjection`** | **`100`** | **Most-recent-N ring-buffer entries pulled per poll. Hard-capped at `250` so misconfigured values can't blast the wire session.** |
|
||||||
|
|
||||||
|
## Sample `appsettings.json` snippet for `ActivePlusHistory`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Drivers": {
|
||||||
|
"FOCAS": {
|
||||||
|
"Devices": [
|
||||||
|
{ "HostAddress": "focas://10.0.0.5:8193", "Series": "Series30i" }
|
||||||
|
],
|
||||||
|
"AlarmProjection": {
|
||||||
|
"Mode": "ActivePlusHistory",
|
||||||
|
"HistoryPollInterval": "00:05:00",
|
||||||
|
"HistoryDepth": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The history projection emits each unseen entry through
|
||||||
|
`IAlarmSource.OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's
|
||||||
|
reported wall-clock — keep CNC clocks on UTC so the dedup key
|
||||||
|
`(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across DST
|
||||||
|
transitions.
|
||||||
102
docs/v2/implementation/focas-simulator-plan.md
Normal file
102
docs/v2/implementation/focas-simulator-plan.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# FOCAS simulator (focas-mock) plan
|
||||||
|
|
||||||
|
Notes on the focas-mock simulator that the FOCAS driver's integration
|
||||||
|
tests will eventually talk to. Today there is no FOCAS integration-test
|
||||||
|
project; this doc is the contract the future fixture will be built
|
||||||
|
against. Keeping the contract tracked in repo means the wire-protocol
|
||||||
|
command ids (and their request/response payloads) don't drift between the
|
||||||
|
.NET wire client and a future Python implementation.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- Append-only command ids. Mirror
|
||||||
|
[`focas-wire-protocol.md`](./focas-wire-protocol.md) verbatim.
|
||||||
|
- Per-profile state. The simulator hosts N CNC profiles concurrently
|
||||||
|
(`Series0i`, `Series30i`, `PowerMotion`, ...). Each profile has its own
|
||||||
|
alarm-history ring buffer + its own override map.
|
||||||
|
- Admin endpoints under `POST /admin/...` mutate state without going
|
||||||
|
through the wire protocol; integration tests use these to seed canned
|
||||||
|
inputs.
|
||||||
|
|
||||||
|
## Protocol surface (current scope)
|
||||||
|
|
||||||
|
| Cmd | API | State impact |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `0x0001` | `cnc_rdcncstat` | reads cached ODBST per profile |
|
||||||
|
| `0x0002` | `cnc_rdparam` | reads parameter map per profile |
|
||||||
|
| `0x0003` | `cnc_rdmacro` | reads macro variables per profile |
|
||||||
|
| `0x0004` | `cnc_rddiag` | reads diagnostic map per profile |
|
||||||
|
| `0x0010` | `pmc_rdpmcrng` | reads PMC byte ranges |
|
||||||
|
| `0x0020` | `cnc_modal` | reads cached modal MSTB per profile |
|
||||||
|
| ... | ... | ... |
|
||||||
|
| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
|
||||||
|
|
||||||
|
## `cnc_rdalmhistry` mock behaviour
|
||||||
|
|
||||||
|
The simulator keeps a per-profile ring buffer of alarm-history entries.
|
||||||
|
Default fixture seeds 5 profiles with 10 canned entries each (per the F3-a
|
||||||
|
plan).
|
||||||
|
|
||||||
|
### Request decode
|
||||||
|
|
||||||
|
```
|
||||||
|
[int16 LE depth]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response encode
|
||||||
|
|
||||||
|
Use `FocasAlarmHistoryDecoder.Encode` semantics in reverse: emit the
|
||||||
|
count followed by `ALMHIS_data` blocks padded to 4-byte boundaries. The
|
||||||
|
.NET-side decoder consumes the same format verbatim, so a Python encoder
|
||||||
|
written against the table in
|
||||||
|
[`focas-wire-protocol.md`](./focas-wire-protocol.md) interoperates without
|
||||||
|
extra glue.
|
||||||
|
|
||||||
|
### Admin endpoint — `POST /admin/mock_patch_alarmhistory`
|
||||||
|
|
||||||
|
Replaces the alarm-history ring buffer for a profile.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/mock_patch_alarmhistory
|
||||||
|
{
|
||||||
|
"profile": "Series30i",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"occurrenceTime": "2025-04-01T09:30:00Z",
|
||||||
|
"axisNo": 1,
|
||||||
|
"alarmType": 2,
|
||||||
|
"alarmNumber": 100,
|
||||||
|
"message": "Spindle overload"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`entries` order is interpreted as ring-buffer order (most-recent first to
|
||||||
|
match FANUC's natural surface).
|
||||||
|
|
||||||
|
### `FocasSimFixture.SeedAlarmHistoryAsync`
|
||||||
|
|
||||||
|
The future test-support helper wraps the admin endpoint:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await fixture.SeedAlarmHistoryAsync(
|
||||||
|
profile: "Series30i",
|
||||||
|
entries: new []
|
||||||
|
{
|
||||||
|
new FocasAlarmHistoryEntry(
|
||||||
|
new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero),
|
||||||
|
AxisNo: 1, AlarmType: 2, AlarmNumber: 100, Message: "Spindle overload"),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration test `Series/AlarmHistoryProjectionTests.cs` will assert:
|
||||||
|
|
||||||
|
- historic events fire once with the seeded timestamps
|
||||||
|
- second poll yields zero new events (dedup honoured end-to-end)
|
||||||
|
- active-alarm raise/clear still works alongside the history poll
|
||||||
|
|
||||||
|
These tests are blocked on the focas-mock + integration-test project
|
||||||
|
landing; the unit-test coverage in `FocasAlarmProjectionTests` already
|
||||||
|
exercises every same-process invariant.
|
||||||
76
docs/v2/implementation/focas-wire-protocol.md
Normal file
76
docs/v2/implementation/focas-wire-protocol.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# FOCAS wire protocol — packed-buffer surface
|
||||||
|
|
||||||
|
Notes on the language-neutral packed-buffer encoding the FOCAS driver +
|
||||||
|
focas-mock simulator share. This format is **not** the FWLIB native struct
|
||||||
|
layout — Tier-C Fwlib32 backends marshal directly from the FANUC C struct.
|
||||||
|
The packed surface exists so the simulator (Python / FastAPI) and the .NET
|
||||||
|
wire client can speak a common format over IPC without piping a Win32 DLL
|
||||||
|
through both ends.
|
||||||
|
|
||||||
|
## Command id table
|
||||||
|
|
||||||
|
Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are
|
||||||
|
**append-only** — never renumber, never reuse.
|
||||||
|
|
||||||
|
| Id | FOCAS API | Surface |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `0x0001` | `cnc_rdcncstat` | ODBST 9-field status struct |
|
||||||
|
| `0x0002` | `cnc_rdparam` | parameter value (one number) |
|
||||||
|
| `0x0003` | `cnc_rdmacro` | macro variable value |
|
||||||
|
| `0x0004` | `cnc_rddiag` | diagnostic value |
|
||||||
|
| ... | ... | ... |
|
||||||
|
| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
|
||||||
|
|
||||||
|
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`)
|
||||||
|
|
||||||
|
Issued by `FocasAlarmProjection` when
|
||||||
|
`FocasDriverOptions.AlarmProjection.Mode == ActivePlusHistory`. Returns up
|
||||||
|
to `depth` most-recent ring-buffer entries.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
| Offset | Width | Field | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `depth` | clamped client-side to `[1..250]` (`FocasAlarmProjectionOptions.MaxHistoryDepth`) |
|
||||||
|
|
||||||
|
### Response (packed buffer, little-endian)
|
||||||
|
|
||||||
|
| Offset | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `num_alm` — number of entries that follow. `< 0` indicates CNC error. |
|
||||||
|
| 2 | repeated | `ALMHIS_data alm[num_alm]` (see below) |
|
||||||
|
|
||||||
|
Each entry block:
|
||||||
|
|
||||||
|
| Offset (rel.) | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `year` |
|
||||||
|
| 2 | `int16 LE` | `month` |
|
||||||
|
| 4 | `int16 LE` | `day` |
|
||||||
|
| 6 | `int16 LE` | `hour` |
|
||||||
|
| 8 | `int16 LE` | `minute` |
|
||||||
|
| 10 | `int16 LE` | `second` |
|
||||||
|
| 12 | `int16 LE` | `axis_no` (1-based; 0 = whole-CNC) |
|
||||||
|
| 14 | `int16 LE` | `alm_type` (P/S/OT/SV/SR/MC/SP/PW/IO encoded numerically) |
|
||||||
|
| 16 | `int16 LE` | `alm_no` |
|
||||||
|
| 18 | `int16 LE` | `msg_len` (0..32 typical) |
|
||||||
|
| 20 | `msg_len` | ASCII message (no null terminator) |
|
||||||
|
| `20 + msg_len` | 0..3 | pad to 4-byte boundary so per-entry blocks stay self-delimiting |
|
||||||
|
|
||||||
|
The CNC stamps `year..second` in **its own local time**. The deployment
|
||||||
|
guide instructs operators to keep CNC clocks on UTC so the projection's
|
||||||
|
dedup key `(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across
|
||||||
|
DST transitions. The .NET decoder
|
||||||
|
(`Wire/FocasAlarmHistoryDecoder.Decode`) constructs each
|
||||||
|
`DateTimeOffset` with `TimeSpan.Zero` (UTC) on that assumption.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- A negative `num_alm` short-circuits decode to an empty list — the
|
||||||
|
projection treats it as "no history this tick" and the next poll
|
||||||
|
retries.
|
||||||
|
- Malformed timestamps (e.g. month=0) are skipped per-entry instead of
|
||||||
|
faulting the whole decode; the dedup key for malformed entries would be
|
||||||
|
unstable anyway.
|
||||||
|
- `msg_len` overrunning the payload truncates the entry list at the
|
||||||
|
malformed entry rather than throwing.
|
||||||
@@ -450,6 +450,104 @@ Test names:
|
|||||||
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
|
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
|
||||||
perspective. No known deltas [3].
|
perspective. No known deltas [3].
|
||||||
|
|
||||||
|
## Performance (native S7comm driver)
|
||||||
|
|
||||||
|
This section covers the native S7comm driver (`ZB.MOM.WW.OtOpcUa.Driver.S7`),
|
||||||
|
not the Modbus-on-S7 quirks above. Both share a CPU but use different ports,
|
||||||
|
different libraries, and different optimization levers.
|
||||||
|
|
||||||
|
### Block-read coalescing
|
||||||
|
|
||||||
|
The S7 driver runs a coalescing planner before every read pass: same-area /
|
||||||
|
same-DB tags are sorted by byte offset and merged into single
|
||||||
|
`Plc.ReadBytesAsync` requests when the gap between them is small. Reading
|
||||||
|
`DB1.DBW0`, `DB1.DBW2`, `DB1.DBW4` issues **one** 6-byte byte-range read
|
||||||
|
covering offsets 0..6, sliced client-side instead of three multi-var items
|
||||||
|
(let alone three individual `Plc.ReadAsync` round-trips). On a 50-tag
|
||||||
|
contiguous workload this reduces wire traffic from 50 single reads (or 3
|
||||||
|
multi-var batches at the 19-item PDU ceiling) to **1 byte-range PDU**.
|
||||||
|
|
||||||
|
#### Default 16-byte gap-merge threshold
|
||||||
|
|
||||||
|
The planner merges two adjacent ranges when the gap between them is at most
|
||||||
|
16 bytes. The default reflects the cost arithmetic on a 240-byte default
|
||||||
|
PDU: an S7 request frame is ~30 bytes and a per-item response header is
|
||||||
|
~12 bytes, so over-fetching 16 bytes (which decode-time discards) is
|
||||||
|
cheaper than paying for one extra PDU round-trip.
|
||||||
|
|
||||||
|
The math also holds for 480/960-byte PDUs but the relative cost flips —
|
||||||
|
on a 960-byte PDU you can fit a much larger request and the over-fetch
|
||||||
|
ceiling is less of a concern. Sites running the extended PDU on S7-1500
|
||||||
|
can safely raise the threshold (see operator guidance below).
|
||||||
|
|
||||||
|
#### Opaque-size opt-out for STRING / array / structured-timestamp tags
|
||||||
|
|
||||||
|
Variable-width and header-prefixed tag types **never** participate in
|
||||||
|
coalescing:
|
||||||
|
|
||||||
|
- **STRING / WSTRING** carry a 2-byte (or 4-byte) length header, and the
|
||||||
|
per-tag width depends on the configured `StringLength`.
|
||||||
|
- **CHAR / WCHAR** are routed through the dedicated `S7StringCodec` decode
|
||||||
|
path, which expects an exact byte slice, not an offset into a larger
|
||||||
|
buffer.
|
||||||
|
- **DTL / DT / S5TIME / TIME / TOD / DATE-as-DateTime** route through
|
||||||
|
`S7DateTimeCodec` for the same reason.
|
||||||
|
- **Arrays** (`ElementCount > 1`) carry a per-tag width of `N × elementBytes`
|
||||||
|
and would silently mis-decode if the slice landed mid-block.
|
||||||
|
|
||||||
|
Each opaque-size tag emits its own standalone `Plc.ReadBytesAsync` call.
|
||||||
|
A STRING in the middle of a contiguous run of DBWs will split the
|
||||||
|
neighbour reads into "before STRING" and "after STRING" merged ranges
|
||||||
|
without straddling the STRING's bytes — verified by the
|
||||||
|
`S7BlockCoalescingPlannerTests` unit suite.
|
||||||
|
|
||||||
|
#### Operator tuning: `BlockCoalescingGapBytes`
|
||||||
|
|
||||||
|
Surface knob in the driver options:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Host": "10.0.0.50",
|
||||||
|
"Port": 102,
|
||||||
|
"CpuType": "S71500",
|
||||||
|
"BlockCoalescingGapBytes": 16, // default
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tuning guidance:
|
||||||
|
|
||||||
|
- **Raise the threshold (32-64 bytes)** when the PLC has chatty firmware
|
||||||
|
(S7-1200 with default 240-byte PDU and many DBs scattered every few
|
||||||
|
bytes). One fewer PDU round-trip beats over-fetching a kilobyte.
|
||||||
|
- **Lower the threshold (4-8 bytes)** when DBs are sparsely populated
|
||||||
|
with hot tags far apart — over-fetching dead bytes wastes the PDU
|
||||||
|
envelope and the saved round-trip never materialises.
|
||||||
|
- **Set to 0** to disable gap merging entirely (only literally adjacent
|
||||||
|
ranges with `gap == 0` coalesce). Useful as a debugging knob: if a
|
||||||
|
driver is misreading values you can flip the threshold to 0 to confirm
|
||||||
|
the slice math isn't the culprit.
|
||||||
|
- **Per-DB tuning isn't supported yet** — the knob is global per driver
|
||||||
|
instance. If a site needs different policies for two DBs they live in
|
||||||
|
different drivers (different `Host:Port` rows in the config DB).
|
||||||
|
|
||||||
|
#### Diagnostics counters
|
||||||
|
|
||||||
|
The driver surfaces three coalescing counters via `DriverHealth.Diagnostics`
|
||||||
|
under the standard `<DriverType>.<Counter>` naming convention:
|
||||||
|
|
||||||
|
- `S7.TotalBlockReads` — number of `Plc.ReadBytesAsync` calls issued by
|
||||||
|
the coalesced path. A fully-coalesced contiguous workload bumps this
|
||||||
|
by 1 per `ReadAsync`.
|
||||||
|
- `S7.TotalMultiVarBatches` — `Plc.ReadMultipleVarsAsync` batches issued
|
||||||
|
for residual singletons that didn't merge. With perfect coalescing this
|
||||||
|
stays at 0.
|
||||||
|
- `S7.TotalSingleReads` — per-tag fallbacks (strings, dates, arrays,
|
||||||
|
64-bit ints, anything that bypasses both the coalescer and the packer).
|
||||||
|
|
||||||
|
Observe via the `driver-diagnostics` RPC (`/api/v2/drivers/{id}/diagnostics`)
|
||||||
|
or the Admin UI's per-driver dashboard.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
||||||
|
|||||||
@@ -94,5 +94,30 @@ $results += Test-SubscribeSeesChange `
|
|||||||
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $subValue)) `
|
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $subValue)) `
|
||||||
-ExpectedValue "$subValue"
|
-ExpectedValue "$subValue"
|
||||||
|
|
||||||
|
# PR abcip-3.2 — Symbolic-vs-Logical sanity assertion. Reads the same tag with both
|
||||||
|
# addressing modes through the CLI's --addressing-mode flag. Logical-mode against ab_server
|
||||||
|
# falls back to Symbolic on the wire (libplctag wrapper limitation; see AbCip-Performance.md
|
||||||
|
# §Addressing mode), so the assertion is "both modes complete + return the same value" — not
|
||||||
|
# a perf comparison. Skipped on Micro800 (driver downgrades Logical → Symbolic with warning,
|
||||||
|
# making both reads identical-by-design + uninteresting to compare here).
|
||||||
|
if ($Family -ne "Micro800") {
|
||||||
|
$symValue = Get-Random -Minimum 40000 -Maximum 49999
|
||||||
|
Write-Host "AB CIP e2e: priming gateway with $symValue then reading via Symbolic + Logical"
|
||||||
|
$writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $symValue)
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
|
||||||
|
$symRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Symbolic"))
|
||||||
|
$logRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Logical"))
|
||||||
|
|
||||||
|
$symMatched = ($symRead -join "`n") -match "$symValue"
|
||||||
|
$logMatched = ($logRead -join "`n") -match "$symValue"
|
||||||
|
$passed = $symMatched -and $logMatched
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "AddressingModeSanity"
|
||||||
|
Passed = $passed
|
||||||
|
Detail = if ($passed) { "Symbolic + Logical both returned $symValue" } else { "Sym=$symMatched Log=$logMatched" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Summary -Title "AB CIP e2e" -Results $results
|
Write-Summary -Title "AB CIP e2e" -Results $results
|
||||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
|
|||||||
@@ -95,5 +95,60 @@ $results += Test-SubscribeSeesChange `
|
|||||||
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) `
|
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) `
|
||||||
-ExpectedValue "$subValue"
|
-ExpectedValue "$subValue"
|
||||||
|
|
||||||
|
# PR 7 — contiguous array read smoke. The default `--tag=N7[120]` in the Docker
|
||||||
|
# fixture's docker-compose.yml has plenty of room for `,10`; against real hardware
|
||||||
|
# the seeded N7 file just needs at least 10 words. Asserts the CLI exits 0 (the
|
||||||
|
# driver issued one PCCC frame for the whole block) — the per-element values are
|
||||||
|
# whatever the device currently holds.
|
||||||
|
Write-Header "Array contiguous read"
|
||||||
|
$arrayResult = Invoke-Cli -Cli $abLegacyCli `
|
||||||
|
-Args (@("read") + $commonAbLegacy + @("-a", "N7:0,10", "-t", "Int"))
|
||||||
|
if ($arrayResult.ExitCode -eq 0) {
|
||||||
|
Write-Pass "array read N7:0,10 succeeded"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "array read N7:0,10 exit=$($arrayResult.ExitCode)"
|
||||||
|
Write-Host $arrayResult.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "array read exit $($arrayResult.ExitCode)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# PR 8 — deadband subscribe assertion. Subscribe with --deadband-absolute 5,
|
||||||
|
# write three small deltas (each within the 5-unit deadband), assert exactly
|
||||||
|
# one notification fires (the first-seen sample). The fourth write breaks
|
||||||
|
# above the threshold and the subscription should fire again.
|
||||||
|
Write-Header "Deadband subscribe (--deadband-absolute 5)"
|
||||||
|
$baseValue = Get-Random -Minimum 100 -Maximum 200
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $baseValue) | Out-Null
|
||||||
|
$subscribeProc = Start-Process -FilePath $abLegacyCli.File `
|
||||||
|
-ArgumentList ($abLegacyCli.PrefixArgs + @("subscribe") + $commonAbLegacy `
|
||||||
|
+ @("-a", $Address, "-t", "Int", "-i", "200", "--deadband-absolute", "5")) `
|
||||||
|
-PassThru -RedirectStandardOutput "$env:TEMP/ablegacy-deadband.out" `
|
||||||
|
-RedirectStandardError "$env:TEMP/ablegacy-deadband.err"
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
# Three small deltas within deadband.
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 1)) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 2)) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 3)) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
Stop-Process -Id $subscribeProc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
$subscribeOutput = Get-Content "$env:TEMP/ablegacy-deadband.out" -ErrorAction SilentlyContinue
|
||||||
|
# Count `=` lines (the SubscribeCommand format prints one per OnDataChange). Expect exactly 1
|
||||||
|
# (the first-seen sample at $baseValue) — none of the +1/+2/+3 deltas crosses the 5 absolute.
|
||||||
|
$notifyLines = @($subscribeOutput | Where-Object { $_ -match " = " })
|
||||||
|
if ($notifyLines.Count -eq 1) {
|
||||||
|
Write-Pass "deadband subscribe emitted 1 notification (initial only); 3 sub-threshold writes suppressed"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "deadband subscribe expected 1 notification; got $($notifyLines.Count)"
|
||||||
|
Write-Host ($subscribeOutput -join "`n")
|
||||||
|
$results += @{ Passed = $false; Reason = "deadband notify count $($notifyLines.Count)" }
|
||||||
|
}
|
||||||
|
|
||||||
Write-Summary -Title "AB Legacy e2e" -Results $results
|
Write-Summary -Title "AB Legacy e2e" -Results $results
|
||||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'ab-sim', 'abcip-001', 1);
|
|||||||
|
|
||||||
-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture
|
-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture
|
||||||
-- gateway. DriverConfig shape mirrors AbCipDriverConfigDto.
|
-- gateway. DriverConfig shape mirrors AbCipDriverConfigDto.
|
||||||
|
--
|
||||||
|
-- The second device entry (CompactLogix L2 example, commented out) demonstrates
|
||||||
|
-- the PR abcip-3.1 ConnectionSize override knob. Uncomment + point at a real
|
||||||
|
-- 5069-L2 to verify the narrow-buffer Forward Open path; ab_server itself
|
||||||
|
-- doesn't enforce the narrow cap (see docs/drivers/AbServer-Test-Fixture.md §5).
|
||||||
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||||
Name, DriverType, DriverConfig, Enabled)
|
Name, DriverType, DriverConfig, Enabled)
|
||||||
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
|
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
|
||||||
@@ -90,6 +95,14 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
|
|||||||
"PlcFamily": "ControlLogix",
|
"PlcFamily": "ControlLogix",
|
||||||
"DeviceName": "ab-server"
|
"DeviceName": "ab-server"
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
, {
|
||||||
|
"HostAddress": "ab://10.0.0.7/1,0",
|
||||||
|
"PlcFamily": "CompactLogix",
|
||||||
|
"DeviceName": "compactlogix-l2-narrow",
|
||||||
|
"ConnectionSize": 504
|
||||||
|
}
|
||||||
|
*/
|
||||||
],
|
],
|
||||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
|
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
|
||||||
"Tags": [
|
"Tags": [
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line';
|
|||||||
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
|
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
|
||||||
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
|
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
|
||||||
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
|
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
|
||||||
|
DECLARE @ArrTagId nvarchar(64) = 'ablegacy-smoke-tag-n7_block';
|
||||||
|
|
||||||
BEGIN TRAN;
|
BEGIN TRAN;
|
||||||
|
|
||||||
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
|
DELETE FROM dbo.Tag WHERE TagId IN (@TagId, @ArrTagId);
|
||||||
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||||
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||||
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||||
@@ -98,7 +99,16 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
|||||||
"Address": "N7:5",
|
"Address": "N7:5",
|
||||||
"DataType": "Int",
|
"DataType": "Int",
|
||||||
"Writable": true,
|
"Writable": true,
|
||||||
"WriteIdempotent": true
|
"WriteIdempotent": true,
|
||||||
|
"AbsoluteDeadband": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "N7_Block",
|
||||||
|
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
|
||||||
|
"Address": "N7:0,10",
|
||||||
|
"DataType": "Int",
|
||||||
|
"Writable": false,
|
||||||
|
"ArrayLength": 10
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}', 1);
|
}', 1);
|
||||||
@@ -108,6 +118,17 @@ INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataTyp
|
|||||||
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
|
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
|
||||||
N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1);
|
N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1);
|
||||||
|
|
||||||
|
-- PR 7 — array contiguous-block tag. The TagConfig JSON carries the address suffix
|
||||||
|
-- + ArrayLength override; the driver picks both up at discovery time and emits the
|
||||||
|
-- DriverAttributeInfo with IsArray=true + ArrayDim=10 so the generic node manager
|
||||||
|
-- materialises a 1-D Int16 array variable. The dbo.Tag schema doesn't carry
|
||||||
|
-- IsArray/ArrayDim columns — the array shape is fully driver-side metadata.
|
||||||
|
-- Read-only because the smoke harness only exercises array reads.
|
||||||
|
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||||
|
AccessLevel, TagConfig, WriteIdempotent)
|
||||||
|
VALUES (@Gen, @ArrTagId, @DrvId, @EqId, 'N7_Block', 'Int16', 'Read',
|
||||||
|
N'{"FullName":"N7_Block","Address":"N7:0,10","DataType":"Int","ArrayLength":10}', 0);
|
||||||
|
|
||||||
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||||
@Notes = N'AB Legacy smoke — task #213';
|
@Notes = N'AB Legacy smoke — task #213';
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
||||||
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="Description">
|
||||||
|
/// Human-readable description for this attribute. When non-null + non-empty the generic
|
||||||
|
/// node-manager surfaces the value as the OPC UA <c>Description</c> attribute on the
|
||||||
|
/// Variable node so SCADA / engineering clients see the field comment from the source
|
||||||
|
/// project (Studio 5000 tag descriptions, Galaxy attribute help text, etc.). Defaults to
|
||||||
|
/// null so drivers that don't carry descriptions are unaffected.
|
||||||
|
/// </param>
|
||||||
public sealed record DriverAttributeInfo(
|
public sealed record DriverAttributeInfo(
|
||||||
string FullName,
|
string FullName,
|
||||||
DriverDataType DriverDataType,
|
DriverDataType DriverDataType,
|
||||||
@@ -56,7 +63,8 @@ public sealed record DriverAttributeInfo(
|
|||||||
bool WriteIdempotent = false,
|
bool WriteIdempotent = false,
|
||||||
NodeSourceKind Source = NodeSourceKind.Driver,
|
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||||
string? VirtualTagId = null,
|
string? VirtualTagId = null,
|
||||||
string? ScriptedAlarmId = null);
|
string? ScriptedAlarmId = null,
|
||||||
|
string? Description = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public enum DriverCapability
|
|||||||
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
|
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
|
||||||
Discover,
|
Discover,
|
||||||
|
|
||||||
/// <summary><see cref="ISubscribable.SubscribeAsync"/> and unsubscribe. Retries by default.</summary>
|
/// <summary><see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> and unsubscribe. Retries by default.</summary>
|
||||||
Subscribe,
|
Subscribe,
|
||||||
|
|
||||||
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>
|
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>
|
||||||
|
|||||||
@@ -25,4 +25,11 @@ public enum DriverDataType
|
|||||||
|
|
||||||
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
|
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
|
||||||
Reference,
|
Reference,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA <c>Duration</c> — a Double-encoded period in milliseconds. Subtype of Double
|
||||||
|
/// in the address space; surfaced as <see cref="System.TimeSpan"/> in the driver layer.
|
||||||
|
/// Used by IEC 61131-3 <c>TIME</c> / <c>TOD</c> attributes (TwinCAT et al.).
|
||||||
|
/// </summary>
|
||||||
|
Duration,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// <param name="State">Current driver-instance state.</param>
|
/// <param name="State">Current driver-instance state.</param>
|
||||||
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
|
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
|
||||||
/// <param name="LastError">Most recent error message; null when state is Healthy.</param>
|
/// <param name="LastError">Most recent error message; null when state is Healthy.</param>
|
||||||
|
/// <param name="Diagnostics">
|
||||||
|
/// Optional driver-attributable counters/metrics surfaced for the <c>driver-diagnostics</c>
|
||||||
|
/// RPC (introduced for Modbus task #154). Drivers populate the dictionary with stable,
|
||||||
|
/// well-known keys (e.g. <c>PublishRequestCount</c>, <c>NotificationsPerSecond</c>);
|
||||||
|
/// Core treats it as opaque metadata. Defaulted to an empty read-only dictionary so
|
||||||
|
/// existing drivers and call-sites that don't construct this field stay back-compat.
|
||||||
|
/// </param>
|
||||||
public sealed record DriverHealth(
|
public sealed record DriverHealth(
|
||||||
DriverState State,
|
DriverState State,
|
||||||
DateTime? LastSuccessfulRead,
|
DateTime? LastSuccessfulRead,
|
||||||
string? LastError);
|
string? LastError,
|
||||||
|
IReadOnlyDictionary<string, double>? Diagnostics = null)
|
||||||
|
{
|
||||||
|
/// <summary>Driver-attributable counters, empty when the driver doesn't surface any.</summary>
|
||||||
|
public IReadOnlyDictionary<string, double> DiagnosticsOrEmpty
|
||||||
|
=> Diagnostics ?? EmptyDiagnostics;
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, double> EmptyDiagnostics
|
||||||
|
= new Dictionary<string, double>(0);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Driver-instance lifecycle state.</summary>
|
/// <summary>Driver-instance lifecycle state.</summary>
|
||||||
public enum DriverState
|
public enum DriverState
|
||||||
|
|||||||
@@ -35,8 +35,159 @@ public interface IAddressSpaceBuilder
|
|||||||
/// <c>_base</c> equipment-class template).
|
/// <c>_base</c> equipment-class template).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void AddProperty(string browseName, DriverDataType dataType, object? value);
|
void AddProperty(string browseName, DriverDataType dataType, object? value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a type-definition node (ObjectType / VariableType / DataType / ReferenceType)
|
||||||
|
/// mirrored from an upstream OPC UA server. Optional surface — drivers that don't mirror
|
||||||
|
/// types simply never call it; address-space builders that don't materialise upstream
|
||||||
|
/// types can leave the default no-op in place. Default implementation drops the call so
|
||||||
|
/// adding this method doesn't break existing <see cref="IAddressSpaceBuilder"/>
|
||||||
|
/// implementations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="info">Metadata describing the type-definition node to mirror.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The OPC UA Client driver is the primary caller — it walks <c>i=86</c>
|
||||||
|
/// (TypesFolder) during <c>DiscoverAsync</c> when
|
||||||
|
/// <c>OpcUaClientDriverOptions.MirrorTypeDefinitions</c> is set so downstream clients
|
||||||
|
/// see the upstream type system instead of rendering structured-type values as opaque
|
||||||
|
/// strings.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The default no-op is intentional — most builders (Galaxy, Modbus, FOCAS, S7,
|
||||||
|
/// TwinCAT, AB-CIP) don't have a meaningful type folder to project into and would
|
||||||
|
/// otherwise need empty-stub overrides.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a method node mirrored from an upstream OPC UA server. The method is
|
||||||
|
/// registered as a child of the current builder scope (i.e. the folder representing
|
||||||
|
/// the upstream Object that owns the method). Optional surface — drivers that don't
|
||||||
|
/// mirror methods simply never call it; address-space builders that don't materialise
|
||||||
|
/// method nodes can leave the default no-op in place. Default implementation drops
|
||||||
|
/// the call so adding this method doesn't break existing
|
||||||
|
/// <see cref="IAddressSpaceBuilder"/> implementations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="info">Metadata describing the method node, including input/output argument schemas.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The OPC UA Client driver is the primary caller — it picks up
|
||||||
|
/// <c>NodeClass.Method</c> nodes during the <c>HierarchicalReferences</c> browse
|
||||||
|
/// pass, then walks each method's <c>HasProperty</c> references to harvest the
|
||||||
|
/// <c>InputArguments</c> / <c>OutputArguments</c> property values.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The OPC UA server-side <c>DriverNodeManager</c> overrides this to materialize
|
||||||
|
/// a real <c>MethodNode</c> in the local address space and wire its
|
||||||
|
/// <c>OnCallMethod</c> handler to the driver's
|
||||||
|
/// <see cref="IMethodInvoker.CallMethodAsync"/>. Other builders (Galaxy, Modbus,
|
||||||
|
/// FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) ignore the projection because their
|
||||||
|
/// backends don't expose method nodes.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
void RegisterMethodNode(MirroredMethodNodeInfo info) { /* default: no-op */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata describing a single method node mirrored from an upstream OPC UA server.
|
||||||
|
/// Built by the OPC UA Client driver during the discovery browse pass and consumed by
|
||||||
|
/// <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
|
||||||
|
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
|
||||||
|
/// <param name="ObjectNodeId">
|
||||||
|
/// Stringified NodeId of the parent Object that owns this method — the <c>ObjectId</c>
|
||||||
|
/// argument the dispatcher passes back to <see cref="IMethodInvoker.CallMethodAsync"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MethodNodeId">
|
||||||
|
/// Stringified NodeId of the method node itself — the <c>MethodId</c> argument.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="InputArguments">
|
||||||
|
/// Declaration of the method's input arguments, in order. <c>null</c> or empty when the
|
||||||
|
/// method takes no inputs (or the upstream property couldn't be read).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="OutputArguments">
|
||||||
|
/// Declaration of the method's output arguments, in order. <c>null</c> or empty when the
|
||||||
|
/// method returns no outputs (or the upstream property couldn't be read).
|
||||||
|
/// </param>
|
||||||
|
public sealed record MirroredMethodNodeInfo(
|
||||||
|
string BrowseName,
|
||||||
|
string DisplayName,
|
||||||
|
string ObjectNodeId,
|
||||||
|
string MethodNodeId,
|
||||||
|
IReadOnlyList<MethodArgumentInfo>? InputArguments,
|
||||||
|
IReadOnlyList<MethodArgumentInfo>? OutputArguments);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row of an OPC UA Argument array — name + data type + array hint. Mirrors the
|
||||||
|
/// <c>Opc.Ua.Argument</c> structure but without the SDK-only types so this DTO can live
|
||||||
|
/// in <c>Core.Abstractions</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Name">Argument name from the upstream Argument structure.</param>
|
||||||
|
/// <param name="DriverDataType">
|
||||||
|
/// Mapped local <see cref="DriverDataType"/>. Unknown / structured upstream types fall
|
||||||
|
/// through to <see cref="DriverDataType.String"/> — same convention as variable mirroring.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ValueRank">
|
||||||
|
/// OPC UA ValueRank: <c>-1</c> = scalar, <c>0</c> = OneOrMoreDimensions, <c>1+</c> = array
|
||||||
|
/// dimensions. Driven directly from the upstream Argument's ValueRank.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Description">
|
||||||
|
/// Human-readable description from the upstream Argument structure; <c>null</c> when the
|
||||||
|
/// upstream doesn't carry one.
|
||||||
|
/// </param>
|
||||||
|
public sealed record MethodArgumentInfo(
|
||||||
|
string Name,
|
||||||
|
DriverDataType DriverDataType,
|
||||||
|
int ValueRank,
|
||||||
|
string? Description);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Categorises a mirrored type-definition node so the receiving builder can route it into
|
||||||
|
/// the right OPC UA standard subtree (<c>ObjectTypesFolder</c>, <c>VariableTypesFolder</c>,
|
||||||
|
/// <c>DataTypesFolder</c>, <c>ReferenceTypesFolder</c>) when projecting upstream types into
|
||||||
|
/// the local address space.
|
||||||
|
/// </summary>
|
||||||
|
public enum MirroredTypeKind
|
||||||
|
{
|
||||||
|
ObjectType,
|
||||||
|
VariableType,
|
||||||
|
DataType,
|
||||||
|
ReferenceType,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata describing a single type-definition node mirrored from an upstream OPC UA
|
||||||
|
/// server. Built by the OPC UA Client driver during type-mirror pass and consumed by
|
||||||
|
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Kind">Type category — drives which standard sub-folder the node lives under.</param>
|
||||||
|
/// <param name="UpstreamNodeId">
|
||||||
|
/// Stringified upstream NodeId (e.g. <c>"ns=2;i=1234"</c>) — preserves the original identity
|
||||||
|
/// so a builder that wants to project the type with a stable cross-namespace reference can do
|
||||||
|
/// so. The driver applies any configured namespace remap before stamping this field.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
|
||||||
|
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
|
||||||
|
/// <param name="SuperTypeNodeId">
|
||||||
|
/// Stringified upstream NodeId of the super-type (parent type), or <c>null</c> when the node
|
||||||
|
/// sits directly under the root (e.g. <c>BaseObjectType</c>, <c>BaseVariableType</c>). Lets
|
||||||
|
/// the builder reconstruct the inheritance chain.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="IsAbstract">
|
||||||
|
/// <c>true</c> when the upstream node has the <c>IsAbstract</c> flag set (Object / Variable /
|
||||||
|
/// ReferenceType). DataTypes also expose this — the driver passes it through verbatim.
|
||||||
|
/// </param>
|
||||||
|
public sealed record MirroredTypeNodeInfo(
|
||||||
|
MirroredTypeKind Kind,
|
||||||
|
string UpstreamNodeId,
|
||||||
|
string BrowseName,
|
||||||
|
string DisplayName,
|
||||||
|
string? SuperTypeNodeId,
|
||||||
|
bool IsAbstract);
|
||||||
|
|
||||||
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
|
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
|
||||||
public interface IVariableHandle
|
public interface IVariableHandle
|
||||||
{
|
{
|
||||||
|
|||||||
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional control-plane capability — drivers whose backend exposes a way to refresh
|
||||||
|
/// the symbol table on-demand (without tearing the driver down) implement this so the
|
||||||
|
/// Admin UI / CLI can trigger a re-walk in response to an operator action.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Distinct from <see cref="IRediscoverable"/>: that interface is the driver telling Core
|
||||||
|
/// a refresh is needed; this one is Core asking the driver to refresh now. For drivers that
|
||||||
|
/// implement both, the typical wiring is "operator clicks Rebrowse → Core calls
|
||||||
|
/// <see cref="RebrowseAsync"/> → driver re-walks → driver fires
|
||||||
|
/// <c>OnRediscoveryNeeded</c> so the address space is rebuilt".
|
||||||
|
///
|
||||||
|
/// For AB CIP this is the "force re-walk of @tags" hook — useful after a controller
|
||||||
|
/// program download added new tags but the static config still drives the address space.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IDriverControl
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Re-run the driver's discovery pass against live backend state and stream the
|
||||||
|
/// resulting nodes through the supplied builder. Implementations must be safe to call
|
||||||
|
/// concurrently with reads / writes; they typically serialize internally so a second
|
||||||
|
/// concurrent rebrowse waits for the first to complete rather than racing it.
|
||||||
|
/// </summary>
|
||||||
|
Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver capability for invoking OPC UA Methods on the upstream backend (the OPC UA
|
||||||
|
/// <c>Call</c> service). Optional — only drivers whose backends carry method nodes
|
||||||
|
/// implement it. Currently the OPC UA Client driver is the only implementer; tag-based
|
||||||
|
/// drivers (Modbus, S7, FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) don't expose method
|
||||||
|
/// nodes so they don't need this surface.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Per <c>docs/v2/plan.md</c> decision #4 (composable capability interfaces) — the
|
||||||
|
/// server-side <c>DriverNodeManager</c> discovers method-bearing drivers via an
|
||||||
|
/// <c>is IMethodInvoker</c> check and routes <c>OnCallMethod</c> handlers to
|
||||||
|
/// <see cref="CallMethodAsync"/>. Drivers that don't implement the interface simply
|
||||||
|
/// never have method nodes registered for them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The address-space mirror is driven by <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>
|
||||||
|
/// — drivers register the method node + its <c>InputArguments</c> /
|
||||||
|
/// <c>OutputArguments</c> properties during discovery, then invocations land back on
|
||||||
|
/// <see cref="CallMethodAsync"/> via the server-side dispatcher.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IMethodInvoker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Invoke an upstream OPC UA Method. The driver translates input arguments into the
|
||||||
|
/// wire-level <c>CallMethodRequest</c>, dispatches via the active session, and packs
|
||||||
|
/// the response back into a <see cref="MethodCallResult"/>. Per-argument validation
|
||||||
|
/// errors flow through <see cref="MethodCallResult.InputArgumentResults"/>; method-level
|
||||||
|
/// errors (<c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, etc.) flow through
|
||||||
|
/// <see cref="MethodCallResult.StatusCode"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="objectNodeId">
|
||||||
|
/// Stringified NodeId of the OPC UA Object that owns the method (the <c>ObjectId</c>
|
||||||
|
/// field of <c>CallMethodRequest</c>). Same serialization as <c>IReadable</c>'s
|
||||||
|
/// <c>fullReference</c> — <c>ns=2;s=…</c> / <c>i=…</c> / <c>nsu=…;…</c>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="methodNodeId">
|
||||||
|
/// Stringified NodeId of the Method node itself (the <c>MethodId</c> field).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="inputs">
|
||||||
|
/// Input arguments in declaration order. The driver wraps each value as a
|
||||||
|
/// <c>Variant</c>; callers pass CLR primitives (plus arrays) — the wire-level
|
||||||
|
/// encoding is the driver's concern.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">Per-call cancellation.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// Result of the call — see <see cref="MethodCallResult"/>. Never throws for a
|
||||||
|
/// <c>Bad</c> upstream status; the bad code is surfaced via the result so the caller
|
||||||
|
/// can map it onto an OPC UA service-result for downstream clients.
|
||||||
|
/// </returns>
|
||||||
|
Task<MethodCallResult> CallMethodAsync(
|
||||||
|
string objectNodeId,
|
||||||
|
string methodNodeId,
|
||||||
|
object[] inputs,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a single OPC UA <c>Call</c> service invocation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="StatusCode">
|
||||||
|
/// Method-level status. <c>0</c> = Good. Bad codes pass through verbatim from the
|
||||||
|
/// upstream so downstream clients see the canonical OPC UA error (e.g.
|
||||||
|
/// <c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, <c>BadArgumentsMissing</c>).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Outputs">
|
||||||
|
/// Output argument values in declaration order. <c>null</c> when the upstream returned
|
||||||
|
/// no output arguments (or returned a Bad status before producing any).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="InputArgumentResults">
|
||||||
|
/// Per-input-argument status codes. <c>null</c> when the upstream didn't surface
|
||||||
|
/// per-argument validation results (typical for Good calls). Each entry is the OPC UA
|
||||||
|
/// status code for the matching input argument — drivers can use this to surface
|
||||||
|
/// <c>BadTypeMismatch</c>, <c>BadOutOfRange</c>, etc. on a specific argument.
|
||||||
|
/// </param>
|
||||||
|
public sealed record MethodCallResult(
|
||||||
|
uint StatusCode,
|
||||||
|
object[]? Outputs,
|
||||||
|
uint[]? InputArgumentResults);
|
||||||
@@ -20,7 +20,29 @@ public interface ISubscribable
|
|||||||
TimeSpan publishingInterval,
|
TimeSpan publishingInterval,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary>
|
/// <summary>
|
||||||
|
/// Subscribe to data changes with per-tag advanced tuning (sampling interval, queue
|
||||||
|
/// size, monitoring mode, deadband filter). Drivers that don't have a native concept
|
||||||
|
/// of these knobs (e.g. polled drivers like Modbus) MAY ignore the per-tag knobs and
|
||||||
|
/// delegate to the simple
|
||||||
|
/// <see cref="SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>
|
||||||
|
/// overload — the default implementation does exactly that, so existing implementers
|
||||||
|
/// compile unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tags">Per-tag subscription specs. <see cref="MonitoredTagSpec.TagName"/> is the driver-side full reference.</param>
|
||||||
|
/// <param name="publishingInterval">Subscription publishing interval, applied to the whole batch.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation.</param>
|
||||||
|
/// <returns>Opaque subscription handle for <see cref="UnsubscribeAsync"/>.</returns>
|
||||||
|
Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<MonitoredTagSpec> tags,
|
||||||
|
TimeSpan publishingInterval,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> SubscribeAsync(
|
||||||
|
tags.Select(t => t.TagName).ToList(),
|
||||||
|
publishingInterval,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Cancel a subscription returned by either <c>SubscribeAsync</c> overload.</summary>
|
||||||
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
|
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -30,7 +52,7 @@ public interface ISubscribable
|
|||||||
event EventHandler<DataChangeEventArgs>? OnDataChange;
|
event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync"/>.</summary>
|
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.</summary>
|
||||||
public interface ISubscriptionHandle
|
public interface ISubscriptionHandle
|
||||||
{
|
{
|
||||||
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
|
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
|
||||||
@@ -38,10 +60,99 @@ public interface ISubscriptionHandle
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
|
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
|
||||||
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
|
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> call.</param>
|
||||||
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
|
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
|
||||||
/// <param name="Snapshot">New value + quality + timestamps.</param>
|
/// <param name="Snapshot">New value + quality + timestamps.</param>
|
||||||
public sealed record DataChangeEventArgs(
|
public sealed record DataChangeEventArgs(
|
||||||
ISubscriptionHandle SubscriptionHandle,
|
ISubscriptionHandle SubscriptionHandle,
|
||||||
string FullReference,
|
string FullReference,
|
||||||
DataValueSnapshot Snapshot);
|
DataValueSnapshot Snapshot);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-tag subscription tuning. Maps onto OPC UA <c>MonitoredItem</c> properties for the
|
||||||
|
/// OpcUaClient driver; non-OPC-UA drivers either map a subset (e.g. ADS picks up
|
||||||
|
/// <see cref="SamplingIntervalMs"/>) or ignore the knobs entirely and fall back to the
|
||||||
|
/// simple <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TagName">Driver-side full reference (e.g. <c>ns=2;s=Foo</c> for OPC UA).</param>
|
||||||
|
/// <param name="SamplingIntervalMs">
|
||||||
|
/// Server-side sampling rate in milliseconds. <c>null</c> = use the publishing interval.
|
||||||
|
/// Sub-publish-interval values let a server sample faster than it publishes (queue +
|
||||||
|
/// coalesce), useful for events that change between publish ticks.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="QueueSize">Server-side notification queue depth. <c>null</c> = driver default (1).</param>
|
||||||
|
/// <param name="DiscardOldest">
|
||||||
|
/// When the server-side queue overflows: <c>true</c> drops oldest, <c>false</c> drops newest.
|
||||||
|
/// <c>null</c> = driver default (true — preserve recency).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MonitoringMode">
|
||||||
|
/// Per-item monitoring mode. <c>Reporting</c> = sample + publish, <c>Sampling</c> = sample
|
||||||
|
/// but suppress publishing (useful with triggering), <c>Disabled</c> = neither.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DataChangeFilter">
|
||||||
|
/// Optional data-change filter (deadband + trigger semantics). <c>null</c> = no filter
|
||||||
|
/// (every change publishes regardless of magnitude).
|
||||||
|
/// </param>
|
||||||
|
public sealed record MonitoredTagSpec(
|
||||||
|
string TagName,
|
||||||
|
double? SamplingIntervalMs = null,
|
||||||
|
uint? QueueSize = null,
|
||||||
|
bool? DiscardOldest = null,
|
||||||
|
SubscriptionMonitoringMode? MonitoringMode = null,
|
||||||
|
DataChangeFilterSpec? DataChangeFilter = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA <c>DataChangeFilter</c> spec. Mirrors the OPC UA Part 4 §7.17.2 structure but
|
||||||
|
/// lives in Core.Abstractions so non-OpcUaClient drivers (e.g. Modbus, S7) can accept it
|
||||||
|
/// as metadata even if they ignore the deadband mechanics.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Trigger">When to fire: status only / status+value / status+value+timestamp.</param>
|
||||||
|
/// <param name="DeadbandType">Deadband mode: none / absolute (engineering units) / percent of EURange.</param>
|
||||||
|
/// <param name="DeadbandValue">
|
||||||
|
/// Magnitude of the deadband. For <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Absolute"/>
|
||||||
|
/// this is in the variable's engineering units; for <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Percent"/>
|
||||||
|
/// it's a 0..100 percentage of EURange (server returns BadFilterNotAllowed if EURange isn't set).
|
||||||
|
/// </param>
|
||||||
|
public sealed record DataChangeFilterSpec(
|
||||||
|
DataChangeTrigger Trigger,
|
||||||
|
DeadbandType DeadbandType,
|
||||||
|
double DeadbandValue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA <c>DataChangeTrigger</c> values. Wraps the SDK enum so Core.Abstractions doesn't
|
||||||
|
/// leak an OPC-UA-stack reference into every driver project.
|
||||||
|
/// </summary>
|
||||||
|
public enum DataChangeTrigger
|
||||||
|
{
|
||||||
|
/// <summary>Fire only when StatusCode changes.</summary>
|
||||||
|
Status = 0,
|
||||||
|
/// <summary>Fire when StatusCode or Value changes (the OPC UA default).</summary>
|
||||||
|
StatusValue = 1,
|
||||||
|
/// <summary>Fire when StatusCode, Value, or SourceTimestamp changes.</summary>
|
||||||
|
StatusValueTimestamp = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>OPC UA deadband-filter modes.</summary>
|
||||||
|
public enum DeadbandType
|
||||||
|
{
|
||||||
|
/// <summary>No deadband — every value change publishes.</summary>
|
||||||
|
None = 0,
|
||||||
|
/// <summary>Deadband expressed in the variable's engineering units.</summary>
|
||||||
|
Absolute = 1,
|
||||||
|
/// <summary>Deadband expressed as 0..100 percent of the variable's EURange.</summary>
|
||||||
|
Percent = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-item subscription monitoring mode. Wraps the OPC UA SDK's <c>MonitoringMode</c>
|
||||||
|
/// so Core.Abstractions stays SDK-free.
|
||||||
|
/// </summary>
|
||||||
|
public enum SubscriptionMonitoringMode
|
||||||
|
{
|
||||||
|
/// <summary>Item is created but neither sampling nor publishing.</summary>
|
||||||
|
Disabled = 0,
|
||||||
|
/// <summary>Item samples and queues but does not publish (useful with triggering).</summary>
|
||||||
|
Sampling = 1,
|
||||||
|
/// <summary>Item samples and publishes — the OPC UA default.</summary>
|
||||||
|
Reporting = 2,
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
|||||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
public int TimeoutMs { get; init; } = 5000;
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.2 — pin the device's CIP addressing mode for this CLI invocation.
|
||||||
|
/// Auto / Symbolic / Logical. Defaults to <see cref="AddressingMode.Auto"/> (resolves
|
||||||
|
/// to Symbolic until a future PR plumbs auto-detection). Logical against an
|
||||||
|
/// unsupported family (Micro800) silently falls back to Symbolic with a logged
|
||||||
|
/// warning, so passing <c>--addressing-mode Logical</c> across a mixed-family
|
||||||
|
/// fleet is safe.
|
||||||
|
/// </summary>
|
||||||
|
[CommandOption("addressing-mode", Description =
|
||||||
|
"CIP addressing mode: Auto / Symbolic / Logical (default Auto, resolves to " +
|
||||||
|
"Symbolic). Logical uses CIP Symbol Object instance IDs after a one-time @tags " +
|
||||||
|
"walk; unsupported on Micro800 (silent fallback to Symbolic with warning).")]
|
||||||
|
public AddressingMode AddressingMode { get; init; } = AddressingMode.Auto;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override TimeSpan Timeout
|
public override TimeSpan Timeout
|
||||||
{
|
{
|
||||||
@@ -43,7 +57,8 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
|||||||
Devices = [new AbCipDeviceOptions(
|
Devices = [new AbCipDeviceOptions(
|
||||||
HostAddress: Gateway,
|
HostAddress: Gateway,
|
||||||
PlcFamily: Family,
|
PlcFamily: Family,
|
||||||
DeviceName: $"cli-{Family}")],
|
DeviceName: $"cli-{Family}",
|
||||||
|
AddressingMode: AddressingMode)],
|
||||||
Tags = tags,
|
Tags = tags,
|
||||||
Timeout = Timeout,
|
Timeout = Timeout,
|
||||||
Probe = new AbCipProbeOptions { Enabled = false },
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Force a controller-side @tags re-walk on a live AbCip driver instance. Issue #233 —
|
||||||
|
/// online tag-DB refresh trigger. The CLI variant builds a transient driver against the
|
||||||
|
/// supplied gateway, runs <see cref="AbCipDriver.RebrowseAsync"/>, and prints the freshly
|
||||||
|
/// discovered tag names. In-server (Tier-A) operators wire this same call to an Admin UI
|
||||||
|
/// button so a controller program-download is reflected in the address space without a
|
||||||
|
/// driver restart.
|
||||||
|
/// </summary>
|
||||||
|
[Command("rebrowse", Description =
|
||||||
|
"Re-walk the AB CIP controller symbol table (force @tags refresh) and print discovered tags.")]
|
||||||
|
public sealed class RebrowseCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
// EnableControllerBrowse must be true for the @tags walk to happen; the CLI baseline
|
||||||
|
// (BuildOptions in AbCipCommandBase) leaves it off for one-shot probes, so we flip it
|
||||||
|
// here without touching the base helper.
|
||||||
|
var baseOpts = BuildOptions(tags: []);
|
||||||
|
var options = new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = baseOpts.Devices,
|
||||||
|
Tags = baseOpts.Tags,
|
||||||
|
Timeout = baseOpts.Timeout,
|
||||||
|
Probe = baseOpts.Probe,
|
||||||
|
EnableControllerBrowse = true,
|
||||||
|
EnableAlarmProjection = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
var builder = new ConsoleAddressSpaceBuilder();
|
||||||
|
await driver.RebrowseAsync(builder, ct);
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||||
|
await console.Output.WriteLineAsync($"Family: {Family}");
|
||||||
|
await console.Output.WriteLineAsync($"Variables: {builder.VariableCount}");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
foreach (var line in builder.Lines)
|
||||||
|
await console.Output.WriteLineAsync(line);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal in-memory <see cref="IAddressSpaceBuilder"/> that flattens the tree to one
|
||||||
|
/// line per variable for CLI display. Folder nesting is captured in the prefix so the
|
||||||
|
/// operator can see the same shape the in-server builder would receive.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ConsoleAddressSpaceBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
private readonly string _prefix;
|
||||||
|
private readonly Counter _counter;
|
||||||
|
public List<string> Lines { get; }
|
||||||
|
public int VariableCount => _counter.Count;
|
||||||
|
|
||||||
|
public ConsoleAddressSpaceBuilder() : this("", new List<string>(), new Counter()) { }
|
||||||
|
private ConsoleAddressSpaceBuilder(string prefix, List<string> sharedLines, Counter counter)
|
||||||
|
{
|
||||||
|
_prefix = prefix;
|
||||||
|
Lines = sharedLines;
|
||||||
|
_counter = counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{
|
||||||
|
var newPrefix = string.IsNullOrEmpty(_prefix) ? browseName : $"{_prefix}/{browseName}";
|
||||||
|
return new ConsoleAddressSpaceBuilder(newPrefix, Lines, _counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||||
|
{
|
||||||
|
_counter.Count++;
|
||||||
|
Lines.Add($" {_prefix}/{browseName} ({info.DriverDataType}, {info.SecurityClass})");
|
||||||
|
return new Handle(info.FullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||||
|
|
||||||
|
private sealed class Counter { public int Count; }
|
||||||
|
|
||||||
|
private sealed class Handle(string fullRef) : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => fullRef;
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||||
|
}
|
||||||
|
private sealed class NullSink : IAlarmConditionSink
|
||||||
|
{
|
||||||
|
public void OnTransition(AlarmEventArgs args) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using CliFx;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Exceptions;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dump the merged tag table from an <see cref="AbCipDriverOptions"/> JSON config to a
|
||||||
|
/// Kepware-format CSV. The command reads the pre-declared <c>Tags</c> list, pulls in any
|
||||||
|
/// <c>L5kImports</c> / <c>L5xImports</c> / <c>CsvImports</c> entries, applies the same
|
||||||
|
/// declared-wins precedence used by the live driver, and writes the union as one CSV.
|
||||||
|
/// Mirrors the round-trip path operators want for Excel-driven editing: export → edit →
|
||||||
|
/// re-import via the driver's <c>CsvImports</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The command does not contact any PLC — it is a pure transform over the options JSON.
|
||||||
|
/// <c>--driver-options-json</c> may point at a full options file or at a fragment that
|
||||||
|
/// deserialises to <see cref="AbCipDriverOptions"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[Command("tag-export", Description = "Export the merged tag table from a driver-options JSON to Kepware CSV.")]
|
||||||
|
public sealed class TagExportCommand : ICommand
|
||||||
|
{
|
||||||
|
[CommandOption("driver-options-json", Description =
|
||||||
|
"Path to a JSON file deserialising to AbCipDriverOptions (Tags + L5kImports + " +
|
||||||
|
"L5xImports + CsvImports). Imports with FilePath are loaded relative to the JSON.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string DriverOptionsJsonPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("out", 'o', Description = "Output CSV path (UTF-8, no BOM).", IsRequired = true)]
|
||||||
|
public string OutputPath { get; init; } = default!;
|
||||||
|
|
||||||
|
public ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
if (!File.Exists(DriverOptionsJsonPath))
|
||||||
|
throw new CommandException($"driver-options-json '{DriverOptionsJsonPath}' does not exist.");
|
||||||
|
|
||||||
|
var json = File.ReadAllText(DriverOptionsJsonPath);
|
||||||
|
var opts = JsonSerializer.Deserialize<AbCipDriverOptions>(json, JsonOpts)
|
||||||
|
?? throw new CommandException("driver-options-json deserialised to null.");
|
||||||
|
|
||||||
|
var basePath = Path.GetDirectoryName(Path.GetFullPath(DriverOptionsJsonPath)) ?? string.Empty;
|
||||||
|
|
||||||
|
var declaredNames = new HashSet<string>(
|
||||||
|
opts.Tags.Select(t => t.Name), StringComparer.OrdinalIgnoreCase);
|
||||||
|
var allTags = new List<AbCipTagDefinition>(opts.Tags);
|
||||||
|
|
||||||
|
foreach (var import in opts.L5kImports)
|
||||||
|
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
|
||||||
|
import.InlineText, import.NamePrefix, L5kParser.Parse, declaredNames, allTags);
|
||||||
|
foreach (var import in opts.L5xImports)
|
||||||
|
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
|
||||||
|
import.InlineText, import.NamePrefix, L5xParser.Parse, declaredNames, allTags);
|
||||||
|
foreach (var import in opts.CsvImports)
|
||||||
|
MergeCsv(import, basePath, declaredNames, allTags);
|
||||||
|
|
||||||
|
CsvTagExporter.WriteFile(allTags, OutputPath);
|
||||||
|
console.Output.WriteLine($"Wrote {allTags.Count} tag(s) to {OutputPath}");
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolvePath(string? path, string basePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return path;
|
||||||
|
return Path.IsPathRooted(path) ? path : Path.Combine(basePath, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeL5(
|
||||||
|
string deviceHost, string? filePath, string? inlineText, string namePrefix,
|
||||||
|
Func<IL5kSource, L5kDocument> parse,
|
||||||
|
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deviceHost)) return;
|
||||||
|
IL5kSource? src = null;
|
||||||
|
if (!string.IsNullOrEmpty(filePath)) src = new FileL5kSource(filePath);
|
||||||
|
else if (!string.IsNullOrEmpty(inlineText)) src = new StringL5kSource(inlineText);
|
||||||
|
if (src is null) return;
|
||||||
|
|
||||||
|
var doc = parse(src);
|
||||||
|
var ingest = new L5kIngest { DefaultDeviceHostAddress = deviceHost, NamePrefix = namePrefix };
|
||||||
|
foreach (var tag in ingest.Ingest(doc).Tags)
|
||||||
|
{
|
||||||
|
if (declaredNames.Contains(tag.Name)) continue;
|
||||||
|
allTags.Add(tag);
|
||||||
|
declaredNames.Add(tag.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeCsv(
|
||||||
|
AbCipCsvImportOptions import, string basePath,
|
||||||
|
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress)) return;
|
||||||
|
string? text = null;
|
||||||
|
var resolved = ResolvePath(import.FilePath, basePath);
|
||||||
|
if (!string.IsNullOrEmpty(resolved)) text = File.ReadAllText(resolved);
|
||||||
|
else if (!string.IsNullOrEmpty(import.InlineText)) text = import.InlineText;
|
||||||
|
if (text is null) return;
|
||||||
|
|
||||||
|
var importer = new CsvTagImporter
|
||||||
|
{
|
||||||
|
DefaultDeviceHostAddress = import.DeviceHostAddress,
|
||||||
|
NamePrefix = import.NamePrefix,
|
||||||
|
};
|
||||||
|
foreach (var tag in importer.Import(text).Tags)
|
||||||
|
{
|
||||||
|
if (declaredNames.Contains(tag.Name)) continue;
|
||||||
|
allTags.Add(tag);
|
||||||
|
declaredNames.Add(tag.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
}
|
||||||
94
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs
Normal file
94
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-1.3 — issues one libplctag tag-create with <c>ElementCount=N</c> per Rockwell
|
||||||
|
/// array-slice tag (<c>Tag[0..N]</c> in <see cref="AbCipTagPath"/>), then decodes the
|
||||||
|
/// contiguous buffer at element stride into <c>N</c> typed values. Mirrors the whole-UDT
|
||||||
|
/// planner pattern (<see cref="AbCipUdtReadPlanner"/>): pure shape — the planner never
|
||||||
|
/// touches the runtime + never reads the PLC, the driver wires the runtime in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Stride is the natural Logix size of the element type (DInt = 4, Real = 4, LInt = 8).
|
||||||
|
/// Bool / String / Structure slices aren't supported here — Logix packs BOOLs into a host
|
||||||
|
/// byte (no fixed stride), STRING members carry a Length+DATA pair that's not a flat array,
|
||||||
|
/// and structure arrays need the CIP Template Object reader (PR-tracked separately).</para>
|
||||||
|
///
|
||||||
|
/// <para>Output is a single <c>object[]</c> snapshot value containing the N decoded
|
||||||
|
/// elements at indices 0..Count-1. Pairing with one slice tag = one snapshot keeps the
|
||||||
|
/// <c>ReadAsync</c> 1:1 contract (one fullReference -> one snapshot) intact.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class AbCipArrayReadPlanner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build the libplctag create-params + decode descriptor for a slice tag. Returns
|
||||||
|
/// <c>null</c> when the slice element type isn't supported under this declaration-only
|
||||||
|
/// decoder (Bool / String / Structure / unrecognised) — the driver falls back to the
|
||||||
|
/// scalar read path so the operator gets a clean per-element result instead.
|
||||||
|
/// </summary>
|
||||||
|
public static AbCipArrayReadPlan? TryBuild(
|
||||||
|
AbCipTagDefinition definition,
|
||||||
|
AbCipTagPath parsedPath,
|
||||||
|
AbCipTagCreateParams baseParams)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(definition);
|
||||||
|
ArgumentNullException.ThrowIfNull(parsedPath);
|
||||||
|
ArgumentNullException.ThrowIfNull(baseParams);
|
||||||
|
if (parsedPath.Slice is null) return null;
|
||||||
|
|
||||||
|
if (!TryGetStride(definition.DataType, out var stride)) return null;
|
||||||
|
|
||||||
|
var slice = parsedPath.Slice;
|
||||||
|
var createParams = baseParams with
|
||||||
|
{
|
||||||
|
TagName = parsedPath.ToLibplctagSliceArrayName(),
|
||||||
|
ElementCount = slice.Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new AbCipArrayReadPlan(definition.DataType, slice, stride, createParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode <paramref name="plan"/>.Count elements from <paramref name="runtime"/> at
|
||||||
|
/// element stride. Caller has already invoked <see cref="IAbCipTagRuntime.ReadAsync"/>
|
||||||
|
/// and confirmed <see cref="IAbCipTagRuntime.GetStatus"/> == 0.
|
||||||
|
/// </summary>
|
||||||
|
public static object?[] Decode(AbCipArrayReadPlan plan, IAbCipTagRuntime runtime)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plan);
|
||||||
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
|
|
||||||
|
var values = new object?[plan.Slice.Count];
|
||||||
|
for (var i = 0; i < plan.Slice.Count; i++)
|
||||||
|
values[i] = runtime.DecodeValueAt(plan.ElementType, i * plan.Stride, bitIndex: null);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetStride(AbCipDataType type, out int stride)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case AbCipDataType.SInt: case AbCipDataType.USInt:
|
||||||
|
stride = 1; return true;
|
||||||
|
case AbCipDataType.Int: case AbCipDataType.UInt:
|
||||||
|
stride = 2; return true;
|
||||||
|
case AbCipDataType.DInt: case AbCipDataType.UDInt:
|
||||||
|
case AbCipDataType.Real: case AbCipDataType.Dt:
|
||||||
|
stride = 4; return true;
|
||||||
|
case AbCipDataType.LInt: case AbCipDataType.ULInt:
|
||||||
|
case AbCipDataType.LReal:
|
||||||
|
stride = 8; return true;
|
||||||
|
default:
|
||||||
|
stride = 0; return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan output: the libplctag create-params for the single array-read tag plus the
|
||||||
|
/// element-type / stride / slice metadata the decoder needs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AbCipArrayReadPlan(
|
||||||
|
AbCipDataType ElementType,
|
||||||
|
AbCipTagPathSlice Slice,
|
||||||
|
int Stride,
|
||||||
|
AbCipTagCreateParams CreateParams);
|
||||||
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs
Normal file
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.1 — bounds + magic numbers for the per-device CIP <c>ConnectionSize</c>
|
||||||
|
/// override. Pulled into a single place so config validation, the legacy-firmware warning,
|
||||||
|
/// and the docs stay in sync.
|
||||||
|
/// </summary>
|
||||||
|
public static class AbCipConnectionSize
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum supported CIP Forward Open buffer size, in bytes. Matches the lower bound of
|
||||||
|
/// Kepware's connection-size slider for ControlLogix drivers + the libplctag native
|
||||||
|
/// floor that still leaves headroom for the CIP MR header.
|
||||||
|
/// </summary>
|
||||||
|
public const int Min = 500;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum supported CIP Forward Open buffer size, in bytes. Matches the upper bound of
|
||||||
|
/// Kepware's slider + the Large Forward Open ceiling on FW20+ ControlLogix.
|
||||||
|
/// </summary>
|
||||||
|
public const int Max = 4002;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soft cap above which legacy ControlLogix firmware (v19 and earlier) rejects the
|
||||||
|
/// Forward Open. CompactLogix L1/L2/L3 narrow-cap parts (5069-L1/L2/L3) and Micro800
|
||||||
|
/// hard-cap below this too. Used as the threshold for the legacy-firmware warning.
|
||||||
|
/// </summary>
|
||||||
|
public const int LegacyFirmwareCap = 511;
|
||||||
|
}
|
||||||
@@ -50,11 +50,12 @@ public static class AbCipDataTypeExtensions
|
|||||||
AbCipDataType.Bool => DriverDataType.Boolean,
|
AbCipDataType.Bool => DriverDataType.Boolean,
|
||||||
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
|
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
|
||||||
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
|
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
|
||||||
AbCipDataType.LInt or AbCipDataType.ULInt => DriverDataType.Int32, // TODO: Int64 — matches Modbus gap
|
AbCipDataType.LInt => DriverDataType.Int64,
|
||||||
|
AbCipDataType.ULInt => DriverDataType.UInt64,
|
||||||
AbCipDataType.Real => DriverDataType.Float32,
|
AbCipDataType.Real => DriverDataType.Float32,
|
||||||
AbCipDataType.LReal => DriverDataType.Float64,
|
AbCipDataType.LReal => DriverDataType.Float64,
|
||||||
AbCipDataType.String => DriverDataType.String,
|
AbCipDataType.String => DriverDataType.String,
|
||||||
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT
|
AbCipDataType.Dt => DriverDataType.Int64, // Logix v32+ DT == LINT epoch-millis
|
||||||
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
|
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,13 @@ public static class AbCipDriverFactoryExtensions
|
|||||||
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
|
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||||
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
|
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
|
||||||
fallback: AbCipPlcFamily.ControlLogix),
|
fallback: AbCipPlcFamily.ControlLogix),
|
||||||
DeviceName: d.DeviceName))]
|
DeviceName: d.DeviceName,
|
||||||
|
ConnectionSize: d.ConnectionSize,
|
||||||
|
AddressingMode: ParseEnum<AddressingMode>(d.AddressingMode, "device", driverInstanceId,
|
||||||
|
"AddressingMode", fallback: AddressingMode.Auto),
|
||||||
|
ReadStrategy: ParseEnum<ReadStrategy>(d.ReadStrategy, "device", driverInstanceId,
|
||||||
|
"ReadStrategy", fallback: ReadStrategy.Auto),
|
||||||
|
MultiPacketSparsityThreshold: d.MultiPacketSparsityThreshold ?? 0.25))]
|
||||||
: [],
|
: [],
|
||||||
Tags = dto.Tags is { Count: > 0 }
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||||
@@ -119,6 +125,38 @@ public static class AbCipDriverFactoryExtensions
|
|||||||
public string? HostAddress { get; init; }
|
public string? HostAddress { get; init; }
|
||||||
public string? PlcFamily { get; init; }
|
public string? PlcFamily { get; init; }
|
||||||
public string? DeviceName { get; init; }
|
public string? DeviceName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.1 — optional per-device CIP <c>ConnectionSize</c> override. Validated
|
||||||
|
/// against <c>[500..4002]</c> at <see cref="AbCipDriver.InitializeAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int? ConnectionSize { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.2 — optional per-device addressing-mode override. <c>"Auto"</c>,
|
||||||
|
/// <c>"Symbolic"</c>, or <c>"Logical"</c>. Defaults to <c>Auto</c> (resolves to
|
||||||
|
/// Symbolic until a future PR adds real auto-detection). Family compatibility is
|
||||||
|
/// enforced at <see cref="AbCipDriver.InitializeAsync"/>: Logical against
|
||||||
|
/// Micro800 / SLC500 / PLC5 falls back to Symbolic with a warning.
|
||||||
|
/// </summary>
|
||||||
|
public string? AddressingMode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — optional per-device read-strategy override. <c>"Auto"</c>,
|
||||||
|
/// <c>"WholeUdt"</c>, or <c>"MultiPacket"</c>. Defaults to <c>Auto</c> (the planner
|
||||||
|
/// picks per-batch using <see cref="MultiPacketSparsityThreshold"/>). Family
|
||||||
|
/// compatibility is enforced at <see cref="AbCipDriver.InitializeAsync"/>: explicit
|
||||||
|
/// <c>MultiPacket</c> against Micro800 (no
|
||||||
|
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>) falls
|
||||||
|
/// back to <c>WholeUdt</c> with a warning.
|
||||||
|
/// </summary>
|
||||||
|
public string? ReadStrategy { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — sparsity-threshold knob applied when <see cref="ReadStrategy"/>
|
||||||
|
/// resolves to <c>Auto</c>. Default <c>0.25</c>; clamped to <c>[0..1]</c>.
|
||||||
|
/// </summary>
|
||||||
|
public double? MultiPacketSparsityThreshold { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class AbCipTagDto
|
internal sealed class AbCipTagDto
|
||||||
|
|||||||
@@ -21,6 +21,37 @@ public sealed class AbCipDriverOptions
|
|||||||
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
||||||
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L5K (Studio 5000 controller export) imports merged into <see cref="Tags"/> at
|
||||||
|
/// <c>InitializeAsync</c>. Each entry points at one L5K file + the device whose tags it
|
||||||
|
/// describes; the parser extracts <c>TAG</c> + <c>DATATYPE</c> blocks and produces
|
||||||
|
/// <see cref="AbCipTagDefinition"/> records (alias tags + ExternalAccess=None tags
|
||||||
|
/// skipped — see <see cref="Import.L5kIngest"/>). Pre-declared <see cref="Tags"/> entries
|
||||||
|
/// win on <c>Name</c> conflicts so operators can override import results without
|
||||||
|
/// editing the L5K source.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AbCipL5kImportOptions> L5kImports { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L5X (Studio 5000 XML controller export) imports merged into <see cref="Tags"/> at
|
||||||
|
/// <c>InitializeAsync</c>. Same shape and merge semantics as <see cref="L5kImports"/> —
|
||||||
|
/// the entries differ only in source format. Pre-declared <see cref="Tags"/> entries win
|
||||||
|
/// on <c>Name</c> conflicts; entries already produced by <see cref="L5kImports"/> also win
|
||||||
|
/// so an L5X re-export of the same controller doesn't double-emit. See
|
||||||
|
/// <see cref="Import.L5xParser"/> for the format-specific mechanics.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AbCipL5xImportOptions> L5xImports { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kepware-format CSV imports merged into <see cref="Tags"/> at <c>InitializeAsync</c>.
|
||||||
|
/// Same merge semantics as <see cref="L5kImports"/> / <see cref="L5xImports"/> —
|
||||||
|
/// pre-declared <see cref="Tags"/> entries win on <c>Name</c> conflicts, and tags
|
||||||
|
/// produced by earlier import collections (L5K → L5X → CSV in call order) also win
|
||||||
|
/// so an Excel-edited copy of the same controller does not double-emit. See
|
||||||
|
/// <see cref="Import.CsvTagImporter"/> for the column layout + parse rules.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AbCipCsvImportOptions> CsvImports { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||||||
public AbCipProbeOptions Probe { get; init; } = new();
|
public AbCipProbeOptions Probe { get; init; } = new();
|
||||||
|
|
||||||
@@ -56,6 +87,14 @@ public sealed class AbCipDriverOptions
|
|||||||
/// 1 second — matches typical SCADA alarm-refresh conventions.
|
/// 1 second — matches typical SCADA alarm-refresh conventions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.1 — optional sink for non-fatal driver warnings (legacy-firmware
|
||||||
|
/// <c>ConnectionSize</c> mis-match, etc.). Production hosting wires this to Serilog;
|
||||||
|
/// unit tests pin a list-collecting lambda to assert which warnings fired. <c>null</c>
|
||||||
|
/// swallows warnings — convenient for back-compat deployments that don't care.
|
||||||
|
/// </summary>
|
||||||
|
public Action<string>? OnWarning { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -67,10 +106,137 @@ public sealed class AbCipDriverOptions
|
|||||||
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
|
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
|
||||||
/// request-packing support, unconnected-only hint, and other quirks.</param>
|
/// request-packing support, unconnected-only hint, and other quirks.</param>
|
||||||
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
|
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
|
||||||
|
/// <param name="ConnectionSize">PR abcip-3.1 — optional override for the family-default
|
||||||
|
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.DefaultConnectionSize"/>. Threads through to
|
||||||
|
/// libplctag's <c>connection_size</c> attribute on the underlying tag handle so operators can
|
||||||
|
/// dial the CIP Forward Open buffer down for legacy firmware (v19-and-earlier ControlLogix
|
||||||
|
/// caps at 504) or up for high-throughput shops on FW20+. Validated against the Kepware
|
||||||
|
/// supported range [500..4002] at <c>InitializeAsync</c>; out-of-range values fault the
|
||||||
|
/// driver. <c>null</c> uses the family default — back-compat with deployments that haven't
|
||||||
|
/// touched the knob.</param>
|
||||||
|
/// <param name="AddressingMode">PR abcip-3.2 — controls whether the driver addresses tags by
|
||||||
|
/// ASCII symbolic path (the default), by CIP logical-segment instance ID, or asks the driver
|
||||||
|
/// to pick. Logical addressing skips per-poll ASCII parsing on every read and unlocks
|
||||||
|
/// symbol-table-cached scans for 500+-tag projects, but requires a one-time symbol-table
|
||||||
|
/// walk at first read + is unsupported on Micro800 / SLC500 / PLC5 (their CIP firmware does
|
||||||
|
/// not honour Symbol Object instance IDs). When the user picks <see cref="AbCip.AddressingMode.Logical"/>
|
||||||
|
/// against an unsupported family the driver logs a warning + falls back to symbolic so
|
||||||
|
/// misconfiguration does not fault the driver. <see cref="AbCip.AddressingMode.Auto"/> currently
|
||||||
|
/// resolves to symbolic — a future PR will plumb a real auto-detection heuristic; the docs
|
||||||
|
/// in <c>docs/drivers/AbCip-Performance.md</c> §"Addressing mode" call this out.</param>
|
||||||
|
/// <param name="ReadStrategy">PR abcip-3.3 — picks how a multi-member UDT batch is read on this
|
||||||
|
/// device. <see cref="AbCip.ReadStrategy.WholeUdt"/> issues one read per parent UDT and decodes
|
||||||
|
/// each subscribed member from the buffer in-memory (the historical behaviour that ships in
|
||||||
|
/// task #194 — best when a large fraction of a UDT's members are subscribed).
|
||||||
|
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> bundles per-member reads into one CIP
|
||||||
|
/// Multi-Service Packet — best for sparse UDT subscriptions where reading the whole UDT
|
||||||
|
/// buffer just to extract one or two fields wastes wire bandwidth. <see cref="AbCip.ReadStrategy.Auto"/>
|
||||||
|
/// (the default) lets the planner pick per-batch using
|
||||||
|
/// <paramref name="MultiPacketSparsityThreshold"/>: if the subscribed-member fraction is below
|
||||||
|
/// the threshold MultiPacket wins, otherwise WholeUdt wins. Family compatibility — Micro800 /
|
||||||
|
/// SLC500 / PLC5 lack Multi-Service-Packet support per
|
||||||
|
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>; user-forced
|
||||||
|
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> against those families logs a warning + falls
|
||||||
|
/// back to <see cref="AbCip.ReadStrategy.WholeUdt"/> at device-init time. The libplctag .NET
|
||||||
|
/// wrapper (1.5.x) does not expose a public knob for explicit Multi-Service-Packet bundling,
|
||||||
|
/// so today's MultiPacket runtime issues one libplctag read per member; the planner's grouping
|
||||||
|
/// is still load-bearing because it gives the runtime the right plan to execute when an
|
||||||
|
/// upstream wrapper release exposes wire-level bundling.</param>
|
||||||
|
/// <param name="MultiPacketSparsityThreshold">PR abcip-3.3 — sparsity-threshold knob the planner
|
||||||
|
/// uses when <paramref name="ReadStrategy"/> is <see cref="AbCip.ReadStrategy.Auto"/>. The
|
||||||
|
/// planner divides <c>subscribedMembers / totalMembers</c> for each parent UDT in a batch;
|
||||||
|
/// a fraction strictly less than the threshold picks
|
||||||
|
/// <see cref="AbCip.ReadStrategy.MultiPacket"/>, else <see cref="AbCip.ReadStrategy.WholeUdt"/>.
|
||||||
|
/// Default <c>0.25</c> — picked because reading 1/4 of a UDT's members is the rough break-even
|
||||||
|
/// where the wire-cost of one whole-UDT read still beats N member reads on ControlLogix's
|
||||||
|
/// 4002-byte connection size; see <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy".
|
||||||
|
/// Clamped to <c>[0..1]</c> at planner time; values outside the range silently saturate.</param>
|
||||||
public sealed record AbCipDeviceOptions(
|
public sealed record AbCipDeviceOptions(
|
||||||
string HostAddress,
|
string HostAddress,
|
||||||
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
|
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
|
||||||
string? DeviceName = null);
|
string? DeviceName = null,
|
||||||
|
int? ConnectionSize = null,
|
||||||
|
AddressingMode AddressingMode = AddressingMode.Auto,
|
||||||
|
ReadStrategy ReadStrategy = ReadStrategy.Auto,
|
||||||
|
double MultiPacketSparsityThreshold = 0.25);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — per-device strategy for reading multi-member UDT batches. <see cref="WholeUdt"/>
|
||||||
|
/// mirrors the task #194 behaviour: one libplctag read on the parent tag, each subscribed member
|
||||||
|
/// decoded from the buffer at its computed offset. <see cref="MultiPacket"/> bundles per-member
|
||||||
|
/// reads into one CIP Multi-Service Packet so sparse UDT subscriptions don't pay for the whole
|
||||||
|
/// UDT buffer. <see cref="Auto"/> lets the planner pick per-batch using
|
||||||
|
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Strategy resolution lives at two layers:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Device init</b> — user-forced <see cref="MultiPacket"/> against a family whose
|
||||||
|
/// profile sets <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>
|
||||||
|
/// = <c>false</c> (Micro800, SLC500, PLC5) falls back to <see cref="WholeUdt"/> with a
|
||||||
|
/// warning. <see cref="Auto"/> stays as-is (the planner re-evaluates per batch).</item>
|
||||||
|
/// <item><b>Per-batch (Auto only)</b> — for each parent UDT in the request set, the planner
|
||||||
|
/// computes <c>subscribedMembers / totalMembers</c> and routes the group through
|
||||||
|
/// <see cref="MultiPacket"/> when the fraction is below the threshold, else
|
||||||
|
/// <see cref="WholeUdt"/>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>libplctag .NET wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling,
|
||||||
|
/// so today's runtime issues one libplctag read per member when the planner picks MultiPacket —
|
||||||
|
/// the same wrapper limitation called out in PR abcip-3.1 (ConnectionSize) and PR abcip-3.2
|
||||||
|
/// (instance-ID addressing). The planner's grouping is still observable from tests + future-proofs
|
||||||
|
/// the driver for when an upstream wrapper release exposes wire-level bundling.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public enum ReadStrategy
|
||||||
|
{
|
||||||
|
/// <summary>Driver picks per-batch based on
|
||||||
|
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>. Default.</summary>
|
||||||
|
Auto = 0,
|
||||||
|
|
||||||
|
/// <summary>One read per parent UDT; members decoded from the buffer in-memory. Best when a
|
||||||
|
/// large fraction of the UDT's members are subscribed (dense reads).</summary>
|
||||||
|
WholeUdt = 1,
|
||||||
|
|
||||||
|
/// <summary>Bundle per-member reads into one CIP Multi-Service Packet. Best when only a few
|
||||||
|
/// members of a large UDT are subscribed (sparse reads). Unsupported on Micro800 / SLC500 /
|
||||||
|
/// PLC5; the driver warns + falls back to <see cref="WholeUdt"/> at device init.</summary>
|
||||||
|
MultiPacket = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.2 — how the AB CIP driver addresses tags on a given device. <see cref="Symbolic"/>
|
||||||
|
/// is the historical default + matches every previous driver build: each read carries the tag
|
||||||
|
/// name as ASCII bytes + the controller parses the path on every request. <see cref="Logical"/>
|
||||||
|
/// uses CIP logical-segment instance IDs (Symbol Object class 0x6B) — the controller looks the
|
||||||
|
/// tag up in its own symbol table once + the driver caches the resolved instance ID for
|
||||||
|
/// subsequent reads, eliminating the per-poll ASCII parse step. <see cref="Auto"/> lets the
|
||||||
|
/// driver pick (today: always Symbolic; a future PR fingerprints the controller and switches
|
||||||
|
/// to Logical when supported).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Logical addressing requires a one-time symbol-table walk at the first read on the device
|
||||||
|
/// (the driver issues an <c>@tags</c> read via <see cref="LibplctagTagEnumerator"/> and stores
|
||||||
|
/// the name → instance-id map on the per-device <c>DeviceState</c>). It is unsupported on
|
||||||
|
/// Micro800 / SLC500 / PLC5 — see <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing"/>.
|
||||||
|
/// The libplctag .NET wrapper (1.5.x) does not expose a public knob for instance-ID
|
||||||
|
/// addressing, so the driver translates Logical → libplctag attribute via reflection on
|
||||||
|
/// <c>NativeTagWrapper.SetAttributeString</c> — same best-effort fallback pattern as
|
||||||
|
/// PR abcip-3.1's ConnectionSize plumbing.
|
||||||
|
/// </remarks>
|
||||||
|
public enum AddressingMode
|
||||||
|
{
|
||||||
|
/// <summary>Driver picks. Currently resolves to <see cref="Symbolic"/>; future PR may
|
||||||
|
/// auto-detect based on family + firmware + symbol-table size.</summary>
|
||||||
|
Auto = 0,
|
||||||
|
|
||||||
|
/// <summary>ASCII symbolic-path addressing — the libplctag default. Per-poll ASCII parse on
|
||||||
|
/// the controller; works on every CIP family.</summary>
|
||||||
|
Symbolic = 1,
|
||||||
|
|
||||||
|
/// <summary>CIP logical-segment / instance-ID addressing. Requires a one-time
|
||||||
|
/// symbol-table walk at first read; subsequent reads skip ASCII parsing on the
|
||||||
|
/// controller. Unsupported on Micro800 / SLC500 / PLC5.</summary>
|
||||||
|
Logical = 2,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
|
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
|
||||||
@@ -92,6 +258,17 @@ public sealed record AbCipDeviceOptions(
|
|||||||
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
|
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
|
||||||
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
||||||
/// write attempt failing at runtime.</param>
|
/// write attempt failing at runtime.</param>
|
||||||
|
/// <param name="StringLength">Capacity of the DATA character array on a Logix STRING / STRINGnn
|
||||||
|
/// UDT — 82 for the stock <c>STRING</c>, 20/40/80/etc for user-defined <c>STRING_20</c>,
|
||||||
|
/// <c>STRING_40</c>, <c>STRING_80</c> variants. Threads through libplctag's
|
||||||
|
/// <c>str_max_capacity</c> attribute so the wrapper allocates the correct backing buffer
|
||||||
|
/// and <c>GetString</c> / <c>SetString</c> truncate at the right boundary. <c>null</c>
|
||||||
|
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
|
||||||
|
/// non-<see cref="AbCipDataType.String"/> types.</param>
|
||||||
|
/// <param name="Description">Tag description carried from the L5K/L5X export (or set explicitly
|
||||||
|
/// in pre-declared config). Surfaces as the OPC UA <c>Description</c> attribute on the
|
||||||
|
/// produced Variable node so SCADA / engineering clients see the comment from the source
|
||||||
|
/// project. <c>null</c> leaves Description unset, matching pre-2.3 behaviour.</param>
|
||||||
public sealed record AbCipTagDefinition(
|
public sealed record AbCipTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string DeviceHostAddress,
|
string DeviceHostAddress,
|
||||||
@@ -100,7 +277,9 @@ public sealed record AbCipTagDefinition(
|
|||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false,
|
bool WriteIdempotent = false,
|
||||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||||
bool SafetyTag = false);
|
bool SafetyTag = false,
|
||||||
|
int? StringLength = null,
|
||||||
|
string? Description = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||||
@@ -108,11 +287,92 @@ public sealed record AbCipTagDefinition(
|
|||||||
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
|
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
|
||||||
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><see cref="Description"/> carries the per-member comment from L5K/L5X UDT definitions so
|
||||||
|
/// the OPC UA Variable nodes produced for individual members surface their descriptions too,
|
||||||
|
/// not just the top-level tag.</para>
|
||||||
|
/// <para>PR abcip-2.6 — <see cref="AoiQualifier"/> tags AOI parameters as Input / Output /
|
||||||
|
/// InOut / Local. Plain UDT members default to <see cref="AoiQualifier.Local"/>. Discovery
|
||||||
|
/// groups Input / Output / InOut members under sub-folders so an AOI-typed tag fans out as
|
||||||
|
/// <c>Tag/Inputs/...</c>, <c>Tag/Outputs/...</c>, <c>Tag/InOut/...</c> while Local stays at the
|
||||||
|
/// UDT root — matching how AOIs visually present in Studio 5000.</para>
|
||||||
|
/// </remarks>
|
||||||
public sealed record AbCipStructureMember(
|
public sealed record AbCipStructureMember(
|
||||||
string Name,
|
string Name,
|
||||||
AbCipDataType DataType,
|
AbCipDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
int? StringLength = null,
|
||||||
|
string? Description = null,
|
||||||
|
AoiQualifier AoiQualifier = AoiQualifier.Local);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.6 — directional qualifier for AOI parameters. Surfaces the Studio 5000
|
||||||
|
/// <c>Usage</c> attribute (<c>Input</c> / <c>Output</c> / <c>InOut</c>) so discovery can group
|
||||||
|
/// AOI members into sub-folders and downstream consumers can reason about parameter direction.
|
||||||
|
/// Plain UDT members (non-AOI types) default to <see cref="Local"/>, which keeps them at the
|
||||||
|
/// UDT root + indicates they are internal storage rather than a directional parameter.
|
||||||
|
/// </summary>
|
||||||
|
public enum AoiQualifier
|
||||||
|
{
|
||||||
|
/// <summary>UDT member or AOI local tag — non-directional, browsed at the parent's root.</summary>
|
||||||
|
Local,
|
||||||
|
|
||||||
|
/// <summary>AOI input parameter — written by the caller, read by the AOI body.</summary>
|
||||||
|
Input,
|
||||||
|
|
||||||
|
/// <summary>AOI output parameter — written by the AOI body, read by the caller.</summary>
|
||||||
|
Output,
|
||||||
|
|
||||||
|
/// <summary>AOI bidirectional parameter — passed by reference, both sides may read/write.</summary>
|
||||||
|
InOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One L5K-import entry. Either <see cref="FilePath"/> or <see cref="InlineText"/> must be
|
||||||
|
/// set (FilePath wins when both supplied — useful for tests that pre-load fixtures into
|
||||||
|
/// options without touching disk).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||||
|
/// <param name="FilePath">On-disk path to a <c>*.L5K</c> export. Loaded eagerly at InitializeAsync.</param>
|
||||||
|
/// <param name="InlineText">Pre-loaded L5K body — used by tests + Admin UI uploads.</param>
|
||||||
|
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
|
||||||
|
/// when ingesting multiple files into one driver instance.</param>
|
||||||
|
public sealed record AbCipL5kImportOptions(
|
||||||
|
string DeviceHostAddress,
|
||||||
|
string? FilePath = null,
|
||||||
|
string? InlineText = null,
|
||||||
|
string NamePrefix = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One L5X-import entry. Mirrors <see cref="AbCipL5kImportOptions"/> field-for-field — the
|
||||||
|
/// two are kept as distinct types so configuration JSON makes the source format explicit
|
||||||
|
/// (an L5X file under an <c>L5kImports</c> entry would parse-fail confusingly otherwise).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||||
|
/// <param name="FilePath">On-disk path to a <c>*.L5X</c> XML export. Loaded eagerly at InitializeAsync.</param>
|
||||||
|
/// <param name="InlineText">Pre-loaded L5X body — used by tests + Admin UI uploads.</param>
|
||||||
|
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
|
||||||
|
/// when ingesting multiple files into one driver instance.</param>
|
||||||
|
public sealed record AbCipL5xImportOptions(
|
||||||
|
string DeviceHostAddress,
|
||||||
|
string? FilePath = null,
|
||||||
|
string? InlineText = null,
|
||||||
|
string NamePrefix = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One Kepware-format CSV import entry. Field shape mirrors <see cref="AbCipL5kImportOptions"/>
|
||||||
|
/// so configuration JSON stays consistent across the three import sources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||||
|
/// <param name="FilePath">On-disk path to a Kepware-format <c>*.csv</c>. Loaded eagerly at InitializeAsync.</param>
|
||||||
|
/// <param name="InlineText">Pre-loaded CSV body — used by tests + Admin UI uploads.</param>
|
||||||
|
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions.</param>
|
||||||
|
public sealed record AbCipCsvImportOptions(
|
||||||
|
string DeviceHostAddress,
|
||||||
|
string? FilePath = null,
|
||||||
|
string? InlineText = null,
|
||||||
|
string NamePrefix = "");
|
||||||
|
|
||||||
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||||
public enum AbCipPlcFamily
|
public enum AbCipPlcFamily
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — sparse-UDT read planner. Where <see cref="AbCipUdtReadPlanner"/> reads each
|
||||||
|
/// parent UDT once and decodes every subscribed member from the buffer in-memory, this planner
|
||||||
|
/// keeps the per-member read shape and bundles the reads into one CIP Multi-Service Packet
|
||||||
|
/// per parent so a 5-of-50-member subscription doesn't pay for the whole UDT buffer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Pure function — like its sibling planner, this one never touches the runtime + never
|
||||||
|
/// reads the PLC. It produces the plan; <see cref="AbCipDriver"/> executes it.</para>
|
||||||
|
///
|
||||||
|
/// <para>The planner is intentionally <c>libplctag</c>-agnostic: the output is just a list of
|
||||||
|
/// <see cref="AbCipMultiPacketReadBatch"/> records that name the parent UDT, the per-member
|
||||||
|
/// read targets, and their byte offsets. The runtime layer decides whether to issue one
|
||||||
|
/// libplctag read per member (today's wrapper-limited fallback) or to flush the batch onto
|
||||||
|
/// one Multi-Service Packet (a future wrapper release). Either way the planner-tier logic
|
||||||
|
/// stays correct, which is why the unit tests in
|
||||||
|
/// <c>AbCipMultiPacketReadPlannerTests</c> assert plan shape rather than wire bytes.</para>
|
||||||
|
///
|
||||||
|
/// <para>Auto-mode dispatch (the heuristic): callers run <see cref="ChooseStrategyForGroup"/>
|
||||||
|
/// for each parent UDT to pick between the WholeUdt and MultiPacket paths per-group. The
|
||||||
|
/// heuristic divides <c>subscribedMembers / totalMembers</c> and picks MultiPacket when the
|
||||||
|
/// fraction is strictly less than the device's
|
||||||
|
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class AbCipMultiPacketReadPlanner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build a multi-packet read plan from <paramref name="requests"/>. Members of the same
|
||||||
|
/// parent UDT collapse into one <see cref="AbCipMultiPacketReadBatch"/>; references that
|
||||||
|
/// don't resolve to a UDT member fall back to <see cref="AbCipUdtReadFallback"/> for the
|
||||||
|
/// existing per-tag read path.
|
||||||
|
/// </summary>
|
||||||
|
public static AbCipMultiPacketReadPlan Build(
|
||||||
|
IReadOnlyList<string> requests,
|
||||||
|
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(requests);
|
||||||
|
ArgumentNullException.ThrowIfNull(tagsByName);
|
||||||
|
|
||||||
|
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
|
||||||
|
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
for (var i = 0; i < requests.Count; i++)
|
||||||
|
{
|
||||||
|
var name = requests[i];
|
||||||
|
if (!tagsByName.TryGetValue(name, out var def))
|
||||||
|
{
|
||||||
|
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (parentName, memberName) = SplitParentMember(name);
|
||||||
|
if (parentName is null || memberName is null
|
||||||
|
|| !tagsByName.TryGetValue(parentName, out var parent)
|
||||||
|
|| parent.DataType != AbCipDataType.Structure
|
||||||
|
|| parent.Members is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
|
||||||
|
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
|
||||||
|
{
|
||||||
|
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!byParent.TryGetValue(parentName, out var members))
|
||||||
|
{
|
||||||
|
members = new List<AbCipUdtReadMember>();
|
||||||
|
byParent[parentName] = members;
|
||||||
|
}
|
||||||
|
members.Add(new AbCipUdtReadMember(i, def, offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
var batches = new List<AbCipMultiPacketReadBatch>(byParent.Count);
|
||||||
|
foreach (var (parentName, members) in byParent)
|
||||||
|
{
|
||||||
|
batches.Add(new AbCipMultiPacketReadBatch(parentName, tagsByName[parentName], members));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AbCipMultiPacketReadPlan(batches, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — Auto-mode heuristic. For a single parent UDT group with
|
||||||
|
/// <paramref name="subscribedMembers"/> of <paramref name="totalMembers"/> declared
|
||||||
|
/// members, pick <see cref="ReadStrategy.MultiPacket"/> when sparsity is strictly below
|
||||||
|
/// <paramref name="threshold"/>, else <see cref="ReadStrategy.WholeUdt"/>. Threshold is
|
||||||
|
/// clamped to <c>[0..1]</c>; out-of-range values saturate. Edge cases:
|
||||||
|
/// <c>totalMembers == 0</c> defaults to <see cref="ReadStrategy.WholeUdt"/> (the
|
||||||
|
/// historical behaviour) so a misconfigured tag map doesn't fault the read.
|
||||||
|
/// </summary>
|
||||||
|
public static ReadStrategy ChooseStrategyForGroup(int subscribedMembers, int totalMembers, double threshold)
|
||||||
|
{
|
||||||
|
if (totalMembers <= 0) return ReadStrategy.WholeUdt;
|
||||||
|
|
||||||
|
// Saturate the threshold to a sane range. 0.0 → never MultiPacket; 1.0 → always
|
||||||
|
// MultiPacket whenever any member is subscribed (deterministic boundary behaviour).
|
||||||
|
var t = threshold;
|
||||||
|
if (t < 0.0) t = 0.0;
|
||||||
|
if (t > 1.0) t = 1.0;
|
||||||
|
|
||||||
|
var fraction = (double)subscribedMembers / totalMembers;
|
||||||
|
return fraction < t ? ReadStrategy.MultiPacket : ReadStrategy.WholeUdt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string? Parent, string? Member) SplitParentMember(string reference)
|
||||||
|
{
|
||||||
|
var dot = reference.IndexOf('.');
|
||||||
|
if (dot <= 0 || dot == reference.Length - 1) return (null, null);
|
||||||
|
return (reference[..dot], reference[(dot + 1)..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A planner output: per-parent multi-packet batches + per-tag fallbacks.</summary>
|
||||||
|
public sealed record AbCipMultiPacketReadPlan(
|
||||||
|
IReadOnlyList<AbCipMultiPacketReadBatch> Batches,
|
||||||
|
IReadOnlyList<AbCipUdtReadFallback> Fallbacks);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One UDT parent whose subscribed members are bundled into a Multi-Service Packet read.
|
||||||
|
/// Reuses <see cref="AbCipUdtReadMember"/> from the WholeUdt planner so callers can decode
|
||||||
|
/// the member offsets uniformly across both planners.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AbCipMultiPacketReadBatch(
|
||||||
|
string ParentName,
|
||||||
|
AbCipTagDefinition ParentDefinition,
|
||||||
|
IReadOnlyList<AbCipUdtReadMember> Members);
|
||||||
112
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiWritePlanner.cs
Normal file
112
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiWritePlanner.cs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-1.4 — multi-tag write planner. Groups a batch of <see cref="WriteRequest"/>s by
|
||||||
|
/// device so the driver can submit one round of writes per device instead of looping
|
||||||
|
/// strictly serially across the whole batch. Honours the per-family
|
||||||
|
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> flag: families that support
|
||||||
|
/// CIP request packing (ControlLogix / CompactLogix / GuardLogix) issue their writes in
|
||||||
|
/// parallel so libplctag's internal scheduler can coalesce them onto one Multi-Service
|
||||||
|
/// Packet (0x0A); Micro800 (no request packing) falls back to per-tag sequential writes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>The libplctag .NET wrapper exposes one CIP service per <c>Tag</c> instance and does
|
||||||
|
/// not surface Multi-Service Packet construction at the API surface — but the underlying
|
||||||
|
/// native library packs concurrent operations against the same connection automatically
|
||||||
|
/// when the family's protocol supports it. Issuing the writes concurrently per device
|
||||||
|
/// therefore gives us the round-trip reduction described in #228 without having to drop to
|
||||||
|
/// raw CIP, while still letting us short-circuit packing on Micro800 where it would be
|
||||||
|
/// unsafe.</para>
|
||||||
|
///
|
||||||
|
/// <para>Bit-RMW writes (BOOL-with-bitIndex against a DINT parent) are excluded from
|
||||||
|
/// packing here because they need a serialised read-modify-write under the per-parent
|
||||||
|
/// <c>SemaphoreSlim</c> in <see cref="AbCipDriver.WriteBitInDIntAsync"/>. Packing two RMWs
|
||||||
|
/// on the same DINT would risk losing one another's update.</para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class AbCipMultiWritePlanner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One classified entry in the input batch. <see cref="OriginalIndex"/> preserves the
|
||||||
|
/// caller's ordering so per-tag <c>StatusCode</c> fan-out lands at the right slot in
|
||||||
|
/// the result array. <see cref="IsBitRmw"/> routes the entry through the RMW path even
|
||||||
|
/// when the device supports packing.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct ClassifiedWrite(
|
||||||
|
int OriginalIndex,
|
||||||
|
WriteRequest Request,
|
||||||
|
AbCipTagDefinition Definition,
|
||||||
|
AbCipTagPath? ParsedPath,
|
||||||
|
bool IsBitRmw);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One device's plan slice. <see cref="Packable"/> entries can be issued concurrently;
|
||||||
|
/// <see cref="BitRmw"/> entries must go through the RMW path one-at-a-time per parent
|
||||||
|
/// DINT.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DevicePlan
|
||||||
|
{
|
||||||
|
public required string DeviceHostAddress { get; init; }
|
||||||
|
public required AbCipPlcFamilyProfile Profile { get; init; }
|
||||||
|
public List<ClassifiedWrite> Packable { get; } = new();
|
||||||
|
public List<ClassifiedWrite> BitRmw { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the per-device plan list. Entries are visited in input order so the resulting
|
||||||
|
/// plan's traversal preserves caller ordering within each device. Entries that fail
|
||||||
|
/// resolution (unknown reference, non-writable tag, unknown device) are reported via
|
||||||
|
/// <paramref name="reportPreflight"/> with the appropriate StatusCode and excluded from
|
||||||
|
/// the plan.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<DevicePlan> Build(
|
||||||
|
IReadOnlyList<WriteRequest> writes,
|
||||||
|
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
|
||||||
|
IReadOnlyDictionary<string, AbCipDriver.DeviceState> devices,
|
||||||
|
Action<int, uint> reportPreflight)
|
||||||
|
{
|
||||||
|
var plans = new Dictionary<string, DevicePlan>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var order = new List<DevicePlan>();
|
||||||
|
|
||||||
|
for (var i = 0; i < writes.Count; i++)
|
||||||
|
{
|
||||||
|
var w = writes[i];
|
||||||
|
if (!tagsByName.TryGetValue(w.FullReference, out var def))
|
||||||
|
{
|
||||||
|
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!def.Writable || def.SafetyTag)
|
||||||
|
{
|
||||||
|
reportPreflight(i, AbCipStatusMapper.BadNotWritable);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||||
|
{
|
||||||
|
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plans.TryGetValue(def.DeviceHostAddress, out var plan))
|
||||||
|
{
|
||||||
|
plan = new DevicePlan
|
||||||
|
{
|
||||||
|
DeviceHostAddress = def.DeviceHostAddress,
|
||||||
|
Profile = device.Profile,
|
||||||
|
};
|
||||||
|
plans[def.DeviceHostAddress] = plan;
|
||||||
|
order.Add(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = AbCipTagPath.TryParse(def.TagPath);
|
||||||
|
var isBitRmw = def.DataType == AbCipDataType.Bool && parsed?.BitIndex is int;
|
||||||
|
var entry = new ClassifiedWrite(i, w, def, parsed, isBitRmw);
|
||||||
|
if (isBitRmw) plan.BitRmw.Add(entry);
|
||||||
|
else plan.Packable.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
public sealed record AbCipTagPath(
|
public sealed record AbCipTagPath(
|
||||||
string? ProgramScope,
|
string? ProgramScope,
|
||||||
IReadOnlyList<AbCipTagPathSegment> Segments,
|
IReadOnlyList<AbCipTagPathSegment> Segments,
|
||||||
int? BitIndex)
|
int? BitIndex,
|
||||||
|
AbCipTagPathSlice? Slice = null)
|
||||||
{
|
{
|
||||||
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
||||||
public string ToLibplctagName()
|
public string ToLibplctagName()
|
||||||
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
|
|||||||
if (seg.Subscripts.Count > 0)
|
if (seg.Subscripts.Count > 0)
|
||||||
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
||||||
}
|
}
|
||||||
|
if (Slice is not null) buf.Append('[').Append(Slice.Start).Append("..").Append(Slice.End).Append(']');
|
||||||
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
|
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
|
||||||
return buf.ToString();
|
return buf.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logix-symbol form for issuing a single libplctag tag-create that reads the slice as a
|
||||||
|
/// contiguous buffer — i.e. the bare array name (with the start subscript) without the
|
||||||
|
/// <c>..End</c> suffix. The driver pairs this with <see cref="AbCipTagCreateParams.ElementCount"/>
|
||||||
|
/// = <see cref="AbCipTagPathSlice.Count"/> to issue a single Rockwell array read.
|
||||||
|
/// </summary>
|
||||||
|
public string ToLibplctagSliceArrayName()
|
||||||
|
{
|
||||||
|
if (Slice is null) return ToLibplctagName();
|
||||||
|
var buf = new System.Text.StringBuilder();
|
||||||
|
if (ProgramScope is not null)
|
||||||
|
buf.Append("Program:").Append(ProgramScope).Append('.');
|
||||||
|
|
||||||
|
for (var i = 0; i < Segments.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) buf.Append('.');
|
||||||
|
var seg = Segments[i];
|
||||||
|
buf.Append(seg.Name);
|
||||||
|
if (seg.Subscripts.Count > 0)
|
||||||
|
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
||||||
|
}
|
||||||
|
// Anchor the read at the slice start; libplctag treats Name=Tag[0] + ElementCount=N as
|
||||||
|
// "read N consecutive elements starting at index 0", which is the exact Rockwell
|
||||||
|
// array-read semantic this PR is wiring up.
|
||||||
|
buf.Append('[').Append(Slice.Start).Append(']');
|
||||||
|
return buf.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
|
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
|
||||||
/// doesn't support — the driver surfaces that as a config-validation error rather than
|
/// doesn't support — the driver surfaces that as a config-validation error rather than
|
||||||
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var segments = new List<AbCipTagPathSegment>(parts.Count);
|
var segments = new List<AbCipTagPathSegment>(parts.Count);
|
||||||
foreach (var part in parts)
|
AbCipTagPathSlice? slice = null;
|
||||||
|
for (var partIdx = 0; partIdx < parts.Count; partIdx++)
|
||||||
{
|
{
|
||||||
|
var part = parts[partIdx];
|
||||||
var bracketIdx = part.IndexOf('[');
|
var bracketIdx = part.IndexOf('[');
|
||||||
if (bracketIdx < 0)
|
if (bracketIdx < 0)
|
||||||
{
|
{
|
||||||
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
|
|||||||
var name = part[..bracketIdx];
|
var name = part[..bracketIdx];
|
||||||
if (!IsValidIdent(name)) return null;
|
if (!IsValidIdent(name)) return null;
|
||||||
var inner = part[(bracketIdx + 1)..^1];
|
var inner = part[(bracketIdx + 1)..^1];
|
||||||
|
|
||||||
|
// Slice syntax `[N..M]` — only allowed on the LAST segment, must not coexist with
|
||||||
|
// multi-dim subscripts, must not be combined with bit-index, and requires M >= N.
|
||||||
|
// Any other shape is rejected so callers see a config-validation error rather than
|
||||||
|
// the driver attempting a best-effort scalar read.
|
||||||
|
if (inner.Contains(".."))
|
||||||
|
{
|
||||||
|
if (partIdx != parts.Count - 1) return null; // slice + sub-element
|
||||||
|
if (bitIndex is not null) return null; // slice + bit index
|
||||||
|
if (inner.Contains(',')) return null; // slice cannot be multi-dim
|
||||||
|
var parts2 = inner.Split("..", 2, StringSplitOptions.None);
|
||||||
|
if (parts2.Length != 2) return null;
|
||||||
|
if (!int.TryParse(parts2[0], out var sliceStart) || sliceStart < 0) return null;
|
||||||
|
if (!int.TryParse(parts2[1], out var sliceEnd) || sliceEnd < sliceStart) return null;
|
||||||
|
slice = new AbCipTagPathSlice(sliceStart, sliceEnd);
|
||||||
|
segments.Add(new AbCipTagPathSegment(name, []));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var subs = new List<int>();
|
var subs = new List<int>();
|
||||||
foreach (var tok in inner.Split(','))
|
foreach (var tok in inner.Split(','))
|
||||||
{
|
{
|
||||||
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
|
|||||||
}
|
}
|
||||||
if (segments.Count == 0) return null;
|
if (segments.Count == 0) return null;
|
||||||
|
|
||||||
return new AbCipTagPath(programScope, segments, bitIndex);
|
return new AbCipTagPath(programScope, segments, bitIndex, slice);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsValidIdent(string s)
|
private static bool IsValidIdent(string s)
|
||||||
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
|
|||||||
|
|
||||||
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
|
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
|
||||||
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
|
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inclusive-on-both-ends array slice carried on the trailing segment of an
|
||||||
|
/// <see cref="AbCipTagPath"/>. <c>Tag[0..15]</c> parses to <c>Start=0, End=15</c>; the
|
||||||
|
/// planner pairs this with libplctag's <c>ElementCount</c> attribute to issue a single
|
||||||
|
/// Rockwell array read covering <c>End - Start + 1</c> elements.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AbCipTagPathSlice(int Start, int End)
|
||||||
|
{
|
||||||
|
/// <summary>Total element count covered by the slice (inclusive both ends).</summary>
|
||||||
|
public int Count => End - Start + 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,10 +65,43 @@ public interface IAbCipTagFactory
|
|||||||
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
|
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
|
||||||
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
|
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
|
||||||
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
|
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
|
||||||
|
/// <param name="StringMaxCapacity">Optional Logix STRINGnn DATA-array capacity (e.g. 20 / 40 / 80
|
||||||
|
/// for <c>STRING_20</c> / <c>STRING_40</c> / <c>STRING_80</c> UDTs). Threads through libplctag's
|
||||||
|
/// <c>str_max_capacity</c> attribute. <c>null</c> keeps libplctag's default 82-byte STRING
|
||||||
|
/// behaviour for back-compat.</param>
|
||||||
|
/// <param name="ElementCount">Optional libplctag <c>ElementCount</c> override — set to <c>N</c>
|
||||||
|
/// to issue a Rockwell array read covering <c>N</c> consecutive elements starting at the
|
||||||
|
/// subscripted index in <see cref="TagName"/>. Drives PR abcip-1.3 array-slice support;
|
||||||
|
/// <c>null</c> leaves libplctag's default scalar-element behaviour for back-compat.</param>
|
||||||
|
/// <param name="ConnectionSize">PR abcip-3.1 — CIP Forward Open buffer size in bytes. Threads
|
||||||
|
/// through to libplctag's <c>connection_size</c> attribute. The driver always supplies a
|
||||||
|
/// value here — either the per-device <see cref="AbCipDeviceOptions.ConnectionSize"/>
|
||||||
|
/// override or the family profile's <see cref="PlcFamilies.AbCipPlcFamilyProfile.DefaultConnectionSize"/>.
|
||||||
|
/// Bigger packets fit more tags per RTT (higher throughput); smaller packets stay compatible
|
||||||
|
/// with legacy firmware (v19-and-earlier ControlLogix caps at 504, Micro800 hard-caps at
|
||||||
|
/// 488).</param>
|
||||||
|
/// <param name="AddressingMode">PR abcip-3.2 — concrete addressing mode the runtime should
|
||||||
|
/// activate for this tag handle. Always either <see cref="AddressingMode.Symbolic"/> or
|
||||||
|
/// <see cref="AddressingMode.Logical"/> at this layer (the driver resolves <c>Auto</c> +
|
||||||
|
/// family-incompatibility before building the create-params). Symbolic is the libplctag
|
||||||
|
/// default and needs no extra attribute. Logical adds the libplctag <c>use_connected_msg=1</c>
|
||||||
|
/// attribute + (when an instance ID is known via <see cref="LogicalInstanceId"/>) reaches
|
||||||
|
/// into <c>NativeTagWrapper.SetAttributeString</c> by reflection because the .NET wrapper
|
||||||
|
/// does not expose a public knob for instance-ID addressing.</param>
|
||||||
|
/// <param name="LogicalInstanceId">PR abcip-3.2 — Symbol Object instance ID the controller
|
||||||
|
/// assigned to this tag, populated by the driver after a one-time <c>@tags</c> walk for
|
||||||
|
/// Logical-mode devices. <c>null</c> for Symbolic mode + for the very first read on a
|
||||||
|
/// Logical device when the symbol-table walk has not yet completed; the runtime falls back
|
||||||
|
/// to Symbolic addressing in either case so the read still completes.</param>
|
||||||
public sealed record AbCipTagCreateParams(
|
public sealed record AbCipTagCreateParams(
|
||||||
string Gateway,
|
string Gateway,
|
||||||
int Port,
|
int Port,
|
||||||
string CipPath,
|
string CipPath,
|
||||||
string LibplctagPlcAttribute,
|
string LibplctagPlcAttribute,
|
||||||
string TagName,
|
string TagName,
|
||||||
TimeSpan Timeout);
|
TimeSpan Timeout,
|
||||||
|
int? StringMaxCapacity = null,
|
||||||
|
int? ElementCount = null,
|
||||||
|
int ConnectionSize = 4002,
|
||||||
|
AddressingMode AddressingMode = AddressingMode.Symbolic,
|
||||||
|
uint? LogicalInstanceId = null);
|
||||||
|
|||||||
99
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
Normal file
99
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render an enumerable of <see cref="AbCipTagDefinition"/> as a Kepware-format CSV
|
||||||
|
/// document. Emits the header expected by <see cref="CsvTagImporter"/> so the importer
|
||||||
|
/// and exporter form a complete round-trip path: load → export → reparse → identical
|
||||||
|
/// entries (modulo unknown-type tags, which export as <c>STRING</c> and reimport as
|
||||||
|
/// <see cref="AbCipDataType.Structure"/> per the importer's fall-through rule).
|
||||||
|
/// </summary>
|
||||||
|
public static class CsvTagExporter
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<string> KepwareColumns =
|
||||||
|
[
|
||||||
|
"Tag Name",
|
||||||
|
"Address",
|
||||||
|
"Data Type",
|
||||||
|
"Respect Data Type",
|
||||||
|
"Client Access",
|
||||||
|
"Scan Rate",
|
||||||
|
"Description",
|
||||||
|
"Scaling",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Write the tag list to <paramref name="writer"/> in Kepware CSV format.</summary>
|
||||||
|
public static void Write(IEnumerable<AbCipTagDefinition> tags, TextWriter writer)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tags);
|
||||||
|
ArgumentNullException.ThrowIfNull(writer);
|
||||||
|
|
||||||
|
writer.WriteLine(string.Join(",", KepwareColumns.Select(EscapeField)));
|
||||||
|
foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
var fields = new[]
|
||||||
|
{
|
||||||
|
tag.Name ?? string.Empty,
|
||||||
|
tag.TagPath ?? string.Empty,
|
||||||
|
FormatDataType(tag.DataType),
|
||||||
|
"1", // Respect Data Type — Kepware EX default.
|
||||||
|
tag.Writable ? "Read/Write" : "Read Only",
|
||||||
|
"100", // Scan Rate (ms) — placeholder default.
|
||||||
|
tag.Description ?? string.Empty,
|
||||||
|
"None", // Scaling — driver doesn't apply scaling.
|
||||||
|
};
|
||||||
|
writer.WriteLine(string.Join(",", fields.Select(EscapeField)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Render the tag list to a string.</summary>
|
||||||
|
public static string ToCsv(IEnumerable<AbCipTagDefinition> tags)
|
||||||
|
{
|
||||||
|
using var sw = new StringWriter(CultureInfo.InvariantCulture);
|
||||||
|
Write(tags, sw);
|
||||||
|
return sw.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Write the tag list to <paramref name="path"/> as UTF-8 (no BOM).</summary>
|
||||||
|
public static void WriteFile(IEnumerable<AbCipTagDefinition> tags, string path)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(path);
|
||||||
|
using var sw = new StreamWriter(path, append: false, new UTF8Encoding(false));
|
||||||
|
Write(tags, sw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDataType(AbCipDataType t) => t switch
|
||||||
|
{
|
||||||
|
AbCipDataType.Bool => "BOOL",
|
||||||
|
AbCipDataType.SInt => "SINT",
|
||||||
|
AbCipDataType.Int => "INT",
|
||||||
|
AbCipDataType.DInt => "DINT",
|
||||||
|
AbCipDataType.LInt => "LINT",
|
||||||
|
AbCipDataType.USInt => "USINT",
|
||||||
|
AbCipDataType.UInt => "UINT",
|
||||||
|
AbCipDataType.UDInt => "UDINT",
|
||||||
|
AbCipDataType.ULInt => "ULINT",
|
||||||
|
AbCipDataType.Real => "REAL",
|
||||||
|
AbCipDataType.LReal => "LREAL",
|
||||||
|
AbCipDataType.String => "STRING",
|
||||||
|
AbCipDataType.Dt => "DT",
|
||||||
|
AbCipDataType.Structure => "STRING", // Surface UDT-typed tags as STRING — Kepware has no UDT cell.
|
||||||
|
_ => "STRING",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Quote a field if it contains comma, quote, CR, or LF; escape embedded quotes by doubling.</summary>
|
||||||
|
private static string EscapeField(string value)
|
||||||
|
{
|
||||||
|
value ??= string.Empty;
|
||||||
|
var needsQuotes =
|
||||||
|
value.IndexOf(',') >= 0 ||
|
||||||
|
value.IndexOf('"') >= 0 ||
|
||||||
|
value.IndexOf('\r') >= 0 ||
|
||||||
|
value.IndexOf('\n') >= 0;
|
||||||
|
if (!needsQuotes) return value;
|
||||||
|
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
Normal file
226
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a Kepware-format AB CIP tag CSV into <see cref="AbCipTagDefinition"/> entries.
|
||||||
|
/// The expected column layout matches the Kepware EX tag-export shape so operators can
|
||||||
|
/// round-trip tags through Excel without re-keying:
|
||||||
|
/// <c>Tag Name, Address, Data Type, Respect Data Type, Client Access, Scan Rate,
|
||||||
|
/// Description, Scaling</c>. The first non-blank, non-comment row is treated as the
|
||||||
|
/// header — column order is honoured by name lookup, so reorderings out of Excel still
|
||||||
|
/// work. Blank rows + rows whose first cell starts with a Kepware section marker
|
||||||
|
/// (<c>;</c> / <c>#</c>) are skipped.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Mapping: <c>Tag Name</c> → <see cref="AbCipTagDefinition.Name"/>;
|
||||||
|
/// <c>Address</c> → <see cref="AbCipTagDefinition.TagPath"/>;
|
||||||
|
/// <c>Data Type</c> → <see cref="AbCipTagDefinition.DataType"/> (Logix atomic name —
|
||||||
|
/// BOOL/SINT/INT/DINT/REAL/STRING/...; unknown values fall through as
|
||||||
|
/// <see cref="AbCipDataType.Structure"/> the same way <see cref="L5kIngest"/> handles
|
||||||
|
/// unknown types);
|
||||||
|
/// <c>Description</c> → <see cref="AbCipTagDefinition.Description"/>;
|
||||||
|
/// <c>Client Access</c> → <see cref="AbCipTagDefinition.Writable"/>: any value
|
||||||
|
/// containing <c>W</c> (case-insensitive) is treated as Read/Write; everything else
|
||||||
|
/// is Read-Only.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// CSV semantics are RFC-4180-ish: double-quoted fields support embedded commas, line
|
||||||
|
/// breaks, and escaped quotes (<c>""</c>). The parser is single-pass + deliberately
|
||||||
|
/// narrow — Kepware's exporter does not produce anything more exotic.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CsvTagImporter
|
||||||
|
{
|
||||||
|
/// <summary>Default device host address applied to every imported tag.</summary>
|
||||||
|
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional prefix prepended to each imported tag's name. Default empty.</summary>
|
||||||
|
public string NamePrefix { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public CsvTagImportResult Import(string csvText)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(csvText);
|
||||||
|
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{nameof(CsvTagImporter)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Import)} is called — every imported tag needs a target device.");
|
||||||
|
|
||||||
|
var rows = CsvReader.ReadAll(csvText);
|
||||||
|
var tags = new List<AbCipTagDefinition>();
|
||||||
|
var skippedBlank = 0;
|
||||||
|
Dictionary<string, int>? header = null;
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (row.Count == 0 || row.All(string.IsNullOrWhiteSpace))
|
||||||
|
{
|
||||||
|
skippedBlank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var first = row[0].TrimStart();
|
||||||
|
if (first.StartsWith(';') || first.StartsWith('#'))
|
||||||
|
{
|
||||||
|
skippedBlank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header is null)
|
||||||
|
{
|
||||||
|
header = BuildHeader(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = GetCell(row, header, "Tag Name");
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
skippedBlank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var address = GetCell(row, header, "Address");
|
||||||
|
var dataTypeText = GetCell(row, header, "Data Type");
|
||||||
|
var description = GetCell(row, header, "Description");
|
||||||
|
var clientAccess = GetCell(row, header, "Client Access");
|
||||||
|
|
||||||
|
var dataType = ParseDataType(dataTypeText);
|
||||||
|
var writable = !string.IsNullOrEmpty(clientAccess)
|
||||||
|
&& clientAccess.IndexOf('W', StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
|
tags.Add(new AbCipTagDefinition(
|
||||||
|
Name: string.IsNullOrEmpty(NamePrefix) ? name : $"{NamePrefix}{name}",
|
||||||
|
DeviceHostAddress: DefaultDeviceHostAddress,
|
||||||
|
TagPath: string.IsNullOrEmpty(address) ? name : address,
|
||||||
|
DataType: dataType,
|
||||||
|
Writable: writable,
|
||||||
|
Description: string.IsNullOrEmpty(description) ? null : description));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CsvTagImportResult(tags, skippedBlank);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CsvTagImportResult ImportFile(string path) =>
|
||||||
|
Import(File.ReadAllText(path, Encoding.UTF8));
|
||||||
|
|
||||||
|
private static Dictionary<string, int> BuildHeader(IReadOnlyList<string> row)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var i = 0; i < row.Count; i++)
|
||||||
|
{
|
||||||
|
var key = row[i]?.Trim() ?? string.Empty;
|
||||||
|
if (key.Length > 0 && !dict.ContainsKey(key))
|
||||||
|
dict[key] = i;
|
||||||
|
}
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCell(IReadOnlyList<string> row, Dictionary<string, int> header, string column)
|
||||||
|
{
|
||||||
|
if (!header.TryGetValue(column, out var idx)) return string.Empty;
|
||||||
|
if (idx < 0 || idx >= row.Count) return string.Empty;
|
||||||
|
return row[idx]?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbCipDataType ParseDataType(string s) =>
|
||||||
|
s?.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"BOOL" or "BIT" => AbCipDataType.Bool,
|
||||||
|
"SINT" or "BYTE" => AbCipDataType.SInt,
|
||||||
|
"INT" or "WORD" or "SHORT" => AbCipDataType.Int,
|
||||||
|
"DINT" or "DWORD" or "LONG" => AbCipDataType.DInt,
|
||||||
|
"LINT" => AbCipDataType.LInt,
|
||||||
|
"USINT" => AbCipDataType.USInt,
|
||||||
|
"UINT" => AbCipDataType.UInt,
|
||||||
|
"UDINT" => AbCipDataType.UDInt,
|
||||||
|
"ULINT" => AbCipDataType.ULInt,
|
||||||
|
"REAL" or "FLOAT" => AbCipDataType.Real,
|
||||||
|
"LREAL" or "DOUBLE" => AbCipDataType.LReal,
|
||||||
|
"STRING" => AbCipDataType.String,
|
||||||
|
"DT" or "DATETIME" or "DATE" => AbCipDataType.Dt,
|
||||||
|
_ => AbCipDataType.Structure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="CsvTagImporter.Import"/>.</summary>
|
||||||
|
public sealed record CsvTagImportResult(
|
||||||
|
IReadOnlyList<AbCipTagDefinition> Tags,
|
||||||
|
int SkippedBlankCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiny RFC-4180-ish CSV reader. Supports double-quoted fields, escaped <c>""</c>
|
||||||
|
/// quotes, and embedded line breaks inside quotes. Internal because the importer +
|
||||||
|
/// exporter are the only two callers and we don't want to add a CSV dep.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CsvReader
|
||||||
|
{
|
||||||
|
public static List<List<string>> ReadAll(string text)
|
||||||
|
{
|
||||||
|
var rows = new List<List<string>>();
|
||||||
|
var row = new List<string>();
|
||||||
|
var field = new StringBuilder();
|
||||||
|
var inQuotes = false;
|
||||||
|
|
||||||
|
for (var i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
var c = text[i];
|
||||||
|
if (inQuotes)
|
||||||
|
{
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
if (i + 1 < text.Length && text[i + 1] == '"')
|
||||||
|
{
|
||||||
|
field.Append('"');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
field.Append(c);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '"':
|
||||||
|
inQuotes = true;
|
||||||
|
break;
|
||||||
|
case ',':
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
break;
|
||||||
|
case '\r':
|
||||||
|
// Swallow CR — handle CRLF and lone CR alike.
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
rows.Add(row);
|
||||||
|
row = new List<string>();
|
||||||
|
if (i + 1 < text.Length && text[i + 1] == '\n') i++;
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
rows.Add(row);
|
||||||
|
row = new List<string>();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
field.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.Length > 0 || row.Count > 0)
|
||||||
|
{
|
||||||
|
row.Add(field.ToString());
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs
Normal file
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction over an L5K text source so the parser can consume strings, files, or streams
|
||||||
|
/// without coupling to <see cref="System.IO"/>. Implementations return the full text in a
|
||||||
|
/// single call — L5K files are typically <10 MB even for large controllers, and the parser
|
||||||
|
/// needs random access to handle nested DATATYPE/TAG blocks regardless.
|
||||||
|
/// </summary>
|
||||||
|
public interface IL5kSource
|
||||||
|
{
|
||||||
|
/// <summary>Reads the full L5K body as a string.</summary>
|
||||||
|
string ReadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>String-backed source — used by tests + when the L5K body is loaded elsewhere.</summary>
|
||||||
|
public sealed class StringL5kSource : IL5kSource
|
||||||
|
{
|
||||||
|
private readonly string _text;
|
||||||
|
public StringL5kSource(string text) => _text = text ?? throw new ArgumentNullException(nameof(text));
|
||||||
|
public string ReadAll() => _text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>File-backed source — used by Admin / driver init to load <c>*.L5K</c> exports.</summary>
|
||||||
|
public sealed class FileL5kSource : IL5kSource
|
||||||
|
{
|
||||||
|
private readonly string _path;
|
||||||
|
public FileL5kSource(string path) => _path = path ?? throw new ArgumentNullException(nameof(path));
|
||||||
|
public string ReadAll() => System.IO.File.ReadAllText(_path);
|
||||||
|
}
|
||||||
161
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
Normal file
161
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a parsed <see cref="L5kDocument"/> into <see cref="AbCipTagDefinition"/> entries
|
||||||
|
/// ready to be merged into <see cref="AbCipDriverOptions.Tags"/>. UDT definitions become
|
||||||
|
/// <see cref="AbCipStructureMember"/> lists keyed by data-type name; tags whose
|
||||||
|
/// <see cref="L5kTag.DataType"/> matches a known UDT get those members attached so the
|
||||||
|
/// discovery code can fan out the structure.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <strong>Alias tags are skipped</strong> — when <see cref="L5kTag.AliasFor"/> is
|
||||||
|
/// non-null the entry is dropped at ingest. Surfacing both the alias + its target
|
||||||
|
/// creates duplicate Variables in the OPC UA address space (Kepware's L5K importer
|
||||||
|
/// takes the same approach for this reason; the alias target is the single source of
|
||||||
|
/// truth for storage).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <strong>Tags with <c>ExternalAccess := None</c> are skipped</strong> — the controller
|
||||||
|
/// actively rejects external reads/writes, so emitting them as Variables would just
|
||||||
|
/// produce permanent BadCommunicationError. <c>Read Only</c> maps to <c>Writable=false</c>;
|
||||||
|
/// <c>Read/Write</c> (or absent) maps to <c>Writable=true</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Unknown data-type names (not atomic + not a parsed UDT) fall through as
|
||||||
|
/// <see cref="AbCipDataType.Structure"/> with no member layout — discovery can still
|
||||||
|
/// expose them as black-box variables and the operator can pin them via dotted paths.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class L5kIngest
|
||||||
|
{
|
||||||
|
/// <summary>Default device host address applied to every imported tag.</summary>
|
||||||
|
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional prefix prepended to imported tag names — useful when ingesting multiple
|
||||||
|
/// L5K exports into one driver instance to avoid name collisions. Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string NamePrefix { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public L5kIngestResult Ingest(L5kDocument document)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(document);
|
||||||
|
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{nameof(L5kIngest)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Ingest)} is called — every imported tag needs a target device.");
|
||||||
|
|
||||||
|
// Index UDT definitions by name so we can fan out structure tags inline.
|
||||||
|
var udtIndex = new Dictionary<string, IReadOnlyList<AbCipStructureMember>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var dt in document.DataTypes)
|
||||||
|
{
|
||||||
|
var members = new List<AbCipStructureMember>(dt.Members.Count);
|
||||||
|
foreach (var m in dt.Members)
|
||||||
|
{
|
||||||
|
var atomic = TryMapAtomic(m.DataType);
|
||||||
|
var memberType = atomic ?? AbCipDataType.Structure;
|
||||||
|
var writable = !IsReadOnly(m.ExternalAccess) && !IsAccessNone(m.ExternalAccess);
|
||||||
|
members.Add(new AbCipStructureMember(
|
||||||
|
Name: m.Name,
|
||||||
|
DataType: memberType,
|
||||||
|
Writable: writable,
|
||||||
|
Description: m.Description,
|
||||||
|
AoiQualifier: MapAoiUsage(m.Usage)));
|
||||||
|
}
|
||||||
|
udtIndex[dt.Name] = members;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags = new List<AbCipTagDefinition>();
|
||||||
|
var skippedAliases = 0;
|
||||||
|
var skippedNoAccess = 0;
|
||||||
|
foreach (var t in document.Tags)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(t.AliasFor)) { skippedAliases++; continue; }
|
||||||
|
if (IsAccessNone(t.ExternalAccess)) { skippedNoAccess++; continue; }
|
||||||
|
|
||||||
|
var atomic = TryMapAtomic(t.DataType);
|
||||||
|
AbCipDataType dataType;
|
||||||
|
IReadOnlyList<AbCipStructureMember>? members = null;
|
||||||
|
if (atomic is { } a)
|
||||||
|
{
|
||||||
|
dataType = a;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dataType = AbCipDataType.Structure;
|
||||||
|
if (udtIndex.TryGetValue(t.DataType, out var udtMembers))
|
||||||
|
members = udtMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagPath = t.ProgramScope is { Length: > 0 }
|
||||||
|
? $"Program:{t.ProgramScope}.{t.Name}"
|
||||||
|
: t.Name;
|
||||||
|
var name = string.IsNullOrEmpty(NamePrefix) ? t.Name : $"{NamePrefix}{t.Name}";
|
||||||
|
// Make the OPC UA tag name unique when both controller-scope + program-scope tags
|
||||||
|
// share the same simple Name.
|
||||||
|
if (t.ProgramScope is { Length: > 0 })
|
||||||
|
name = string.IsNullOrEmpty(NamePrefix)
|
||||||
|
? $"{t.ProgramScope}.{t.Name}"
|
||||||
|
: $"{NamePrefix}{t.ProgramScope}.{t.Name}";
|
||||||
|
|
||||||
|
var writable = !IsReadOnly(t.ExternalAccess);
|
||||||
|
|
||||||
|
tags.Add(new AbCipTagDefinition(
|
||||||
|
Name: name,
|
||||||
|
DeviceHostAddress: DefaultDeviceHostAddress,
|
||||||
|
TagPath: tagPath,
|
||||||
|
DataType: dataType,
|
||||||
|
Writable: writable,
|
||||||
|
Members: members,
|
||||||
|
Description: t.Description));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kIngestResult(tags, skippedAliases, skippedNoAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsReadOnly(string? externalAccess) =>
|
||||||
|
externalAccess is not null
|
||||||
|
&& externalAccess.Trim().Replace(" ", string.Empty).Equals("ReadOnly", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool IsAccessNone(string? externalAccess) =>
|
||||||
|
externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.6 — map the AOI <c>Usage</c> attribute string to <see cref="AoiQualifier"/>.
|
||||||
|
/// Plain UDT members (Usage = null) + unrecognised values map to <see cref="AoiQualifier.Local"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static AoiQualifier MapAoiUsage(string? usage) =>
|
||||||
|
usage?.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"INPUT" => AoiQualifier.Input,
|
||||||
|
"OUTPUT" => AoiQualifier.Output,
|
||||||
|
"INOUT" => AoiQualifier.InOut,
|
||||||
|
_ => AoiQualifier.Local,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Map a Logix atomic type name. Returns <c>null</c> for UDT/structure references.</summary>
|
||||||
|
private static AbCipDataType? TryMapAtomic(string logixType) =>
|
||||||
|
logixType?.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"BOOL" or "BIT" => AbCipDataType.Bool,
|
||||||
|
"SINT" => AbCipDataType.SInt,
|
||||||
|
"INT" => AbCipDataType.Int,
|
||||||
|
"DINT" => AbCipDataType.DInt,
|
||||||
|
"LINT" => AbCipDataType.LInt,
|
||||||
|
"USINT" => AbCipDataType.USInt,
|
||||||
|
"UINT" => AbCipDataType.UInt,
|
||||||
|
"UDINT" => AbCipDataType.UDInt,
|
||||||
|
"ULINT" => AbCipDataType.ULInt,
|
||||||
|
"REAL" => AbCipDataType.Real,
|
||||||
|
"LREAL" => AbCipDataType.LReal,
|
||||||
|
"STRING" => AbCipDataType.String,
|
||||||
|
"DT" or "DATETIME" => AbCipDataType.Dt,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="L5kIngest.Ingest"/> — produced tags + per-skip-reason counts.</summary>
|
||||||
|
public sealed record L5kIngestResult(
|
||||||
|
IReadOnlyList<AbCipTagDefinition> Tags,
|
||||||
|
int SkippedAliasCount,
|
||||||
|
int SkippedNoAccessCount);
|
||||||
469
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal file
469
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure-text parser for Studio 5000 L5K controller exports. L5K is a labelled-section export
|
||||||
|
/// with TAG/END_TAG, DATATYPE/END_DATATYPE, PROGRAM/END_PROGRAM blocks. This parser handles
|
||||||
|
/// the common shapes:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Controller-scope <c>TAG ... END_TAG</c> with <c>Name</c>, <c>DataType</c>,
|
||||||
|
/// optional <c>ExternalAccess</c>, optional <c>Description</c>.</item>
|
||||||
|
/// <item>Program-scope tags inside <c>PROGRAM ... END_PROGRAM</c>.</item>
|
||||||
|
/// <item>UDT definitions via <c>DATATYPE ... END_DATATYPE</c> with <c>MEMBER</c> lines.</item>
|
||||||
|
/// <item>Alias tags (<c>AliasFor</c>) — recognised + flagged so callers can skip them.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Unknown sections (CONFIG, MODULE, AOI, MOTION_GROUP, etc.) are skipped silently.
|
||||||
|
/// Per Kepware precedent, alias tags are typically skipped on ingest because the alias target
|
||||||
|
/// is what owns the storage — surfacing both creates duplicate writes/reads.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is a permissive line-oriented parser, not a full L5K grammar. Comments
|
||||||
|
/// (<c>(* ... *)</c>) are stripped before tokenization. The parser is deliberately tolerant of
|
||||||
|
/// extra whitespace, unknown attributes, and trailing semicolons — real-world L5K files are
|
||||||
|
/// produced by RSLogix exports that vary across versions.
|
||||||
|
/// </remarks>
|
||||||
|
public static class L5kParser
|
||||||
|
{
|
||||||
|
public static L5kDocument Parse(IL5kSource source)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
var raw = source.ReadAll();
|
||||||
|
var stripped = StripBlockComments(raw);
|
||||||
|
var lines = stripped.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None);
|
||||||
|
|
||||||
|
var tags = new List<L5kTag>();
|
||||||
|
var datatypes = new List<L5kDataType>();
|
||||||
|
string? currentProgram = null;
|
||||||
|
var i = 0;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (line.Length == 0) { i++; continue; }
|
||||||
|
|
||||||
|
// PROGRAM block — opens a program scope; the body contains nested TAG blocks.
|
||||||
|
if (StartsWithKeyword(line, "PROGRAM"))
|
||||||
|
{
|
||||||
|
currentProgram = ExtractFirstQuotedOrToken(line.Substring("PROGRAM".Length).Trim());
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (StartsWithKeyword(line, "END_PROGRAM"))
|
||||||
|
{
|
||||||
|
currentProgram = null;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TAG block — collects 1..N tag entries until END_TAG.
|
||||||
|
if (StartsWithKeyword(line, "TAG"))
|
||||||
|
{
|
||||||
|
var consumed = ParseTagBlock(lines, i, currentProgram, tags);
|
||||||
|
i += consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DATATYPE block.
|
||||||
|
if (StartsWithKeyword(line, "DATATYPE"))
|
||||||
|
{
|
||||||
|
var consumed = ParseDataTypeBlock(lines, i, datatypes);
|
||||||
|
i += consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION block. AOI parameters carry a Usage
|
||||||
|
// attribute (Input / Output / InOut); each PARAMETER becomes a member of the AOI's
|
||||||
|
// L5kDataType entry so AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
||||||
|
if (StartsWithKeyword(line, "ADD_ON_INSTRUCTION_DEFINITION"))
|
||||||
|
{
|
||||||
|
var consumed = ParseAoiDefinitionBlock(lines, i, datatypes);
|
||||||
|
i += consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kDocument(tags, datatypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TAG block ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Each TAG block contains 1..N entries of the form:
|
||||||
|
// TagName : DataType (Description := "...", ExternalAccess := Read/Write) := initialValue;
|
||||||
|
// until END_TAG. Entries can span multiple lines, terminated by ';'.
|
||||||
|
private static int ParseTagBlock(string[] lines, int start, string? program, List<L5kTag> into)
|
||||||
|
{
|
||||||
|
var i = start + 1;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (StartsWithKeyword(line, "END_TAG")) return i - start + 1;
|
||||||
|
if (line.Length == 0) { i++; continue; }
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder(line);
|
||||||
|
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||||
|
{
|
||||||
|
var peek = lines[i + 1].Trim();
|
||||||
|
if (StartsWithKeyword(peek, "END_TAG")) break;
|
||||||
|
i++;
|
||||||
|
sb.Append(' ').Append(peek);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
|
||||||
|
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||||
|
var tag = ParseTagEntry(entry, program);
|
||||||
|
if (tag is not null) into.Add(tag);
|
||||||
|
}
|
||||||
|
return i - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kTag? ParseTagEntry(string entry, string? program)
|
||||||
|
{
|
||||||
|
// entry shape: Name : DataType [ (attribute := value, ...) ] [ := initialValue ]
|
||||||
|
// Find the first ':' that separates Name from DataType. Avoid ':=' (the assign op).
|
||||||
|
var colonIdx = FindBareColon(entry);
|
||||||
|
if (colonIdx < 0) return null;
|
||||||
|
|
||||||
|
var name = entry.Substring(0, colonIdx).Trim();
|
||||||
|
if (name.Length == 0) return null;
|
||||||
|
|
||||||
|
var rest = entry.Substring(colonIdx + 1).Trim();
|
||||||
|
// The attribute parens themselves contain ':=' assignments, so locate the top-level
|
||||||
|
// assignment (depth-0 ':=') that introduces the initial value before stripping.
|
||||||
|
var assignIdx = FindTopLevelAssign(rest);
|
||||||
|
var head = assignIdx >= 0 ? rest.Substring(0, assignIdx).Trim() : rest;
|
||||||
|
|
||||||
|
// Pull attribute tuple out of head: "DataType (attr := val, attr := val)".
|
||||||
|
string dataType;
|
||||||
|
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var openParen = head.IndexOf('(');
|
||||||
|
if (openParen >= 0)
|
||||||
|
{
|
||||||
|
dataType = head.Substring(0, openParen).Trim();
|
||||||
|
var closeParen = head.LastIndexOf(')');
|
||||||
|
if (closeParen > openParen)
|
||||||
|
{
|
||||||
|
var attrBody = head.Substring(openParen + 1, closeParen - openParen - 1);
|
||||||
|
ParseAttributeList(attrBody, attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dataType = head.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataType.Length == 0) return null;
|
||||||
|
|
||||||
|
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
||||||
|
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
||||||
|
var aliasFor = attributes.TryGetValue("AliasFor", out var af) ? Unquote(af) : null;
|
||||||
|
|
||||||
|
return new L5kTag(
|
||||||
|
Name: name,
|
||||||
|
DataType: dataType,
|
||||||
|
ProgramScope: program,
|
||||||
|
ExternalAccess: externalAccess,
|
||||||
|
Description: description,
|
||||||
|
AliasFor: aliasFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first ':=' at depth 0 (not inside parens / brackets / quotes). Returns -1 if none.
|
||||||
|
private static int FindTopLevelAssign(string entry)
|
||||||
|
{
|
||||||
|
var depth = 0;
|
||||||
|
var inQuote = false;
|
||||||
|
for (var k = 0; k < entry.Length - 1; k++)
|
||||||
|
{
|
||||||
|
var c = entry[k];
|
||||||
|
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||||
|
if (inQuote) continue;
|
||||||
|
if (c == '(' || c == '[' || c == '{') depth++;
|
||||||
|
else if (c == ')' || c == ']' || c == '}') depth--;
|
||||||
|
else if (c == ':' && entry[k + 1] == '=' && depth == 0) return k;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first colon that is NOT part of ':=' and not inside a quoted string.
|
||||||
|
private static int FindBareColon(string entry)
|
||||||
|
{
|
||||||
|
var inQuote = false;
|
||||||
|
for (var k = 0; k < entry.Length; k++)
|
||||||
|
{
|
||||||
|
var c = entry[k];
|
||||||
|
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||||
|
if (inQuote) continue;
|
||||||
|
if (c != ':') continue;
|
||||||
|
if (k + 1 < entry.Length && entry[k + 1] == '=') continue;
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseAttributeList(string body, Dictionary<string, string> into)
|
||||||
|
{
|
||||||
|
foreach (var part in SplitTopLevelCommas(body))
|
||||||
|
{
|
||||||
|
var assign = part.IndexOf(":=", StringComparison.Ordinal);
|
||||||
|
if (assign < 0) continue;
|
||||||
|
var key = part.Substring(0, assign).Trim();
|
||||||
|
var val = part.Substring(assign + 2).Trim();
|
||||||
|
if (key.Length > 0) into[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> SplitTopLevelCommas(string body)
|
||||||
|
{
|
||||||
|
var depth = 0;
|
||||||
|
var inQuote = false;
|
||||||
|
var start = 0;
|
||||||
|
for (var k = 0; k < body.Length; k++)
|
||||||
|
{
|
||||||
|
var c = body[k];
|
||||||
|
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||||
|
if (inQuote) continue;
|
||||||
|
if (c == '(' || c == '[' || c == '{') depth++;
|
||||||
|
else if (c == ')' || c == ']' || c == '}') depth--;
|
||||||
|
else if (c == ',' && depth == 0)
|
||||||
|
{
|
||||||
|
yield return body.Substring(start, k - start);
|
||||||
|
start = k + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start < body.Length) yield return body.Substring(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DATATYPE block ----------------------------------------------------
|
||||||
|
|
||||||
|
private static int ParseDataTypeBlock(string[] lines, int start, List<L5kDataType> into)
|
||||||
|
{
|
||||||
|
var first = lines[start].Trim();
|
||||||
|
var head = first.Substring("DATATYPE".Length).Trim();
|
||||||
|
var name = ExtractFirstQuotedOrToken(head);
|
||||||
|
var members = new List<L5kMember>();
|
||||||
|
var i = start + 1;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (StartsWithKeyword(line, "END_DATATYPE"))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start + 1;
|
||||||
|
}
|
||||||
|
if (line.Length == 0) { i++; continue; }
|
||||||
|
|
||||||
|
if (StartsWithKeyword(line, "MEMBER"))
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder(line);
|
||||||
|
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||||
|
{
|
||||||
|
var peek = lines[i + 1].Trim();
|
||||||
|
if (StartsWithKeyword(peek, "END_DATATYPE")) break;
|
||||||
|
i++;
|
||||||
|
sb.Append(' ').Append(peek);
|
||||||
|
}
|
||||||
|
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||||
|
entry = entry.Substring("MEMBER".Length).Trim();
|
||||||
|
var member = ParseMemberEntry(entry);
|
||||||
|
if (member is not null) members.Add(member);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kMember? ParseMemberEntry(string entry)
|
||||||
|
{
|
||||||
|
// entry shape: MemberName : DataType [ [arrayDim] ] [ (attr := val, ...) ] [ := default ]
|
||||||
|
var colonIdx = FindBareColon(entry);
|
||||||
|
if (colonIdx < 0) return null;
|
||||||
|
var name = entry.Substring(0, colonIdx).Trim();
|
||||||
|
if (name.Length == 0) return null;
|
||||||
|
|
||||||
|
var rest = entry.Substring(colonIdx + 1).Trim();
|
||||||
|
var assignIdx = FindTopLevelAssign(rest);
|
||||||
|
if (assignIdx >= 0) rest = rest.Substring(0, assignIdx).Trim();
|
||||||
|
|
||||||
|
int? arrayDim = null;
|
||||||
|
var bracketOpen = rest.IndexOf('[');
|
||||||
|
if (bracketOpen >= 0)
|
||||||
|
{
|
||||||
|
var bracketClose = rest.IndexOf(']', bracketOpen + 1);
|
||||||
|
if (bracketClose > bracketOpen)
|
||||||
|
{
|
||||||
|
var dimText = rest.Substring(bracketOpen + 1, bracketClose - bracketOpen - 1).Trim();
|
||||||
|
if (int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim))
|
||||||
|
arrayDim = dim;
|
||||||
|
rest = (rest.Substring(0, bracketOpen) + rest.Substring(bracketClose + 1)).Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string typePart;
|
||||||
|
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var openParen = rest.IndexOf('(');
|
||||||
|
if (openParen >= 0)
|
||||||
|
{
|
||||||
|
typePart = rest.Substring(0, openParen).Trim();
|
||||||
|
var closeParen = rest.LastIndexOf(')');
|
||||||
|
if (closeParen > openParen)
|
||||||
|
{
|
||||||
|
var attrBody = rest.Substring(openParen + 1, closeParen - openParen - 1);
|
||||||
|
ParseAttributeList(attrBody, attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
typePart = rest.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typePart.Length == 0) return null;
|
||||||
|
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
||||||
|
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
||||||
|
// PR abcip-2.6 — Usage attribute on AOI parameters (Input / Output / InOut). Plain UDT
|
||||||
|
// members don't carry it; null on a regular DATATYPE MEMBER is the default + maps to Local
|
||||||
|
// in the ingest layer.
|
||||||
|
var usage = attributes.TryGetValue("Usage", out var u) ? u.Trim() : null;
|
||||||
|
return new L5kMember(name, typePart, arrayDim, externalAccess, description, usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AOI block ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.6 — parse <c>ADD_ON_INSTRUCTION_DEFINITION ... END_ADD_ON_INSTRUCTION_DEFINITION</c>
|
||||||
|
/// blocks. Body is structured around PARAMETER entries (each carrying a <c>Usage</c>
|
||||||
|
/// attribute) and optional LOCAL_TAGS / ROUTINE blocks. We extract the parameters as
|
||||||
|
/// <see cref="L5kMember"/> rows + leave routines alone — only the surface API matters for
|
||||||
|
/// tag-discovery fan-out. The L5K format encloses parameters either inside a
|
||||||
|
/// <c>PARAMETERS ... END_PARAMETERS</c> block or as bare <c>PARAMETER ... ;</c> lines at
|
||||||
|
/// the AOI top level depending on Studio 5000 export options; this parser accepts both.
|
||||||
|
/// </summary>
|
||||||
|
private static int ParseAoiDefinitionBlock(string[] lines, int start, List<L5kDataType> into)
|
||||||
|
{
|
||||||
|
var first = lines[start].Trim();
|
||||||
|
var head = first.Substring("ADD_ON_INSTRUCTION_DEFINITION".Length).Trim();
|
||||||
|
var name = ExtractFirstQuotedOrToken(head);
|
||||||
|
var members = new List<L5kMember>();
|
||||||
|
var i = start + 1;
|
||||||
|
var inLocalsBlock = false;
|
||||||
|
var inRoutineBlock = false;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (StartsWithKeyword(line, "END_ADD_ON_INSTRUCTION_DEFINITION"))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start + 1;
|
||||||
|
}
|
||||||
|
if (line.Length == 0) { i++; continue; }
|
||||||
|
|
||||||
|
// Skip routine bodies — they hold ladder / ST / FBD code we don't care about for
|
||||||
|
// tag-discovery, and their own END_ROUTINE / END_LOCAL_TAGS tokens close them out.
|
||||||
|
if (StartsWithKeyword(line, "ROUTINE")) { inRoutineBlock = true; i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "END_ROUTINE")) { inRoutineBlock = false; i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "LOCAL_TAGS")) { inLocalsBlock = true; i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "END_LOCAL_TAGS")) { inLocalsBlock = false; i++; continue; }
|
||||||
|
if (inRoutineBlock || inLocalsBlock) { i++; continue; }
|
||||||
|
|
||||||
|
// PARAMETERS / END_PARAMETERS wrappers are skipped — bare PARAMETER lines drive parsing.
|
||||||
|
if (StartsWithKeyword(line, "PARAMETERS")) { i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "END_PARAMETERS")) { i++; continue; }
|
||||||
|
|
||||||
|
if (StartsWithKeyword(line, "PARAMETER"))
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder(line);
|
||||||
|
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||||
|
{
|
||||||
|
var peek = lines[i + 1].Trim();
|
||||||
|
if (StartsWithKeyword(peek, "END_ADD_ON_INSTRUCTION_DEFINITION")) break;
|
||||||
|
i++;
|
||||||
|
sb.Append(' ').Append(peek);
|
||||||
|
}
|
||||||
|
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||||
|
entry = entry.Substring("PARAMETER".Length).Trim();
|
||||||
|
var member = ParseMemberEntry(entry);
|
||||||
|
if (member is not null) members.Add(member);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
private static bool StartsWithKeyword(string line, string keyword)
|
||||||
|
{
|
||||||
|
if (line.Length < keyword.Length) return false;
|
||||||
|
if (!line.StartsWith(keyword, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
if (line.Length == keyword.Length) return true;
|
||||||
|
var next = line[keyword.Length];
|
||||||
|
return !char.IsLetterOrDigit(next) && next != '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractFirstQuotedOrToken(string fragment)
|
||||||
|
{
|
||||||
|
var trimmed = fragment.TrimStart();
|
||||||
|
if (trimmed.Length == 0) return string.Empty;
|
||||||
|
if (trimmed[0] == '"' || trimmed[0] == '\'')
|
||||||
|
{
|
||||||
|
var quote = trimmed[0];
|
||||||
|
var end = trimmed.IndexOf(quote, 1);
|
||||||
|
if (end > 0) return trimmed.Substring(1, end - 1);
|
||||||
|
}
|
||||||
|
var k = 0;
|
||||||
|
while (k < trimmed.Length)
|
||||||
|
{
|
||||||
|
var c = trimmed[k];
|
||||||
|
if (char.IsWhiteSpace(c) || c == '(' || c == ',' || c == ';') break;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
return trimmed.Substring(0, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Unquote(string s)
|
||||||
|
{
|
||||||
|
s = s.Trim();
|
||||||
|
if (s.Length >= 2 && (s[0] == '"' || s[0] == '\'') && s[s.Length - 1] == s[0])
|
||||||
|
return s.Substring(1, s.Length - 2);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripBlockComments(string text)
|
||||||
|
{
|
||||||
|
// L5K comments: `(* ... *)`. Strip so the line scanner doesn't trip on tokens inside.
|
||||||
|
var pattern = new Regex(@"\(\*.*?\*\)", RegexOptions.Singleline);
|
||||||
|
return pattern.Replace(text, string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Output of <see cref="L5kParser.Parse(IL5kSource)"/>.</summary>
|
||||||
|
public sealed record L5kDocument(IReadOnlyList<L5kTag> Tags, IReadOnlyList<L5kDataType> DataTypes);
|
||||||
|
|
||||||
|
/// <summary>One L5K tag entry (controller- or program-scope).</summary>
|
||||||
|
public sealed record L5kTag(
|
||||||
|
string Name,
|
||||||
|
string DataType,
|
||||||
|
string? ProgramScope,
|
||||||
|
string? ExternalAccess,
|
||||||
|
string? Description,
|
||||||
|
string? AliasFor);
|
||||||
|
|
||||||
|
/// <summary>One UDT definition extracted from a <c>DATATYPE ... END_DATATYPE</c> block.</summary>
|
||||||
|
public sealed record L5kDataType(string Name, IReadOnlyList<L5kMember> Members);
|
||||||
|
|
||||||
|
/// <summary>One member line inside a UDT definition or AOI parameter list.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PR abcip-2.6 — <see cref="Usage"/> carries the AOI <c>Usage</c> attribute (<c>Input</c> /
|
||||||
|
/// <c>Output</c> / <c>InOut</c>) raw text. Plain UDT members + L5K AOI <c>LOCAL_TAGS</c> leave
|
||||||
|
/// it null; the ingest layer maps null → <see cref="AoiQualifier.Local"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record L5kMember(
|
||||||
|
string Name,
|
||||||
|
string DataType,
|
||||||
|
int? ArrayDim,
|
||||||
|
string? ExternalAccess,
|
||||||
|
string? Description = null,
|
||||||
|
string? Usage = null);
|
||||||
237
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
Normal file
237
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.XPath;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// XML-format parser for Studio 5000 L5X controller exports. L5X is the XML sibling of L5K
|
||||||
|
/// and carries the same tag / datatype / program shape, plus richer metadata (notably the
|
||||||
|
/// AddOnInstructionDefinition catalogue and explicit <c>TagType</c> attributes).
|
||||||
|
/// <para>
|
||||||
|
/// This parser produces the same <see cref="L5kDocument"/> bundle as
|
||||||
|
/// <see cref="L5kParser"/> so <see cref="L5kIngest"/> consumes both formats interchangeably.
|
||||||
|
/// The two parsers share the post-parse downstream layer; the only difference is how the
|
||||||
|
/// bundle is materialized from the source bytes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// AOIs (<c>AddOnInstructionDefinition</c>) are surfaced as L5K-style UDT entries — their
|
||||||
|
/// parameters become <see cref="L5kMember"/> rows so AOI-typed tags pick up a member layout
|
||||||
|
/// the same way UDT-typed tags do. Full Inputs/Outputs/InOut directional metadata + per-call
|
||||||
|
/// parameter scoping is deferred to PR 2.6 per plan; this PR keeps AOIs visible without
|
||||||
|
/// attempting to model their call semantics.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Uses <see cref="System.Xml.XPath"/> with an <see cref="XPathDocument"/> for read-only
|
||||||
|
/// traversal. L5X exports are typically <50 MB, so a single in-memory navigator beats
|
||||||
|
/// forward-only <c>XmlReader</c> on simplicity for the same throughput at this size class.
|
||||||
|
/// The parser is permissive about missing optional attributes — a real export always has
|
||||||
|
/// <c>Name</c> + <c>DataType</c>, but <c>ExternalAccess</c> defaults to <c>Read/Write</c>
|
||||||
|
/// when absent (matching Studio 5000's own default for new tags).
|
||||||
|
/// </remarks>
|
||||||
|
public static class L5xParser
|
||||||
|
{
|
||||||
|
public static L5kDocument Parse(IL5kSource source)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
var xml = source.ReadAll();
|
||||||
|
|
||||||
|
using var reader = XmlReader.Create(
|
||||||
|
new System.IO.StringReader(xml),
|
||||||
|
new XmlReaderSettings
|
||||||
|
{
|
||||||
|
// L5X exports never include a DOCTYPE, but disable DTD processing defensively.
|
||||||
|
DtdProcessing = DtdProcessing.Prohibit,
|
||||||
|
IgnoreWhitespace = true,
|
||||||
|
IgnoreComments = true,
|
||||||
|
});
|
||||||
|
var doc = new XPathDocument(reader);
|
||||||
|
var nav = doc.CreateNavigator();
|
||||||
|
|
||||||
|
var tags = new List<L5kTag>();
|
||||||
|
var datatypes = new List<L5kDataType>();
|
||||||
|
|
||||||
|
// Controller-scope tags: /RSLogix5000Content/Controller/Tags/Tag
|
||||||
|
foreach (XPathNavigator tagNode in nav.Select("/RSLogix5000Content/Controller/Tags/Tag"))
|
||||||
|
{
|
||||||
|
var t = ReadTag(tagNode, programScope: null);
|
||||||
|
if (t is not null) tags.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Program-scope tags: /RSLogix5000Content/Controller/Programs/Program/Tags/Tag
|
||||||
|
foreach (XPathNavigator programNode in nav.Select("/RSLogix5000Content/Controller/Programs/Program"))
|
||||||
|
{
|
||||||
|
var programName = programNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(programName)) continue;
|
||||||
|
foreach (XPathNavigator tagNode in programNode.Select("Tags/Tag"))
|
||||||
|
{
|
||||||
|
var t = ReadTag(tagNode, programName);
|
||||||
|
if (t is not null) tags.Add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDTs: /RSLogix5000Content/Controller/DataTypes/DataType
|
||||||
|
foreach (XPathNavigator dtNode in nav.Select("/RSLogix5000Content/Controller/DataTypes/DataType"))
|
||||||
|
{
|
||||||
|
var udt = ReadDataType(dtNode);
|
||||||
|
if (udt is not null) datatypes.Add(udt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AOIs: surfaced as L5kDataType entries so AOI-typed tags pick up a member layout.
|
||||||
|
// Per the plan, full directional Input/Output/InOut modelling is deferred to PR 2.6.
|
||||||
|
foreach (XPathNavigator aoiNode in nav.Select("/RSLogix5000Content/Controller/AddOnInstructionDefinitions/AddOnInstructionDefinition"))
|
||||||
|
{
|
||||||
|
var aoi = ReadAddOnInstruction(aoiNode);
|
||||||
|
if (aoi is not null) datatypes.Add(aoi);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kDocument(tags, datatypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kTag? ReadTag(XPathNavigator tagNode, string? programScope)
|
||||||
|
{
|
||||||
|
var name = tagNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
|
||||||
|
var tagType = tagNode.GetAttribute("TagType", string.Empty); // Base | Alias | Produced | Consumed
|
||||||
|
var dataType = tagNode.GetAttribute("DataType", string.Empty);
|
||||||
|
var aliasFor = tagNode.GetAttribute("AliasFor", string.Empty);
|
||||||
|
var externalAccess = tagNode.GetAttribute("ExternalAccess", string.Empty);
|
||||||
|
|
||||||
|
// Alias tags often omit DataType (it's inherited from the target). Surface them with
|
||||||
|
// an empty type — L5kIngest skips alias entries before TryMapAtomic ever sees the type.
|
||||||
|
if (string.IsNullOrEmpty(dataType)
|
||||||
|
&& !string.Equals(tagType, "Alias", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description child — L5X wraps description text in <Description> (sometimes inside CDATA).
|
||||||
|
string? description = null;
|
||||||
|
var descNode = tagNode.SelectSingleNode("Description");
|
||||||
|
if (descNode is not null)
|
||||||
|
{
|
||||||
|
var raw = descNode.Value;
|
||||||
|
if (!string.IsNullOrEmpty(raw)) description = raw.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kTag(
|
||||||
|
Name: name,
|
||||||
|
DataType: string.IsNullOrEmpty(dataType) ? string.Empty : dataType,
|
||||||
|
ProgramScope: programScope,
|
||||||
|
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||||
|
Description: description,
|
||||||
|
AliasFor: string.IsNullOrEmpty(aliasFor) ? null : aliasFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kDataType? ReadDataType(XPathNavigator dtNode)
|
||||||
|
{
|
||||||
|
var name = dtNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
|
||||||
|
var members = new List<L5kMember>();
|
||||||
|
foreach (XPathNavigator memberNode in dtNode.Select("Members/Member"))
|
||||||
|
{
|
||||||
|
var m = ReadMember(memberNode);
|
||||||
|
if (m is not null) members.Add(m);
|
||||||
|
}
|
||||||
|
return new L5kDataType(name, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kMember? ReadMember(XPathNavigator memberNode)
|
||||||
|
{
|
||||||
|
var name = memberNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
|
||||||
|
// Skip auto-inserted hidden host members for backing storage of BOOL packing — they're
|
||||||
|
// emitted by RSLogix as members named with the ZZZZZZZZZZ prefix and aren't useful to
|
||||||
|
// surface as OPC UA variables.
|
||||||
|
if (name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) return null;
|
||||||
|
|
||||||
|
var dataType = memberNode.GetAttribute("DataType", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(dataType)) return null;
|
||||||
|
|
||||||
|
var externalAccess = memberNode.GetAttribute("ExternalAccess", string.Empty);
|
||||||
|
|
||||||
|
int? arrayDim = null;
|
||||||
|
var dimText = memberNode.GetAttribute("Dimension", string.Empty);
|
||||||
|
if (!string.IsNullOrEmpty(dimText)
|
||||||
|
&& int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim)
|
||||||
|
&& dim > 0)
|
||||||
|
{
|
||||||
|
arrayDim = dim;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description child — same shape as on Tag nodes; sometimes wrapped in CDATA.
|
||||||
|
string? description = null;
|
||||||
|
var descNode = memberNode.SelectSingleNode("Description");
|
||||||
|
if (descNode is not null)
|
||||||
|
{
|
||||||
|
var raw = descNode.Value;
|
||||||
|
if (!string.IsNullOrEmpty(raw)) description = raw.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kMember(
|
||||||
|
Name: name,
|
||||||
|
DataType: dataType,
|
||||||
|
ArrayDim: arrayDim,
|
||||||
|
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||||
|
Description: description);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kDataType? ReadAddOnInstruction(XPathNavigator aoiNode)
|
||||||
|
{
|
||||||
|
var name = aoiNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
|
||||||
|
var members = new List<L5kMember>();
|
||||||
|
foreach (XPathNavigator paramNode in aoiNode.Select("Parameters/Parameter"))
|
||||||
|
{
|
||||||
|
var paramName = paramNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(paramName)) continue;
|
||||||
|
|
||||||
|
// RSLogix marks the implicit EnableIn / EnableOut parameters as Hidden=true.
|
||||||
|
// Skip them — they aren't part of the AOI's user-facing surface.
|
||||||
|
var hidden = paramNode.GetAttribute("Hidden", string.Empty);
|
||||||
|
if (string.Equals(hidden, "true", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var dataType = paramNode.GetAttribute("DataType", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(dataType)) continue;
|
||||||
|
|
||||||
|
var externalAccess = paramNode.GetAttribute("ExternalAccess", string.Empty);
|
||||||
|
|
||||||
|
int? arrayDim = null;
|
||||||
|
var dimText = paramNode.GetAttribute("Dimension", string.Empty);
|
||||||
|
if (!string.IsNullOrEmpty(dimText)
|
||||||
|
&& int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim)
|
||||||
|
&& dim > 0)
|
||||||
|
{
|
||||||
|
arrayDim = dim;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? paramDescription = null;
|
||||||
|
var paramDescNode = paramNode.SelectSingleNode("Description");
|
||||||
|
if (paramDescNode is not null)
|
||||||
|
{
|
||||||
|
var raw = paramDescNode.Value;
|
||||||
|
if (!string.IsNullOrEmpty(raw)) paramDescription = raw.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR abcip-2.6 — capture the AOI Usage attribute (Input / Output / InOut). RSLogix
|
||||||
|
// also serialises Local AOI tags inside <LocalTags>, but those don't go through this
|
||||||
|
// path — only <Parameters>/<Parameter> entries do — so any Usage value on a parameter
|
||||||
|
// is one of the directional buckets.
|
||||||
|
var usage = paramNode.GetAttribute("Usage", string.Empty);
|
||||||
|
|
||||||
|
members.Add(new L5kMember(
|
||||||
|
Name: paramName,
|
||||||
|
DataType: dataType,
|
||||||
|
ArrayDim: arrayDim,
|
||||||
|
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||||
|
Description: paramDescription,
|
||||||
|
Usage: string.IsNullOrEmpty(usage) ? null : usage));
|
||||||
|
}
|
||||||
|
return new L5kDataType(name, members);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Reflection;
|
||||||
using libplctag;
|
using libplctag;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
@@ -12,6 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||||
{
|
{
|
||||||
private readonly Tag _tag;
|
private readonly Tag _tag;
|
||||||
|
private readonly int _connectionSize;
|
||||||
|
private readonly AddressingMode _addressingMode;
|
||||||
|
private readonly uint? _logicalInstanceId;
|
||||||
|
|
||||||
public LibplctagTagRuntime(AbCipTagCreateParams p)
|
public LibplctagTagRuntime(AbCipTagCreateParams p)
|
||||||
{
|
{
|
||||||
@@ -24,12 +28,119 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
Name = p.TagName,
|
Name = p.TagName,
|
||||||
Timeout = p.Timeout,
|
Timeout = p.Timeout,
|
||||||
};
|
};
|
||||||
|
// PR abcip-1.2 — Logix STRINGnn variant decoding. When the caller pins a non-default
|
||||||
|
// DATA-array capacity (STRING_20 / STRING_40 / STRING_80 etc.), forward it to libplctag
|
||||||
|
// via the StringMaxCapacity attribute so GetString / SetString truncate at the right
|
||||||
|
// boundary. Null leaves libplctag at its default 82-byte STRING for back-compat.
|
||||||
|
if (p.StringMaxCapacity is int cap && cap > 0)
|
||||||
|
_tag.StringMaxCapacity = (uint)cap;
|
||||||
|
// PR abcip-1.3 — slice reads. Setting ElementCount tells libplctag to allocate a buffer
|
||||||
|
// covering N consecutive elements; the array-read planner pairs this with TagName=Tag[N]
|
||||||
|
// to issue one Rockwell array read for a [N..M] slice.
|
||||||
|
if (p.ElementCount is int n && n > 0)
|
||||||
|
_tag.ElementCount = n;
|
||||||
|
_connectionSize = p.ConnectionSize;
|
||||||
|
_addressingMode = p.AddressingMode;
|
||||||
|
_logicalInstanceId = p.LogicalInstanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
// PR abcip-3.1 — propagate the configured CIP connection size to the native libplctag
|
||||||
|
// handle. The 1.5.x C# wrapper does not expose <c>connection_size</c> as a public Tag
|
||||||
|
// property, so we reach into the internal <c>NativeTagWrapper</c>'s
|
||||||
|
// <c>SetIntAttribute</c> (mirroring libplctag's <c>plc_tag_set_int_attribute</c>).
|
||||||
|
// libplctag native parses <c>connection_size</c> at create time, so this best-effort
|
||||||
|
// call lights up automatically when a future wrapper release exposes the attribute or
|
||||||
|
// when libplctag native gains post-create hot-update support — until then it falls back
|
||||||
|
// to the wrapper default. Failures (older / patched wrappers without the internal API)
|
||||||
|
// are intentionally swallowed so the driver keeps initialising.
|
||||||
|
TrySetConnectionSize(_tag, _connectionSize);
|
||||||
|
|
||||||
|
// PR abcip-3.2 — propagate the addressing mode + (when known) the resolved Symbol
|
||||||
|
// Object instance ID. Same reflection-fallback shape as ConnectionSize: the libplctag
|
||||||
|
// .NET wrapper (1.5.x) doesn't expose a public knob for instance-ID addressing, so
|
||||||
|
// we forward the relevant attribute string through NativeTagWrapper.SetAttributeString.
|
||||||
|
// Logical mode lights up only when the driver has populated LogicalInstanceId via the
|
||||||
|
// one-time @tags walk; first reads on a Logical device + every Symbolic-mode read take
|
||||||
|
// the libplctag default ASCII-symbolic path.
|
||||||
|
if (_addressingMode == AddressingMode.Logical)
|
||||||
|
TrySetLogicalAddressing(_tag, _logicalInstanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
|
||||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort propagation of <c>connection_size</c> to libplctag native. Reflects into
|
||||||
|
/// the wrapper's internal <c>NativeTagWrapper.SetIntAttribute(string, int)</c>; isolated
|
||||||
|
/// in a static helper so the lookup costs run once + the failure path is one line.
|
||||||
|
/// </summary>
|
||||||
|
private static void TrySetConnectionSize(Tag tag, int connectionSize)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var wrapperField = typeof(Tag).GetField("_tag", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
var wrapper = wrapperField?.GetValue(tag);
|
||||||
|
if (wrapper is null) return;
|
||||||
|
var setInt = wrapper.GetType().GetMethod(
|
||||||
|
"SetIntAttribute",
|
||||||
|
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
|
||||||
|
binder: null,
|
||||||
|
types: [typeof(string), typeof(int)],
|
||||||
|
modifiers: null);
|
||||||
|
setInt?.Invoke(wrapper, ["connection_size", connectionSize]);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Wrapper internals shifted (newer libplctag.NET) — drop quietly. Either the new
|
||||||
|
// wrapper exposes ConnectionSize directly (our reflection no-ops) or operators must
|
||||||
|
// upgrade to a known-good version per docs/drivers/AbCip-Performance.md.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.2 — best-effort propagation of CIP logical-segment / instance-ID
|
||||||
|
/// addressing to libplctag native. Two attributes are forwarded:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>use_connected_msg=1</c> — instance-ID addressing only works over a
|
||||||
|
/// connected CIP session; switch the tag to use Forward Open + Class3 messaging.</item>
|
||||||
|
/// <item><c>cip_addr=0x6B,N</c> — replace the ASCII Symbol Object lookup with a
|
||||||
|
/// direct logical segment reference, where <c>N</c> is the resolved instance ID
|
||||||
|
/// from the driver's one-time <c>@tags</c> walk.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Same reflection-via-<c>NativeTagWrapper.SetAttributeString</c> shape as
|
||||||
|
/// <see cref="TrySetConnectionSize"/> — the 1.5.x .NET wrapper does not expose a
|
||||||
|
/// public knob, so we degrade gracefully when the internal API is not present.
|
||||||
|
/// </summary>
|
||||||
|
private static void TrySetLogicalAddressing(Tag tag, uint? logicalInstanceId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var wrapperField = typeof(Tag).GetField("_tag", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
var wrapper = wrapperField?.GetValue(tag);
|
||||||
|
if (wrapper is null) return;
|
||||||
|
var setStr = wrapper.GetType().GetMethod(
|
||||||
|
"SetAttributeString",
|
||||||
|
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
|
||||||
|
binder: null,
|
||||||
|
types: [typeof(string), typeof(string)],
|
||||||
|
modifiers: null);
|
||||||
|
if (setStr is null) return;
|
||||||
|
setStr.Invoke(wrapper, ["use_connected_msg", "1"]);
|
||||||
|
if (logicalInstanceId is uint id)
|
||||||
|
setStr.Invoke(wrapper, ["cip_addr", $"0x6B,{id}"]);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Wrapper internals not present / shifted — fall back to symbolic addressing on
|
||||||
|
// the wire. Driver-level logical-mode bookkeeping (the @tags map) is still useful
|
||||||
|
// because future wrapper releases may expose this attribute publicly + the
|
||||||
|
// reflection lights up cleanly then.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public int GetStatus() => (int)_tag.GetStatus();
|
public int GetStatus() => (int)_tag.GetStatus();
|
||||||
|
|
||||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
|
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
|
||||||
@@ -50,7 +161,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
AbCipDataType.Real => _tag.GetFloat32(offset),
|
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||||
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||||
AbCipDataType.String => _tag.GetString(offset),
|
AbCipDataType.String => _tag.GetString(offset),
|
||||||
AbCipDataType.Dt => _tag.GetInt32(offset),
|
AbCipDataType.Dt => _tag.GetInt64(offset),
|
||||||
AbCipDataType.Structure => null,
|
AbCipDataType.Structure => null,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
@@ -105,7 +216,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||||
break;
|
break;
|
||||||
case AbCipDataType.Dt:
|
case AbCipDataType.Dt:
|
||||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
_tag.SetInt64(0, Convert.ToInt64(value));
|
||||||
break;
|
break;
|
||||||
case AbCipDataType.Structure:
|
case AbCipDataType.Structure:
|
||||||
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
|
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public sealed record AbCipPlcFamilyProfile(
|
|||||||
string DefaultCipPath,
|
string DefaultCipPath,
|
||||||
bool SupportsRequestPacking,
|
bool SupportsRequestPacking,
|
||||||
bool SupportsConnectedMessaging,
|
bool SupportsConnectedMessaging,
|
||||||
int MaxFragmentBytes)
|
int MaxFragmentBytes,
|
||||||
|
bool SupportsLogicalAddressing = true)
|
||||||
{
|
{
|
||||||
/// <summary>Look up the profile for a configured family.</summary>
|
/// <summary>Look up the profile for a configured family.</summary>
|
||||||
public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch
|
public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch
|
||||||
@@ -34,7 +35,8 @@ public sealed record AbCipPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
SupportsRequestPacking: true,
|
SupportsRequestPacking: true,
|
||||||
SupportsConnectedMessaging: true,
|
SupportsConnectedMessaging: true,
|
||||||
MaxFragmentBytes: 4000);
|
MaxFragmentBytes: 4000,
|
||||||
|
SupportsLogicalAddressing: true);
|
||||||
|
|
||||||
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
|
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
|
||||||
LibplctagPlcAttribute: "compactlogix",
|
LibplctagPlcAttribute: "compactlogix",
|
||||||
@@ -42,15 +44,21 @@ public sealed record AbCipPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
SupportsRequestPacking: true,
|
SupportsRequestPacking: true,
|
||||||
SupportsConnectedMessaging: true,
|
SupportsConnectedMessaging: true,
|
||||||
MaxFragmentBytes: 500);
|
MaxFragmentBytes: 500,
|
||||||
|
SupportsLogicalAddressing: true);
|
||||||
|
|
||||||
|
// PR abcip-3.2 — Micro800 firmware does not implement the Symbol Object class 0x6B
|
||||||
|
// instance-ID addressing path; @tags returns the symbol set but reads keyed on instance
|
||||||
|
// IDs trip a CIP "Path Segment Error" (0x04). Logical mode is therefore disabled here
|
||||||
|
// + the driver silently falls back to Symbolic with a warning per AbCipDriverOptions.OnWarning.
|
||||||
public static readonly AbCipPlcFamilyProfile Micro800 = new(
|
public static readonly AbCipPlcFamilyProfile Micro800 = new(
|
||||||
LibplctagPlcAttribute: "micro800",
|
LibplctagPlcAttribute: "micro800",
|
||||||
DefaultConnectionSize: 488, // Micro800 hard cap
|
DefaultConnectionSize: 488, // Micro800 hard cap
|
||||||
DefaultCipPath: "", // no backplane routing
|
DefaultCipPath: "", // no backplane routing
|
||||||
SupportsRequestPacking: false,
|
SupportsRequestPacking: false,
|
||||||
SupportsConnectedMessaging: false, // unconnected-only on most models
|
SupportsConnectedMessaging: false, // unconnected-only on most models
|
||||||
MaxFragmentBytes: 484);
|
MaxFragmentBytes: 484,
|
||||||
|
SupportsLogicalAddressing: false);
|
||||||
|
|
||||||
public static readonly AbCipPlcFamilyProfile GuardLogix = new(
|
public static readonly AbCipPlcFamilyProfile GuardLogix = new(
|
||||||
LibplctagPlcAttribute: "controllogix", // wire protocol identical; safety partition is tag-level
|
LibplctagPlcAttribute: "controllogix", // wire protocol identical; safety partition is tag-level
|
||||||
@@ -58,5 +66,6 @@ public sealed record AbCipPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
SupportsRequestPacking: true,
|
SupportsRequestPacking: true,
|
||||||
SupportsConnectedMessaging: true,
|
SupportsConnectedMessaging: true,
|
||||||
MaxFragmentBytes: 4000);
|
MaxFragmentBytes: 4000,
|
||||||
|
SupportsLogicalAddressing: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
|
|||||||
"Publishing interval in milliseconds (default 1000).")]
|
"Publishing interval in milliseconds (default 1000).")]
|
||||||
public int IntervalMs { get; init; } = 1000;
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
[CommandOption("deadband-absolute", Description =
|
||||||
|
"PR 8 — absolute change filter. Suppress notifications until |new - prev| >= this value. " +
|
||||||
|
"Booleans bypass; strings + status changes always publish.")]
|
||||||
|
public double? DeadbandAbsolute { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("deadband-percent", Description =
|
||||||
|
"PR 8 — percent-of-previous change filter. Suppress notifications until " +
|
||||||
|
"|new - prev| >= |prev * pct / 100|. prev=0 always publishes.")]
|
||||||
|
public double? DeadbandPercent { get; init; }
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
@@ -35,7 +45,9 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
|
|||||||
DeviceHostAddress: Gateway,
|
DeviceHostAddress: Gateway,
|
||||||
Address: Address,
|
Address: Address,
|
||||||
DataType: DataType,
|
DataType: DataType,
|
||||||
Writable: false);
|
Writable: false,
|
||||||
|
AbsoluteDeadband: DeadbandAbsolute,
|
||||||
|
PercentDeadband: DeadbandPercent);
|
||||||
var options = BuildOptions([tag]);
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -30,35 +32,102 @@ public sealed record AbLegacyAddress(
|
|||||||
int? FileNumber,
|
int? FileNumber,
|
||||||
int WordNumber,
|
int WordNumber,
|
||||||
int? BitIndex,
|
int? BitIndex,
|
||||||
string? SubElement)
|
string? SubElement,
|
||||||
|
AbLegacyAddress? IndirectFileSource = null,
|
||||||
|
AbLegacyAddress? IndirectWordSource = null,
|
||||||
|
int? ArrayCount = null)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — PCCC frame ceiling. A single SLC/PLC-5 PCCC read can return up to about 240
|
||||||
|
/// bytes (~120 INT words / 60 DINTs / 60 floats). The parser caps <see cref="ArrayCount"/>
|
||||||
|
/// at 120 so a misconfigured tag fails fast instead of bouncing off the wire as a fragmented
|
||||||
|
/// multi-frame read.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxArrayCount = 120;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when either the file number or the word number is sourced from another PCCC
|
||||||
|
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
|
||||||
|
/// <c>N[N7:0]:5</c>). libplctag PCCC does not natively decode bracket-form indirection,
|
||||||
|
/// so the runtime layer must resolve the inner address first and rewrite the tag name
|
||||||
|
/// before issuing the actual read/write. See <see cref="ToLibplctagName"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
|
||||||
|
|
||||||
public string ToLibplctagName()
|
public string ToLibplctagName()
|
||||||
{
|
{
|
||||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
// Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
|
||||||
var wordPart = $"{file}:{WordNumber}";
|
// accept the bracket form directly — callers that need a libplctag-ready name must
|
||||||
|
// resolve the inner addresses first and substitute concrete numbers. Driver runtime
|
||||||
|
// path (TODO: resolve-then-read) is gated on IsIndirect.
|
||||||
|
string filePart;
|
||||||
|
if (IndirectFileSource is not null)
|
||||||
|
{
|
||||||
|
filePart = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
filePart = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||||
|
}
|
||||||
|
|
||||||
|
string wordSegment = IndirectWordSource is not null
|
||||||
|
? $"[{IndirectWordSource.ToLibplctagName()}]"
|
||||||
|
: WordNumber.ToString();
|
||||||
|
|
||||||
|
var wordPart = $"{filePart}:{wordSegment}";
|
||||||
|
// PR 7 — emit libplctag's `[N]` array suffix when the parsed address carries an
|
||||||
|
// ArrayCount. libplctag's PCCC text decoder treats `N7:0[10]` as "10 consecutive
|
||||||
|
// words starting at N7:0"; the comma form (`N7:0,10`) is Rockwell-native and gets
|
||||||
|
// canonicalised to bracket form here so the driver always hands libplctag a single
|
||||||
|
// recognisable shape.
|
||||||
|
if (ArrayCount is int n) wordPart += $"[{n}]";
|
||||||
if (SubElement is not null) wordPart += $".{SubElement}";
|
if (SubElement is not null) wordPart += $".{SubElement}";
|
||||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||||
return wordPart;
|
return wordPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AbLegacyAddress? TryParse(string? value)
|
public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Family-aware parser. PLC-5 (RSLogix 5) displays the word + bit indices on
|
||||||
|
/// <c>I:</c>/<c>O:</c> file references as octal — <c>I:001/17</c> is rack 1, bit 15.
|
||||||
|
/// Pass the device's family so the parser can interpret those digits as octal when the
|
||||||
|
/// family's <see cref="AbLegacyPlcFamilyProfile.OctalIoAddressing"/> is true. The parsed
|
||||||
|
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
|
||||||
|
/// is what libplctag's PCCC layer expects.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Also accepts indirect / indexed forms (Issue #247): <c>N7:[N7:0]</c> reads file 7,
|
||||||
|
/// word=value-of(N7:0); <c>N[N7:0]:5</c> reads file=value-of(N7:0), word 5. Recursion
|
||||||
|
/// depth is capped at 1 — the inner address must be a plain direct PCCC address.
|
||||||
|
/// </remarks>
|
||||||
|
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
var src = value.Trim();
|
var src = value.Trim();
|
||||||
|
|
||||||
// BitIndex: trailing /N
|
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
|
||||||
int? bitIndex = null;
|
|
||||||
var slashIdx = src.IndexOf('/');
|
// BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
|
||||||
if (slashIdx >= 0)
|
// I:/O: bit indices are octal in RSLogix 5, everything else is decimal.
|
||||||
|
string? bitText = null;
|
||||||
|
var slashIdx = src.LastIndexOf('/');
|
||||||
|
if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']'))
|
||||||
{
|
{
|
||||||
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null;
|
bitText = src[(slashIdx + 1)..];
|
||||||
bitIndex = bit;
|
|
||||||
src = src[..slashIdx];
|
src = src[..slashIdx];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ParseTail(src, bitText, profile, allowIndirect: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile, bool allowIndirect)
|
||||||
|
{
|
||||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
||||||
|
// Only consider dots OUTSIDE of any bracketed inner address — the inner address may
|
||||||
|
// itself contain a sub-element dot (e.g. N[T4:0.ACC]:5).
|
||||||
string? subElement = null;
|
string? subElement = null;
|
||||||
var dotIdx = src.LastIndexOf('.');
|
var dotIdx = LastIndexOfTopLevel(src, '.');
|
||||||
if (dotIdx >= 0)
|
if (dotIdx >= 0)
|
||||||
{
|
{
|
||||||
var candidate = src[(dotIdx + 1)..];
|
var candidate = src[(dotIdx + 1)..];
|
||||||
@@ -69,29 +138,220 @@ public sealed record AbLegacyAddress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var colonIdx = src.IndexOf(':');
|
var colonIdx = IndexOfTopLevel(src, ':');
|
||||||
if (colonIdx <= 0) return null;
|
if (colonIdx <= 0) return null;
|
||||||
var filePart = src[..colonIdx];
|
var filePart = src[..colonIdx];
|
||||||
var wordPart = src[(colonIdx + 1)..];
|
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).
|
// File letter (always literal) + optional file number — either decimal digits or a
|
||||||
|
// bracketed indirect address like N[N7:0].
|
||||||
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||||
var letterEnd = 1;
|
var letterEnd = 1;
|
||||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||||
|
|
||||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||||
int? fileNumber = null;
|
int? fileNumber = null;
|
||||||
|
AbLegacyAddress? indirectFile = null;
|
||||||
if (letterEnd < filePart.Length)
|
if (letterEnd < filePart.Length)
|
||||||
{
|
{
|
||||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
var fileTail = filePart[letterEnd..];
|
||||||
fileNumber = fn;
|
if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']')
|
||||||
|
{
|
||||||
|
if (!allowIndirect) return null;
|
||||||
|
var inner = fileTail[1..^1];
|
||||||
|
indirectFile = ParseInner(inner, 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 — these cover SLC/ML/PLC-5 canonical families.
|
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
||||||
if (!IsKnownFileLetter(letter)) return null;
|
// Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only.
|
||||||
|
// Structure-file letters (PD/MG/PLS/BT) are gated per family — PD/MG are common on
|
||||||
|
// SLC500 + PLC-5; PLS/BT are PLC-5 only. MicroLogix and LogixPccc reject them.
|
||||||
|
if (!IsKnownFileLetter(letter))
|
||||||
|
{
|
||||||
|
if (IsFunctionFileLetter(letter))
|
||||||
|
{
|
||||||
|
if (profile?.SupportsFunctionFiles != true) return null;
|
||||||
|
}
|
||||||
|
else if (IsStructureFileLetter(letter))
|
||||||
|
{
|
||||||
|
if (!StructureFileSupported(letter, profile)) return null;
|
||||||
|
}
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
|
||||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
|
||||||
|
|
||||||
|
// PR 7 — strip an optional array suffix from the trailing edge of the word part.
|
||||||
|
// Two accepted forms: Rockwell-native `,N` (e.g. `N7:0,10`) and libplctag-native
|
||||||
|
// `[N]` (e.g. `N7:0[10]`). Both resolve to the same ArrayCount. The bracket form
|
||||||
|
// collides syntactically with the indirect-word form (`N7:[N7:0]`) — the
|
||||||
|
// disambiguation is "leading bracket = indirect; trailing bracket after the
|
||||||
|
// numeric word literal = array". A trailing `[N]` may also follow an indirect
|
||||||
|
// word (`N7:[N7:0][10]`) — supported.
|
||||||
|
int? arrayCount = null;
|
||||||
|
|
||||||
|
// Try comma form first — only meaningful when no leading-bracket indirect form is
|
||||||
|
// present. Comma never appears in indirect-word source addresses (those use ':').
|
||||||
|
var commaIdx = wordPart.LastIndexOf(',');
|
||||||
|
if (commaIdx > 0 && wordPart[0] != '[')
|
||||||
|
{
|
||||||
|
var arrayText = wordPart[(commaIdx + 1)..];
|
||||||
|
if (!int.TryParse(arrayText, out var ac) || ac < 1 || ac > MaxArrayCount) return null;
|
||||||
|
arrayCount = ac;
|
||||||
|
wordPart = wordPart[..commaIdx];
|
||||||
|
}
|
||||||
|
else if (wordPart.Length > 0 && wordPart[^1] == ']')
|
||||||
|
{
|
||||||
|
// Trailing `[N]` — only valid when there's already a primary word/indirect
|
||||||
|
// segment in front of it. Walk back to the matching `[`.
|
||||||
|
// Use top-level-aware index so a nested indirect like `[N7:0]` doesn't trip us.
|
||||||
|
// We want the LAST top-level `[` whose body is a pure integer.
|
||||||
|
var openIdx = MatchingOpenBracket(wordPart, wordPart.Length - 1);
|
||||||
|
if (openIdx > 0)
|
||||||
|
{
|
||||||
|
var arrayText = wordPart[(openIdx + 1)..^1];
|
||||||
|
if (int.TryParse(arrayText, out var ac))
|
||||||
|
{
|
||||||
|
if (ac < 1 || ac > MaxArrayCount) return null;
|
||||||
|
arrayCount = ac;
|
||||||
|
wordPart = wordPart[..openIdx];
|
||||||
|
}
|
||||||
|
// If the bracket body isn't a pure integer, leave wordPart alone — likely
|
||||||
|
// an indirect-word source address (handled below) or malformed input.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word part: either a numeric literal (octal-aware for PLC-5 I:/O:) or a bracketed
|
||||||
|
// indirect address.
|
||||||
|
int word = 0;
|
||||||
|
AbLegacyAddress? indirectWord = null;
|
||||||
|
if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']')
|
||||||
|
{
|
||||||
|
if (!allowIndirect) return null;
|
||||||
|
var inner = wordPart[1..^1];
|
||||||
|
indirectWord = ParseInner(inner, profile);
|
||||||
|
if (indirectWord is null) return null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!TryParseIndex(wordPart, octalForIo, out word) || word < 0) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? bitIndex = null;
|
||||||
|
if (bitText is not null)
|
||||||
|
{
|
||||||
|
if (!TryParseIndex(bitText, octalForIo, out var bit) || bit < 0 || bit > 31) return null;
|
||||||
|
bitIndex = bit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR 7 — array tags can't combine with a bit suffix (`N7:0,10/3` is meaningless —
|
||||||
|
// "the third bit of ten different words"?) or with a sub-element pull (`T4:0,5.ACC`
|
||||||
|
// is also meaningless — the sub-element targets one timer's accumulator). The
|
||||||
|
// libplctag PCCC layer would silently accept the combination; reject up-front so
|
||||||
|
// the OPC UA client sees a clean parse failure rather than a wire-level surprise.
|
||||||
|
if (arrayCount is not null)
|
||||||
|
{
|
||||||
|
if (bitIndex is not null) return null;
|
||||||
|
if (subElement is not null) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord, arrayCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Find the index of the `[` that matches the `]` at <paramref name="closeIdx"/> in
|
||||||
|
/// <paramref name="s"/>, accounting for nested brackets. Returns -1 if no match.
|
||||||
|
/// </summary>
|
||||||
|
private static int MatchingOpenBracket(string s, int closeIdx)
|
||||||
|
{
|
||||||
|
if (closeIdx < 0 || closeIdx >= s.Length || s[closeIdx] != ']') return -1;
|
||||||
|
var depth = 1;
|
||||||
|
for (var i = closeIdx - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (s[i] == ']') depth++;
|
||||||
|
else if (s[i] == '[')
|
||||||
|
{
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself
|
||||||
|
/// must NOT be indirect — nesting beyond one level is rejected.
|
||||||
|
/// </summary>
|
||||||
|
private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(inner)) return null;
|
||||||
|
var src = inner.Trim();
|
||||||
|
// Reject any further bracket — depth cap at 1.
|
||||||
|
if (src.IndexOf('[') >= 0 || src.IndexOf(']') >= 0) return null;
|
||||||
|
|
||||||
|
string? bitText = null;
|
||||||
|
var slashIdx = src.LastIndexOf('/');
|
||||||
|
if (slashIdx >= 0)
|
||||||
|
{
|
||||||
|
bitText = src[(slashIdx + 1)..];
|
||||||
|
src = src[..slashIdx];
|
||||||
|
}
|
||||||
|
return ParseTail(src, bitText, 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)
|
||||||
|
{
|
||||||
|
// Octal accepts only digits 0-7. Reject 8/9 explicitly.
|
||||||
|
if (text.Length == 0) { value = 0; return false; }
|
||||||
|
var start = 0;
|
||||||
|
var sign = 1;
|
||||||
|
if (text[0] == '-') { sign = -1; start = 1; }
|
||||||
|
if (start >= text.Length) { value = 0; return false; }
|
||||||
|
var acc = 0;
|
||||||
|
for (var i = start; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
var c = text[i];
|
||||||
|
if (c < '0' || c > '7') { value = 0; return false; }
|
||||||
|
acc = (acc * 8) + (c - '0');
|
||||||
|
}
|
||||||
|
value = sign * acc;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return int.TryParse(text, out value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||||
@@ -99,4 +359,38 @@ public sealed record AbLegacyAddress(
|
|||||||
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
"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,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MicroLogix 1100/1400 function-file prefixes. Each maps to a single fixed instance with a
|
||||||
|
/// known sub-element catalogue (see <see cref="AbLegacyDataType"/>).
|
||||||
|
/// </summary>
|
||||||
|
internal 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>
|
||||||
|
/// Structure-file prefixes added in #248: PD (PID), MG (Message), PLS (Programmable Limit
|
||||||
|
/// Switch), BT (Block Transfer). Per-family availability is gated by the matching
|
||||||
|
/// <c>Supports*File</c> flag on <see cref="AbLegacyPlcFamilyProfile"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsStructureFileLetter(string letter) => letter switch
|
||||||
|
{
|
||||||
|
"PD" or "MG" or "PLS" or "BT" => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool StructureFileSupported(string letter, AbLegacyPlcFamilyProfile? profile)
|
||||||
|
{
|
||||||
|
if (profile is null) return false;
|
||||||
|
return letter switch
|
||||||
|
{
|
||||||
|
"PD" => profile.SupportsPidFile,
|
||||||
|
"MG" => profile.SupportsMessageFile,
|
||||||
|
"PLS" => profile.SupportsPlsFile,
|
||||||
|
"BT" => profile.SupportsBlockTransferFile,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,96 @@ public enum AbLegacyDataType
|
|||||||
CounterElement,
|
CounterElement,
|
||||||
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
||||||
ControlElement,
|
ControlElement,
|
||||||
|
/// <summary>
|
||||||
|
/// MicroLogix 1100/1400 function-file sub-element (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI).
|
||||||
|
/// Sub-element catalogue lives in <see cref="AbLegacyFunctionFile.SubElementType"/>.
|
||||||
|
/// </summary>
|
||||||
|
MicroLogixFunctionFile,
|
||||||
|
/// <summary>
|
||||||
|
/// PD-file (PID) sub-element — caller addresses <c>.SP</c>, <c>.PV</c>, <c>.CV</c>,
|
||||||
|
/// <c>.KP</c>, <c>.KI</c>, <c>.KD</c>, <c>.MAXS</c>, <c>.MINS</c>, <c>.DB</c>, <c>.OUT</c>
|
||||||
|
/// (Float) and <c>.EN</c>, <c>.DN</c>, <c>.MO</c>, <c>.PE</c>, <c>.AUTO</c>, <c>.MAN</c>
|
||||||
|
/// (Boolean status bits in word 0).
|
||||||
|
/// </summary>
|
||||||
|
PidElement,
|
||||||
|
/// <summary>
|
||||||
|
/// MG-file (Message) sub-element — caller addresses <c>.RBE</c>, <c>.MS</c>, <c>.SIZE</c>,
|
||||||
|
/// <c>.LEN</c> (Int32) and <c>.EN</c>, <c>.EW</c>, <c>.ER</c>, <c>.DN</c>, <c>.ST</c>,
|
||||||
|
/// <c>.CO</c>, <c>.NR</c>, <c>.TO</c> (Boolean status bits).
|
||||||
|
/// </summary>
|
||||||
|
MessageElement,
|
||||||
|
/// <summary>
|
||||||
|
/// PLS-file (Programmable Limit Switch) sub-element — caller addresses <c>.LEN</c>
|
||||||
|
/// (Int32). Bit semantics vary by PLC; unknown sub-elements fall back to Int32.
|
||||||
|
/// </summary>
|
||||||
|
PlsElement,
|
||||||
|
/// <summary>
|
||||||
|
/// BT-file (Block Transfer) sub-element — caller addresses <c>.RLEN</c>, <c>.DLEN</c>
|
||||||
|
/// (Int32) and <c>.EN</c>, <c>.ST</c>, <c>.DN</c>, <c>.ER</c>, <c>.CO</c>, <c>.EW</c>,
|
||||||
|
/// <c>.TO</c>, <c>.NR</c> (Boolean status bits in word 0).
|
||||||
|
/// </summary>
|
||||||
|
BlockTransferElement,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MicroLogix function-file sub-element catalogue. Covers the most-commonly-addressed members
|
||||||
|
/// per file — not exhaustive (Rockwell defines 30+ on RTC alone). Unknown sub-elements fall
|
||||||
|
/// back to <see cref="DriverDataType.Int32"/> at the <see cref="AbLegacyDataTypeExtensions"/>
|
||||||
|
/// boundary so the driver never refuses a tag the customer happens to know about.
|
||||||
|
/// </summary>
|
||||||
|
public static class AbLegacyFunctionFile
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-surface type for <paramref name="fileLetter"/>.<paramref name="subElement"/>.
|
||||||
|
/// Returns <see cref="DriverDataType.Int32"/> if the sub-element is unrecognised — keeps
|
||||||
|
/// the driver permissive without forcing every quirk into the catalogue.
|
||||||
|
/// </summary>
|
||||||
|
public static DriverDataType SubElementType(string fileLetter, string? subElement)
|
||||||
|
{
|
||||||
|
if (subElement is null) return DriverDataType.Int32;
|
||||||
|
var key = (fileLetter.ToUpperInvariant(), subElement.ToUpperInvariant());
|
||||||
|
return key switch
|
||||||
|
{
|
||||||
|
// Real-time clock — all stored as Int16 (year is 4-digit Int16).
|
||||||
|
("RTC", "HR") or ("RTC", "MIN") or ("RTC", "SEC") or
|
||||||
|
("RTC", "MON") or ("RTC", "DAY") or ("RTC", "YR") or ("RTC", "DOW") => DriverDataType.Int32,
|
||||||
|
("RTC", "DS") or ("RTC", "BL") or ("RTC", "EN") => DriverDataType.Boolean,
|
||||||
|
|
||||||
|
// High-speed counter — accumulator/preset are Int32, status flags are bits.
|
||||||
|
("HSC", "ACC") or ("HSC", "PRE") or ("HSC", "OVF") or ("HSC", "UNF") => DriverDataType.Int32,
|
||||||
|
("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", "STR") or ("DLS", "STD") => DriverDataType.Int32,
|
||||||
|
("DLS", "EN") => DriverDataType.Boolean,
|
||||||
|
("MMI", "FT") or ("MMI", "LBN") => DriverDataType.Int32,
|
||||||
|
("MMI", "MP") or ("MMI", "MCP") => DriverDataType.Boolean,
|
||||||
|
|
||||||
|
// Pulse-train / PWM output blocks.
|
||||||
|
("PTO", "ACC") or ("PTO", "OF") or ("PTO", "IDA") or ("PTO", "ODA") => DriverDataType.Int32,
|
||||||
|
("PTO", "EN") or ("PTO", "DN") or ("PTO", "EH") or ("PTO", "ED") or
|
||||||
|
("PTO", "RP") or ("PTO", "OUT") => DriverDataType.Boolean,
|
||||||
|
("PWM", "ACC") or ("PWM", "OF") or ("PWM", "PE") or ("PWM", "PD") => DriverDataType.Int32,
|
||||||
|
("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", "SPM") or ("STI", "ER") or ("STI", "PFN") => DriverDataType.Int32,
|
||||||
|
("STI", "EN") or ("STI", "TIE") or ("STI", "DN") or
|
||||||
|
("STI", "PS") or ("STI", "ED") => DriverDataType.Boolean,
|
||||||
|
("EII", "PFN") or ("EII", "ER") => DriverDataType.Int32,
|
||||||
|
("EII", "EN") or ("EII", "TIE") or ("EII", "PE") or
|
||||||
|
("EII", "ES") or ("EII", "ED") => DriverDataType.Boolean,
|
||||||
|
|
||||||
|
// I/O status + base hardware info — mostly status flags + a few counters.
|
||||||
|
("IOS", "ID") or ("IOS", "TYP") => DriverDataType.Int32,
|
||||||
|
("BHI", "OS") or ("BHI", "FRN") or ("BHI", "BSN") or ("BHI", "CC") => DriverDataType.Int32,
|
||||||
|
|
||||||
|
_ => DriverDataType.Int32,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||||
@@ -40,6 +130,196 @@ public static class AbLegacyDataTypeExtensions
|
|||||||
AbLegacyDataType.String => DriverDataType.String,
|
AbLegacyDataType.String => DriverDataType.String,
|
||||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||||
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
||||||
|
AbLegacyDataType.MicroLogixFunctionFile => DriverDataType.Int32,
|
||||||
|
// PD/MG/PLS/BT default to Int32 at the parent-element level. The sub-element-aware
|
||||||
|
// EffectiveDriverDataType refines specific members (Float for PID gains, Boolean for
|
||||||
|
// status bits).
|
||||||
|
AbLegacyDataType.PidElement or AbLegacyDataType.MessageElement
|
||||||
|
or AbLegacyDataType.PlsElement or AbLegacyDataType.BlockTransferElement
|
||||||
|
=> DriverDataType.Int32,
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sub-element-aware driver type. Timer/Counter/Control elements expose Boolean status
|
||||||
|
/// bits (<c>.DN</c>, <c>.EN</c>, <c>.TT</c>, <c>.CU</c>, <c>.CD</c>, <c>.OV</c>,
|
||||||
|
/// <c>.UN</c>, <c>.ER</c>, etc.) and Int32 word members (<c>.PRE</c>, <c>.ACC</c>,
|
||||||
|
/// <c>.LEN</c>, <c>.POS</c>). Unknown sub-elements fall back to
|
||||||
|
/// <see cref="ToDriverDataType"/> so the driver remains permissive.
|
||||||
|
/// </summary>
|
||||||
|
public static DriverDataType EffectiveDriverDataType(AbLegacyDataType t, string? subElement)
|
||||||
|
{
|
||||||
|
if (subElement is null) return t.ToDriverDataType();
|
||||||
|
var key = subElement.ToUpperInvariant();
|
||||||
|
return t switch
|
||||||
|
{
|
||||||
|
AbLegacyDataType.TimerElement => key switch
|
||||||
|
{
|
||||||
|
"EN" or "TT" or "DN" => DriverDataType.Boolean,
|
||||||
|
"PRE" or "ACC" => DriverDataType.Int32,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
AbLegacyDataType.CounterElement => key switch
|
||||||
|
{
|
||||||
|
"CU" or "CD" or "DN" or "OV" or "UN" => DriverDataType.Boolean,
|
||||||
|
"PRE" or "ACC" => DriverDataType.Int32,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
AbLegacyDataType.ControlElement => key switch
|
||||||
|
{
|
||||||
|
"EN" or "EU" or "DN" or "EM" or "ER" or "UL" or "IN" or "FD" => DriverDataType.Boolean,
|
||||||
|
"LEN" or "POS" => DriverDataType.Int32,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
// PD-file (PID): SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT are 32-bit floats; EN/DN/MO/PE/
|
||||||
|
// AUTO/MAN/SP_VAL/SP_LL/SP_HL are status bits in word 0.
|
||||||
|
AbLegacyDataType.PidElement => key switch
|
||||||
|
{
|
||||||
|
"SP" or "PV" or "CV" or "KP" or "KI" or "KD"
|
||||||
|
or "MAXS" or "MINS" or "DB" or "OUT" => DriverDataType.Float32,
|
||||||
|
"EN" or "DN" or "MO" or "PE"
|
||||||
|
or "AUTO" or "MAN" or "SP_VAL" or "SP_LL" or "SP_HL" => DriverDataType.Boolean,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
// MG-file (Message): RBE/MS/SIZE/LEN are control words; EN/EW/ER/DN/ST/CO/NR/TO are
|
||||||
|
// status bits.
|
||||||
|
AbLegacyDataType.MessageElement => key switch
|
||||||
|
{
|
||||||
|
"RBE" or "MS" or "SIZE" or "LEN" => DriverDataType.Int32,
|
||||||
|
"EN" or "EW" or "ER" or "DN" or "ST" or "CO" or "NR" or "TO" => DriverDataType.Boolean,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
// PLS-file (Programmable Limit Switch): LEN is a length word; bit semantics vary by
|
||||||
|
// PLC so unknown sub-elements stay Int32.
|
||||||
|
AbLegacyDataType.PlsElement => key switch
|
||||||
|
{
|
||||||
|
"LEN" => DriverDataType.Int32,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
// BT-file (Block Transfer, PLC-5): RLEN/DLEN are length words; EN/ST/DN/ER/CO/EW/
|
||||||
|
// TO/NR are status bits in word 0.
|
||||||
|
AbLegacyDataType.BlockTransferElement => key switch
|
||||||
|
{
|
||||||
|
"RLEN" or "DLEN" => DriverDataType.Int32,
|
||||||
|
"EN" or "ST" or "DN" or "ER" or "CO" or "EW" or "TO" or "NR" => DriverDataType.Boolean,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bit position within the parent control word for Timer/Counter/Control status bits.
|
||||||
|
/// Returns <c>null</c> if the sub-element is not a known bit member of the given element
|
||||||
|
/// type. Bit numbering follows Rockwell DTAM / PCCC documentation.
|
||||||
|
/// </summary>
|
||||||
|
public static int? StatusBitIndex(AbLegacyDataType t, string? subElement)
|
||||||
|
{
|
||||||
|
if (subElement is null) return null;
|
||||||
|
var key = subElement.ToUpperInvariant();
|
||||||
|
return t switch
|
||||||
|
{
|
||||||
|
// T4 element word 0: bit 13=DN, 14=TT, 15=EN.
|
||||||
|
AbLegacyDataType.TimerElement => key switch
|
||||||
|
{
|
||||||
|
"DN" => 13,
|
||||||
|
"TT" => 14,
|
||||||
|
"EN" => 15,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// C5 element word 0: bit 10=UN, 11=OV, 12=DN, 13=CD, 14=CU.
|
||||||
|
AbLegacyDataType.CounterElement => key switch
|
||||||
|
{
|
||||||
|
"UN" => 10,
|
||||||
|
"OV" => 11,
|
||||||
|
"DN" => 12,
|
||||||
|
"CD" => 13,
|
||||||
|
"CU" => 14,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// R6 element word 0: bit 8=FD, 9=IN, 10=UL, 11=ER, 12=EM, 13=DN, 14=EU, 15=EN.
|
||||||
|
AbLegacyDataType.ControlElement => key switch
|
||||||
|
{
|
||||||
|
"FD" => 8,
|
||||||
|
"IN" => 9,
|
||||||
|
"UL" => 10,
|
||||||
|
"ER" => 11,
|
||||||
|
"EM" => 12,
|
||||||
|
"DN" => 13,
|
||||||
|
"EU" => 14,
|
||||||
|
"EN" => 15,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// PD element word 0 (SLC 5/02+ PID, 1747-RM001 / PLC-5 PID-RM): bit 0=EN, 1=PE,
|
||||||
|
// 2=DN, 3=MO (manual mode), 4=AUTO, 5=MAN, 6=SP_VAL, 7=SP_LL, 8=SP_HL. Bits 4–8 are
|
||||||
|
// the SP-validity / SP-limit flags exposed in RSLogix 5 / 500.
|
||||||
|
AbLegacyDataType.PidElement => key switch
|
||||||
|
{
|
||||||
|
"EN" => 0,
|
||||||
|
"PE" => 1,
|
||||||
|
"DN" => 2,
|
||||||
|
"MO" => 3,
|
||||||
|
"AUTO" => 4,
|
||||||
|
"MAN" => 5,
|
||||||
|
"SP_VAL" => 6,
|
||||||
|
"SP_LL" => 7,
|
||||||
|
"SP_HL" => 8,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// MG element word 0 (PLC-5 MSG / SLC 5/05 MSG, 1785-6.5.12 / 1747-RM001):
|
||||||
|
// bit 15=EN, 14=ST, 13=DN, 12=ER, 11=CO, 10=EW, 9=NR, 8=TO.
|
||||||
|
AbLegacyDataType.MessageElement => key switch
|
||||||
|
{
|
||||||
|
"TO" => 8,
|
||||||
|
"NR" => 9,
|
||||||
|
"EW" => 10,
|
||||||
|
"CO" => 11,
|
||||||
|
"ER" => 12,
|
||||||
|
"DN" => 13,
|
||||||
|
"ST" => 14,
|
||||||
|
"EN" => 15,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// BT element word 0 (PLC-5 chassis BTR/BTW, 1785-6.5.12):
|
||||||
|
// bit 15=EN, 14=ST, 13=DN, 12=ER, 11=CO, 10=EW, 9=NR, 8=TO. Same layout as MG.
|
||||||
|
AbLegacyDataType.BlockTransferElement => key switch
|
||||||
|
{
|
||||||
|
"TO" => 8,
|
||||||
|
"NR" => 9,
|
||||||
|
"EW" => 10,
|
||||||
|
"CO" => 11,
|
||||||
|
"ER" => 12,
|
||||||
|
"DN" => 13,
|
||||||
|
"ST" => 14,
|
||||||
|
"EN" => 15,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PLC-set status bits — read-only from the OPC UA side. Operator-controllable bits
|
||||||
|
/// (e.g. <c>.EN</c> on a timer/counter, <c>.CU</c>/<c>.CD</c> rung-driven inputs) are
|
||||||
|
/// omitted so they keep default writable behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsPlcSetStatusBit(AbLegacyDataType t, string? subElement)
|
||||||
|
{
|
||||||
|
if (subElement is null) return false;
|
||||||
|
var key = subElement.ToUpperInvariant();
|
||||||
|
return t switch
|
||||||
|
{
|
||||||
|
AbLegacyDataType.TimerElement => key is "DN" or "TT",
|
||||||
|
AbLegacyDataType.CounterElement => key is "DN" or "OV" or "UN",
|
||||||
|
AbLegacyDataType.ControlElement => key is "DN" or "EM" or "ER" or "FD" or "UL" or "IN",
|
||||||
|
// PID: PE (PID-error), DN (process-done), SP_VAL/SP_LL/SP_HL are PLC-set status.
|
||||||
|
// EN/MO/AUTO/MAN are operator-controllable via the .EN bit / mode select.
|
||||||
|
AbLegacyDataType.PidElement => key is "PE" or "DN" or "SP_VAL" or "SP_LL" or "SP_HL",
|
||||||
|
// MG/BT: ST (started), DN (done), ER (error), CO (continuous), EW (enabled-waiting),
|
||||||
|
// NR (no-response), TO (timeout) are PLC-set. EN is operator-driven via the rung.
|
||||||
|
AbLegacyDataType.MessageElement => key is "ST" or "DN" or "ER" or "CO" or "EW" or "NR" or "TO",
|
||||||
|
AbLegacyDataType.BlockTransferElement => key is "ST" or "DN" or "ER" or "CO" or "EW" or "NR" or "TO",
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 8 — per-tag last published <c>(value, status)</c> cache for the deadband filter.
|
||||||
|
/// Layered on top of <see cref="PollGroupEngine"/> because the engine's change-detection
|
||||||
|
/// is binary (publish on any value/status diff). Cleared on <see cref="ShutdownAsync"/>
|
||||||
|
/// so a reconnect doesn't suppress legitimate post-reconnect updates against stale state.
|
||||||
|
/// Keyed by full reference (== tag name) — matches the engine's own <c>LastValues</c> key
|
||||||
|
/// space.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, (object? Value, uint StatusCode)> _lastPublished =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly object _lastPublishedLock = new();
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
@@ -31,8 +43,99 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||||
_poll = new PollGroupEngine(
|
_poll = new PollGroupEngine(
|
||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
onChange: DispatchPollChange);
|
||||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 8 — wraps the <see cref="PollGroupEngine"/> change callback with a per-tag
|
||||||
|
/// deadband filter. Booleans bypass (publish on every edge); strings + status changes
|
||||||
|
/// always publish; numerics pass only when <c>|new - prev|</c> meets the configured
|
||||||
|
/// absolute and / or percent deadband. First-seen always publishes.
|
||||||
|
/// </summary>
|
||||||
|
private void DispatchPollChange(ISubscriptionHandle handle, string tagRef, DataValueSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (!ShouldPublish(tagRef, snapshot)) return;
|
||||||
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 8 — deadband decision for one new sample. Updates the per-tag last-published
|
||||||
|
/// cache when the publish goes through so the next sample compares against the actual
|
||||||
|
/// emitted value (not every polled value).
|
||||||
|
/// </summary>
|
||||||
|
internal bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
|
||||||
|
{
|
||||||
|
// Tags absent from config (impossible via the engine path, defensive against callers
|
||||||
|
// that exercise the dispatch logic in isolation) bypass the filter.
|
||||||
|
var hasTag = _tagsByName.TryGetValue(tagRef, out var def);
|
||||||
|
|
||||||
|
lock (_lastPublishedLock)
|
||||||
|
{
|
||||||
|
var firstSeen = !_lastPublished.TryGetValue(tagRef, out var prev);
|
||||||
|
|
||||||
|
// First-seen, status change, or no tag config: always publish.
|
||||||
|
if (firstSeen || prev.StatusCode != snapshot.StatusCode || !hasTag)
|
||||||
|
{
|
||||||
|
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No deadband configured -> defer to PollGroupEngine's value-equality decision
|
||||||
|
// (the engine already filtered to "different from last engine snapshot" before we
|
||||||
|
// got here, so any sample reaching this point is a legitimate change).
|
||||||
|
if (def!.AbsoluteDeadband is null && def.PercentDeadband is null)
|
||||||
|
{
|
||||||
|
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Booleans + strings + non-numerics: deadband is meaningless; publish whenever the
|
||||||
|
// value differs from the last published one.
|
||||||
|
if (!TryAsDouble(snapshot.Value, out var newD) || !TryAsDouble(prev.Value, out var prevD))
|
||||||
|
{
|
||||||
|
if (Equals(prev.Value, snapshot.Value)) return false;
|
||||||
|
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delta = Math.Abs(newD - prevD);
|
||||||
|
var absPass = def.AbsoluteDeadband is double abs && delta >= abs;
|
||||||
|
|
||||||
|
// Percent: |prev| == 0 short-circuits to "always publish on any change" — avoids
|
||||||
|
// div-by-zero and matches Kepware's documented behaviour.
|
||||||
|
bool percentPass;
|
||||||
|
if (def.PercentDeadband is double pct)
|
||||||
|
{
|
||||||
|
if (prevD == 0) percentPass = delta > 0;
|
||||||
|
else percentPass = delta >= Math.Abs(prevD * pct / 100.0);
|
||||||
|
}
|
||||||
|
else percentPass = false;
|
||||||
|
|
||||||
|
// Logical OR — either filter triggering is enough. Matches the spec note in the
|
||||||
|
// PR plan ("Both deadbands set -> either triggers, Kepware semantics").
|
||||||
|
var pass = (def.AbsoluteDeadband is not null && absPass)
|
||||||
|
|| (def.PercentDeadband is not null && percentPass);
|
||||||
|
|
||||||
|
if (!pass) return false;
|
||||||
|
|
||||||
|
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryAsDouble(object? value, out double result)
|
||||||
|
{
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case null: result = 0; return false;
|
||||||
|
case bool: result = 0; return false; // booleans use the equality fast path
|
||||||
|
case string: result = 0; return false;
|
||||||
|
case Array: result = 0; return false;
|
||||||
|
case IConvertible conv:
|
||||||
|
try { result = conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture); return true; }
|
||||||
|
catch { result = 0; return false; }
|
||||||
|
default: result = 0; return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string DriverInstanceId => _driverInstanceId;
|
public string DriverInstanceId => _driverInstanceId;
|
||||||
@@ -91,6 +194,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
}
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.Clear();
|
_tagsByName.Clear();
|
||||||
|
// PR 8 — clear the deadband last-published cache so a ReinitializeAsync (or a
|
||||||
|
// reconnect-driven shutdown) doesn't suppress the very first post-reconnect sample
|
||||||
|
// by comparing it against pre-disconnect state.
|
||||||
|
lock (_lastPublishedLock) { _lastPublished.Clear(); }
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +247,32 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
// PR 7 — array contiguous block. Decode N consecutive elements via the runtime's
|
||||||
|
// per-index accessor and box the result as a typed .NET array. The parser has
|
||||||
|
// already rejected array+bit and array+sub-element combinations, so the array
|
||||||
|
// path can ignore the bit/sub-element decoders entirely.
|
||||||
|
int arrayCount;
|
||||||
|
if (parsed is not null && (def.ArrayLength is not null || (parsed.ArrayCount ?? 1) > 1))
|
||||||
|
{
|
||||||
|
arrayCount = ResolveElementCount(def, parsed);
|
||||||
|
}
|
||||||
|
else arrayCount = 1;
|
||||||
|
|
||||||
|
if (arrayCount > 1)
|
||||||
|
{
|
||||||
|
var arr = DecodeArrayAs(runtime, def.DataType, arrayCount);
|
||||||
|
results[i] = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now);
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer/Counter/Control status bits route through GetBit at the parent-word
|
||||||
|
// address — translate the .DN/.EN/etc. sub-element to its standard bit position
|
||||||
|
// and pass it down to the runtime as a synthetic bitIndex.
|
||||||
|
var decodeBit = parsed?.BitIndex
|
||||||
|
?? AbLegacyDataTypeExtensions.StatusBitIndex(def.DataType, parsed?.SubElement);
|
||||||
|
var value = runtime.DecodeValue(def.DataType, decodeBit);
|
||||||
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
}
|
}
|
||||||
@@ -186,7 +317,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||||
|
|
||||||
|
// Timer/Counter/Control PLC-set status bits (DN, TT, OV, UN, FD, ER, EM, UL,
|
||||||
|
// IN) are read-only — the PLC sets them; any client write would be silently
|
||||||
|
// overwritten on the next scan. Reject up front with BadNotWritable.
|
||||||
|
if (AbLegacyDataTypeExtensions.IsPlcSetStatusBit(def.DataType, parsed?.SubElement))
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
||||||
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
||||||
@@ -223,6 +363,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||||
}
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
// ST-file string writes exceeding the 82-byte fixed element. Surfaces from
|
||||||
|
// LibplctagLegacyTagRuntime.EncodeValue's length guard; mapped to BadOutOfRange so
|
||||||
|
// the OPC UA client sees a clean rejection rather than a silent truncation.
|
||||||
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||||
@@ -247,12 +394,24 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||||
foreach (var tag in tagsForDevice)
|
foreach (var tag in tagsForDevice)
|
||||||
{
|
{
|
||||||
|
var parsed = AbLegacyAddress.TryParse(tag.Address, device.PlcFamily);
|
||||||
|
// Timer/Counter/Control sub-elements (.DN/.EN/.TT/.PRE/.ACC/etc.) refine the
|
||||||
|
// base element's Int32 to Boolean for status bits and Int32 for word members.
|
||||||
|
var effectiveType = AbLegacyDataTypeExtensions.EffectiveDriverDataType(
|
||||||
|
tag.DataType, parsed?.SubElement);
|
||||||
|
var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit(
|
||||||
|
tag.DataType, parsed?.SubElement);
|
||||||
|
// PR 7 — array contiguous-block tags advertise IsArray + ArrayDim so the OPC UA
|
||||||
|
// generic node-manager builds a 1-D array variable. ArrayLength on the tag
|
||||||
|
// definition wins over the parsed `,N` / `[N]` suffix; both null = scalar.
|
||||||
|
var arrayLen = tag.ArrayLength
|
||||||
|
?? (parsed?.ArrayCount is int n && n > 1 ? n : (int?)null);
|
||||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||||
FullName: tag.Name,
|
FullName: tag.Name,
|
||||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
DriverDataType: effectiveType,
|
||||||
IsArray: false,
|
IsArray: arrayLen is int al && al > 1,
|
||||||
ArrayDim: null,
|
ArrayDim: arrayLen is int al2 && al2 > 1 ? (uint)al2 : null,
|
||||||
SecurityClass: tag.Writable
|
SecurityClass: tag.Writable && !plcSetBit
|
||||||
? SecurityClassification.Operate
|
? SecurityClassification.Operate
|
||||||
: SecurityClassification.ViewOnly,
|
: SecurityClassification.ViewOnly,
|
||||||
IsHistorized: false,
|
IsHistorized: false,
|
||||||
@@ -413,17 +572,37 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
{
|
{
|
||||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||||
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||||
|
|
||||||
|
// TODO(#247): libplctag's PCCC text decoder does not natively accept the bracket-form
|
||||||
|
// indirect address. Resolving N7:[N7:0] requires reading the inner address first, then
|
||||||
|
// rewriting the tag name with the resolved word number, then issuing the actual read.
|
||||||
|
// For now we surface a clear runtime error rather than letting libplctag fail with an
|
||||||
|
// opaque parser error.
|
||||||
|
if (parsed.IsIndirect)
|
||||||
|
throw new NotSupportedException(
|
||||||
|
$"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented.");
|
||||||
|
|
||||||
|
// PR 7 — resolve the effective array length: explicit ArrayLength override on the tag
|
||||||
|
// definition wins over the parsed `,N` / `[N]` suffix. ElementCount of 1 means
|
||||||
|
// single-element scalar (libplctag's default); >1 triggers the contiguous-block path.
|
||||||
|
var elementCount = ResolveElementCount(def, parsed);
|
||||||
|
// Drop the parsed array suffix from the libplctag tag name when ArrayLength overrides
|
||||||
|
// it — libplctag would otherwise read the parsed length, not the override.
|
||||||
|
var tagName = (def.ArrayLength is int && parsed.ArrayCount is not null)
|
||||||
|
? (parsed with { ArrayCount = null }).ToLibplctagName()
|
||||||
|
: parsed.ToLibplctagName();
|
||||||
|
|
||||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||||
Gateway: device.ParsedAddress.Gateway,
|
Gateway: device.ParsedAddress.Gateway,
|
||||||
Port: device.ParsedAddress.Port,
|
Port: device.ParsedAddress.Port,
|
||||||
CipPath: device.ParsedAddress.CipPath,
|
CipPath: device.ParsedAddress.CipPath,
|
||||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||||
TagName: parsed.ToLibplctagName(),
|
TagName: tagName,
|
||||||
Timeout: _options.Timeout));
|
Timeout: _options.Timeout,
|
||||||
|
ElementCount: elementCount));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||||
@@ -437,6 +616,54 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — pull <paramref name="elementCount"/> consecutive elements from a runtime that
|
||||||
|
/// just completed a single contiguous-block read. Element type drives both the .NET
|
||||||
|
/// array shape (Int32[] / Single[] / Boolean[]) and the per-index decoder routing.
|
||||||
|
/// </summary>
|
||||||
|
private static object DecodeArrayAs(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int elementCount)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
AbLegacyDataType.Bit => BuildArray<bool>(runtime, type, elementCount),
|
||||||
|
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => BuildArray<int>(runtime, type, elementCount),
|
||||||
|
AbLegacyDataType.Long => BuildArray<int>(runtime, type, elementCount),
|
||||||
|
AbLegacyDataType.Float => BuildArray<float>(runtime, type, elementCount),
|
||||||
|
_ => throw new NotSupportedException(
|
||||||
|
$"AbLegacyDataType {type} is not supported in array contiguous-block reads."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T[] BuildArray<T>(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int n)
|
||||||
|
{
|
||||||
|
var arr = new T[n];
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var element = runtime.DecodeArrayElement(type, i);
|
||||||
|
arr[i] = (T)Convert.ChangeType(element!, typeof(T))!;
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — resolve the effective array element count for a tag. Explicit
|
||||||
|
/// <see cref="AbLegacyTagDefinition.ArrayLength"/> on the tag definition wins; otherwise
|
||||||
|
/// the parsed <see cref="AbLegacyAddress.ArrayCount"/> from the address suffix is used;
|
||||||
|
/// otherwise 1 (scalar). Validates the override against the same PCCC frame ceiling
|
||||||
|
/// enforced by the parser so config-overrides can't bypass the limit.
|
||||||
|
/// </summary>
|
||||||
|
internal static int ResolveElementCount(AbLegacyTagDefinition def, AbLegacyAddress parsed)
|
||||||
|
{
|
||||||
|
if (def.ArrayLength is int n)
|
||||||
|
{
|
||||||
|
if (n < 1 || n > AbLegacyAddress.MaxArrayCount)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"AbLegacy tag '{def.Name}' has ArrayLength {n}; expected 1..{AbLegacyAddress.MaxArrayCount}.");
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
return parsed.ArrayCount ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ public static class AbLegacyDriverFactoryExtensions
|
|||||||
DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
|
DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
|
||||||
tagName: t.Name),
|
tagName: t.Name),
|
||||||
Writable: t.Writable ?? true,
|
Writable: t.Writable ?? true,
|
||||||
WriteIdempotent: t.WriteIdempotent ?? false))]
|
WriteIdempotent: t.WriteIdempotent ?? false,
|
||||||
|
ArrayLength: t.ArrayLength,
|
||||||
|
AbsoluteDeadband: t.AbsoluteDeadband,
|
||||||
|
PercentDeadband: t.PercentDeadband))]
|
||||||
: [],
|
: [],
|
||||||
Probe = new AbLegacyProbeOptions
|
Probe = new AbLegacyProbeOptions
|
||||||
{
|
{
|
||||||
@@ -112,6 +115,25 @@ public static class AbLegacyDriverFactoryExtensions
|
|||||||
public string? DataType { get; init; }
|
public string? DataType { get; init; }
|
||||||
public bool? Writable { get; init; }
|
public bool? Writable { get; init; }
|
||||||
public bool? WriteIdempotent { get; init; }
|
public bool? WriteIdempotent { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — optional override for the parsed array suffix. When set and > 1 the
|
||||||
|
/// driver issues a single contiguous PCCC block read for N elements.
|
||||||
|
/// </summary>
|
||||||
|
public int? ArrayLength { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 8 — optional absolute change filter for numeric tags. <c>OnDataChange</c> is
|
||||||
|
/// suppressed unless <c>|new - prev| >= AbsoluteDeadband</c>. Booleans bypass;
|
||||||
|
/// strings + status changes always publish.
|
||||||
|
/// </summary>
|
||||||
|
public double? AbsoluteDeadband { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 8 — optional percent-of-previous change filter for numeric tags.
|
||||||
|
/// <c>OnDataChange</c> is suppressed unless <c>|new - prev| >= |prev * Percent / 100|</c>.
|
||||||
|
/// <c>prev == 0</c> always publishes (avoids division-by-zero).
|
||||||
|
/// </summary>
|
||||||
|
public double? PercentDeadband { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class AbLegacyProbeDto
|
internal sealed class AbLegacyProbeDto
|
||||||
|
|||||||
@@ -22,16 +22,31 @@ public sealed record AbLegacyDeviceOptions(
|
|||||||
string? DeviceName = null);
|
string? DeviceName = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
/// One PCCC-backed OPC UA variable. <c>Address</c> is the canonical PCCC file-address
|
||||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
|
/// string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PR 8 deadband fields:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>AbsoluteDeadband</c> — when set, suppresses <c>OnDataChange</c> for numeric
|
||||||
|
/// tags unless <c>|new - prev| >= AbsoluteDeadband</c>.</item>
|
||||||
|
/// <item><c>PercentDeadband</c> — when set, suppresses unless
|
||||||
|
/// <c>|new - prev| >= |prev * Percent / 100|</c>; <c>prev == 0</c> always publishes.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Booleans bypass deadband entirely (every transition publishes); strings + status
|
||||||
|
/// changes always publish; first-seen always publishes; both set → logical-OR (Kepware
|
||||||
|
/// semantics).
|
||||||
|
/// </remarks>
|
||||||
public sealed record AbLegacyTagDefinition(
|
public sealed record AbLegacyTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string DeviceHostAddress,
|
string DeviceHostAddress,
|
||||||
string Address,
|
string Address,
|
||||||
AbLegacyDataType DataType,
|
AbLegacyDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
int? ArrayLength = null,
|
||||||
|
double? AbsoluteDeadband = null,
|
||||||
|
double? PercentDeadband = null);
|
||||||
|
|
||||||
public sealed class AbLegacyProbeOptions
|
public sealed class AbLegacyProbeOptions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ public interface IAbLegacyTagRuntime : IDisposable
|
|||||||
int GetStatus();
|
int GetStatus();
|
||||||
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
|
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
|
||||||
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — decode element <paramref name="elementIndex"/> of an N-element contiguous
|
||||||
|
/// block read. Implementations call the same per-element accessors used by
|
||||||
|
/// <see cref="DecodeValue"/> at offset <c>elementIndex × elementBytes</c>. Default
|
||||||
|
/// implementation throws so existing fakes that don't override remain explicit.
|
||||||
|
/// </summary>
|
||||||
|
object? DecodeArrayElement(AbLegacyDataType type, int elementIndex)
|
||||||
|
=> throw new NotSupportedException(
|
||||||
|
"Array decoding requires an IAbLegacyTagRuntime that overrides DecodeArrayElement.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAbLegacyTagFactory
|
public interface IAbLegacyTagFactory
|
||||||
@@ -26,4 +36,5 @@ public sealed record AbLegacyTagCreateParams(
|
|||||||
string CipPath,
|
string CipPath,
|
||||||
string LibplctagPlcAttribute,
|
string LibplctagPlcAttribute,
|
||||||
string TagName,
|
string TagName,
|
||||||
TimeSpan Timeout);
|
TimeSpan Timeout,
|
||||||
|
int ElementCount = 1);
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
{
|
{
|
||||||
private readonly Tag _tag;
|
private readonly Tag _tag;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum payload length for an ST (string) file element on SLC / MicroLogix / PLC-5.
|
||||||
|
/// The on-wire layout is a 1-word length prefix followed by 82 ASCII bytes — libplctag's
|
||||||
|
/// <c>SetString</c> handles the framing internally, but it does NOT validate length, so a
|
||||||
|
/// 93-byte source string would silently truncate. We reject up-front so the OPC UA client
|
||||||
|
/// gets a clean <c>BadOutOfRange</c> rather than a corrupted PLC value.
|
||||||
|
/// </summary>
|
||||||
|
internal const int StFileMaxStringLength = 82;
|
||||||
|
|
||||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||||
{
|
{
|
||||||
_tag = new Tag
|
_tag = new Tag
|
||||||
@@ -23,6 +32,11 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
Name = p.TagName,
|
Name = p.TagName,
|
||||||
Timeout = p.Timeout,
|
Timeout = p.Timeout,
|
||||||
};
|
};
|
||||||
|
// PR 7 — array contiguous-block reads. Setting ElementCount tells libplctag to allocate
|
||||||
|
// a buffer covering N consecutive PCCC words (one frame, up to ~120 elements). The
|
||||||
|
// driver decodes element-by-element through DecodeArrayElement after a single ReadAsync.
|
||||||
|
if (p.ElementCount > 1)
|
||||||
|
_tag.ElementCount = p.ElementCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||||
@@ -40,8 +54,25 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
AbLegacyDataType.Long => _tag.GetInt32(0),
|
AbLegacyDataType.Long => _tag.GetInt32(0),
|
||||||
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
||||||
AbLegacyDataType.String => _tag.GetString(0),
|
AbLegacyDataType.String => _tag.GetString(0),
|
||||||
|
// Timer/Counter/Control sub-elements: bitIndex is the status bit position within the
|
||||||
|
// parent control word (encoded by AbLegacyDriver from the .DN / .EN / etc. sub-element
|
||||||
|
// name). Word members (.PRE / .ACC / .LEN / .POS) come through with bitIndex=null and
|
||||||
|
// decode as Int32 like before.
|
||||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||||
or AbLegacyDataType.ControlElement => _tag.GetInt32(0),
|
or AbLegacyDataType.ControlElement => bitIndex is int statusBit
|
||||||
|
? _tag.GetBit(statusBit)
|
||||||
|
: _tag.GetInt32(0),
|
||||||
|
// PD-file (PID): non-bit members (SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT) are 32-bit floats.
|
||||||
|
// Status bits (EN/DN/MO/PE/AUTO/MAN/SP_VAL/SP_LL/SP_HL) live in the parent control word
|
||||||
|
// and read through GetBit — the driver encodes the position via StatusBitIndex.
|
||||||
|
AbLegacyDataType.PidElement => bitIndex is int pidBit
|
||||||
|
? _tag.GetBit(pidBit)
|
||||||
|
: _tag.GetFloat32(0),
|
||||||
|
// MG/BT/PLS: non-bit members (RBE/MS/SIZE/LEN, RLEN/DLEN) are word-sized integers.
|
||||||
|
AbLegacyDataType.MessageElement or AbLegacyDataType.BlockTransferElement
|
||||||
|
or AbLegacyDataType.PlsElement => bitIndex is int statusBit2
|
||||||
|
? _tag.GetBit(statusBit2)
|
||||||
|
: _tag.GetInt32(0),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,18 +101,63 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
_tag.SetFloat32(0, Convert.ToSingle(value));
|
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||||
break;
|
break;
|
||||||
case AbLegacyDataType.String:
|
case AbLegacyDataType.String:
|
||||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
{
|
||||||
|
var s = Convert.ToString(value) ?? string.Empty;
|
||||||
|
if (s.Length > StFileMaxStringLength)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(value),
|
||||||
|
$"ST string write exceeds {StFileMaxStringLength}-byte file element capacity (was {s.Length}).");
|
||||||
|
_tag.SetString(0, s);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case AbLegacyDataType.TimerElement:
|
case AbLegacyDataType.TimerElement:
|
||||||
case AbLegacyDataType.CounterElement:
|
case AbLegacyDataType.CounterElement:
|
||||||
case AbLegacyDataType.ControlElement:
|
case AbLegacyDataType.ControlElement:
|
||||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||||
break;
|
break;
|
||||||
|
// PD-file non-bit writes route to the Float backing store. Status-bit writes within
|
||||||
|
// the parent word are blocked at the driver layer (PLC-set bits are read-only and
|
||||||
|
// operator-controllable bits go through the bit-RMW path with the parent word typed
|
||||||
|
// as Int).
|
||||||
|
case AbLegacyDataType.PidElement:
|
||||||
|
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||||
|
break;
|
||||||
|
case AbLegacyDataType.MessageElement:
|
||||||
|
case AbLegacyDataType.BlockTransferElement:
|
||||||
|
case AbLegacyDataType.PlsElement:
|
||||||
|
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
|
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — decode element <paramref name="elementIndex"/> of an N-element contiguous
|
||||||
|
/// PCCC block read. Element width is fixed per data type: Int / AnalogInt / Bit-as-word
|
||||||
|
/// are 16-bit (2 bytes/element), Long / Float are 32-bit (4 bytes/element). Mirrors the
|
||||||
|
/// non-array decoder shape but at byte offset <c>elementIndex × elementBytes</c>.
|
||||||
|
/// </summary>
|
||||||
|
public object? DecodeArrayElement(AbLegacyDataType type, int elementIndex)
|
||||||
|
{
|
||||||
|
if (elementIndex < 0) throw new ArgumentOutOfRangeException(nameof(elementIndex));
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
// Bit / N-array reads — Rockwell convention is one BOOL per word (e.g. `B3:0,10`
|
||||||
|
// returns 10 BOOLs, not 160 individual bits). Each word is non-zero → true.
|
||||||
|
AbLegacyDataType.Bit => _tag.GetInt16(elementIndex * 2) != 0,
|
||||||
|
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)_tag.GetInt16(elementIndex * 2),
|
||||||
|
AbLegacyDataType.Long => _tag.GetInt32(elementIndex * 4),
|
||||||
|
AbLegacyDataType.Float => _tag.GetFloat32(elementIndex * 4),
|
||||||
|
// String + element types are out-of-scope for PR 7 array reads — the PCCC layer's
|
||||||
|
// 240-byte frame ceiling means an ST array would only fit a couple of strings, and
|
||||||
|
// sub-element arrays (`T4:0,5.ACC`) are rejected at parse time. Surface a clear
|
||||||
|
// error if the driver mis-routes us here.
|
||||||
|
_ => throw new NotSupportedException(
|
||||||
|
$"AbLegacyDataType {type} cannot be decoded as a contiguous array element."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() => _tag.Dispose();
|
public void Dispose() => _tag.Dispose();
|
||||||
|
|
||||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
string DefaultCipPath,
|
string DefaultCipPath,
|
||||||
int MaxTagBytes,
|
int MaxTagBytes,
|
||||||
bool SupportsStringFile,
|
bool SupportsStringFile,
|
||||||
bool SupportsLongFile)
|
bool SupportsLongFile,
|
||||||
|
bool OctalIoAddressing,
|
||||||
|
bool SupportsFunctionFiles,
|
||||||
|
bool SupportsPidFile,
|
||||||
|
bool SupportsMessageFile,
|
||||||
|
bool SupportsPlsFile,
|
||||||
|
bool SupportsBlockTransferFile)
|
||||||
{
|
{
|
||||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||||
{
|
{
|
||||||
@@ -25,21 +31,39 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||||
SupportsStringFile: true, // ST file available SLC 5/04+
|
SupportsStringFile: true, // ST file available SLC 5/04+
|
||||||
SupportsLongFile: true); // L file available SLC 5/05+
|
SupportsLongFile: true, // L file available SLC 5/05+
|
||||||
|
OctalIoAddressing: false, // SLC500 I:/O: indices are decimal in RSLogix 500
|
||||||
|
SupportsFunctionFiles: false, // SLC500 has no function files
|
||||||
|
SupportsPidFile: true, // SLC 5/02+ supports PD via PID instruction
|
||||||
|
SupportsMessageFile: true, // SLC 5/02+ supports MG via MSG instruction
|
||||||
|
SupportsPlsFile: false, // SLC500 has no native PLS file (uses SQO/SQC instead)
|
||||||
|
SupportsBlockTransferFile: false); // SLC500 has no BT file (BT is PLC-5 ChassisIO only)
|
||||||
|
|
||||||
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
||||||
LibplctagPlcAttribute: "micrologix",
|
LibplctagPlcAttribute: "micrologix",
|
||||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||||
MaxTagBytes: 232,
|
MaxTagBytes: 232,
|
||||||
SupportsStringFile: true,
|
SupportsStringFile: true,
|
||||||
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
|
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
|
||||||
|
OctalIoAddressing: false, // MicroLogix follows SLC-style decimal I/O addressing
|
||||||
|
SupportsFunctionFiles: true, // ML 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI
|
||||||
|
SupportsPidFile: false, // MicroLogix 1100/1400 use PID-instruction-only addressing — no PD file type
|
||||||
|
SupportsMessageFile: false, // No MG file — MSG instruction control words live in standard files
|
||||||
|
SupportsPlsFile: false,
|
||||||
|
SupportsBlockTransferFile: false);
|
||||||
|
|
||||||
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
||||||
LibplctagPlcAttribute: "plc5",
|
LibplctagPlcAttribute: "plc5",
|
||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||||
SupportsStringFile: true,
|
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
|
||||||
|
SupportsFunctionFiles: false,
|
||||||
|
SupportsPidFile: true, // PLC-5 PID instruction needs PD file
|
||||||
|
SupportsMessageFile: true, // PLC-5 MSG instruction needs MG file
|
||||||
|
SupportsPlsFile: true, // PLC-5 has PLS (programmable limit switch) file
|
||||||
|
SupportsBlockTransferFile: true); // PLC-5 chassis I/O block transfer (BTR/BTW) needs BT file
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||||
@@ -51,7 +75,15 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240,
|
MaxTagBytes: 240,
|
||||||
SupportsStringFile: true,
|
SupportsStringFile: true,
|
||||||
SupportsLongFile: true);
|
SupportsLongFile: true,
|
||||||
|
OctalIoAddressing: false, // Logix natively uses decimal arrays even via the PCCC bridge
|
||||||
|
SupportsFunctionFiles: false,
|
||||||
|
// Logix native UDTs (PID_ENHANCED / MESSAGE) replace the legacy PD/MG file types — the
|
||||||
|
// PCCC bridge does not expose them as letter-prefixed files.
|
||||||
|
SupportsPidFile: false,
|
||||||
|
SupportsMessageFile: false,
|
||||||
|
SupportsPlsFile: false,
|
||||||
|
SupportsBlockTransferFile: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Which PCCC PLC family the device is.</summary>
|
/// <summary>Which PCCC PLC family the device is.</summary>
|
||||||
|
|||||||
@@ -1,35 +1,57 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
|
/// Parsed FOCAS address covering the four addressing spaces a driver touches:
|
||||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
|
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
|
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||||
|
/// <see cref="FocasAreaKind.Diagnostic"/> (CNC diagnostic number, optionally per-axis —
|
||||||
|
/// <c>DIAG:1031</c>, <c>DIAG:280/2</c>) routed through <c>cnc_rddiag</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
||||||
|
/// Diagnostic addresses reuse the <c>/N</c> form to encode an axis index — <c>BitIndex</c>
|
||||||
|
/// carries the 1-based axis number (0 = whole-CNC diagnostic).
|
||||||
|
/// <para>
|
||||||
|
/// Multi-path / multi-channel CNCs (e.g. lathe + sub-spindle, dual-turret) expose multiple
|
||||||
|
/// "paths"; <see cref="PathId"/> selects which one a given address is read from. Encoded
|
||||||
|
/// as a trailing <c>@N</c> after the address body but before any bit / axis suffix —
|
||||||
|
/// <c>R100@2</c>, <c>PARAM:1815@2</c>, <c>PARAM:1815@2/0</c>, <c>MACRO:500@3</c>,
|
||||||
|
/// <c>DIAG:280@2/1</c>. Defaults to <c>1</c> for back-compat (single-path CNCs).
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record FocasAddress(
|
public sealed record FocasAddress(
|
||||||
FocasAreaKind Kind,
|
FocasAreaKind Kind,
|
||||||
string? PmcLetter,
|
string? PmcLetter,
|
||||||
int Number,
|
int Number,
|
||||||
int? BitIndex)
|
int? BitIndex,
|
||||||
|
int PathId = 1)
|
||||||
{
|
{
|
||||||
public string Canonical => Kind switch
|
public string Canonical
|
||||||
{
|
{
|
||||||
FocasAreaKind.Pmc => BitIndex is null
|
get
|
||||||
? $"{PmcLetter}{Number}"
|
{
|
||||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
var pathSuffix = PathId == 1 ? string.Empty : $"@{PathId}";
|
||||||
FocasAreaKind.Parameter => BitIndex is null
|
return Kind switch
|
||||||
? $"PARAM:{Number}"
|
{
|
||||||
: $"PARAM:{Number}/{BitIndex}",
|
FocasAreaKind.Pmc => BitIndex is null
|
||||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
? $"{PmcLetter}{Number}{pathSuffix}"
|
||||||
_ => $"?{Number}",
|
: $"{PmcLetter}{Number}{pathSuffix}.{BitIndex}",
|
||||||
};
|
FocasAreaKind.Parameter => BitIndex is null
|
||||||
|
? $"PARAM:{Number}{pathSuffix}"
|
||||||
|
: $"PARAM:{Number}{pathSuffix}/{BitIndex}",
|
||||||
|
FocasAreaKind.Macro => $"MACRO:{Number}{pathSuffix}",
|
||||||
|
FocasAreaKind.Diagnostic => BitIndex is null or 0
|
||||||
|
? $"DIAG:{Number}{pathSuffix}"
|
||||||
|
: $"DIAG:{Number}{pathSuffix}/{BitIndex}",
|
||||||
|
_ => $"?{Number}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static FocasAddress? TryParse(string? value)
|
public static FocasAddress? TryParse(string? value)
|
||||||
{
|
{
|
||||||
@@ -42,7 +64,10 @@ public sealed record FocasAddress(
|
|||||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||||
|
|
||||||
// PMC path: letter + digits + optional .bit
|
if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return ParseScoped(src["DIAG:".Length..], FocasAreaKind.Diagnostic, bitSeparator: '/');
|
||||||
|
|
||||||
|
// PMC path: letter + digits + optional @path + optional .bit
|
||||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||||
var letter = src[0..1].ToUpperInvariant();
|
var letter = src[0..1].ToUpperInvariant();
|
||||||
if (!IsValidPmcLetter(letter)) return null;
|
if (!IsValidPmcLetter(letter)) return null;
|
||||||
@@ -57,8 +82,15 @@ public sealed record FocasAddress(
|
|||||||
bit = bitValue;
|
bit = bitValue;
|
||||||
remainder = remainder[..dotIdx];
|
remainder = remainder[..dotIdx];
|
||||||
}
|
}
|
||||||
|
var pmcPath = 1;
|
||||||
|
var atIdx = remainder.IndexOf('@');
|
||||||
|
if (atIdx >= 0)
|
||||||
|
{
|
||||||
|
if (!TryParsePathId(remainder[(atIdx + 1)..], out pmcPath)) return null;
|
||||||
|
remainder = remainder[..atIdx];
|
||||||
|
}
|
||||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit, pmcPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||||
@@ -75,8 +107,30 @@ public sealed record FocasAddress(
|
|||||||
body = body[..slashIdx];
|
body = body[..slashIdx];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Path suffix (@N) sits between the body number and any bit/axis (which has already
|
||||||
|
// been peeled off above): PARAM:1815@2/0 → body="1815@2", bit=0.
|
||||||
|
var path = 1;
|
||||||
|
var atIdx = body.IndexOf('@');
|
||||||
|
if (atIdx >= 0)
|
||||||
|
{
|
||||||
|
if (!TryParsePathId(body[(atIdx + 1)..], out path)) return null;
|
||||||
|
body = body[..atIdx];
|
||||||
|
}
|
||||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
return new FocasAddress(kind, PmcLetter: null, number, bit, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParsePathId(string text, out int pathId)
|
||||||
|
{
|
||||||
|
// Path 0 is reserved (FOCAS path numbering is 1-based); upper-bound is the FWLIB
|
||||||
|
// ceiling — Fanuc spec lists 10 paths max even on the largest 30i-B configurations.
|
||||||
|
if (int.TryParse(text, out var v) && v is >= 1 and <= 10)
|
||||||
|
{
|
||||||
|
pathId = v;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pathId = 0;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||||
@@ -92,4 +146,12 @@ public enum FocasAreaKind
|
|||||||
Pmc,
|
Pmc,
|
||||||
Parameter,
|
Parameter,
|
||||||
Macro,
|
Macro,
|
||||||
|
/// <summary>
|
||||||
|
/// CNC diagnostic number routed through <c>cnc_rddiag</c>. <c>DIAG:nnn</c> is a
|
||||||
|
/// whole-CNC diagnostic (axis = 0); <c>DIAG:nnn/axis</c> is per-axis (axis is the
|
||||||
|
/// 1-based FANUC axis index). Like parameters, diagnostics span Int / Float /
|
||||||
|
/// Bit shapes — the driver picks the wire shape based on the configured tag's
|
||||||
|
/// <see cref="FocasDataType"/>.
|
||||||
|
/// </summary>
|
||||||
|
Diagnostic,
|
||||||
}
|
}
|
||||||
|
|||||||
255
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs
Normal file
255
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #267 (plan PR F3-a) — projects FANUC CNC alarms onto the OPC UA alarm surface
|
||||||
|
/// via <see cref="IAlarmSource"/>. Two modes:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="FocasAlarmProjectionMode.ActiveOnly"/> (default) — only
|
||||||
|
/// currently-active alarms surface. Subscribe / unsubscribe / acknowledge wire up,
|
||||||
|
/// but no history poll runs. This is the conservative mode operators get when
|
||||||
|
/// they don't explicitly opt into history.</item>
|
||||||
|
/// <item><see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> — additionally
|
||||||
|
/// polls <c>cnc_rdalmhistry</c> on connect and on every
|
||||||
|
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> tick. Each
|
||||||
|
/// previously-unseen entry fires an <c>OnAlarmEvent</c> with
|
||||||
|
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp (not Now)
|
||||||
|
/// so OPC UA dashboards see the real occurrence time.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Dedup</b> — an in-memory <see cref="HashSet{T}"/> keyed on
|
||||||
|
/// <c>(OccurrenceTime, AlarmNumber, AlarmType)</c> tracks every entry the projection has
|
||||||
|
/// emitted. The same triple across two polls only emits once. The set resets on reconnect
|
||||||
|
/// — first poll after reconnect re-emits everything in the ring buffer; OPC UA clients
|
||||||
|
/// that care about exactly-once semantics dedupe on their side via the
|
||||||
|
/// timestamp + number + type tuple.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>HistoryDepth clamp</b> — user-supplied depth is bounded to
|
||||||
|
/// <c>[1..<see cref="FocasAlarmProjectionOptions.MaxHistoryDepth"/>]</c> so an operator
|
||||||
|
/// who types <c>10000</c> by accident doesn't blow up the wire session. The clamp lives
|
||||||
|
/// in <see cref="ResolveDepth"/>.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Active alarms</b> — first cut surfaces history only. Active alarms (raise +
|
||||||
|
/// clear via <c>cnc_rdalmmsg</c>/<c>cnc_rdalmmsg2</c>) are a follow-up; this projection's
|
||||||
|
/// subscribe path returns a handle but does not poll for active alarms today. The
|
||||||
|
/// ActiveOnly mode therefore is functionally a no-op subscribe — the IAlarmSource
|
||||||
|
/// contract still wires up so capability negotiation works + a future PR can add the
|
||||||
|
/// active-alarm poll without reshaping the projection. The plan deliberately scopes F3-a
|
||||||
|
/// to the history extension; the active poll lands as F3-b.</para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly Func<CancellationToken, Task<IFocasClient?>> _connectAsync;
|
||||||
|
private readonly Action<AlarmEventArgs> _emit;
|
||||||
|
private readonly FocasAlarmProjectionOptions _options;
|
||||||
|
private readonly string _diagnosticPrefix;
|
||||||
|
|
||||||
|
private readonly Dictionary<long, Subscription> _subs = new();
|
||||||
|
private readonly Lock _subsLock = new();
|
||||||
|
private long _nextId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dedup set across the entire projection — alarm history is per-CNC, not
|
||||||
|
/// per-subscription, so a single set across all subscriptions matches operator
|
||||||
|
/// intent (one CNC, one ring buffer, one set of history events even if multiple
|
||||||
|
/// OPC UA clients have subscribed).
|
||||||
|
/// </summary>
|
||||||
|
private readonly HashSet<DedupKey> _seen = new();
|
||||||
|
private readonly Lock _seenLock = new();
|
||||||
|
|
||||||
|
public FocasAlarmProjection(
|
||||||
|
FocasAlarmProjectionOptions options,
|
||||||
|
Func<CancellationToken, Task<IFocasClient?>> connectAsync,
|
||||||
|
Action<AlarmEventArgs> emit,
|
||||||
|
string diagnosticPrefix = "focas-alarm-sub")
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(connectAsync);
|
||||||
|
ArgumentNullException.ThrowIfNull(emit);
|
||||||
|
_options = options;
|
||||||
|
_connectAsync = connectAsync;
|
||||||
|
_emit = emit;
|
||||||
|
_diagnosticPrefix = diagnosticPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var id = Interlocked.Increment(ref _nextId);
|
||||||
|
var handle = new FocasAlarmSubscriptionHandle(id, _diagnosticPrefix);
|
||||||
|
|
||||||
|
if (_options.Mode != FocasAlarmProjectionMode.ActivePlusHistory)
|
||||||
|
{
|
||||||
|
// ActiveOnly — return the handle so capability negotiation works, but skip the
|
||||||
|
// history poll entirely. The active-alarm poll lands as a follow-up PR.
|
||||||
|
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var sub = new Subscription(handle, [..sourceNodeIds], cts);
|
||||||
|
lock (_subsLock) _subs[id] = sub;
|
||||||
|
sub.Loop = Task.Run(() => RunHistoryPollAsync(sub, cts.Token), cts.Token);
|
||||||
|
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (handle is not FocasAlarmSubscriptionHandle h) return;
|
||||||
|
Subscription? sub;
|
||||||
|
lock (_subsLock)
|
||||||
|
{
|
||||||
|
if (!_subs.Remove(h.Id, out sub)) return;
|
||||||
|
}
|
||||||
|
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
|
||||||
|
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||||
|
sub.Cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acknowledge stub — FANUC's history surface is read-only (the ring buffer only
|
||||||
|
/// records what the CNC has cleared internally), so per-history-entry ack is a no-op.
|
||||||
|
/// A future PR may extend the active-alarm flow with a per-CNC reset call.
|
||||||
|
/// </summary>
|
||||||
|
public Task AcknowledgeAsync(
|
||||||
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
List<Subscription> snap;
|
||||||
|
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
|
||||||
|
foreach (var sub in snap)
|
||||||
|
{
|
||||||
|
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
|
||||||
|
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||||
|
sub.Cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset the dedup set — used after reconnect so the next history poll re-emits
|
||||||
|
/// everything in the ring buffer. Public for tests + the driver's reconnect hook.
|
||||||
|
/// </summary>
|
||||||
|
public void ResetDedup()
|
||||||
|
{
|
||||||
|
lock (_seenLock) _seen.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pull one history snapshot + emit unseen entries. Extracted from the timer loop so
|
||||||
|
/// unit tests can drive a single tick without standing up Task.Run.
|
||||||
|
/// </summary>
|
||||||
|
internal async Task<int> PollOnceAsync(Subscription sub, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var client = await _connectAsync(ct).ConfigureAwait(false);
|
||||||
|
if (client is null) return 0;
|
||||||
|
|
||||||
|
var depth = ResolveDepth(_options.HistoryDepth);
|
||||||
|
IReadOnlyList<FocasAlarmHistoryEntry> entries;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entries = await client.ReadAlarmHistoryAsync(depth, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Per-tick failure — leave dedup intact, next tick retries. Matches the
|
||||||
|
// AbCip alarm projection's "non-fatal per-tick" pattern (#177).
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var emitted = 0;
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var key = new DedupKey(entry.OccurrenceTime, entry.AlarmNumber, entry.AlarmType);
|
||||||
|
bool added;
|
||||||
|
lock (_seenLock) added = _seen.Add(key);
|
||||||
|
if (!added) continue;
|
||||||
|
|
||||||
|
// Each subscription gets its own copy of the event — multiple OPC UA clients
|
||||||
|
// can subscribe + each sees the historic events through their own subscription
|
||||||
|
// handle. Source node id is the first declared id (sub.SourceNodeIds[0]) when
|
||||||
|
// present; empty subscriptions get a synthetic "alarm-history" id so the
|
||||||
|
// event still threads through the IAlarmSource contract cleanly.
|
||||||
|
var sourceNodeId = sub.SourceNodeIds.Count > 0 ? sub.SourceNodeIds[0] : "alarm-history";
|
||||||
|
|
||||||
|
_emit(new AlarmEventArgs(
|
||||||
|
SubscriptionHandle: sub.Handle,
|
||||||
|
SourceNodeId: sourceNodeId,
|
||||||
|
ConditionId: $"focas-history#{entry.AlarmType}-{entry.AlarmNumber}-{entry.OccurrenceTime:O}",
|
||||||
|
AlarmType: $"FOCAS_T{entry.AlarmType}",
|
||||||
|
Message: BuildMessage(entry),
|
||||||
|
Severity: AlarmSeverity.High,
|
||||||
|
SourceTimestampUtc: entry.OccurrenceTime.UtcDateTime));
|
||||||
|
emitted++;
|
||||||
|
}
|
||||||
|
return emitted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunHistoryPollAsync(Subscription sub, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// First poll fires immediately on subscribe (== "on connect" per F3-a) so operators
|
||||||
|
// get history dashboard data without waiting for the cadence to elapse.
|
||||||
|
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||||
|
catch { /* swallowed in PollOnceAsync; defensive double-catch */ }
|
||||||
|
|
||||||
|
var interval = _options.HistoryPollInterval > TimeSpan.Zero
|
||||||
|
? _options.HistoryPollInterval
|
||||||
|
: FocasAlarmProjectionOptions.DefaultHistoryPollInterval;
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
|
||||||
|
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||||
|
catch { /* per-tick failures are non-fatal */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bound user-requested depth to <c>[1..MaxHistoryDepth]</c>. <c>0</c>/negative
|
||||||
|
/// values fall back to <see cref="FocasAlarmProjectionOptions.DefaultHistoryDepth"/>
|
||||||
|
/// so misconfigured options still pull a reasonable batch.
|
||||||
|
/// </summary>
|
||||||
|
internal static int ResolveDepth(int requested)
|
||||||
|
{
|
||||||
|
if (requested <= 0) return FocasAlarmProjectionOptions.DefaultHistoryDepth;
|
||||||
|
return Math.Min(requested, FocasAlarmProjectionOptions.MaxHistoryDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildMessage(FocasAlarmHistoryEntry entry)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(entry.Message))
|
||||||
|
return $"FOCAS alarm T{entry.AlarmType} #{entry.AlarmNumber}";
|
||||||
|
return $"FOCAS T{entry.AlarmType} #{entry.AlarmNumber}: {entry.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Composite dedup key — see class-level remarks.</summary>
|
||||||
|
private readonly record struct DedupKey(DateTimeOffset OccurrenceTime, int AlarmNumber, int AlarmType);
|
||||||
|
|
||||||
|
internal sealed class Subscription
|
||||||
|
{
|
||||||
|
public Subscription(FocasAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
|
||||||
|
{
|
||||||
|
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
|
||||||
|
}
|
||||||
|
public FocasAlarmSubscriptionHandle Handle { get; }
|
||||||
|
public IReadOnlyList<string> SourceNodeIds { get; }
|
||||||
|
public CancellationTokenSource Cts { get; }
|
||||||
|
public Task Loop { get; set; } = Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
|
||||||
|
public sealed record FocasAlarmSubscriptionHandle(long Id, string DiagnosticPrefix) : IAlarmSubscriptionHandle
|
||||||
|
{
|
||||||
|
public string DiagnosticId => $"{DiagnosticPrefix}-{Id}";
|
||||||
|
}
|
||||||
@@ -32,9 +32,10 @@ public static class FocasCapabilityMatrix
|
|||||||
|
|
||||||
return address.Kind switch
|
return address.Kind switch
|
||||||
{
|
{
|
||||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||||
|
FocasAreaKind.Diagnostic => ValidateDiagnostic(series, address.Number),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -73,11 +74,35 @@ public static class FocasCapabilityMatrix
|
|||||||
_ => (0, int.MaxValue),
|
_ => (0, int.MaxValue),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
|
/// <summary>
|
||||||
/// signal groups that 30i-family ladder programs use.</summary>
|
/// CNC diagnostic number range accepted by a series; from <c>cnc_rddiag</c>
|
||||||
|
/// (and <c>cnc_rddiagdgn</c> for axis-scoped reads). Returning <c>null</c>
|
||||||
|
/// means the series doesn't support <c>cnc_rddiag</c> at all — the driver
|
||||||
|
/// rejects every <c>DIAG:</c> address on that series. Conservative ceilings
|
||||||
|
/// per the FOCAS Developer Kit: legacy 16i-family caps at 499; modern 0i-F
|
||||||
|
/// family at 999; 30i / 31i / 32i extend to 1023. Power Motion i has a
|
||||||
|
/// narrow diagnostic surface (0..255).
|
||||||
|
/// </summary>
|
||||||
|
internal static (int min, int max)? DiagnosticRange(FocasCncSeries series) => series switch
|
||||||
|
{
|
||||||
|
FocasCncSeries.Sixteen_i => (0, 499),
|
||||||
|
FocasCncSeries.Zero_i_D => (0, 499),
|
||||||
|
FocasCncSeries.Zero_i_F or
|
||||||
|
FocasCncSeries.Zero_i_MF or
|
||||||
|
FocasCncSeries.Zero_i_TF => (0, 999),
|
||||||
|
FocasCncSeries.Thirty_i or
|
||||||
|
FocasCncSeries.ThirtyOne_i or
|
||||||
|
FocasCncSeries.ThirtyTwo_i => (0, 1023),
|
||||||
|
FocasCncSeries.PowerMotion_i => (0, 255),
|
||||||
|
_ => (0, int.MaxValue),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>PMC letters accepted per series. Legacy 16i ladders use X/Y/F/G
|
||||||
|
/// for handshakes plus R/D for retained/data; M/C/E/A/K/T are the 0i-F /
|
||||||
|
/// 30i-family extensions.</summary>
|
||||||
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
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_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
|
||||||
FocasCncSeries.Zero_i_F or
|
FocasCncSeries.Zero_i_F or
|
||||||
FocasCncSeries.Zero_i_MF or
|
FocasCncSeries.Zero_i_MF or
|
||||||
@@ -106,6 +131,27 @@ public static class FocasCapabilityMatrix
|
|||||||
_ => int.MaxValue,
|
_ => int.MaxValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the FOCAS driver should expose the per-device <c>Tooling/</c>
|
||||||
|
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
|
||||||
|
/// <c>cnc_rdtnum</c>, which is documented for every modern Fanuc series
|
||||||
|
/// (0i / 16i / 30i families) — defaulting to <c>true</c>. The capability
|
||||||
|
/// hook exists so a future controller without <c>cnc_rdtnum</c> can opt
|
||||||
|
/// out without touching the driver. <see cref="FocasCncSeries.Unknown"/>
|
||||||
|
/// stays permissive (matches the modal / override fixed-tree precedent in
|
||||||
|
/// issue #259). Issue #260.
|
||||||
|
/// </summary>
|
||||||
|
public static bool SupportsTooling(FocasCncSeries series) => true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the FOCAS driver should expose the per-device <c>Offsets/</c>
|
||||||
|
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
|
||||||
|
/// <c>cnc_rdzofs(n=1..6)</c> for the standard G54..G59 surfaces; extended
|
||||||
|
/// G54.1 P1..P48 surfaces are deferred to a follow-up. Same permissive
|
||||||
|
/// policy as <see cref="SupportsTooling"/>. Issue #260.
|
||||||
|
/// </summary>
|
||||||
|
public static bool SupportsWorkOffsets(FocasCncSeries series) => true;
|
||||||
|
|
||||||
private static string? ValidateMacro(FocasCncSeries series, int number)
|
private static string? ValidateMacro(FocasCncSeries series, int number)
|
||||||
{
|
{
|
||||||
var (min, max) = MacroRange(series);
|
var (min, max) = MacroRange(series);
|
||||||
@@ -122,6 +168,16 @@ public static class FocasCapabilityMatrix
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ValidateDiagnostic(FocasCncSeries series, int number)
|
||||||
|
{
|
||||||
|
if (DiagnosticRange(series) is not { } range)
|
||||||
|
return $"Diagnostic addresses are not supported on {series} (no documented cnc_rddiag range).";
|
||||||
|
var (min, max) = range;
|
||||||
|
return (number < min || number > max)
|
||||||
|
? $"Diagnostic #{number} is outside the documented range [{min}, {max}] for {series}."
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
|
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
|
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,22 +11,135 @@ public sealed class FocasDriverOptions
|
|||||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||||
public FocasProbeOptions Probe { get; init; } = new();
|
public FocasProbeOptions Probe { get; init; } = new();
|
||||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fixed-tree behaviour knobs (issue #262, plan PR F1-f). Carries the
|
||||||
|
/// <c>ApplyFigureScaling</c> toggle that gates the <c>cnc_getfigure</c>
|
||||||
|
/// decimal-place division applied to position values before publishing.
|
||||||
|
/// </summary>
|
||||||
|
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alarm projection knobs (issue #267, plan PR F3-a). Default mode is
|
||||||
|
/// <see cref="FocasAlarmProjectionMode.ActiveOnly"/> — the projection only surfaces
|
||||||
|
/// currently-active alarms. Operators who want the on-CNC ring-buffer history
|
||||||
|
/// replayed as historic OPC UA events (so dashboards see the real CNC timestamp,
|
||||||
|
/// not the moment the projection polled) flip this to
|
||||||
|
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
||||||
|
/// </summary>
|
||||||
|
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mode for the FOCAS alarm projection (issue #267, plan PR F3-a). Default
|
||||||
|
/// <see cref="ActiveOnly"/> matches today's behaviour — only currently-active
|
||||||
|
/// alarms surface as OPC UA events. <see cref="ActivePlusHistory"/> additionally
|
||||||
|
/// polls <c>cnc_rdalmhistry</c> on connect + on a configurable cadence and emits the
|
||||||
|
/// ring-buffer entries as historic events, deduped by <c>(OccurrenceTime, AlarmNumber,
|
||||||
|
/// AlarmType)</c> so a polled entry never re-fires.
|
||||||
|
/// </summary>
|
||||||
|
public enum FocasAlarmProjectionMode
|
||||||
|
{
|
||||||
|
/// <summary>Surface only currently-active CNC alarms. No history poll. Default.</summary>
|
||||||
|
ActiveOnly = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Surface active alarms plus the on-CNC ring-buffer history. The projection
|
||||||
|
/// polls <c>cnc_rdalmhistry</c> on connect and on
|
||||||
|
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> ticks afterward.
|
||||||
|
/// Each new entry (keyed by <c>(OccurrenceTime, AlarmNumber, AlarmType)</c>)
|
||||||
|
/// fires an <see cref="Core.Abstractions.IAlarmSource.OnAlarmEvent"/> with
|
||||||
|
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp, not Now.
|
||||||
|
/// </summary>
|
||||||
|
ActivePlusHistory = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FOCAS alarm-projection knobs (issue #267, plan PR F3-a). Carries the mode switch +
|
||||||
|
/// the cadence / depth tuning for the <c>cnc_rdalmhistry</c> poll loop. Defaults match
|
||||||
|
/// "operator dashboard with five-minute refresh" — the single most common deployment
|
||||||
|
/// shape per the F3-a deployment doc.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasAlarmProjectionOptions
|
||||||
|
{
|
||||||
|
/// <summary>Default poll interval — 5 minutes. Matches dashboard-class cadences.</summary>
|
||||||
|
public static readonly TimeSpan DefaultHistoryPollInterval = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default ring-buffer depth requested per poll — <c>100</c>. Most FANUC controllers
|
||||||
|
/// keep ~100 entries by default; pulling the full depth on every poll keeps the
|
||||||
|
/// dedup set authoritative across reconnects without burning extra wire bandwidth on
|
||||||
|
/// entries the dedup key would discard anyway.
|
||||||
|
/// </summary>
|
||||||
|
public const int DefaultHistoryDepth = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hard ceiling on <see cref="HistoryDepth"/>. The projection clamps user-requested
|
||||||
|
/// depths above this value down — typical CNC ring buffers cap well below this and
|
||||||
|
/// letting an operator type <c>10000</c> by accident shouldn't take down the wire
|
||||||
|
/// session with a giant <c>cnc_rdalmhistry</c> request.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxHistoryDepth = 250;
|
||||||
|
|
||||||
|
/// <summary>Active-only (default) vs Active-plus-history. See <see cref="FocasAlarmProjectionMode"/>.</summary>
|
||||||
|
public FocasAlarmProjectionMode Mode { get; init; } = FocasAlarmProjectionMode.ActiveOnly;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cadence at which the projection re-polls <c>cnc_rdalmhistry</c> when
|
||||||
|
/// <see cref="Mode"/> is <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
||||||
|
/// Default <see cref="DefaultHistoryPollInterval"/> = 5 minutes. Only applies after
|
||||||
|
/// the on-connect poll fires.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan HistoryPollInterval { get; init; } = DefaultHistoryPollInterval;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of most-recent ring-buffer entries to request per poll. Clamped to
|
||||||
|
/// <c>[1..<see cref="MaxHistoryDepth"/>]</c> at projection startup so misconfigured
|
||||||
|
/// values can't hammer the CNC. Default <see cref="DefaultHistoryDepth"/> = 100.
|
||||||
|
/// </summary>
|
||||||
|
public int HistoryDepth { get; init; } = DefaultHistoryDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-driver fixed-tree options. New installs default <see cref="ApplyFigureScaling"/>
|
||||||
|
/// to <c>true</c> so position values surface in user units (mm / inch). Existing
|
||||||
|
/// deployments that already published raw scaled integers can flip this to <c>false</c>
|
||||||
|
/// for migration parity — the operator-facing concern is that switching the flag
|
||||||
|
/// mid-deployment changes the values clients see, so the migration path is
|
||||||
|
/// documentation-only (issue #262).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasFixedTreeOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When <c>true</c> (default), position values from <c>cnc_absolute</c> /
|
||||||
|
/// <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> /
|
||||||
|
/// <c>cnc_actf</c> are divided by <c>10^decimalPlaces</c> per axis using the
|
||||||
|
/// <c>cnc_getfigure</c> snapshot cached at probe time. When <c>false</c>, the
|
||||||
|
/// raw integer values are published unchanged — used for migrations from
|
||||||
|
/// older drivers that didn't apply the scaling.
|
||||||
|
/// </summary>
|
||||||
|
public bool ApplyFigureScaling { get; init; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
||||||
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
||||||
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
||||||
|
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
|
||||||
|
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
|
||||||
|
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record FocasDeviceOptions(
|
public sealed record FocasDeviceOptions(
|
||||||
string HostAddress,
|
string HostAddress,
|
||||||
string? DeviceName = null,
|
string? DeviceName = null,
|
||||||
FocasCncSeries Series = FocasCncSeries.Unknown);
|
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||||
|
FocasOverrideParameters? OverrideParameters = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||||
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
|
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
|
||||||
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
|
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c> /
|
||||||
|
/// <c>DIAG:1031</c> / <c>DIAG:280/2</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record FocasTagDefinition(
|
public sealed record FocasTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -59,10 +59,20 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
||||||
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
||||||
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
||||||
|
FocasAreaKind.Diagnostic => Task.FromResult(
|
||||||
|
ReadDiagnostic(address.Number, address.BitIndex ?? 0, type)),
|
||||||
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
|
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||||
|
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return Task.FromResult(ReadDiagnostic(diagNumber, axisOrZero, type));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<uint> WriteAsync(
|
public async Task<uint> WriteAsync(
|
||||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -129,6 +139,26 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> GetPathCountAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult(1);
|
||||||
|
var buf = new FwlibNative.ODBPATH();
|
||||||
|
var ret = FwlibNative.RdPathNum(_handle, ref buf);
|
||||||
|
// EW_FUNC / EW_NOOPT on single-path controllers — fall back to 1 rather than failing.
|
||||||
|
if (ret != 0 || buf.MaxPath < 1) return Task.FromResult(1);
|
||||||
|
return Task.FromResult((int)buf.MaxPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SetPathAsync(int pathId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.CompletedTask;
|
||||||
|
var ret = FwlibNative.SetPath(_handle, (short)pathId);
|
||||||
|
if (ret != 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"FWLIB cnc_setpath failed with EW_{ret} switching to path {pathId}.");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_connected) return Task.FromResult(false);
|
if (!_connected) return Task.FromResult(false);
|
||||||
@@ -137,6 +167,256 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
return Task.FromResult(ret == 0);
|
return Task.FromResult(ret == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasStatusInfo?>(null);
|
||||||
|
var buf = new FwlibNative.ODBST();
|
||||||
|
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||||
|
if (ret != 0) return Task.FromResult<FocasStatusInfo?>(null);
|
||||||
|
return Task.FromResult<FocasStatusInfo?>(new FocasStatusInfo(
|
||||||
|
Dummy: buf.Dummy,
|
||||||
|
Tmmode: buf.TmMode,
|
||||||
|
Aut: buf.Aut,
|
||||||
|
Run: buf.Run,
|
||||||
|
Motion: buf.Motion,
|
||||||
|
Mstb: buf.Mstb,
|
||||||
|
EmergencyStop: buf.Emergency,
|
||||||
|
Alarm: buf.Alarm,
|
||||||
|
Edit: buf.Edit));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasProductionInfo?>(null);
|
||||||
|
if (!TryReadInt32Param(6711, out var produced) ||
|
||||||
|
!TryReadInt32Param(6712, out var required) ||
|
||||||
|
!TryReadInt32Param(6713, out var total))
|
||||||
|
{
|
||||||
|
return Task.FromResult<FocasProductionInfo?>(null);
|
||||||
|
}
|
||||||
|
// Cycle-time timer (type=2). Total seconds = minute*60 + msec/1000. Best-effort:
|
||||||
|
// a non-zero return leaves cycle-time at 0 rather than failing the whole snapshot
|
||||||
|
// — the parts counters are still useful even when cycle-time isn't supported.
|
||||||
|
var cycleSeconds = 0;
|
||||||
|
var tmrBuf = new FwlibNative.IODBTMR();
|
||||||
|
if (FwlibNative.RdTimer(_handle, type: 2, ref tmrBuf) == 0)
|
||||||
|
cycleSeconds = checked(tmrBuf.Minute * 60 + tmrBuf.Msec / 1000);
|
||||||
|
return Task.FromResult<FocasProductionInfo?>(new FocasProductionInfo(
|
||||||
|
PartsProduced: produced,
|
||||||
|
PartsRequired: required,
|
||||||
|
PartsTotal: total,
|
||||||
|
CycleTimeSeconds: cycleSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryReadInt32Param(ushort number, out int value)
|
||||||
|
{
|
||||||
|
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||||
|
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 4, ref buf);
|
||||||
|
if (ret != 0) { value = 0; return false; }
|
||||||
|
value = BinaryPrimitives.ReadInt32LittleEndian(buf.Data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryReadInt16Param(ushort number, out short value)
|
||||||
|
{
|
||||||
|
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||||
|
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf);
|
||||||
|
if (ret != 0) { value = 0; return false; }
|
||||||
|
value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasModalInfo?>(null);
|
||||||
|
// type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0).
|
||||||
|
// Best-effort — if any single read fails we still surface the others as 0; the
|
||||||
|
// probe loop only updates the cache on a non-null return so a partial snapshot
|
||||||
|
// is preferable to throwing away every successful field.
|
||||||
|
return Task.FromResult<FocasModalInfo?>(new FocasModalInfo(
|
||||||
|
MCode: ReadModalAux(type: 100),
|
||||||
|
SCode: ReadModalAux(type: 101),
|
||||||
|
TCode: ReadModalAux(type: 102),
|
||||||
|
BCode: ReadModalAux(type: 103)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private short ReadModalAux(short type)
|
||||||
|
{
|
||||||
|
var buf = new FwlibNative.ODBMDL { Data = new byte[8] };
|
||||||
|
var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf);
|
||||||
|
if (ret != 0) return 0;
|
||||||
|
// For aux types (100..103) the union holds the code at offset 0 as a 2-byte
|
||||||
|
// value (<c>aux_data</c>). Reading as Int16 keeps the surface identical to the
|
||||||
|
// record contract; oversized values would have been truncated by FWLIB anyway.
|
||||||
|
return BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||||
|
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasOverrideInfo?>(null);
|
||||||
|
// Each parameter is independently nullable — a null parameter number keeps the
|
||||||
|
// corresponding field at null + skips the wire call. A successful read on at
|
||||||
|
// least one parameter is enough to publish a snapshot; this matches the
|
||||||
|
// best-effort policy used by GetProductionAsync (issue #259).
|
||||||
|
var feed = TryReadOverride(parameters.FeedParam);
|
||||||
|
var rapid = TryReadOverride(parameters.RapidParam);
|
||||||
|
var spindle = TryReadOverride(parameters.SpindleParam);
|
||||||
|
var jog = TryReadOverride(parameters.JogParam);
|
||||||
|
return Task.FromResult<FocasOverrideInfo?>(new FocasOverrideInfo(feed, rapid, spindle, jog));
|
||||||
|
}
|
||||||
|
|
||||||
|
private short? TryReadOverride(ushort? param)
|
||||||
|
{
|
||||||
|
if (param is null) return null;
|
||||||
|
return TryReadInt16Param(param.Value, out var v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasToolingInfo?>(null);
|
||||||
|
var buf = new FwlibNative.IODBTNUM();
|
||||||
|
var ret = FwlibNative.RdToolNumber(_handle, ref buf);
|
||||||
|
if (ret != 0) return Task.FromResult<FocasToolingInfo?>(null);
|
||||||
|
// FWLIB returns long; clamp to short for the surfaced Int16 (T-codes
|
||||||
|
// overflowing 32767 are vanishingly rare on Fanuc tool tables).
|
||||||
|
var t = buf.Data;
|
||||||
|
if (t > short.MaxValue) t = short.MaxValue;
|
||||||
|
else if (t < short.MinValue) t = short.MinValue;
|
||||||
|
return Task.FromResult<FocasToolingInfo?>(new FocasToolingInfo((short)t));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasWorkOffsetsInfo?>(null);
|
||||||
|
|
||||||
|
// 1..6 = G54..G59. Extended G54.1 P1..P48 use cnc_rdzofsr and are deferred.
|
||||||
|
// Pass axis=-1 so FWLIB fills every axis it has; we read the first 3 (X/Y/Z).
|
||||||
|
// Length = 4-byte header + 3 axes * 10-byte OFSB = 34. We request 4 + 8*10 = 84
|
||||||
|
// (the buffer ceiling) so a CNC with more axes still completes the call.
|
||||||
|
var slots = new List<FocasWorkOffset>(6);
|
||||||
|
string[] names = ["G54", "G55", "G56", "G57", "G58", "G59"];
|
||||||
|
for (short n = 1; n <= 6; n++)
|
||||||
|
{
|
||||||
|
var buf = new FwlibNative.IODBZOFS { Data = new byte[80] };
|
||||||
|
var ret = FwlibNative.RdWorkOffset(_handle, n, axis: -1, length: 4 + 8 * 10, ref buf);
|
||||||
|
if (ret != 0)
|
||||||
|
{
|
||||||
|
// Best-effort — a single-slot failure leaves the slot at 0.0; the cache
|
||||||
|
// still publishes so reads on the other offsets serve Good. The probe
|
||||||
|
// loop will retry on the next tick.
|
||||||
|
slots.Add(new FocasWorkOffset(names[n - 1], 0, 0, 0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
slots.Add(new FocasWorkOffset(
|
||||||
|
Name: names[n - 1],
|
||||||
|
X: DecodeOfsbAxis(buf.Data, axisIndex: 0),
|
||||||
|
Y: DecodeOfsbAxis(buf.Data, axisIndex: 1),
|
||||||
|
Z: DecodeOfsbAxis(buf.Data, axisIndex: 2)));
|
||||||
|
}
|
||||||
|
return Task.FromResult<FocasWorkOffsetsInfo?>(new FocasWorkOffsetsInfo(slots));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||||
|
// type 0..3 = OPMSG / MACRO / EXTERN / REJ-EXT (issue #261). Single-slot read
|
||||||
|
// (length 4 + 256 = 260) returns the most-recent message in each class — best-
|
||||||
|
// effort: a single-class failure leaves that class out of the snapshot rather
|
||||||
|
// than failing the whole call, mirroring GetProductionAsync's policy.
|
||||||
|
var list = new List<FocasOperatorMessage>(4);
|
||||||
|
string[] classNames = ["OPMSG", "MACRO", "EXTERN", "REJ-EXT"];
|
||||||
|
for (short t = 0; t < 4; t++)
|
||||||
|
{
|
||||||
|
var buf = new FwlibNative.OPMSG3 { Data = new byte[256] };
|
||||||
|
var ret = FwlibNative.RdOpMsg3(_handle, t, length: 4 + 256, ref buf);
|
||||||
|
if (ret != 0) continue;
|
||||||
|
var text = TrimAnsiPadding(buf.Data);
|
||||||
|
if (string.IsNullOrEmpty(text)) continue;
|
||||||
|
list.Add(new FocasOperatorMessage(buf.Datano, classNames[t], text));
|
||||||
|
}
|
||||||
|
return Task.FromResult<FocasOperatorMessagesInfo?>(new FocasOperatorMessagesInfo(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||||
|
var buf = new FwlibNative.ODBACTPT { Data = new byte[256] };
|
||||||
|
var ret = FwlibNative.RdActPt(_handle, ref buf);
|
||||||
|
if (ret != 0) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||||
|
return Task.FromResult<FocasCurrentBlockInfo?>(
|
||||||
|
new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||||
|
// kind=0 → position figures (absolute/relative/machine/distance share the same
|
||||||
|
// increment system per axis). cnc_rdaxisname is deferred — the wire impl keys
|
||||||
|
// by fallback "axis{n}" (1-based), the driver re-keys when it gains axis-name
|
||||||
|
// discovery in a follow-up. Issue #262, plan PR F1-f.
|
||||||
|
short count = 0;
|
||||||
|
var buf = new FwlibNative.IODBAXIS { Data = new byte[FwlibNative.MAX_AXIS * 8] };
|
||||||
|
var ret = FwlibNative.GetFigure(_handle, kind: 0, ref count, ref buf);
|
||||||
|
if (ret != 0) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||||
|
return Task.FromResult<IReadOnlyDictionary<string, int>?>(DecodeFigureScaling(buf.Data, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode the per-axis decimal-place counts from a <c>cnc_getfigure</c> reply
|
||||||
|
/// buffer. Each axis entry per <c>fwlib32.h</c> is 8 bytes laid out as
|
||||||
|
/// <c>short dec</c> + <c>short unit</c> + 4 reserved bytes; we read only
|
||||||
|
/// <c>dec</c>. Keys are 1-based <c>"axis{n}"</c> placeholders — a follow-up
|
||||||
|
/// PR can rewire to <c>cnc_rdaxisname</c> once that surface lands without
|
||||||
|
/// changing the cache contract (issue #262).
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyDictionary<string, int> DecodeFigureScaling(byte[] data, short count)
|
||||||
|
{
|
||||||
|
var clamped = Math.Max((short)0, Math.Min(count, (short)FwlibNative.MAX_AXIS));
|
||||||
|
var result = new Dictionary<string, int>(clamped, StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var i = 0; i < clamped; i++)
|
||||||
|
{
|
||||||
|
var offset = i * 8;
|
||||||
|
if (offset + 2 > data.Length) break;
|
||||||
|
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset, 2));
|
||||||
|
if (dec < 0 || dec > 9) dec = 0;
|
||||||
|
result[$"axis{i + 1}"] = dec;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode + trim a Fanuc ANSI byte buffer. The CNC right-pads block text + opmsg
|
||||||
|
/// bodies with nulls or spaces; trim them so the round-trip through the OPC UA
|
||||||
|
/// address space stays stable (issue #261). Stops at the first NUL so any wire
|
||||||
|
/// buffer that gets reused doesn't leak old bytes.
|
||||||
|
/// </summary>
|
||||||
|
internal static string TrimAnsiPadding(byte[] data)
|
||||||
|
{
|
||||||
|
if (data is null) return string.Empty;
|
||||||
|
var len = 0;
|
||||||
|
for (; len < data.Length; len++)
|
||||||
|
if (data[len] == 0) break;
|
||||||
|
return System.Text.Encoding.ASCII.GetString(data, 0, len).TrimEnd(' ', '\0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode one OFSB axis block from a <c>cnc_rdzofs</c> data buffer. Each axis
|
||||||
|
/// occupies 10 bytes per <c>fwlib32.h</c>: <c>int data</c> + <c>short dec</c> +
|
||||||
|
/// <c>short unit</c> + <c>short disp</c>. The user-facing offset is
|
||||||
|
/// <c>data / 10^dec</c> — same convention as <c>cnc_rdmacro</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal static double DecodeOfsbAxis(byte[] data, int axisIndex)
|
||||||
|
{
|
||||||
|
const int blockSize = 10;
|
||||||
|
var offset = axisIndex * blockSize;
|
||||||
|
if (offset + blockSize > data.Length) return 0;
|
||||||
|
var raw = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4));
|
||||||
|
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset + 4, 2));
|
||||||
|
if (dec < 0 || dec > 9) dec = 0;
|
||||||
|
return raw / Math.Pow(10.0, dec);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- PMC ----
|
// ---- PMC ----
|
||||||
|
|
||||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||||
@@ -165,6 +445,42 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
return (value, FocasStatusMapper.Good);
|
return (value, FocasStatusMapper.Good);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Range read for the PMC coalescer (issue #266). FWLIB's <c>pmc_rdpmcrng</c>
|
||||||
|
/// payload is capped at 40 bytes (the IODBPMC.Data union width), so requested
|
||||||
|
/// ranges larger than that are chunked into 32-byte sub-calls internally —
|
||||||
|
/// callers still see one logical range, which matches the
|
||||||
|
/// <see cref="Wire.FocasPmcCoalescer"/>'s "one wire call per group" semantics.
|
||||||
|
/// </summary>
|
||||||
|
public Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
||||||
|
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (byteCount <= 0) return Task.FromResult<(byte[]?, uint)>((Array.Empty<byte>(), FocasStatusMapper.Good));
|
||||||
|
|
||||||
|
var addrType = FocasPmcAddrType.FromLetter(letter)
|
||||||
|
?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'.");
|
||||||
|
var result = new byte[byteCount];
|
||||||
|
const int chunkBytes = 32;
|
||||||
|
var offset = 0;
|
||||||
|
while (offset < byteCount)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var thisChunk = Math.Min(chunkBytes, byteCount - offset);
|
||||||
|
var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||||
|
var ret = FwlibNative.PmcRdPmcRng(
|
||||||
|
_handle, addrType, FocasPmcDataType.Byte,
|
||||||
|
(ushort)(startByte + offset),
|
||||||
|
(ushort)(startByte + offset + thisChunk - 1),
|
||||||
|
(ushort)(8 + thisChunk), ref buf);
|
||||||
|
if (ret != 0) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.MapFocasReturn(ret)));
|
||||||
|
Array.Copy(buf.Data, 0, result, offset, thisChunk);
|
||||||
|
offset += thisChunk;
|
||||||
|
}
|
||||||
|
return Task.FromResult<(byte[]?, uint)>((result, FocasStatusMapper.Good));
|
||||||
|
}
|
||||||
|
|
||||||
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
|
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
|
||||||
{
|
{
|
||||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||||
@@ -217,6 +533,36 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private (object? value, uint status) ReadDiagnostic(int diagNumber, int axisOrZero, FocasDataType type)
|
||||||
|
{
|
||||||
|
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||||
|
var length = DiagnosticReadLength(type);
|
||||||
|
var ret = FwlibNative.RdDiag(_handle, (ushort)diagNumber, (short)axisOrZero, (short)length, ref buf);
|
||||||
|
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||||
|
|
||||||
|
var value = type switch
|
||||||
|
{
|
||||||
|
FocasDataType.Bit => (object)ExtractBit(buf.Data[0], 0),
|
||||||
|
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||||
|
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||||
|
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||||
|
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
|
||||||
|
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
|
||||||
|
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||||
|
};
|
||||||
|
return (value, FocasStatusMapper.Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int DiagnosticReadLength(FocasDataType type) => type switch
|
||||||
|
{
|
||||||
|
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
|
||||||
|
FocasDataType.Int16 => 4 + 2,
|
||||||
|
FocasDataType.Int32 => 4 + 4,
|
||||||
|
FocasDataType.Float32 => 4 + 4,
|
||||||
|
FocasDataType.Float64 => 4 + 8,
|
||||||
|
_ => 4 + 4,
|
||||||
|
};
|
||||||
|
|
||||||
private (object? value, uint status) ReadMacro(FocasAddress address)
|
private (object? value, uint status) ReadMacro(FocasAddress address)
|
||||||
{
|
{
|
||||||
var buf = new FwlibNative.ODBM();
|
var buf = new FwlibNative.ODBM();
|
||||||
|
|||||||
@@ -88,6 +88,144 @@ internal static class FwlibNative
|
|||||||
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
||||||
|
|
||||||
|
// ---- Timers ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_rdtimer</c> — read CNC running timers. <paramref name="type"/>: 0 = power-on
|
||||||
|
/// time (ms), 1 = operating time (ms), 2 = cycle time (ms), 3 = cutting time (ms).
|
||||||
|
/// Only the cycle-time variant is consumed today (issue #258); the call is generic
|
||||||
|
/// so the surface can grow without another P/Invoke.
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)]
|
||||||
|
public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer);
|
||||||
|
|
||||||
|
// ---- Modal codes ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_modal</c> — read modal information for one G-group or auxiliary code.
|
||||||
|
/// <paramref name="type"/>: 1..21 = G-group N (single group), 100 = M, 101 = S,
|
||||||
|
/// 102 = T, 103 = B (per Fanuc FOCAS reference). <paramref name="block"/>: 0 =
|
||||||
|
/// active modal commands. We only consume types 100..103 today (M/S/T/B); the
|
||||||
|
/// G-group decode is deferred to a follow-up because the <c>ODBMDL</c> union
|
||||||
|
/// varies by group + series (issue #259).
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
|
||||||
|
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
|
||||||
|
|
||||||
|
// ---- Tooling ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_rdtnum</c> — read the currently selected tool number. Returns
|
||||||
|
/// <c>EW_OK</c> + populates <see cref="IODBTNUM.Data"/> with the active T-code.
|
||||||
|
/// Tool life + current offset index reads (<c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c>/
|
||||||
|
/// <c>cnc_rdtofs</c>) are deferred per the F1-d plan — those calls use ODBTLIFE*
|
||||||
|
/// unions whose shape varies per series.
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_rdtnum", ExactSpelling = true)]
|
||||||
|
public static extern short RdToolNumber(ushort handle, ref IODBTNUM buffer);
|
||||||
|
|
||||||
|
// ---- Work coordinate offsets ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_rdzofs</c> — read one work-coordinate offset slot. <paramref name="number"/>:
|
||||||
|
/// 1..6 = G54..G59 (standard). Extended <c>G54.1 P1..P48</c> use <c>cnc_rdzofsr</c>
|
||||||
|
/// and are deferred. <paramref name="axis"/>: -1 = all axes returned, 1..N = single
|
||||||
|
/// axis. <paramref name="length"/>: 12 + (N axes * 8) — we request -1 and let FWLIB
|
||||||
|
/// fill up to <see cref="IODBZOFS.Data"/>'s 8-axis ceiling.
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_rdzofs", ExactSpelling = true)]
|
||||||
|
public static extern short RdWorkOffset(
|
||||||
|
ushort handle,
|
||||||
|
short number,
|
||||||
|
short axis,
|
||||||
|
short length,
|
||||||
|
ref IODBZOFS buffer);
|
||||||
|
|
||||||
|
// ---- Operator messages ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_rdopmsg3</c> — read FANUC operator messages by class. <paramref name="type"/>:
|
||||||
|
/// 0 = OPMSG (op-msg ladder/macro), 1 = MACRO, 2 = EXTERN (external operator message),
|
||||||
|
/// 3 = REJ-EXT (rejected EXTERN). <paramref name="length"/>: per <c>fwlib32.h</c> the
|
||||||
|
/// buffer is <c>4 + 256 = 260</c> bytes per message slot — single-slot reads (length 260)
|
||||||
|
/// return the most-recent message in that class. Issue #261, plan PR F1-e.
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_rdopmsg3", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||||
|
public static extern short RdOpMsg3(
|
||||||
|
ushort handle,
|
||||||
|
short type,
|
||||||
|
short length,
|
||||||
|
ref OPMSG3 buffer);
|
||||||
|
|
||||||
|
// ---- Figure (per-axis decimal scaling) ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_getfigure</c> — read per-axis figure info (decimal-place counts + units).
|
||||||
|
/// <paramref name="kind"/>: 0 = absolute / relative / machine position figures,
|
||||||
|
/// 1 = work-coord shift figures (per Fanuc reference). The reply struct holds
|
||||||
|
/// up to <see cref="MAX_AXIS"/> axis entries; the managed side reads the count
|
||||||
|
/// out via <paramref name="outCount"/>. Position values from <c>cnc_absolute</c>
|
||||||
|
/// / <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> / <c>cnc_actf</c>
|
||||||
|
/// are scaled integers — divide by <c>10^figureinfo[axis].dec</c> for user units
|
||||||
|
/// (issue #262, plan PR F1-f).
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_getfigure", ExactSpelling = true)]
|
||||||
|
public static extern short GetFigure(
|
||||||
|
ushort handle,
|
||||||
|
short kind,
|
||||||
|
ref short outCount,
|
||||||
|
ref IODBAXIS figureinfo);
|
||||||
|
|
||||||
|
// ---- Diagnostics ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_rddiag</c> — read a CNC diagnostic value. <paramref name="number"/> is the
|
||||||
|
/// diagnostic number (e.g. 1031 = current alarm cause); <paramref name="axis"/> is 0
|
||||||
|
/// for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
|
||||||
|
/// <paramref name="length"/> is sized like <see cref="RdParam"/> — 4-byte header +
|
||||||
|
/// widest payload (8 bytes for Float64). The shape of the payload depends on the
|
||||||
|
/// diagnostic; the managed side decodes via <see cref="FocasDataType"/> on the
|
||||||
|
/// configured tag (issue #263).
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_rddiag", ExactSpelling = true)]
|
||||||
|
public static extern short RdDiag(
|
||||||
|
ushort handle,
|
||||||
|
ushort number,
|
||||||
|
short axis,
|
||||||
|
short length,
|
||||||
|
ref IODBPSD buffer);
|
||||||
|
|
||||||
|
// ---- Multi-path / multi-channel ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_rdpathnum</c> — read the number of CNC paths (channels) the controller
|
||||||
|
/// exposes + the currently-active path. Multi-path CNCs (lathe + sub-spindle,
|
||||||
|
/// dual-turret) return 2..N; single-path CNCs return 1. The driver caches
|
||||||
|
/// <see cref="ODBPATH.MaxPath"/> at connect and uses it to validate per-tag
|
||||||
|
/// <c>PathId</c> values (issue #264).
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_rdpathnum", ExactSpelling = true)]
|
||||||
|
public static extern short RdPathNum(ushort handle, ref ODBPATH buffer);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_setpath</c> — switch the active CNC path (channel) for subsequent
|
||||||
|
/// calls. <paramref name="path"/> is 1-based. The driver issues this before
|
||||||
|
/// every read whose path differs from the last one set on the session;
|
||||||
|
/// single-path tags (PathId=1 only) skip the call entirely (issue #264).
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_setpath", ExactSpelling = true)]
|
||||||
|
public static extern short SetPath(ushort handle, short path);
|
||||||
|
|
||||||
|
// ---- Currently-executing block ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_rdactpt</c> — read the currently-executing program block text. The
|
||||||
|
/// reply struct holds the program / sequence numbers + the active block as a
|
||||||
|
/// null-padded ASCII string. Issue #261, plan PR F1-e.
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_rdactpt", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||||
|
public static extern short RdActPt(ushort handle, ref ODBACTPT buffer);
|
||||||
|
|
||||||
// ---- Structs ----
|
// ---- Structs ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -129,6 +267,134 @@ internal static class FwlibNative
|
|||||||
public short DecVal; // decimal-point count
|
public short DecVal; // decimal-point count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IODBTMR — running-timer read buffer per <c>fwlib32.h</c>. Minute portion in
|
||||||
|
/// <see cref="Minute"/>; sub-minute remainder in milliseconds in <see cref="Msec"/>.
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct IODBTMR
|
||||||
|
{
|
||||||
|
public int Minute;
|
||||||
|
public int Msec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ODBMDL — single-group modal read buffer. 4-byte header + a 4-byte union which we
|
||||||
|
/// marshal as a fixed byte array. For type=100..103 (M/S/T/B) the union holds an
|
||||||
|
/// <c>int aux_data</c> at offset 0; we read the first <c>short</c> for symmetry with
|
||||||
|
/// the FWLIB <c>g_modal.aux_data</c> width on G-group reads. The G-group decode
|
||||||
|
/// (type=1..21) is deferred — see <see cref="Modal"/> for context (issue #259).
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct ODBMDL
|
||||||
|
{
|
||||||
|
public short Datano;
|
||||||
|
public short Type;
|
||||||
|
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
|
||||||
|
public byte[] Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IODBTNUM — current tool number read buffer. <see cref="Data"/> holds the active
|
||||||
|
/// T-code (Fanuc reference uses <c>long</c>; we narrow to <c>short</c> on the
|
||||||
|
/// managed side because <see cref="FocasToolingInfo.CurrentTool"/> surfaces as
|
||||||
|
/// <c>Int16</c>). Issue #260, F1-d.
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct IODBTNUM
|
||||||
|
{
|
||||||
|
public short Datano;
|
||||||
|
public short Type;
|
||||||
|
public int Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IODBZOFS — work-coordinate offset read buffer. 4-byte header + per-axis
|
||||||
|
/// <c>OFSB</c> blocks (8 bytes each: 4-byte signed integer <c>data</c> + 2-byte
|
||||||
|
/// <c>dec</c> decimal-point count + 2-byte <c>unit</c> + 2-byte <c>disp</c>).
|
||||||
|
/// We marshal a fixed ceiling of 8 axes (= 64 bytes); the managed side reads
|
||||||
|
/// only the first 3 (X / Y / Z) per the F1-d effort sizing.
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct IODBZOFS
|
||||||
|
{
|
||||||
|
public short Datano;
|
||||||
|
public short Type;
|
||||||
|
// Up to 8 axes * 8 bytes per OFSB = 64 bytes. Each block: int data, short dec,
|
||||||
|
// short unit, short disp (10 bytes per fwlib32.h). We size for the worst case.
|
||||||
|
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 80)]
|
||||||
|
public byte[] Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPMSG3 — single-slot operator-message read buffer per <c>fwlib32.h</c>. Per Fanuc
|
||||||
|
/// reference: <c>short datano</c> + <c>short type</c> + <c>char data[256]</c>. The
|
||||||
|
/// text is null-terminated + space-padded; the managed side trims trailing nulls /
|
||||||
|
/// spaces before publishing. Length = 4 + 256 = 260 bytes; total 256 wide enough
|
||||||
|
/// for the longest documented operator message body (issue #261).
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct OPMSG3
|
||||||
|
{
|
||||||
|
public short Datano;
|
||||||
|
public short Type;
|
||||||
|
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||||
|
public byte[] Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ODBACTPT — current-block read buffer per <c>fwlib32.h</c>. Per Fanuc reference:
|
||||||
|
/// <c>long o_no</c> (currently active O-number) + <c>long n_no</c> (sequence) +
|
||||||
|
/// <c>char data[256]</c> (active block text). The text is null-terminated +
|
||||||
|
/// space-padded; trimmed before publishing for stable round-trip (issue #261).
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct ODBACTPT
|
||||||
|
{
|
||||||
|
public int ONo;
|
||||||
|
public int NNo;
|
||||||
|
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||||
|
public byte[] Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum axis count per the FWLIB <c>fwlib32.h</c> ceiling for figure-info reads.
|
||||||
|
/// Real Fanuc CNCs cap at 8 simultaneous axes for most series; we marshal an
|
||||||
|
/// 8-entry array (matches <see cref="IODBAXIS"/>) so the call completes regardless
|
||||||
|
/// of the deployment's axis count (issue #262).
|
||||||
|
/// </summary>
|
||||||
|
public const int MAX_AXIS = 8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IODBAXIS — per-axis figure info read buffer for <c>cnc_getfigure</c>. Each
|
||||||
|
/// axis entry carries the decimal-place count (<c>dec</c>) the CNC reports for
|
||||||
|
/// that axis's increment system + a unit code. The managed side reads the first
|
||||||
|
/// <c>outCount</c> entries returned by FWLIB; we marshal a fixed 8-entry ceiling
|
||||||
|
/// (issue #262, plan PR F1-f).
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct IODBAXIS
|
||||||
|
{
|
||||||
|
// Each entry per fwlib32.h is { short dec, short unit, short reserved, short reserved2 }
|
||||||
|
// = 8 bytes. 8 axes * 8 bytes = 64 bytes; we marshal a fixed byte buffer + decode on
|
||||||
|
// the managed side so axis-count growth doesn't churn the P/Invoke surface.
|
||||||
|
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8 * 8)]
|
||||||
|
public byte[] Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ODBPATH — <c>cnc_rdpathnum</c> reply. <see cref="PathNo"/> is the currently-active
|
||||||
|
/// path (1-based); <see cref="MaxPath"/> is the controller's path count. We consume
|
||||||
|
/// <see cref="MaxPath"/> at bootstrap to validate per-tag PathId; runtime path
|
||||||
|
/// selection happens via <see cref="SetPath"/> (issue #264).
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct ODBPATH
|
||||||
|
{
|
||||||
|
public short PathNo;
|
||||||
|
public short MaxPath;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
public struct ODBST
|
public struct ODBST
|
||||||
|
|||||||
@@ -48,8 +48,349 @@ public interface IFocasClient : IDisposable
|
|||||||
/// responds with any valid status.
|
/// responds with any valid status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the full <c>cnc_rdcncstat</c> ODBST struct (9 small-int status flags). The
|
||||||
|
/// boolean <see cref="ProbeAsync"/> is preserved for cheap reachability checks; this
|
||||||
|
/// method exposes the per-field detail used by the FOCAS driver's <c>Status/</c>
|
||||||
|
/// fixed-tree nodes (see issue #257). Returns <c>null</c> if the wire client cannot
|
||||||
|
/// supply the struct (e.g. transport/IPC variant where the contract has not been
|
||||||
|
/// extended yet) — callers fall back to surfacing Bad on the per-field nodes.
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasStatusInfo?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the per-CNC production counters (parts produced / required / total via
|
||||||
|
/// <c>cnc_rdparam(6711/6712/6713)</c>) plus the current cycle-time seconds counter
|
||||||
|
/// (<c>cnc_rdtimer(2)</c>). Surfaced on the FOCAS driver's <c>Production/</c>
|
||||||
|
/// fixed-tree per device (issue #258). Returns <c>null</c> when the wire client
|
||||||
|
/// cannot supply the snapshot (e.g. older transport variant) — the driver leaves
|
||||||
|
/// the cache untouched and the per-field nodes report Bad until the first refresh.
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasProductionInfo?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the active modal M/S/T/B codes via <c>cnc_modal</c>. G-group decoding is
|
||||||
|
/// deferred — the FWLIB <c>ODBMDL</c> union differs per series + group and the
|
||||||
|
/// issue body permits surfacing only the universally-present M/S/T/B fields in
|
||||||
|
/// the first cut (issue #259). Returns <c>null</c> when the wire client cannot
|
||||||
|
/// supply the snapshot.
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasModalInfo?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the four operator override values (feed / rapid / spindle / jog) via
|
||||||
|
/// <c>cnc_rdparam</c>. The parameter numbers are MTB-specific so the caller passes
|
||||||
|
/// them in via <paramref name="parameters"/>; a <c>null</c> entry suppresses that
|
||||||
|
/// field's read (the corresponding node is also omitted from the address space).
|
||||||
|
/// Returns <c>null</c> when the wire client cannot supply the snapshot (issue #259).
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||||
|
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasOverrideInfo?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the current tool number via <c>cnc_rdtnum</c>. Surfaced on the FOCAS driver's
|
||||||
|
/// <c>Tooling/</c> fixed-tree per device (issue #260). Tool life + current offset
|
||||||
|
/// index are deferred — <c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c> vary heavily across
|
||||||
|
/// CNC series + the FWLIB <c>ODBTLIFE*</c> unions need per-series shape handling
|
||||||
|
/// that exceeds the L-sized scope of this PR. Returns <c>null</c> when the wire
|
||||||
|
/// client cannot supply the snapshot (e.g. older transport variant).
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasToolingInfo?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the standard G54..G59 work-coordinate offsets via
|
||||||
|
/// <c>cnc_rdzofs(handle, n=1..6)</c>. Returns one <see cref="FocasWorkOffset"/>
|
||||||
|
/// per slot (issue #260). Extended G54.1 P1..P48 offsets are deferred — they use
|
||||||
|
/// a different FOCAS call (<c>cnc_rdzofsr</c>) + different range handling. Each
|
||||||
|
/// offset surfaces a fixed X/Y/Z view; lathes/mills with extra rotational axes
|
||||||
|
/// have those columns reported as 0.0. Returns <c>null</c> when the wire client
|
||||||
|
/// cannot supply the snapshot.
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasWorkOffsetsInfo?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the four FANUC operator-message classes via <c>cnc_rdopmsg3</c> (issue #261).
|
||||||
|
/// The call returns up to 4 active messages per class; the driver collapses the
|
||||||
|
/// latest non-empty message per class onto the <c>Messages/External/Latest</c>
|
||||||
|
/// fixed-tree node — the issue body permits this minimal surface in the first cut.
|
||||||
|
/// Trailing nulls / spaces are trimmed before publishing so the same message
|
||||||
|
/// round-trips with stable text. Returns <c>null</c> when the wire client cannot
|
||||||
|
/// supply the snapshot (older transport variant).
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the currently-executing block text via <c>cnc_rdactpt</c> (issue #261).
|
||||||
|
/// The call returns the active block of the running program; surfaced as
|
||||||
|
/// <c>Program/CurrentBlock</c> Float-trimmed string. Returns <c>null</c> when the
|
||||||
|
/// wire client cannot supply the snapshot.
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the per-axis decimal-place counts via <c>cnc_getfigure</c> (issue #262).
|
||||||
|
/// Returned dictionary maps axis name (or fallback <c>"axis{n}"</c> when
|
||||||
|
/// <c>cnc_rdaxisname</c> isn't available) to the decimal-place count the CNC
|
||||||
|
/// reports for that axis's increment system. Cached at bootstrap by the driver +
|
||||||
|
/// applied to position values before publishing — raw integer / 10^decimalPlaces.
|
||||||
|
/// Returns <c>null</c> when the wire client cannot supply the snapshot (older
|
||||||
|
/// transport variant) — the driver leaves the cache untouched and falls back to
|
||||||
|
/// publishing raw values.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read a CNC diagnostic value via <c>cnc_rddiag</c>. <paramref name="diagNumber"/> is
|
||||||
|
/// the diagnostic number (validated against <see cref="FocasCapabilityMatrix.DiagnosticRange"/>
|
||||||
|
/// by <see cref="FocasDriver.InitializeAsync"/>). <paramref name="axisOrZero"/>
|
||||||
|
/// is 0 for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
|
||||||
|
/// The shape of the returned value depends on the diagnostic — Int / Float / Bit are
|
||||||
|
/// all possible. Returns <c>null</c> on default (transport variants that haven't yet
|
||||||
|
/// implemented diagnostics) so the driver falls back to BadNotSupported on those nodes
|
||||||
|
/// until the wire client is extended (issue #263).
|
||||||
|
/// </summary>
|
||||||
|
Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||||
|
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discover the number of CNC paths (channels) the controller exposes via
|
||||||
|
/// <c>cnc_rdpathnum</c>. Multi-path CNCs (lathe + sub-spindle, dual-turret,
|
||||||
|
/// etc.) report 2..N; single-path CNCs return 1. The driver caches the result
|
||||||
|
/// once per device after connect + uses it to validate per-tag <c>PathId</c>
|
||||||
|
/// values (issue #264). Default returns 1 so transports that haven't extended
|
||||||
|
/// their wire surface keep behaving as single-path.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetPathCountAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switch the active CNC path (channel) for subsequent reads via
|
||||||
|
/// <c>cnc_setpath</c>. Called by the driver before every read whose
|
||||||
|
/// <c>FocasAddress.PathId</c> differs from the path most recently set on the
|
||||||
|
/// session — single-path devices (PathId=1 only) skip the wire call entirely.
|
||||||
|
/// Default is a no-op so transports that haven't extended their wire surface
|
||||||
|
/// simply read whatever path the CNC has selected (issue #264).
|
||||||
|
/// </summary>
|
||||||
|
Task SetPathAsync(int pathId, CancellationToken cancellationToken)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read up to <paramref name="depth"/> most-recent entries from the CNC's alarm-history
|
||||||
|
/// ring buffer via <c>cnc_rdalmhistry</c>. Used by <see cref="FocasAlarmProjection"/>
|
||||||
|
/// when <see cref="FocasAlarmProjectionOptions.Mode"/> is
|
||||||
|
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> (issue #267, plan PR F3-a).
|
||||||
|
/// Default returns an empty list so transport variants that have not yet implemented
|
||||||
|
/// the call keep working — the projection's history poll becomes a no-op rather than
|
||||||
|
/// faulting. Wire decode of the FWLIB <c>ODBALMHIS</c> struct lives in
|
||||||
|
/// <see cref="Wire.FocasAlarmHistoryDecoder"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<FocasAlarmHistoryEntry>> ReadAlarmHistoryAsync(
|
||||||
|
int depth, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlyList<FocasAlarmHistoryEntry>>(Array.Empty<FocasAlarmHistoryEntry>());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS
|
||||||
|
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>
|
||||||
|
/// (<c>R</c>, <c>D</c>, <c>X</c>, etc.) starting at <paramref name="startByte"/> and
|
||||||
|
/// spanning <paramref name="byteCount"/> bytes. Returned tuple has the byte buffer
|
||||||
|
/// (length <paramref name="byteCount"/> on success) + the OPC UA status mapped through
|
||||||
|
/// <see cref="FocasStatusMapper"/>. Used by <see cref="FocasDriver"/> to coalesce
|
||||||
|
/// same-letter/same-path PMC reads in a batch into one round trip per range
|
||||||
|
/// (issue #266 — see <see cref="Wire.FocasPmcCoalescer"/>).
|
||||||
|
/// <para>
|
||||||
|
/// Default falls back to per-byte <see cref="ReadAsync(FocasAddress, FocasDataType, CancellationToken)"/>
|
||||||
|
/// calls so transport variants that haven't extended their wire surface still work
|
||||||
|
/// correctly — they just won't see the round-trip reduction. The fallback short-circuits
|
||||||
|
/// on the first non-Good status so a partial buffer isn't returned with a Good code.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
async Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
||||||
|
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (byteCount <= 0) return (Array.Empty<byte>(), FocasStatusMapper.Good);
|
||||||
|
var buf = new byte[byteCount];
|
||||||
|
for (var i = 0; i < byteCount; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var addr = new FocasAddress(FocasAreaKind.Pmc, letter, startByte + i, BitIndex: null, PathId: pathId);
|
||||||
|
var (value, status) = await ReadAsync(addr, FocasDataType.Byte, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (status != FocasStatusMapper.Good) return (null, status);
|
||||||
|
buf[i] = value switch
|
||||||
|
{
|
||||||
|
sbyte s => unchecked((byte)s),
|
||||||
|
byte b => b,
|
||||||
|
int n => unchecked((byte)n),
|
||||||
|
short s => unchecked((byte)s),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return (buf, FocasStatusMapper.Good);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the 9 fields returned by Fanuc's <c>cnc_rdcncstat</c> (ODBST). All fields
|
||||||
|
/// are <c>short</c> per the FWLIB header — small enums whose meaning is documented in the
|
||||||
|
/// Fanuc FOCAS reference (e.g. <c>emergency</c>: 0=released, 1=stop, 2=reset). Surfaced as
|
||||||
|
/// <c>Int16</c> in the OPC UA address space rather than mapped enums so operators see
|
||||||
|
/// exactly what the CNC reported.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasStatusInfo(
|
||||||
|
short Dummy,
|
||||||
|
short Tmmode,
|
||||||
|
short Aut,
|
||||||
|
short Run,
|
||||||
|
short Motion,
|
||||||
|
short Mstb,
|
||||||
|
short EmergencyStop,
|
||||||
|
short Alarm,
|
||||||
|
short Edit);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of per-CNC production counters refreshed on the probe tick (issue #258).
|
||||||
|
/// Sourced from <c>cnc_rdparam(6711/6712/6713)</c> for the parts counts + the cycle-time
|
||||||
|
/// timer counter (FWLIB <c>cnc_rdtimer</c> when available). All values surfaced as
|
||||||
|
/// <c>Int32</c> in the OPC UA address space.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasProductionInfo(
|
||||||
|
int PartsProduced,
|
||||||
|
int PartsRequired,
|
||||||
|
int PartsTotal,
|
||||||
|
int CycleTimeSeconds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a
|
||||||
|
/// deferred follow-up — the FWLIB <c>ODBMDL</c> union differs per series + group, and
|
||||||
|
/// the issue body permits the first cut to surface only the universally-present
|
||||||
|
/// M/S/T/B fields. <c>short</c> matches the FWLIB <c>aux_data</c> width.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasModalInfo(
|
||||||
|
short MCode,
|
||||||
|
short SCode,
|
||||||
|
short TCode,
|
||||||
|
short BCode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259).
|
||||||
|
/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A
|
||||||
|
/// <c>null</c> entry suppresses that field's read on the wire and removes the matching
|
||||||
|
/// node from the address space; this lets a deployment hide overrides their MTB doesn't
|
||||||
|
/// wire up rather than always serving Bad.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasOverrideParameters(
|
||||||
|
ushort? FeedParam,
|
||||||
|
ushort? RapidParam,
|
||||||
|
ushort? SpindleParam,
|
||||||
|
ushort? JogParam)
|
||||||
|
{
|
||||||
|
/// <summary>Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015.</summary>
|
||||||
|
public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the four operator overrides (issue #259). Each value is a percentage
|
||||||
|
/// surfaced as <c>Int16</c>; a value of <c>null</c> means the corresponding parameter
|
||||||
|
/// was not configured (suppressed at <see cref="FocasOverrideParameters"/>). All four
|
||||||
|
/// fields nullable so the driver can omit nodes whose MTB parameter is unset.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasOverrideInfo(
|
||||||
|
short? Feed,
|
||||||
|
short? Rapid,
|
||||||
|
short? Spindle,
|
||||||
|
short? Jog);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the currently selected tool number (issue #260). Sourced from
|
||||||
|
/// <c>cnc_rdtnum</c>. The active offset index is deferred — most modern CNCs
|
||||||
|
/// interleave tool number and offset H/D codes through different FOCAS calls
|
||||||
|
/// (<c>cnc_rdtofs</c> against a specific slot) and the issue body permits
|
||||||
|
/// surfacing tool number alone in the first cut. Surfaced as <c>Int16</c> in
|
||||||
|
/// the OPC UA address space.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasToolingInfo(short CurrentTool);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One work-coordinate offset slot (G54..G59). Three axis columns are surfaced
|
||||||
|
/// (X / Y / Z) — the issue body permits a fixed 3-axis view because lathes and
|
||||||
|
/// mills typically don't expose extended rotational offsets via the standard
|
||||||
|
/// <c>cnc_rdzofs</c> call. Extended <c>G54.1 Pn</c> offsets via <c>cnc_rdzofsr</c>
|
||||||
|
/// are deferred to a follow-up PR. Values surfaced as <c>Float64</c> in microns
|
||||||
|
/// converted to user units (the FWLIB <c>data</c> field is an integer + decimal-
|
||||||
|
/// point count, decoded the same way <c>cnc_rdmacro</c> values are).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the six standard work-coordinate offsets (G54..G59). Refreshed on
|
||||||
|
/// the probe tick + served from the per-device cache by reads of the
|
||||||
|
/// <c>Offsets/{name}/{X|Y|Z}</c> fixed-tree nodes (issue #260).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One FANUC operator message — the <see cref="Number"/> + <see cref="Class"/>
|
||||||
|
/// + <see cref="Text"/> tuple returned by <c>cnc_rdopmsg3</c> for a single
|
||||||
|
/// active message slot. <see cref="Class"/> is one of <c>"OPMSG"</c> /
|
||||||
|
/// <c>"MACRO"</c> / <c>"EXTERN"</c> / <c>"REJ-EXT"</c> per the FOCAS reference
|
||||||
|
/// for the four message types. <see cref="Text"/> is trimmed of trailing
|
||||||
|
/// nulls + spaces so round-trips through the OPC UA address space stay stable
|
||||||
|
/// (issue #261).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasOperatorMessage(short Number, string Class, string Text);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of all active FANUC operator messages across the four message
|
||||||
|
/// classes (issue #261). Surfaced under the FOCAS driver's
|
||||||
|
/// <c>Messages/External/Latest</c> fixed-tree node — the latest non-empty
|
||||||
|
/// message in the list is what gets published. Empty list means the CNC
|
||||||
|
/// reported no active messages; the node publishes an empty string in that
|
||||||
|
/// case.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessage> Messages);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the currently-executing program block text via
|
||||||
|
/// <c>cnc_rdactpt</c> (issue #261). <see cref="Text"/> is trimmed of trailing
|
||||||
|
/// nulls + spaces so the same block round-trips with stable text. Surfaced
|
||||||
|
/// as a String node at <c>Program/CurrentBlock</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasCurrentBlockInfo(string Text);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One entry returned by <c>cnc_rdalmhistry</c> — a single historical alarm
|
||||||
|
/// occurrence the CNC retained in its ring buffer (issue #267, plan PR F3-a).
|
||||||
|
/// The projection emits these as historic <see cref="Core.Abstractions.AlarmEventArgs"/>
|
||||||
|
/// with <c>SourceTimestampUtc</c> set from <see cref="OccurrenceTime"/> so OPC UA clients
|
||||||
|
/// see the real CNC timestamp rather than the moment the projection polled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>The dedup key for the projection is
|
||||||
|
/// <c>(<see cref="OccurrenceTime"/>, <see cref="AlarmNumber"/>, <see cref="AlarmType"/>)</c>.
|
||||||
|
/// Same triple across two polls only emits once — see
|
||||||
|
/// <see cref="FocasAlarmProjection"/>.</para>
|
||||||
|
///
|
||||||
|
/// <para>FANUC ring buffers are typically capped at ~100 entries; the host parameter that
|
||||||
|
/// governs the cap varies by series + MTB so the driver clamps user-requested depth to a
|
||||||
|
/// conservative <c>250</c> ceiling (see <see cref="FocasAlarmProjectionOptions.HistoryDepth"/>).</para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record FocasAlarmHistoryEntry(
|
||||||
|
DateTimeOffset OccurrenceTime,
|
||||||
|
int AxisNo,
|
||||||
|
int AlarmType,
|
||||||
|
int AlarmNumber,
|
||||||
|
string Message);
|
||||||
|
|
||||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||||
public interface IFocasClientFactory
|
public interface IFocasClientFactory
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FWLIB <c>ODBALMHIS</c> struct decoder for the <c>cnc_rdalmhistry</c> alarm-history
|
||||||
|
/// extension (issue #267, plan PR F3-a). Documents + decodes the historical-alarm
|
||||||
|
/// payload returned by FANUC controllers when asked for the most-recent N ring-buffer
|
||||||
|
/// entries.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>ODBALMHIS layout (per FOCAS reference, abridged)</b>:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>short num_alm</c> — number of valid alarm-history records that follow.
|
||||||
|
/// Negative on CNC-reported error.</item>
|
||||||
|
/// <item><c>ALMHIS_data alm[N]</c> — repeated entry record. Each record carries:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>short year, month, day, hour, minute, second</c> — wall-clock
|
||||||
|
/// time the CNC stamped on the entry. Surfaced here as
|
||||||
|
/// <see cref="DateTimeOffset"/> in UTC; the wire field is the CNC's
|
||||||
|
/// local time, but the deployment doc instructs operators to keep their
|
||||||
|
/// CNC clocks on UTC for the history projection so the dedup key stays
|
||||||
|
/// stable across DST transitions.</item>
|
||||||
|
/// <item><c>short axis_no</c> — axis the alarm relates to (1-based;
|
||||||
|
/// 0 means "no specific axis").</item>
|
||||||
|
/// <item><c>short alm_type</c> — alarm type (P/S/OT/SV/SR/MC/SP/PW/IO).
|
||||||
|
/// The numeric encoding varies slightly per series; surfaced as-is so
|
||||||
|
/// downstream consumers don't lose detail.</item>
|
||||||
|
/// <item><c>short alm_no</c> — alarm number within the type.</item>
|
||||||
|
/// <item><c>short msg_len</c> — length of the message string that follows.
|
||||||
|
/// Capped server-side at 32 chars on most series.</item>
|
||||||
|
/// <item><c>char msg[msg_len]</c> — message text. Trimmed of trailing
|
||||||
|
/// nulls + spaces before publishing.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>The simulator-mock surface assigns command id <c>0x0F1A</c> to
|
||||||
|
/// <c>cnc_rdalmhistry</c> — see <c>docs/v2/implementation/focas-simulator-plan.md</c>.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class FocasAlarmHistoryDecoder
|
||||||
|
{
|
||||||
|
/// <summary>Wire-protocol command identifier the simulator routes <c>cnc_rdalmhistry</c> on.</summary>
|
||||||
|
public const ushort CommandId = 0x0F1A;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode a packed ODBALMHIS payload into a list of
|
||||||
|
/// <see cref="FocasAlarmHistoryEntry"/> records ordered most-recent-first (the
|
||||||
|
/// FANUC ring buffer's natural order). Returns an empty list when the buffer is
|
||||||
|
/// too small to hold the count prefix or when the CNC reported zero entries.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Layout of <paramref name="payload"/> in little-endian wire form:</para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Bytes 0..1 — <c>short num_alm</c></item>
|
||||||
|
/// <item>Bytes 2..N — repeated entry blocks. Each block: 14 bytes of fixed
|
||||||
|
/// header (<c>year, month, day, hour, minute, second, axis_no, alm_type,
|
||||||
|
/// alm_no, msg_len</c> — 7×short with the seventh shared between
|
||||||
|
/// <c>axis_no</c>+packing — laid out as 10 little-endian shorts here for
|
||||||
|
/// simplicity), followed by <c>msg_len</c> ASCII bytes. The simulator pads
|
||||||
|
/// each block to a 4-byte boundary; this decoder follows.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>Real FWLIB hands back a Marshal-shaped struct, not a packed buffer; the
|
||||||
|
/// packed-buffer convention here is purely for the simulator + IPC transport so
|
||||||
|
/// the wire protocol stays language-neutral. Tier-C Fwlib32-backed clients
|
||||||
|
/// short-circuit this decoder by surfacing the struct fields directly.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static IReadOnlyList<FocasAlarmHistoryEntry> Decode(ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
if (payload.Length < 2) return Array.Empty<FocasAlarmHistoryEntry>();
|
||||||
|
|
||||||
|
var count = BinaryPrimitives.ReadInt16LittleEndian(payload[..2]);
|
||||||
|
if (count <= 0) return Array.Empty<FocasAlarmHistoryEntry>();
|
||||||
|
|
||||||
|
var entries = new List<FocasAlarmHistoryEntry>(count);
|
||||||
|
var offset = 2;
|
||||||
|
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
// Each entry: 10 little-endian shorts of header (20 bytes) + msg_len bytes.
|
||||||
|
// Header layout: year, month, day, hour, minute, second, axis_no, alm_type,
|
||||||
|
// alm_no, msg_len.
|
||||||
|
const int headerBytes = 20;
|
||||||
|
if (offset + headerBytes > payload.Length) break;
|
||||||
|
|
||||||
|
var header = payload.Slice(offset, headerBytes);
|
||||||
|
var year = BinaryPrimitives.ReadInt16LittleEndian(header[0..2]);
|
||||||
|
var month = BinaryPrimitives.ReadInt16LittleEndian(header[2..4]);
|
||||||
|
var day = BinaryPrimitives.ReadInt16LittleEndian(header[4..6]);
|
||||||
|
var hour = BinaryPrimitives.ReadInt16LittleEndian(header[6..8]);
|
||||||
|
var minute = BinaryPrimitives.ReadInt16LittleEndian(header[8..10]);
|
||||||
|
var second = BinaryPrimitives.ReadInt16LittleEndian(header[10..12]);
|
||||||
|
var axisNo = BinaryPrimitives.ReadInt16LittleEndian(header[12..14]);
|
||||||
|
var almType = BinaryPrimitives.ReadInt16LittleEndian(header[14..16]);
|
||||||
|
var almNo = BinaryPrimitives.ReadInt16LittleEndian(header[16..18]);
|
||||||
|
var msgLen = BinaryPrimitives.ReadInt16LittleEndian(header[18..20]);
|
||||||
|
|
||||||
|
offset += headerBytes;
|
||||||
|
if (msgLen < 0 || offset + msgLen > payload.Length) break;
|
||||||
|
|
||||||
|
var msgBytes = payload.Slice(offset, msgLen);
|
||||||
|
var msg = Encoding.ASCII.GetString(msgBytes).TrimEnd('\0', ' ');
|
||||||
|
offset += msgLen;
|
||||||
|
|
||||||
|
// Pad to 4-byte boundary so per-entry blocks stay self-delimiting on the wire.
|
||||||
|
var pad = (4 - (msgLen % 4)) % 4;
|
||||||
|
offset += pad;
|
||||||
|
|
||||||
|
DateTimeOffset occurrence;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
occurrence = new DateTimeOffset(
|
||||||
|
year, month, day, hour, minute, second, TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
// CNC reported a malformed timestamp — skip the entry rather than
|
||||||
|
// exception-spew the entire history poll. The dedup key would be
|
||||||
|
// unstable for malformed timestamps anyway.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.Add(new FocasAlarmHistoryEntry(
|
||||||
|
OccurrenceTime: occurrence,
|
||||||
|
AxisNo: axisNo,
|
||||||
|
AlarmType: almType,
|
||||||
|
AlarmNumber: almNo,
|
||||||
|
Message: msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode <paramref name="entries"/> into the wire format <see cref="Decode"/>
|
||||||
|
/// consumes. Used by the simulator-mock + tests to build canned payloads without
|
||||||
|
/// having to know the byte-level layout. Output is a fresh array; callers don't
|
||||||
|
/// need to manage a pooled buffer.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] Encode(IReadOnlyList<FocasAlarmHistoryEntry> entries)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
// Pre-size: 2-byte count + 20-byte header + msg + pad per entry.
|
||||||
|
var size = 2;
|
||||||
|
foreach (var e in entries)
|
||||||
|
{
|
||||||
|
var msg = e.Message ?? string.Empty;
|
||||||
|
var msgBytes = Encoding.ASCII.GetByteCount(msg);
|
||||||
|
size += 20 + msgBytes + ((4 - (msgBytes % 4)) % 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf = new byte[size];
|
||||||
|
var span = buf.AsSpan();
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span[..2], (short)Math.Min(entries.Count, short.MaxValue));
|
||||||
|
var offset = 2;
|
||||||
|
|
||||||
|
foreach (var e in entries)
|
||||||
|
{
|
||||||
|
var msg = e.Message ?? string.Empty;
|
||||||
|
var t = e.OccurrenceTime.ToUniversalTime();
|
||||||
|
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 0, 2), (short)t.Year);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 2, 2), (short)t.Month);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 4, 2), (short)t.Day);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 6, 2), (short)t.Hour);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 8, 2), (short)t.Minute);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 10, 2), (short)t.Second);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 12, 2), (short)e.AxisNo);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 14, 2), (short)e.AlarmType);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 16, 2), (short)e.AlarmNumber);
|
||||||
|
var msgLen = Encoding.ASCII.GetByteCount(msg);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 18, 2), (short)msgLen);
|
||||||
|
offset += 20;
|
||||||
|
|
||||||
|
Encoding.ASCII.GetBytes(msg, span.Slice(offset, msgLen));
|
||||||
|
offset += msgLen;
|
||||||
|
offset += (4 - (msgLen % 4)) % 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
Normal file
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One PMC byte-level read request a caller wants to satisfy. <see cref="ByteWidth"/> is
|
||||||
|
/// how many consecutive PMC bytes the caller's tag occupies (e.g. Bit/Byte = 1, Int16 = 2,
|
||||||
|
/// Int32/Float32 = 4, Float64 = 8). <see cref="OriginalIndex"/> is the caller's row index
|
||||||
|
/// in the batch — the coalescer carries it through to the planned group so the driver can
|
||||||
|
/// fan-out the slice back to the original snapshot slot.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Bit-addressed PMC tags (e.g. <c>R100.3</c>) supply their parent byte (<c>100</c>) +
|
||||||
|
/// <c>ByteWidth = 1</c>; the slice-then-mask happens in the existing decode path, so
|
||||||
|
/// the coalescer doesn't need to know about the bit index.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record PmcAddressRequest(
|
||||||
|
string Letter,
|
||||||
|
int PathId,
|
||||||
|
int ByteNumber,
|
||||||
|
int ByteWidth,
|
||||||
|
int OriginalIndex);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One member of a coalesced PMC range — the original index + the byte offset within
|
||||||
|
/// the planned range buffer where the member's bytes start. The caller slices
|
||||||
|
/// <c>buffer[Offset .. Offset + ByteWidth]</c> to recover the per-tag wire-shape bytes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PmcRangeMember(int OriginalIndex, int Offset, int ByteWidth);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One coalesced PMC range — a single FOCAS <c>pmc_rdpmcrng</c> wire call satisfies
|
||||||
|
/// every member. <see cref="ByteCount"/> is bounded by <see cref="FocasPmcCoalescer.MaxRangeBytes"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PmcRangeGroup(
|
||||||
|
string Letter,
|
||||||
|
int PathId,
|
||||||
|
int StartByte,
|
||||||
|
int ByteCount,
|
||||||
|
IReadOnlyList<PmcRangeMember> Members);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plans one or more coalesced PMC range reads from a flat batch of per-tag requests.
|
||||||
|
/// Same-letter / same-path requests whose byte ranges overlap or whose gap is no larger
|
||||||
|
/// than <see cref="MaxBridgeGap"/> are merged into a single wire call up to a
|
||||||
|
/// <see cref="MaxRangeBytes"/> cap (issue #266).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>The cap matches the conservative ceiling Fanuc spec lists for
|
||||||
|
/// <c>pmc_rdpmcrng</c> — most controllers accept larger ranges but 256 is the lowest
|
||||||
|
/// common denominator across 0i / 16i / 30i firmware. Splitting on the cap is fine —
|
||||||
|
/// each partition still saves N-1 round trips relative to per-byte reads.</para>
|
||||||
|
///
|
||||||
|
/// <para>The <see cref="MaxBridgeGap"/> = 16 mirrors the Modbus coalescer's bridge
|
||||||
|
/// policy: small gaps are cheaper to over-read (one extra wire call vs. several short
|
||||||
|
/// ones) but unbounded bridging would pull large unused regions over the wire on sparse
|
||||||
|
/// PMC layouts.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class FocasPmcCoalescer
|
||||||
|
{
|
||||||
|
/// <summary>Maximum bytes per coalesced range — conservative ceiling for older Fanuc firmware.</summary>
|
||||||
|
public const int MaxRangeBytes = 256;
|
||||||
|
|
||||||
|
/// <summary>Maximum gap (in bytes) bridged between consecutive sub-requests within a group.</summary>
|
||||||
|
public const int MaxBridgeGap = 16;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan range reads from <paramref name="addresses"/>. Group key is
|
||||||
|
/// <c>(Letter, PathId)</c>. Within a group, requests are sorted by start byte then
|
||||||
|
/// greedily packed into ranges that respect <see cref="MaxRangeBytes"/> +
|
||||||
|
/// <see cref="MaxBridgeGap"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<PmcRangeGroup> Plan(IEnumerable<PmcAddressRequest> addresses)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(addresses);
|
||||||
|
|
||||||
|
var groups = new List<PmcRangeGroup>();
|
||||||
|
var byKey = addresses
|
||||||
|
.Where(a => !string.IsNullOrEmpty(a.Letter) && a.ByteWidth > 0 && a.ByteNumber >= 0)
|
||||||
|
.GroupBy(a => (Letter: a.Letter.ToUpperInvariant(), a.PathId));
|
||||||
|
|
||||||
|
foreach (var key in byKey)
|
||||||
|
{
|
||||||
|
var sorted = key.OrderBy(a => a.ByteNumber).ThenBy(a => a.OriginalIndex).ToList();
|
||||||
|
var pending = new List<PmcAddressRequest>();
|
||||||
|
var rangeStart = -1;
|
||||||
|
var rangeEnd = -1;
|
||||||
|
|
||||||
|
void Flush()
|
||||||
|
{
|
||||||
|
if (pending.Count == 0) return;
|
||||||
|
var members = pending.Select(p =>
|
||||||
|
new PmcRangeMember(p.OriginalIndex, p.ByteNumber - rangeStart, p.ByteWidth)).ToList();
|
||||||
|
groups.Add(new PmcRangeGroup(key.Key.Letter, key.Key.PathId, rangeStart,
|
||||||
|
rangeEnd - rangeStart + 1, members));
|
||||||
|
pending.Clear();
|
||||||
|
rangeStart = -1;
|
||||||
|
rangeEnd = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var req in sorted)
|
||||||
|
{
|
||||||
|
var reqStart = req.ByteNumber;
|
||||||
|
var reqEnd = req.ByteNumber + req.ByteWidth - 1;
|
||||||
|
if (pending.Count == 0)
|
||||||
|
{
|
||||||
|
rangeStart = reqStart;
|
||||||
|
rangeEnd = reqEnd;
|
||||||
|
pending.Add(req);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge if the gap between the existing range end + this request's start is
|
||||||
|
// within the bridge cap. Overlapping or contiguous ranges always bridge
|
||||||
|
// (gap <= 0). The cap is enforced on the projected union: extending the range
|
||||||
|
// must not exceed MaxRangeBytes from rangeStart.
|
||||||
|
var gap = reqStart - rangeEnd - 1;
|
||||||
|
var projectedEnd = Math.Max(rangeEnd, reqEnd);
|
||||||
|
var projectedSize = projectedEnd - rangeStart + 1;
|
||||||
|
if (gap <= MaxBridgeGap && projectedSize <= MaxRangeBytes)
|
||||||
|
{
|
||||||
|
rangeEnd = projectedEnd;
|
||||||
|
pending.Add(req);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Flush();
|
||||||
|
rangeStart = reqStart;
|
||||||
|
rangeEnd = reqEnd;
|
||||||
|
pending.Add(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of consecutive PMC bytes a tag of <paramref name="type"/> occupies on
|
||||||
|
/// the wire. Used by the driver to populate <see cref="PmcAddressRequest.ByteWidth"/>
|
||||||
|
/// before planning. Bit-addressed tags supply <c>1</c> here — the bit-extract happens
|
||||||
|
/// in the decode path after the slice.
|
||||||
|
/// </summary>
|
||||||
|
public static int ByteWidth(FocasDataType type) => type switch
|
||||||
|
{
|
||||||
|
FocasDataType.Bit or FocasDataType.Byte => 1,
|
||||||
|
FocasDataType.Int16 => 2,
|
||||||
|
FocasDataType.Int32 or FocasDataType.Float32 => 4,
|
||||||
|
FocasDataType.Float64 => 8,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-driver counters surfaced via <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
||||||
|
/// for the <c>driver-diagnostics</c> RPC (task #276). Hot-path increments use
|
||||||
|
/// <see cref="Interlocked"/> so they're lock-free; the read path snapshots into a
|
||||||
|
/// <see cref="IReadOnlyDictionary{TKey, TValue}"/> keyed by stable counter names.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The counters are operational metrics, not config — they reset to zero when the
|
||||||
|
/// driver instance is recreated (Reinitialize tear-down + rebuild) and there is no
|
||||||
|
/// persistence across process restarts. NotificationsPerSecond is a simple decay-EWMA
|
||||||
|
/// so a quiet subscription doesn't latch the value at the last burst rate.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class OpcUaClientDiagnostics
|
||||||
|
{
|
||||||
|
// ---- Hot-path counters (Interlocked) ----
|
||||||
|
|
||||||
|
private long _publishRequestCount;
|
||||||
|
private long _notificationCount;
|
||||||
|
private long _missingPublishRequestCount;
|
||||||
|
private long _droppedNotificationCount;
|
||||||
|
private long _sessionResetCount;
|
||||||
|
|
||||||
|
// ---- EWMA state for NotificationsPerSecond ----
|
||||||
|
//
|
||||||
|
// Use ticks (long) for the timestamp so we can swap atomically. The rate is a double
|
||||||
|
// updated under a tight lock — the EWMA arithmetic (load, blend, store) isn't naturally
|
||||||
|
// atomic on doubles, and the spinlock is held only for arithmetic so contention is
|
||||||
|
// bounded. A subscription firing at 10 kHz with one driver instance is dominated by
|
||||||
|
// the SDK's notification path, not this lock.
|
||||||
|
private readonly object _ewmaLock = new();
|
||||||
|
private double _notificationsPerSecond;
|
||||||
|
private long _lastNotificationTicks;
|
||||||
|
|
||||||
|
/// <summary>Half-life ~5 seconds — recent activity dominates but a paused subscription decays toward zero.</summary>
|
||||||
|
private static readonly TimeSpan EwmaHalfLife = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
// ---- Reconnect state (lock-free, single-writer in OnReconnectComplete) ----
|
||||||
|
private long _lastReconnectUtcTicks;
|
||||||
|
|
||||||
|
public long PublishRequestCount => Interlocked.Read(ref _publishRequestCount);
|
||||||
|
public long NotificationCount => Interlocked.Read(ref _notificationCount);
|
||||||
|
public long MissingPublishRequestCount => Interlocked.Read(ref _missingPublishRequestCount);
|
||||||
|
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotificationCount);
|
||||||
|
public long SessionResetCount => Interlocked.Read(ref _sessionResetCount);
|
||||||
|
|
||||||
|
public DateTime? LastReconnectUtc
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var ticks = Interlocked.Read(ref _lastReconnectUtcTicks);
|
||||||
|
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double NotificationsPerSecond
|
||||||
|
{
|
||||||
|
get { lock (_ewmaLock) return _notificationsPerSecond; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void IncrementPublishRequest() => Interlocked.Increment(ref _publishRequestCount);
|
||||||
|
|
||||||
|
public void IncrementMissingPublishRequest() => Interlocked.Increment(ref _missingPublishRequestCount);
|
||||||
|
|
||||||
|
public void IncrementDroppedNotification() => Interlocked.Increment(ref _droppedNotificationCount);
|
||||||
|
|
||||||
|
/// <summary>Records one delivered notification (any monitored item) + folds the inter-arrival into the EWMA rate.</summary>
|
||||||
|
public void RecordNotification() => RecordNotification(DateTime.UtcNow);
|
||||||
|
|
||||||
|
internal void RecordNotification(DateTime nowUtc)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _notificationCount);
|
||||||
|
|
||||||
|
// EWMA over instantaneous rate. instRate = 1 / dt (events per second since last sample).
|
||||||
|
// Decay factor a = 2^(-dt/halfLife) puts a five-second window on the smoothing — recent
|
||||||
|
// bursts win, idle periods bleed back to zero.
|
||||||
|
var nowTicks = nowUtc.Ticks;
|
||||||
|
lock (_ewmaLock)
|
||||||
|
{
|
||||||
|
if (_lastNotificationTicks == 0)
|
||||||
|
{
|
||||||
|
_lastNotificationTicks = nowTicks;
|
||||||
|
// First sample: seed at 0 — we don't know the prior rate. The next sample
|
||||||
|
// produces a real instRate.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dtTicks = nowTicks - _lastNotificationTicks;
|
||||||
|
if (dtTicks <= 0)
|
||||||
|
{
|
||||||
|
// Same-tick collisions on bursts: treat as no time elapsed for rate purposes
|
||||||
|
// (count was already incremented above) so we don't divide by zero or feed
|
||||||
|
// an absurd instRate spike.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dtSeconds = (double)dtTicks / TimeSpan.TicksPerSecond;
|
||||||
|
var instRate = 1.0 / dtSeconds;
|
||||||
|
var alpha = System.Math.Pow(0.5, dtSeconds / EwmaHalfLife.TotalSeconds);
|
||||||
|
_notificationsPerSecond = (alpha * _notificationsPerSecond) + ((1.0 - alpha) * instRate);
|
||||||
|
_lastNotificationTicks = nowTicks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordSessionReset(DateTime nowUtc)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _sessionResetCount);
|
||||||
|
Interlocked.Exchange(ref _lastReconnectUtcTicks, nowUtc.Ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot the counters into the dictionary shape <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
||||||
|
/// surfaces. Numeric-only (so the RPC can render generically); LastReconnectUtc is
|
||||||
|
/// emitted as ticks to keep the value type uniform.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, double> Snapshot()
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, double>(7, System.StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["PublishRequestCount"] = PublishRequestCount,
|
||||||
|
["NotificationCount"] = NotificationCount,
|
||||||
|
["NotificationsPerSecond"] = NotificationsPerSecond,
|
||||||
|
["MissingPublishRequestCount"] = MissingPublishRequestCount,
|
||||||
|
["DroppedNotificationCount"] = DroppedNotificationCount,
|
||||||
|
["SessionResetCount"] = SessionResetCount,
|
||||||
|
};
|
||||||
|
var last = LastReconnectUtc;
|
||||||
|
if (last is not null)
|
||||||
|
dict["LastReconnectUtcTicks"] = last.Value.Ticks;
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,34 @@ public sealed class OpcUaClientDriverOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
|
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional discovery URL pointing at a Local Discovery Server (LDS) or a server's
|
||||||
|
/// own discovery endpoint. When set, the driver runs <c>FindServers</c> +
|
||||||
|
/// <c>GetEndpoints</c> against this URL during <see cref="OpcUaClientDriver.InitializeAsync"/>
|
||||||
|
/// and prepends the discovered endpoint URLs to the failover candidate list. When
|
||||||
|
/// <see cref="EndpointUrls"/> is empty (and only <see cref="EndpointUrl"/> is set as
|
||||||
|
/// a fallback), the discovered URLs replace the candidate list entirely so a
|
||||||
|
/// discovery-driven deployment can be configured without specifying any endpoints
|
||||||
|
/// up front. Discovery failures are non-fatal — the driver logs and falls back to the
|
||||||
|
/// statically configured candidates.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>FindServers requires SecurityMode=None on the discovery channel</b> per the
|
||||||
|
/// OPC UA spec — discovery is unauthenticated even when the data channel uses
|
||||||
|
/// <c>Sign</c> or <c>SignAndEncrypt</c>. The driver opens the discovery channel
|
||||||
|
/// unsecured regardless of <see cref="SecurityMode"/>; only the resulting data
|
||||||
|
/// session is bound to the configured policy.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Endpoints returned by discovery are filtered to those matching
|
||||||
|
/// <see cref="SecurityPolicy"/> + <see cref="SecurityMode"/> before being added to
|
||||||
|
/// the candidate list, so a discovery sweep against a multi-policy server only
|
||||||
|
/// surfaces endpoints the driver could actually connect to.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string? DiscoveryUrl { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Security policy to require when selecting an endpoint. Either a
|
/// Security policy to require when selecting an endpoint. Either a
|
||||||
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
||||||
@@ -134,8 +162,198 @@ public sealed class OpcUaClientDriverOptions
|
|||||||
/// browse forever.
|
/// browse forever.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaxBrowseDepth { get; init; } = 10;
|
public int MaxBrowseDepth { get; init; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-subscription tuning knobs applied when the driver creates data + alarm
|
||||||
|
/// subscriptions on the upstream session. Defaults preserve the previous hard-coded
|
||||||
|
/// values so existing deployments see no behaviour change.
|
||||||
|
/// </summary>
|
||||||
|
public OpcUaSubscriptionDefaults Subscriptions { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-certificate validation knobs applied during the
|
||||||
|
/// <c>CertificateValidator.CertificateValidation</c> callback. Surfaces explicit
|
||||||
|
/// handling for revoked certs (always rejected, never auto-accepted), unknown
|
||||||
|
/// revocation status (rejected only when <see cref="OpcUaCertificateValidationOptions.RejectUnknownRevocationStatus"/>
|
||||||
|
/// is set), SHA-1 signature rejection, and minimum RSA key size. Defaults preserve
|
||||||
|
/// existing behaviour wherever possible — the one tightening is
|
||||||
|
/// <see cref="OpcUaCertificateValidationOptions.RejectSHA1SignedCertificates"/>=true
|
||||||
|
/// since SHA-1 is spec-deprecated for OPC UA.
|
||||||
|
/// </summary>
|
||||||
|
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Curation rules applied to the upstream address space during
|
||||||
|
/// <c>DiscoverAsync</c>. Lets operators trim the mirrored tree to the subset their
|
||||||
|
/// downstream clients actually need, rename namespace URIs so the local-side metadata
|
||||||
|
/// stays consistent across upstream-server swaps, and override the default
|
||||||
|
/// <c>"Remote"</c> root folder name. Defaults are empty / null which preserves the
|
||||||
|
/// pre-curation behaviour exactly — empty include = include all.
|
||||||
|
/// </summary>
|
||||||
|
public OpcUaClientCurationOptions Curation { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <c>true</c>, <c>DiscoverAsync</c> runs an additional pass that walks the upstream
|
||||||
|
/// <c>TypesFolder</c> (<c>i=86</c>) — ObjectTypes (<c>i=88</c>), VariableTypes
|
||||||
|
/// (<c>i=89</c>), DataTypes (<c>i=90</c>), ReferenceTypes (<c>i=91</c>) — and projects the
|
||||||
|
/// discovered type-definition nodes into the local address space via
|
||||||
|
/// <c>IAddressSpaceBuilder.RegisterTypeNode</c>. Default <c>false</c> — opt-in so
|
||||||
|
/// existing deployments don't suddenly see a flood of type nodes after upgrade. Enable
|
||||||
|
/// when downstream clients need the upstream type system to render structured values or
|
||||||
|
/// decode custom event fields.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The type-mirror pass uses <c>Session.FetchTypeTreeAsync</c> on each of the four
|
||||||
|
/// root type nodes so the SDK's local TypeTree cache is populated efficiently (one
|
||||||
|
/// batched browse per root rather than per-node round trips). This PR ships the
|
||||||
|
/// <i>structural</i> mirror only — every type node is registered with its identity,
|
||||||
|
/// super-type chain, and IsAbstract flag, but structured-type binary encodings are
|
||||||
|
/// NOT primed. (The OPCFoundation SDK removed
|
||||||
|
/// <c>ISession.LoadDataTypeSystem(NodeId, CancellationToken)</c> from the public
|
||||||
|
/// surface in 1.5.378+; loading binary encodings now requires per-node walks of
|
||||||
|
/// <c>HasEncoding</c> + dictionary nodes which is tracked as a follow-up.) Clients
|
||||||
|
/// that need structured-type decoding can still consume
|
||||||
|
/// <c>Variant<ExtensionObject></c> on the wire.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="OpcUaClientCurationOptions.IncludePaths"/> +
|
||||||
|
/// <see cref="OpcUaClientCurationOptions.ExcludePaths"/> still apply to the type
|
||||||
|
/// walk; paths are slash-joined under their root (e.g.
|
||||||
|
/// <c>"ObjectTypes/BaseObjectType/SomeType"</c>). Most operators want all types so
|
||||||
|
/// empty include = include all is the right default.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool MirrorTypeDefinitions { get; init; } = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selective import + namespace remap rules for the OPC UA Client driver. Pure local
|
||||||
|
/// filtering inside <c>BrowseRecursiveAsync</c> + <c>EnrichAndRegisterVariablesAsync</c>;
|
||||||
|
/// no new SDK calls.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Glob semantics</b>: patterns are matched against the slash-joined BrowseName
|
||||||
|
/// segments accumulated during the browse pass (e.g. <c>"Server/Diagnostics/SessionsDiagnosticsArray"</c>).
|
||||||
|
/// Two wildcards are supported — <c>*</c> matches any sequence of characters
|
||||||
|
/// (including empty / slashes) and <c>?</c> matches exactly one character. No
|
||||||
|
/// character classes, no <c>**</c>, no escapes — keep the surface tight so the doc
|
||||||
|
/// + behaviour stay simple.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Empty <see cref="IncludePaths"/> = include all (existing behaviour).
|
||||||
|
/// <see cref="ExcludePaths"/> wins over <see cref="IncludePaths"/> when both match.
|
||||||
|
/// Folders pruned by the rules are skipped wholesale — their descendants don't get
|
||||||
|
/// browsed, which keeps the wire cost down on large servers.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="IncludePaths">
|
||||||
|
/// Glob patterns matched against the BrowsePath segment list. Empty = include all
|
||||||
|
/// (default — preserves pre-curation behaviour).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ExcludePaths">
|
||||||
|
/// Glob patterns matched against the BrowsePath segment list. Wins over
|
||||||
|
/// <see cref="IncludePaths"/> — useful for "include everything under <c>Plant/*</c>
|
||||||
|
/// except <c>Plant/Diagnostics</c>" rules.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="NamespaceRemap">
|
||||||
|
/// Upstream-namespace-URI → local-namespace-URI translation table applied to the
|
||||||
|
/// <c>FullName</c> field of <c>DriverAttributeInfo</c> when registering variables.
|
||||||
|
/// The driver's stored <c>FullName</c> swaps the prefix before persisting so downstream
|
||||||
|
/// clients see the remapped URI. Lookup is case-sensitive — match the upstream URI
|
||||||
|
/// exactly. Defaults to empty (no remap).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="RootAlias">
|
||||||
|
/// Replaces the default <c>"Remote"</c> folder name at the top of the mirrored tree.
|
||||||
|
/// Useful when multiple OPC UA Client drivers are aggregated and operators need to
|
||||||
|
/// distinguish them in the local browse tree. Default <c>null</c> = use <c>"Remote"</c>.
|
||||||
|
/// </param>
|
||||||
|
public sealed record OpcUaClientCurationOptions(
|
||||||
|
IReadOnlyList<string>? IncludePaths = null,
|
||||||
|
IReadOnlyList<string>? ExcludePaths = null,
|
||||||
|
IReadOnlyDictionary<string, string>? NamespaceRemap = null,
|
||||||
|
string? RootAlias = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Knobs governing the server-certificate validation callback. Plumbed onto
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.CertificateValidation"/> rather than the top-level
|
||||||
|
/// options to keep cert-related config grouped together.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>CRL discovery:</b> the OPC UA SDK reads CRL files automatically from the
|
||||||
|
/// <c>crl/</c> sub-directory of each cert store (own, trusted, issuers). Drop the
|
||||||
|
/// issuer's <c>.crl</c> in that folder and the SDK picks it up — no driver-side wiring
|
||||||
|
/// required. When the directory is absent or empty, the SDK reports
|
||||||
|
/// <c>BadCertificateRevocationUnknown</c>, which this driver gates with
|
||||||
|
/// <see cref="RejectUnknownRevocationStatus"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="RejectSHA1SignedCertificates">
|
||||||
|
/// Reject server certificates whose signature uses SHA-1. Default <c>true</c> — SHA-1 was
|
||||||
|
/// deprecated by the OPC UA spec and is treated as a hard fail in production. Flip to
|
||||||
|
/// <c>false</c> only for short-term interop with legacy controllers.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="RejectUnknownRevocationStatus">
|
||||||
|
/// When the SDK can't determine revocation status (no CRL present, or stale CRL),
|
||||||
|
/// reject the cert if <c>true</c>; allow if <c>false</c>. Default <c>false</c> — many
|
||||||
|
/// plant deployments don't run CRL infrastructure, and a hard-fail default would break
|
||||||
|
/// them on first connection. Set <c>true</c> in environments with a managed PKI.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MinimumCertificateKeySize">
|
||||||
|
/// Minimum RSA key size (bits) accepted. Certs with shorter keys are rejected. Default
|
||||||
|
/// <c>2048</c> matches the current OPC UA spec floor; raise to 3072 or 4096 for stricter
|
||||||
|
/// deployments. Non-RSA keys (ECC) bypass this check.
|
||||||
|
/// </param>
|
||||||
|
public sealed record OpcUaCertificateValidationOptions(
|
||||||
|
bool RejectSHA1SignedCertificates = true,
|
||||||
|
bool RejectUnknownRevocationStatus = false,
|
||||||
|
int MinimumCertificateKeySize = 2048);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tuning surface for OPC UA subscriptions created by <see cref="OpcUaClientDriver"/>.
|
||||||
|
/// Lifted from the per-call hard-coded literals so operators can tune publish cadence,
|
||||||
|
/// keep-alive ratio, and alarm-vs-data prioritisation without recompiling the driver.
|
||||||
|
/// Defaults match the original hard-coded values (KeepAlive=10, Lifetime=1000,
|
||||||
|
/// MaxNotifications=0 unlimited, Priority=0, MinPublishingInterval=50ms).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="KeepAliveCount">
|
||||||
|
/// Number of consecutive empty publish cycles before the server sends a keep-alive
|
||||||
|
/// response. Default 10 — high enough to suppress idle traffic, low enough that the
|
||||||
|
/// client notices a stalled subscription within ~5x the publish interval.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="LifetimeCount">
|
||||||
|
/// Number of consecutive missed publish responses before the server tears down the
|
||||||
|
/// subscription. Must be ≥3×<see cref="KeepAliveCount"/> per OPC UA spec; default 1000
|
||||||
|
/// gives ~100 keep-alives of slack which is conservative on flaky networks.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MaxNotificationsPerPublish">
|
||||||
|
/// Cap on notifications returned per publish response. <c>0</c> = unlimited (the OPC UA
|
||||||
|
/// spec sentinel). Lower this to bound publish-message size on bursty servers.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Priority">
|
||||||
|
/// Subscription priority for data subscriptions (0..255). Higher = scheduled ahead of
|
||||||
|
/// lower. Default 0 matches the SDK's default for ordinary tag subscriptions.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MinPublishingIntervalMs">
|
||||||
|
/// Floor (ms) applied to <c>publishingInterval</c> requests. Sub-floor values are
|
||||||
|
/// clamped up so wire-side negotiations don't waste round-trips on intervals the server
|
||||||
|
/// will only round up anyway. Default 50ms.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AlarmsPriority">
|
||||||
|
/// Subscription priority for the alarm subscription (0..255). Higher than
|
||||||
|
/// <see cref="Priority"/> by default (1 vs 0) so alarm publishes aren't starved during
|
||||||
|
/// data-tag bursts.
|
||||||
|
/// </param>
|
||||||
|
public sealed record OpcUaSubscriptionDefaults(
|
||||||
|
int KeepAliveCount = 10,
|
||||||
|
uint LifetimeCount = 1000,
|
||||||
|
uint MaxNotificationsPerPublish = 0,
|
||||||
|
byte Priority = 0,
|
||||||
|
int MinPublishingIntervalMs = 50,
|
||||||
|
byte AlarmsPriority = 1);
|
||||||
|
|
||||||
/// <summary>OPC UA message security mode.</summary>
|
/// <summary>OPC UA message security mode.</summary>
|
||||||
public enum OpcUaSecurityMode
|
public enum OpcUaSecurityMode
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using S7NetCpuType = global::S7.Net.CpuType;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -26,10 +28,12 @@ public enum S7Size
|
|||||||
Byte, // B
|
Byte, // B
|
||||||
Word, // W — 16-bit
|
Word, // W — 16-bit
|
||||||
DWord, // D — 32-bit
|
DWord, // D — 32-bit
|
||||||
|
LWord, // LD / DBL — 64-bit (LInt/ULInt/LReal). S7.Net has no native size suffix; the
|
||||||
|
// driver issues an 8-byte ReadBytes and converts big-endian in-process.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>.
|
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse(string)"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
|
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
|
||||||
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
|
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
|
||||||
@@ -48,9 +52,12 @@ public readonly record struct S7ParsedAddress(
|
|||||||
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
|
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
|
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
|
||||||
|
/// <item><c>DB{n}.{DBLD|DBL}{offset}</c> — 64-bit (LInt / ULInt / LReal) e.g. <c>DB1.DBLD0</c>, <c>DB1.DBL8</c></item>
|
||||||
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
|
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
|
||||||
|
/// <item><c>M{LD}{offset}</c> — 64-bit Merker, e.g. <c>MLD0</c></item>
|
||||||
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
|
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
|
||||||
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
|
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
|
||||||
|
/// <item><c>I{LD}{offset}</c> / <c>Q{LD}{offset}</c> — 64-bit Input/Output, e.g. <c>ILD0</c>, <c>QLD0</c></item>
|
||||||
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
|
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
|
||||||
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
@@ -69,7 +76,29 @@ public static class S7AddressParser
|
|||||||
/// the offending input echoed in the message so operators can correlate to the tag
|
/// the offending input echoed in the message so operators can correlate to the tag
|
||||||
/// config that produced the fault.
|
/// config that produced the fault.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static S7ParsedAddress Parse(string address)
|
/// <remarks>
|
||||||
|
/// The CPU-agnostic overload rejects the <c>V</c> area letter; <c>V</c> is only
|
||||||
|
/// meaningful on S7-200 / S7-200 Smart / LOGO! where it maps to a fixed DB number
|
||||||
|
/// (DB1 by convention) — call <see cref="Parse(string, S7NetCpuType?)"/> with the
|
||||||
|
/// device's CPU family for V-memory tags.
|
||||||
|
/// </remarks>
|
||||||
|
public static S7ParsedAddress Parse(string address) => Parse(address, cpuType: null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse an S7 address with knowledge of the device's CPU family. Required for the
|
||||||
|
/// <c>V</c> area letter (S7-200 / S7-200 Smart / LOGO! V-memory), which maps to
|
||||||
|
/// DataBlock DB1 on those families. On S7-300 / S7-400 / S7-1200 / S7-1500 the
|
||||||
|
/// <c>V</c> letter is rejected because it has no equivalent — those families use
|
||||||
|
/// explicit <c>DB{n}.DB...</c> addressing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// LOGO! firmware bands map V-memory to different underlying DB numbers in some
|
||||||
|
/// 0BA editions; the driver currently uses DB1 (the most common LOGO! 8 / 0BA8
|
||||||
|
/// mapping). If a future site ships a firmware band where VM lives in a different
|
||||||
|
/// DB, the mapping table in <see cref="VMemoryDbNumberFor"/> is the single point
|
||||||
|
/// to extend. Live LOGO! testing is out of scope for the initial PR.
|
||||||
|
/// </remarks>
|
||||||
|
public static S7ParsedAddress Parse(string address, S7NetCpuType? cpuType)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(address))
|
if (string.IsNullOrWhiteSpace(address))
|
||||||
throw new FormatException("S7 address must not be empty");
|
throw new FormatException("S7 address must not be empty");
|
||||||
@@ -92,21 +121,28 @@ public static class S7AddressParser
|
|||||||
case 'Q': return ParseMIQ(S7Area.Output, rest, address);
|
case 'Q': return ParseMIQ(S7Area.Output, rest, address);
|
||||||
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
|
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
|
||||||
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
|
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
|
||||||
|
case 'V': return ParseV(rest, address, cpuType);
|
||||||
default:
|
default:
|
||||||
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)");
|
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C/V)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
|
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
|
||||||
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
|
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
|
||||||
/// would throw from <see cref="Parse"/>.
|
/// would throw from <see cref="Parse(string)"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryParse(string address, out S7ParsedAddress result)
|
public static bool TryParse(string address, out S7ParsedAddress result)
|
||||||
|
=> TryParse(address, cpuType: null, out result);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try-parse variant that accepts a CPU family for V-memory addressing.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParse(string address, S7NetCpuType? cpuType, out S7ParsedAddress result)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = Parse(address);
|
result = Parse(address, cpuType);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
@@ -130,18 +166,36 @@ public static class S7AddressParser
|
|||||||
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
|
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
|
||||||
|
|
||||||
if (!tail.StartsWith("DB") || tail.Length < 4)
|
if (!tail.StartsWith("DB") || tail.Length < 4)
|
||||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}");
|
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D|LD|L}}");
|
||||||
|
|
||||||
var sizeChar = tail[2];
|
// 64-bit suffixes are two-letter (LD or DBL-as-prefix). Detect them up front so the
|
||||||
var offsetStart = 3;
|
// single-char switch below stays readable. "DBLD" is the symmetric extension of
|
||||||
var size = sizeChar switch
|
// DBX/DBB/DBW/DBD; "DBL" is the shorter Siemens "long" alias accepted as an alternate.
|
||||||
|
S7Size size;
|
||||||
|
int offsetStart;
|
||||||
|
if (tail.Length >= 5 && tail[2] == 'L' && tail[3] == 'D')
|
||||||
{
|
{
|
||||||
'X' => S7Size.Bit,
|
size = S7Size.LWord;
|
||||||
'B' => S7Size.Byte,
|
offsetStart = 4;
|
||||||
'W' => S7Size.Word,
|
}
|
||||||
'D' => S7Size.DWord,
|
else if (tail.Length >= 4 && tail[2] == 'L')
|
||||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
|
{
|
||||||
};
|
size = S7Size.LWord;
|
||||||
|
offsetStart = 3;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var sizeChar = tail[2];
|
||||||
|
offsetStart = 3;
|
||||||
|
size = sizeChar switch
|
||||||
|
{
|
||||||
|
'X' => S7Size.Bit,
|
||||||
|
'B' => S7Size.Byte,
|
||||||
|
'W' => S7Size.Word,
|
||||||
|
'D' => S7Size.DWord,
|
||||||
|
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D/LD/L"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
|
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
|
||||||
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
|
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
|
||||||
@@ -156,23 +210,73 @@ public static class S7AddressParser
|
|||||||
var first = rest[0];
|
var first = rest[0];
|
||||||
S7Size size;
|
S7Size size;
|
||||||
int offsetStart;
|
int offsetStart;
|
||||||
switch (first)
|
// Two-char "LD" prefix (8-byte LWord) checked first so it doesn't get swallowed by
|
||||||
|
// the single-letter cases below.
|
||||||
|
if (rest.Length >= 2 && first == 'L' && rest[1] == 'D')
|
||||||
{
|
{
|
||||||
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
size = S7Size.LWord;
|
||||||
case 'W': size = S7Size.Word; offsetStart = 1; break;
|
offsetStart = 2;
|
||||||
case 'D': size = S7Size.DWord; offsetStart = 1; break;
|
}
|
||||||
default:
|
else
|
||||||
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
|
{
|
||||||
// ParseOffsetAndOptionalBit will demand the dot.
|
switch (first)
|
||||||
size = S7Size.Bit;
|
{
|
||||||
offsetStart = 0;
|
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
||||||
break;
|
case 'W': size = S7Size.Word; offsetStart = 1; break;
|
||||||
|
case 'D': size = S7Size.DWord; offsetStart = 1; break;
|
||||||
|
default:
|
||||||
|
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
|
||||||
|
// ParseOffsetAndOptionalBit will demand the dot.
|
||||||
|
size = S7Size.Bit;
|
||||||
|
offsetStart = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
|
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
|
||||||
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
|
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a <c>V</c>-area address (S7-200 / S7-200 Smart / LOGO! V-memory). Same width
|
||||||
|
/// suffixes as M/I/Q (<c>VB</c>, <c>VW</c>, <c>VD</c>, <c>V0.0</c>) but rewritten as
|
||||||
|
/// a DataBlock access so the rest of the driver — which speaks S7.Net's DB-centric
|
||||||
|
/// API — needs no special-casing downstream.
|
||||||
|
/// </summary>
|
||||||
|
private static S7ParsedAddress ParseV(string rest, string original, S7NetCpuType? cpuType)
|
||||||
|
{
|
||||||
|
var dbNumber = VMemoryDbNumberFor(cpuType, original);
|
||||||
|
// Reuse the M/I/Q grammar — V's size suffixes are identical (B/W/D/LD or .bit).
|
||||||
|
var parsed = ParseMIQ(S7Area.Memory, rest, original);
|
||||||
|
return parsed with { Area = S7Area.DataBlock, DbNumber = dbNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map a CPU family to the underlying DB number that backs V-memory. Returns DB1
|
||||||
|
/// for S7-200, S7-200 Smart, and LOGO! 0BA8 (the only LOGO! the S7.Net <c>CpuType</c>
|
||||||
|
/// enum surfaces). Throws for families that have no V-area concept.
|
||||||
|
/// </summary>
|
||||||
|
private static int VMemoryDbNumberFor(S7NetCpuType? cpuType, string original)
|
||||||
|
{
|
||||||
|
if (cpuType is null)
|
||||||
|
throw new FormatException(
|
||||||
|
$"S7 V-memory address '{original}' requires a CPU family (S7-200 / S7-200 Smart / LOGO!) — " +
|
||||||
|
"the CPU-agnostic Parse overload cannot resolve V-memory to a DB number");
|
||||||
|
|
||||||
|
return cpuType.Value switch
|
||||||
|
{
|
||||||
|
S7NetCpuType.S7200 => 1,
|
||||||
|
S7NetCpuType.S7200Smart => 1,
|
||||||
|
// LOGO! 8 / 0BA8 firmware bands typically expose VM as DB1 over S7comm. Older
|
||||||
|
// 0BA editions can differ; the mapping is centralised here for easy extension
|
||||||
|
// once a site provides a non-DB1 firmware band to test against.
|
||||||
|
S7NetCpuType.Logo0BA8 => 1,
|
||||||
|
_ => throw new FormatException(
|
||||||
|
$"S7 V-memory address '{original}' is only valid on S7-200 / S7-200 Smart / LOGO! " +
|
||||||
|
$"(got CpuType={cpuType.Value}); use explicit DB{{n}}.DB... addressing on this family"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
|
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
|
||||||
{
|
{
|
||||||
if (rest.Length == 0)
|
if (rest.Length == 0)
|
||||||
|
|||||||
241
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7BlockCoalescingPlanner.cs
Normal file
241
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7BlockCoalescingPlanner.cs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Block-read coalescing planner for the S7 driver (PR-S7-B2). Where the
|
||||||
|
/// <see cref="S7ReadPacker"/> coalesces N scalar tags into ⌈N/19⌉
|
||||||
|
/// <c>Plc.ReadMultipleVarsAsync</c> PDUs, this planner takes one further pass:
|
||||||
|
/// it groups same-area, same-DB tags by contiguous byte range and folds them
|
||||||
|
/// into a single <c>Plc.ReadBytesAsync</c> covering the merged span. The
|
||||||
|
/// response is sliced client-side per tag so the per-tag decode path is
|
||||||
|
/// unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why coalesce</b>: Reading <c>DB1.DBW0</c> + <c>DB1.DBW2</c> +
|
||||||
|
/// <c>DB1.DBW4</c> as three multi-var items still uses three slots in a
|
||||||
|
/// single PDU; coalescing into one 6-byte byte-range read drops the per-item
|
||||||
|
/// framing entirely and makes the request fit in fewer (sometimes zero
|
||||||
|
/// additional) PDUs. On a typical contiguous DB the wire-level reduction is
|
||||||
|
/// 50:1 for 50 contiguous DBWs.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Gap-merge threshold</b>: The planner merges adjacent tag ranges when
|
||||||
|
/// the gap between them is at most the <c>gapMergeBytes</c> argument to
|
||||||
|
/// <see cref="Plan"/>. The default <see cref="DefaultGapMergeBytes"/> is
|
||||||
|
/// 16 bytes — over-fetching 16 bytes is cheaper than one extra PDU
|
||||||
|
/// (240-byte default PDU envelope, ~18 bytes per request frame). Operators
|
||||||
|
/// can tune the threshold per driver instance via
|
||||||
|
/// <see cref="S7DriverOptions.BlockCoalescingGapBytes"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Opaque-size opt-out</b>: STRING / WSTRING / CHAR / WCHAR and DTL /
|
||||||
|
/// DT / S5TIME / TIME / TOD / DATE-as-DateTime tags carry a header (or
|
||||||
|
/// have a per-tag width that varies with <c>StringLength</c>) and are
|
||||||
|
/// flagged <c>OpaqueSize=true</c>. The planner emits these as standalone
|
||||||
|
/// single-tag ranges and never merges them into a sibling block — the
|
||||||
|
/// per-tag decode path needs an exact byte slice and a wrong slice from
|
||||||
|
/// a coalesced read would silently corrupt every neighbour.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Order-preserving</b>: Each <see cref="BlockReadRange"/> carries a list
|
||||||
|
/// of <see cref="TagSlice"/> values pointing back at the original
|
||||||
|
/// caller-index. The driver's <c>ReadAsync</c> uses the index to write the
|
||||||
|
/// decoded value into the correct slot of the result array, so caller
|
||||||
|
/// ordering of the input <c>fullReferences</c> is preserved across the
|
||||||
|
/// coalescing step.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class S7BlockCoalescingPlanner
|
||||||
|
{
|
||||||
|
/// <summary>Default gap-merge threshold in bytes.</summary>
|
||||||
|
internal const int DefaultGapMergeBytes = 16;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One coalesced byte-range request. The driver issues a single
|
||||||
|
/// <c>Plc.ReadBytesAsync</c> covering <see cref="StartByte"/>..
|
||||||
|
/// <see cref="StartByte"/>+<see cref="ByteCount"/>; each entry in
|
||||||
|
/// <see cref="Tags"/> carries the offset within the response buffer to
|
||||||
|
/// slice for that tag.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record BlockReadRange(
|
||||||
|
S7Area Area,
|
||||||
|
int DbNumber,
|
||||||
|
int StartByte,
|
||||||
|
int ByteCount,
|
||||||
|
IReadOnlyList<TagSlice> Tags);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One tag's slot inside a <see cref="BlockReadRange"/>. <see cref="OffsetInBlock"/>
|
||||||
|
/// is the byte offset within the coalesced buffer; <see cref="ByteCount"/> is the
|
||||||
|
/// per-tag width that the slice covers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="CallerIndex">Original index in the caller's <c>fullReferences</c> list.</param>
|
||||||
|
/// <param name="OffsetInBlock">Byte offset into <see cref="BlockReadRange"/>'s buffer.</param>
|
||||||
|
/// <param name="ByteCount">Bytes the tag claims from the buffer.</param>
|
||||||
|
internal sealed record TagSlice(int CallerIndex, int OffsetInBlock, int ByteCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Input row. Captures everything the planner needs to make a coalescing
|
||||||
|
/// decision without needing the full <see cref="S7TagDefinition"/> graph.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="CallerIndex">Caller-supplied stable index used to thread the decoded value back.</param>
|
||||||
|
/// <param name="Area">Memory area; M and DB never merge into the same range.</param>
|
||||||
|
/// <param name="DbNumber">DB number when <see cref="Area"/> is DataBlock; 0 otherwise.</param>
|
||||||
|
/// <param name="StartByte">Byte offset in the area where the tag's storage begins.</param>
|
||||||
|
/// <param name="ByteCount">On-wire byte width of the tag.</param>
|
||||||
|
/// <param name="OpaqueSize">
|
||||||
|
/// True for tags whose effective decode width is variable / header-prefixed
|
||||||
|
/// (STRING/WSTRING/CHAR/WCHAR and structured timestamps DTL/DT/etc.) so the
|
||||||
|
/// planner skips them — they emit standalone reads and never merge with
|
||||||
|
/// neighbours.
|
||||||
|
/// </param>
|
||||||
|
internal sealed record TagSpec(
|
||||||
|
int CallerIndex,
|
||||||
|
S7Area Area,
|
||||||
|
int DbNumber,
|
||||||
|
int StartByte,
|
||||||
|
int ByteCount,
|
||||||
|
bool OpaqueSize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan a list of byte-range reads from <paramref name="tags"/>. Same-area /
|
||||||
|
/// same-DB rows are sorted by <see cref="TagSpec.StartByte"/> then merged
|
||||||
|
/// greedily when the gap between their byte ranges is <=
|
||||||
|
/// <paramref name="gapMergeBytes"/>. Opaque-size rows always emit as their
|
||||||
|
/// own single-tag range and never extend a sibling block.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Order of returned ranges is not significant — the driver issues them
|
||||||
|
/// sequentially against the same connection gate so wire-level ordering is
|
||||||
|
/// determined by the loop, not by this list. The planner DOES preserve
|
||||||
|
/// the caller-index inside each range so the per-tag decode result lands
|
||||||
|
/// in the correct slot of the response array.
|
||||||
|
/// </remarks>
|
||||||
|
internal static List<BlockReadRange> Plan(IReadOnlyList<TagSpec> tags, int gapMergeBytes = DefaultGapMergeBytes)
|
||||||
|
{
|
||||||
|
if (gapMergeBytes < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(gapMergeBytes), "Gap-merge threshold must be non-negative.");
|
||||||
|
var ranges = new List<BlockReadRange>(tags.Count);
|
||||||
|
if (tags.Count == 0) return ranges;
|
||||||
|
|
||||||
|
// Phase 1: opaque rows emit as standalone single-tag ranges. Strip them
|
||||||
|
// out of the merge candidate set so neighbour ranges don't accidentally
|
||||||
|
// straddle a STRING header / DTL block.
|
||||||
|
var mergeable = new List<TagSpec>(tags.Count);
|
||||||
|
foreach (var t in tags)
|
||||||
|
{
|
||||||
|
if (t.OpaqueSize)
|
||||||
|
{
|
||||||
|
ranges.Add(new BlockReadRange(
|
||||||
|
t.Area, t.DbNumber, t.StartByte, t.ByteCount,
|
||||||
|
[new TagSlice(t.CallerIndex, OffsetInBlock: 0, t.ByteCount)]));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mergeable.Add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: bucket by (Area, DbNumber). Memory M and DataBlock DB1 (etc.)
|
||||||
|
// share neither the wire request type nor an addressable space, so they
|
||||||
|
// can never coalesce.
|
||||||
|
var groups = mergeable.GroupBy(t => (t.Area, t.DbNumber));
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
// Sort ascending by start byte so the greedy merge below is O(n).
|
||||||
|
// Stable secondary sort on caller index keeps tag-slice ordering
|
||||||
|
// deterministic for tags with identical byte offsets.
|
||||||
|
var sorted = group
|
||||||
|
.OrderBy(t => t.StartByte)
|
||||||
|
.ThenBy(t => t.CallerIndex)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var blockStart = sorted[0].StartByte;
|
||||||
|
var blockEnd = sorted[0].StartByte + sorted[0].ByteCount;
|
||||||
|
var blockSlices = new List<TagSlice>
|
||||||
|
{
|
||||||
|
new(sorted[0].CallerIndex, 0, sorted[0].ByteCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var i = 1; i < sorted.Count; i++)
|
||||||
|
{
|
||||||
|
var t = sorted[i];
|
||||||
|
var gap = t.StartByte - blockEnd;
|
||||||
|
// gap < 0 means the next tag overlaps with the current block — treat
|
||||||
|
// as zero-gap merge (overlap is fine, the slice just reuses earlier
|
||||||
|
// bytes). gap <= threshold = merge; otherwise close the current
|
||||||
|
// block and start a new one.
|
||||||
|
if (gap <= gapMergeBytes)
|
||||||
|
{
|
||||||
|
var newEnd = Math.Max(blockEnd, t.StartByte + t.ByteCount);
|
||||||
|
blockSlices.Add(new TagSlice(t.CallerIndex, t.StartByte - blockStart, t.ByteCount));
|
||||||
|
blockEnd = newEnd;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ranges.Add(new BlockReadRange(
|
||||||
|
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
|
||||||
|
blockStart = t.StartByte;
|
||||||
|
blockEnd = t.StartByte + t.ByteCount;
|
||||||
|
blockSlices = [new TagSlice(t.CallerIndex, 0, t.ByteCount)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges.Add(new BlockReadRange(
|
||||||
|
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="tag"/>'s on-wire width is variable / header-prefixed.
|
||||||
|
/// Such tags MUST NOT participate in block coalescing because the slice into a
|
||||||
|
/// coalesced byte buffer would land at a wrong offset for any neighbour.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsOpaqueSize(S7TagDefinition tag)
|
||||||
|
{
|
||||||
|
// Variable-width string types — STRING/WSTRING carry a 2-byte (or 4-byte)
|
||||||
|
// header and the actual length depends on the runtime value, not the
|
||||||
|
// declared StringLength. CHAR/WCHAR are fixed-width (1 / 2 bytes) but
|
||||||
|
// routed via the per-tag string codec path, so coalescing them would
|
||||||
|
// bypass the codec; treat them as opaque to keep the decode surface
|
||||||
|
// unchanged.
|
||||||
|
if (tag.DataType is S7DataType.String or S7DataType.WString
|
||||||
|
or S7DataType.Char or S7DataType.WChar)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Structured timestamps — DTL is 12 bytes, DT is 8 bytes BCD-encoded;
|
||||||
|
// both decode through S7DateTimeCodec and would silently mis-decode if
|
||||||
|
// the slice landed mid-block. S5TIME/TIME/TOD/DATE are fixed-width 2/4
|
||||||
|
// bytes but currently flow through the per-tag codec path; treat them
|
||||||
|
// all as opaque so the planner emits a single-tag range and the existing
|
||||||
|
// codec dispatch stays the source of truth for date/time decode.
|
||||||
|
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime
|
||||||
|
or S7DataType.S5Time or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Arrays opt out: per-tag width is N × elementBytes, the slice must be
|
||||||
|
// exact. Routing them as opaque keeps the array-aware byte-range read
|
||||||
|
// path in S7Driver.ReadOneAsync.
|
||||||
|
if (tag.ElementCount is int n && n > 1)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte width of a packable scalar tag for byte-range coalescing. Mirrors the
|
||||||
|
/// size suffix the address grammar carried (<see cref="S7Size.Bit"/>=1 byte
|
||||||
|
/// because reading a single bit still requires reading the containing byte;
|
||||||
|
/// bit-extraction happens in the slice step).
|
||||||
|
/// </summary>
|
||||||
|
internal static int ScalarByteCount(S7Size size) => size switch
|
||||||
|
{
|
||||||
|
S7Size.Bit => 1,
|
||||||
|
S7Size.Byte => 1,
|
||||||
|
S7Size.Word => 2,
|
||||||
|
S7Size.DWord => 4,
|
||||||
|
S7Size.LWord => 8,
|
||||||
|
_ => throw new InvalidOperationException($"Unknown S7Size {size}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
358
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs
Normal file
358
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte-level codecs for the six Siemens S7 date/time-shaped types: DTL, DATE_AND_TIME
|
||||||
|
/// (DT), S5TIME, TIME, TIME_OF_DAY (TOD), DATE. Pulled out of <see cref="S7Driver"/> so
|
||||||
|
/// the encoding rules are unit-testable against golden byte vectors without standing
|
||||||
|
/// up a Plc instance — same pattern as <see cref="S7StringCodec"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Wire formats (all big-endian, matching S7's native byte order):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <b>DTL</b> (12 bytes): year UInt16 BE / month / day / day-of-week / hour /
|
||||||
|
/// minute / second (1 byte each) / nanoseconds UInt32 BE. Year range 1970-2554.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>DATE_AND_TIME (DT)</b> (8 bytes BCD): year-since-1990 / month / day / hour /
|
||||||
|
/// minute / second (1 BCD byte each) + ms (3 BCD digits packed in 1.5 bytes) +
|
||||||
|
/// day-of-week (1 BCD digit, 1=Sunday..7=Saturday). Years 90-99 → 1990-1999;
|
||||||
|
/// years 00-89 → 2000-2089.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>S5TIME</b> (16 bits): bits 15..14 reserved (0), bits 13..12 timebase
|
||||||
|
/// (00=10ms, 01=100ms, 10=1s, 11=10s), bits 11..0 = 3-digit BCD count (0-999).
|
||||||
|
/// Total range 0..9990s.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>TIME</b> (Int32 ms BE): signed milliseconds. Negative durations allowed.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>TOD</b> (UInt32 ms BE): milliseconds since midnight, 0..86399999.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>DATE</b> (UInt16 BE): days since 1990-01-01. Range 0..65535 (1990-2168).
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Uninitialized PLC bytes</b>: an all-zero DTL or DT buffer (year 0 / month 0)
|
||||||
|
/// is rejected as <see cref="InvalidDataException"/> rather than decoded as
|
||||||
|
/// year-0001 garbage — operators see "BadOutOfRange" instead of a misleading
|
||||||
|
/// valid-but-wrong timestamp.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class S7DateTimeCodec
|
||||||
|
{
|
||||||
|
// ---- DTL (12 bytes) ----
|
||||||
|
|
||||||
|
/// <summary>Wire size of an S7 DTL value.</summary>
|
||||||
|
public const int DtlSize = 12;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode a 12-byte DTL buffer into a DateTime. Throws
|
||||||
|
/// <see cref="InvalidDataException"/> when the buffer is uninitialized
|
||||||
|
/// (all-zero year+month) or when components are out of range.
|
||||||
|
/// </summary>
|
||||||
|
public static DateTime DecodeDtl(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != DtlSize)
|
||||||
|
throw new InvalidDataException($"S7 DTL expected {DtlSize} bytes, got {bytes.Length}");
|
||||||
|
|
||||||
|
int year = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(0, 2));
|
||||||
|
int month = bytes[2];
|
||||||
|
int day = bytes[3];
|
||||||
|
// bytes[4] = day-of-week (1=Sunday..7=Saturday); ignored on read — the .NET
|
||||||
|
// DateTime carries its own and the PLC value can be inconsistent on uninit data.
|
||||||
|
int hour = bytes[5];
|
||||||
|
int minute = bytes[6];
|
||||||
|
int second = bytes[7];
|
||||||
|
uint nanos = BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(8, 4));
|
||||||
|
|
||||||
|
if (year == 0 && month == 0 && day == 0)
|
||||||
|
throw new InvalidDataException("S7 DTL is uninitialized (all-zero year/month/day)");
|
||||||
|
if (year is < 1970 or > 2554)
|
||||||
|
throw new InvalidDataException($"S7 DTL year {year} out of range 1970..2554");
|
||||||
|
if (month is < 1 or > 12)
|
||||||
|
throw new InvalidDataException($"S7 DTL month {month} out of range 1..12");
|
||||||
|
if (day is < 1 or > 31)
|
||||||
|
throw new InvalidDataException($"S7 DTL day {day} out of range 1..31");
|
||||||
|
if (hour > 23) throw new InvalidDataException($"S7 DTL hour {hour} out of range 0..23");
|
||||||
|
if (minute > 59) throw new InvalidDataException($"S7 DTL minute {minute} out of range 0..59");
|
||||||
|
if (second > 59) throw new InvalidDataException($"S7 DTL second {second} out of range 0..59");
|
||||||
|
if (nanos > 999_999_999)
|
||||||
|
throw new InvalidDataException($"S7 DTL nanoseconds {nanos} out of range 0..999999999");
|
||||||
|
|
||||||
|
// .NET DateTime resolution is 100 ns ticks (1 tick = 100 ns).
|
||||||
|
var dt = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified);
|
||||||
|
return dt.AddTicks(nanos / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a DateTime as a 12-byte DTL buffer.</summary>
|
||||||
|
public static byte[] EncodeDtl(DateTime value)
|
||||||
|
{
|
||||||
|
if (value.Year is < 1970 or > 2554)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DTL year must be 1970..2554");
|
||||||
|
|
||||||
|
var buf = new byte[DtlSize];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), (ushort)value.Year);
|
||||||
|
buf[2] = (byte)value.Month;
|
||||||
|
buf[3] = (byte)value.Day;
|
||||||
|
// S7 day-of-week: 1=Sunday..7=Saturday. .NET DayOfWeek: Sunday=0..Saturday=6.
|
||||||
|
buf[4] = (byte)((int)value.DayOfWeek + 1);
|
||||||
|
buf[5] = (byte)value.Hour;
|
||||||
|
buf[6] = (byte)value.Minute;
|
||||||
|
buf[7] = (byte)value.Second;
|
||||||
|
|
||||||
|
// Sub-second portion → nanoseconds. 1 tick = 100 ns, so ticks % 10_000_000 gives
|
||||||
|
// the fractional second in ticks; multiply by 100 for nanoseconds.
|
||||||
|
long fracTicks = value.Ticks % TimeSpan.TicksPerSecond;
|
||||||
|
uint nanos = (uint)(fracTicks * 100);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(8, 4), nanos);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DATE_AND_TIME / DT (8 bytes BCD) ----
|
||||||
|
|
||||||
|
/// <summary>Wire size of an S7 DATE_AND_TIME value.</summary>
|
||||||
|
public const int DtSize = 8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode an 8-byte DATE_AND_TIME (BCD) buffer into a DateTime. Year encoding:
|
||||||
|
/// 90..99 → 1990..1999, 00..89 → 2000..2089 (per Siemens spec).
|
||||||
|
/// </summary>
|
||||||
|
public static DateTime DecodeDt(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != DtSize)
|
||||||
|
throw new InvalidDataException($"S7 DATE_AND_TIME expected {DtSize} bytes, got {bytes.Length}");
|
||||||
|
|
||||||
|
int yy = FromBcd(bytes[0]);
|
||||||
|
int month = FromBcd(bytes[1]);
|
||||||
|
int day = FromBcd(bytes[2]);
|
||||||
|
int hour = FromBcd(bytes[3]);
|
||||||
|
int minute = FromBcd(bytes[4]);
|
||||||
|
int second = FromBcd(bytes[5]);
|
||||||
|
|
||||||
|
// bytes[6] and high nibble of bytes[7] = milliseconds (3 BCD digits).
|
||||||
|
// Low nibble of bytes[7] = day-of-week (1=Sunday..7=Saturday); ignored on read.
|
||||||
|
int msHigh = (bytes[6] >> 4) & 0xF;
|
||||||
|
int msMid = bytes[6] & 0xF;
|
||||||
|
int msLow = (bytes[7] >> 4) & 0xF;
|
||||||
|
if (msHigh > 9 || msMid > 9 || msLow > 9)
|
||||||
|
throw new InvalidDataException($"S7 DT ms BCD digits invalid: {msHigh:X}{msMid:X}{msLow:X}");
|
||||||
|
int ms = msHigh * 100 + msMid * 10 + msLow;
|
||||||
|
|
||||||
|
if (yy == 0 && month == 0 && day == 0)
|
||||||
|
throw new InvalidDataException("S7 DT is uninitialized (all-zero year/month/day)");
|
||||||
|
|
||||||
|
int year = yy >= 90 ? 1900 + yy : 2000 + yy;
|
||||||
|
if (month is < 1 or > 12) throw new InvalidDataException($"S7 DT month {month} out of range 1..12");
|
||||||
|
if (day is < 1 or > 31) throw new InvalidDataException($"S7 DT day {day} out of range 1..31");
|
||||||
|
if (hour > 23) throw new InvalidDataException($"S7 DT hour {hour} out of range 0..23");
|
||||||
|
if (minute > 59) throw new InvalidDataException($"S7 DT minute {minute} out of range 0..59");
|
||||||
|
if (second > 59) throw new InvalidDataException($"S7 DT second {second} out of range 0..59");
|
||||||
|
|
||||||
|
return new DateTime(year, month, day, hour, minute, second, ms, DateTimeKind.Unspecified);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a DateTime as an 8-byte DATE_AND_TIME (BCD) buffer.</summary>
|
||||||
|
public static byte[] EncodeDt(DateTime value)
|
||||||
|
{
|
||||||
|
if (value.Year is < 1990 or > 2089)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DATE_AND_TIME year must be 1990..2089");
|
||||||
|
|
||||||
|
int yy = value.Year >= 2000 ? value.Year - 2000 : value.Year - 1900;
|
||||||
|
int ms = value.Millisecond;
|
||||||
|
// S7 day-of-week: 1=Sunday..7=Saturday.
|
||||||
|
int dow = (int)value.DayOfWeek + 1;
|
||||||
|
|
||||||
|
var buf = new byte[DtSize];
|
||||||
|
buf[0] = ToBcd(yy);
|
||||||
|
buf[1] = ToBcd(value.Month);
|
||||||
|
buf[2] = ToBcd(value.Day);
|
||||||
|
buf[3] = ToBcd(value.Hour);
|
||||||
|
buf[4] = ToBcd(value.Minute);
|
||||||
|
buf[5] = ToBcd(value.Second);
|
||||||
|
// ms = 3 digits packed across bytes [6] (high+mid nibbles) and [7] high nibble.
|
||||||
|
buf[6] = (byte)(((ms / 100) << 4) | ((ms / 10) % 10));
|
||||||
|
buf[7] = (byte)((((ms % 10) & 0xF) << 4) | (dow & 0xF));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- S5TIME (16 bits BCD) ----
|
||||||
|
|
||||||
|
/// <summary>Wire size of an S7 S5TIME value.</summary>
|
||||||
|
public const int S5TimeSize = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode a 2-byte S5TIME buffer into a TimeSpan. Layout:
|
||||||
|
/// <c>0000 TTBB BBBB BBBB</c> where TT is the timebase (00=10ms, 01=100ms,
|
||||||
|
/// 10=1s, 11=10s) and BBB is the 3-digit BCD count (0..999).
|
||||||
|
/// </summary>
|
||||||
|
public static TimeSpan DecodeS5Time(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != S5TimeSize)
|
||||||
|
throw new InvalidDataException($"S7 S5TIME expected {S5TimeSize} bytes, got {bytes.Length}");
|
||||||
|
|
||||||
|
int hi = bytes[0];
|
||||||
|
int lo = bytes[1];
|
||||||
|
int tb = (hi >> 4) & 0x3;
|
||||||
|
int d2 = hi & 0xF;
|
||||||
|
int d1 = (lo >> 4) & 0xF;
|
||||||
|
int d0 = lo & 0xF;
|
||||||
|
if (d2 > 9 || d1 > 9 || d0 > 9)
|
||||||
|
throw new InvalidDataException($"S7 S5TIME BCD digits invalid: {d2:X}{d1:X}{d0:X}");
|
||||||
|
|
||||||
|
int count = d2 * 100 + d1 * 10 + d0;
|
||||||
|
long unitMs = tb switch
|
||||||
|
{
|
||||||
|
0 => 10L,
|
||||||
|
1 => 100L,
|
||||||
|
2 => 1000L,
|
||||||
|
3 => 10_000L,
|
||||||
|
_ => throw new InvalidDataException($"S7 S5TIME timebase {tb} invalid"),
|
||||||
|
};
|
||||||
|
return TimeSpan.FromMilliseconds(count * unitMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode a TimeSpan as a 2-byte S5TIME. Picks the smallest timebase that fits
|
||||||
|
/// <paramref name="value"/> in 999 units. Rejects negative or > 9990s durations
|
||||||
|
/// and any value not a multiple of the chosen timebase.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] EncodeS5Time(TimeSpan value)
|
||||||
|
{
|
||||||
|
if (value < TimeSpan.Zero)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME must be non-negative");
|
||||||
|
long totalMs = (long)value.TotalMilliseconds;
|
||||||
|
if (totalMs > 9_990_000)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME max is 9990 seconds");
|
||||||
|
|
||||||
|
int tb;
|
||||||
|
long unit;
|
||||||
|
if (totalMs <= 9_990 && totalMs % 10 == 0) { tb = 0; unit = 10; }
|
||||||
|
else if (totalMs <= 99_900 && totalMs % 100 == 0) { tb = 1; unit = 100; }
|
||||||
|
else if (totalMs <= 999_000 && totalMs % 1000 == 0) { tb = 2; unit = 1_000; }
|
||||||
|
else if (totalMs % 10_000 == 0) { tb = 3; unit = 10_000; }
|
||||||
|
else
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"S7 S5TIME duration {value} cannot be represented in any timebase without truncation",
|
||||||
|
nameof(value));
|
||||||
|
|
||||||
|
long count = totalMs / unit;
|
||||||
|
if (count > 999)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME count exceeds 999 in chosen timebase");
|
||||||
|
|
||||||
|
int d2 = (int)(count / 100);
|
||||||
|
int d1 = (int)((count / 10) % 10);
|
||||||
|
int d0 = (int)(count % 10);
|
||||||
|
|
||||||
|
var buf = new byte[2];
|
||||||
|
buf[0] = (byte)(((tb & 0x3) << 4) | (d2 & 0xF));
|
||||||
|
buf[1] = (byte)(((d1 & 0xF) << 4) | (d0 & 0xF));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TIME (Int32 ms BE) ----
|
||||||
|
|
||||||
|
/// <summary>Wire size of an S7 TIME value.</summary>
|
||||||
|
public const int TimeSize = 4;
|
||||||
|
|
||||||
|
/// <summary>Decode a 4-byte TIME buffer into a TimeSpan (signed milliseconds).</summary>
|
||||||
|
public static TimeSpan DecodeTime(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != TimeSize)
|
||||||
|
throw new InvalidDataException($"S7 TIME expected {TimeSize} bytes, got {bytes.Length}");
|
||||||
|
int ms = BinaryPrimitives.ReadInt32BigEndian(bytes);
|
||||||
|
return TimeSpan.FromMilliseconds(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a TimeSpan as a 4-byte TIME (signed Int32 milliseconds, big-endian).</summary>
|
||||||
|
public static byte[] EncodeTime(TimeSpan value)
|
||||||
|
{
|
||||||
|
long totalMs = (long)value.TotalMilliseconds;
|
||||||
|
if (totalMs is < int.MinValue or > int.MaxValue)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TIME exceeds Int32 ms range");
|
||||||
|
var buf = new byte[TimeSize];
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(buf, (int)totalMs);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TOD / TIME_OF_DAY (UInt32 ms BE, 0..86399999) ----
|
||||||
|
|
||||||
|
/// <summary>Wire size of an S7 TIME_OF_DAY value.</summary>
|
||||||
|
public const int TodSize = 4;
|
||||||
|
|
||||||
|
/// <summary>Decode a 4-byte TOD buffer into a TimeSpan (ms since midnight).</summary>
|
||||||
|
public static TimeSpan DecodeTod(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != TodSize)
|
||||||
|
throw new InvalidDataException($"S7 TOD expected {TodSize} bytes, got {bytes.Length}");
|
||||||
|
uint ms = BinaryPrimitives.ReadUInt32BigEndian(bytes);
|
||||||
|
if (ms > 86_399_999)
|
||||||
|
throw new InvalidDataException($"S7 TOD value {ms} exceeds 86399999 ms (one day)");
|
||||||
|
return TimeSpan.FromMilliseconds(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a TimeSpan as a 4-byte TOD (UInt32 ms since midnight, big-endian).</summary>
|
||||||
|
public static byte[] EncodeTod(TimeSpan value)
|
||||||
|
{
|
||||||
|
if (value < TimeSpan.Zero)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TOD must be non-negative");
|
||||||
|
long totalMs = (long)value.TotalMilliseconds;
|
||||||
|
if (totalMs > 86_399_999)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TOD max is 86399999 ms (23:59:59.999)");
|
||||||
|
var buf = new byte[TodSize];
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(buf, (uint)totalMs);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DATE (UInt16 BE, days since 1990-01-01) ----
|
||||||
|
|
||||||
|
/// <summary>Wire size of an S7 DATE value.</summary>
|
||||||
|
public const int DateSize = 2;
|
||||||
|
|
||||||
|
/// <summary>S7 DATE epoch — 1990-01-01 (UTC-unspecified per Siemens spec).</summary>
|
||||||
|
public static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
|
||||||
|
|
||||||
|
/// <summary>Decode a 2-byte DATE buffer into a DateTime.</summary>
|
||||||
|
public static DateTime DecodeDate(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != DateSize)
|
||||||
|
throw new InvalidDataException($"S7 DATE expected {DateSize} bytes, got {bytes.Length}");
|
||||||
|
ushort days = BinaryPrimitives.ReadUInt16BigEndian(bytes);
|
||||||
|
return DateEpoch.AddDays(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a DateTime as a 2-byte DATE (UInt16 days since 1990-01-01, big-endian).</summary>
|
||||||
|
public static byte[] EncodeDate(DateTime value)
|
||||||
|
{
|
||||||
|
var days = (value.Date - DateEpoch).TotalDays;
|
||||||
|
if (days is < 0 or > ushort.MaxValue)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DATE must be 1990-01-01..2168-06-06");
|
||||||
|
var buf = new byte[DateSize];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf, (ushort)days);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BCD helpers ----
|
||||||
|
|
||||||
|
/// <summary>Decode a single BCD byte (each nibble must be a decimal digit 0-9).</summary>
|
||||||
|
private static int FromBcd(byte b)
|
||||||
|
{
|
||||||
|
int hi = (b >> 4) & 0xF;
|
||||||
|
int lo = b & 0xF;
|
||||||
|
if (hi > 9 || lo > 9)
|
||||||
|
throw new InvalidDataException($"S7 BCD byte 0x{b:X2} has non-decimal nibble");
|
||||||
|
return hi * 10 + lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a 0-99 value as a single BCD byte.</summary>
|
||||||
|
private static byte ToBcd(int value)
|
||||||
|
{
|
||||||
|
if (value is < 0 or > 99)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), value, "BCD byte source must be 0..99");
|
||||||
|
return (byte)(((value / 10) << 4) | (value % 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Generic;
|
||||||
using S7.Net;
|
using S7.Net;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
@@ -53,6 +55,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
|
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
|
||||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hard upper bound on <see cref="S7TagDefinition.ElementCount"/>. The S7 PDU envelope
|
||||||
|
/// for negotiated default 240-byte and extended 960-byte payloads cannot fit a single
|
||||||
|
/// byte-range read larger than ~960 bytes, so a Float64 array of more than ~120
|
||||||
|
/// elements is already lossy. 8000 is an order-of-magnitude generous ceiling that still
|
||||||
|
/// rejects obvious config typos (e.g. ElementCount = 65535) at init time.
|
||||||
|
/// </summary>
|
||||||
|
internal const int MaxArrayElements = 8000;
|
||||||
|
|
||||||
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -76,6 +87,31 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
|
||||||
|
//
|
||||||
|
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
|
||||||
|
// RPC and integration tests can verify wire-level reduction without needing
|
||||||
|
// access to the underlying S7.Net PDU stream. Names match the
|
||||||
|
// "<DriverType>.<Counter>" convention adopted for the modbus and opcuaclient
|
||||||
|
// drivers — see decision #154.
|
||||||
|
private long _totalBlockReads; // Plc.ReadBytesAsync calls issued by the coalesced path
|
||||||
|
private long _totalMultiVarBatches; // Plc.ReadMultipleVarsAsync calls issued
|
||||||
|
private long _totalSingleReads; // per-tag ReadOneAsync fallbacks
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total <c>Plc.ReadBytesAsync</c> calls the coalesced byte-range path issued.
|
||||||
|
/// Test-only entry point for the integration assertion that 50 contiguous DBWs
|
||||||
|
/// coalesce into exactly 1 byte-range read.
|
||||||
|
/// </summary>
|
||||||
|
internal long TotalBlockReads => Interlocked.Read(ref _totalBlockReads);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total <c>Plc.ReadMultipleVarsAsync</c> batches issued. For a fully-coalesced
|
||||||
|
/// contiguous workload this stays at 0 — every tag flows through the byte-range
|
||||||
|
/// path instead.
|
||||||
|
/// </summary>
|
||||||
|
internal long TotalMultiVarBatches => Interlocked.Read(ref _totalMultiVarBatches);
|
||||||
|
|
||||||
public string DriverInstanceId => driverInstanceId;
|
public string DriverInstanceId => driverInstanceId;
|
||||||
public string DriverType => "S7";
|
public string DriverType => "S7";
|
||||||
|
|
||||||
@@ -84,6 +120,34 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Parse + validate every tag before opening the TCP socket so config bugs
|
||||||
|
// (bad address, oversized array, unsupported array element) surface as
|
||||||
|
// FormatException without waiting on a connect timeout. Per the v1 driver-config
|
||||||
|
// story this lets the Admin UI's "Save" round-trip stay sub-second on bad input.
|
||||||
|
_tagsByName.Clear();
|
||||||
|
_parsedByName.Clear();
|
||||||
|
foreach (var t in _options.Tags)
|
||||||
|
{
|
||||||
|
// Pass CpuType so V-memory addresses (S7-200 / S7-200 Smart / LOGO!) resolve
|
||||||
|
// against the device's family-specific DB mapping.
|
||||||
|
var parsed = S7AddressParser.Parse(t.Address, _options.CpuType); // throws FormatException
|
||||||
|
if (t.ElementCount is int n && n > 1)
|
||||||
|
{
|
||||||
|
// Array sanity: cap at S7 PDU realistic limit, reject variable-width
|
||||||
|
// element types and BOOL (packed-bit layout) up-front so a config typo
|
||||||
|
// fails at init instead of surfacing as BadInternalError on every read.
|
||||||
|
if (n > MaxArrayElements)
|
||||||
|
throw new FormatException(
|
||||||
|
$"S7 tag '{t.Name}' ElementCount {n} exceeds S7 PDU realistic limit ({MaxArrayElements})");
|
||||||
|
if (!IsArrayElementSupported(t.DataType))
|
||||||
|
throw new FormatException(
|
||||||
|
$"S7 tag '{t.Name}' DataType {t.DataType} not supported as an array element " +
|
||||||
|
$"(variable-width string types and BOOL packed-bit arrays are a follow-up)");
|
||||||
|
}
|
||||||
|
_tagsByName[t.Name] = t;
|
||||||
|
_parsedByName[t.Name] = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
|
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
|
||||||
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
||||||
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
||||||
@@ -97,18 +161,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
|
|
||||||
Plc = plc;
|
Plc = plc;
|
||||||
|
|
||||||
// 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
|
|
||||||
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
|
|
||||||
_tagsByName.Clear();
|
|
||||||
_parsedByName.Clear();
|
|
||||||
foreach (var t in _options.Tags)
|
|
||||||
{
|
|
||||||
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
|
|
||||||
_tagsByName[t.Name] = t;
|
|
||||||
_parsedByName[t.Name] = parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
|
||||||
// Kick off the probe loop once the connection is up. Initial HostState stays
|
// Kick off the probe loop once the connection is up. Initial HostState stays
|
||||||
@@ -179,6 +231,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Phase 1: classify each request into (a) unknown / not-found, (b) packable
|
||||||
|
// scalar (Bool/Byte/Int16/UInt16/Int32/UInt32/Float32/Float64) which can
|
||||||
|
// potentially coalesce into a byte-range read, or (c) per-tag fallback
|
||||||
|
// (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed
|
||||||
|
// the block-coalescing planner first (PR-S7-B2); whatever survives as a
|
||||||
|
// singleton range falls through to the multi-var packer (PR-S7-B1).
|
||||||
|
var packableIndexes = new List<int>(fullReferences.Count);
|
||||||
|
var fallbackIndexes = new List<int>();
|
||||||
for (var i = 0; i < fullReferences.Count; i++)
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
{
|
{
|
||||||
var name = fullReferences[i];
|
var name = fullReferences[i];
|
||||||
@@ -187,39 +247,415 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try
|
var addr = _parsedByName[name];
|
||||||
|
if (S7ReadPacker.IsPackable(tag, addr)) packableIndexes.Add(i);
|
||||||
|
else fallbackIndexes.Add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2a: block-read coalescing — group same-area / same-DB packable
|
||||||
|
// tags into contiguous byte ranges (gap-merge threshold from
|
||||||
|
// S7DriverOptions.BlockCoalescingGapBytes, default 16). Multi-tag ranges
|
||||||
|
// dispatch via Plc.ReadBytesAsync; singleton ranges fall through to the
|
||||||
|
// multi-var packer below.
|
||||||
|
var singletons = new List<int>();
|
||||||
|
if (packableIndexes.Count > 0)
|
||||||
|
{
|
||||||
|
var specs = new List<S7BlockCoalescingPlanner.TagSpec>(packableIndexes.Count);
|
||||||
|
foreach (var idx in packableIndexes)
|
||||||
{
|
{
|
||||||
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
|
var tag = _tagsByName[fullReferences[idx]];
|
||||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
var addr = _parsedByName[fullReferences[idx]];
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
specs.Add(new S7BlockCoalescingPlanner.TagSpec(
|
||||||
|
CallerIndex: idx,
|
||||||
|
Area: addr.Area,
|
||||||
|
DbNumber: addr.DbNumber,
|
||||||
|
StartByte: addr.ByteOffset,
|
||||||
|
ByteCount: S7BlockCoalescingPlanner.ScalarByteCount(addr.Size),
|
||||||
|
OpaqueSize: false));
|
||||||
}
|
}
|
||||||
catch (NotSupportedException)
|
var ranges = S7BlockCoalescingPlanner.Plan(specs, _options.BlockCoalescingGapBytes);
|
||||||
|
|
||||||
|
foreach (var range in ranges)
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
if (range.Tags.Count == 1)
|
||||||
|
{
|
||||||
|
// Singleton — let the multi-var packer batch it with other
|
||||||
|
// singletons in the same ReadAsync call. Cheaper than its
|
||||||
|
// own one-tag ReadBytesAsync round-trip.
|
||||||
|
singletons.Add(range.Tags[0].CallerIndex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ReadCoalescedRangeAsync(plc, range, fullReferences, results, now, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (global::S7.Net.PlcException pex)
|
}
|
||||||
|
|
||||||
|
// Phase 2b: bin-pack residual singletons through ReadMultipleVarsAsync.
|
||||||
|
// On a per-batch S7.Net failure the whole batch falls back to ReadOneAsync
|
||||||
|
// per tag — that way one bad item doesn't poison the rest of the batch
|
||||||
|
// and each tag still gets its own per-item StatusCode (BadDeviceFailure
|
||||||
|
// for PUT/GET refusal, BadCommunicationError for transport faults).
|
||||||
|
if (singletons.Count > 0)
|
||||||
|
{
|
||||||
|
var budget = S7ReadPacker.ItemBudget(S7ReadPacker.DefaultPduSize);
|
||||||
|
var batches = S7ReadPacker.BinPack(singletons, budget);
|
||||||
|
foreach (var batch in batches)
|
||||||
{
|
{
|
||||||
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on
|
await ReadBatchAsync(plc, batch, fullReferences, results, now, cancellationToken)
|
||||||
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a
|
.ConfigureAwait(false);
|
||||||
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
|
|
||||||
// transient fault — per driver-specs.md §5.
|
|
||||||
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 3: per-tag fallback for everything that can't pack into a single
|
||||||
|
// DataItem. Keeps the existing decode path as the source of truth for
|
||||||
|
// string/date/array/64-bit semantics.
|
||||||
|
foreach (var i in fallbackIndexes)
|
||||||
|
{
|
||||||
|
var tag = _tagsByName[fullReferences[i]];
|
||||||
|
results[i] = await ReadOneAsSnapshotAsync(plc, tag, now, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally { _gate.Release(); }
|
finally { _gate.Release(); }
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue one coalesced <c>Plc.ReadBytesAsync</c> covering
|
||||||
|
/// <paramref name="range"/> and slice the response per tag. On a transport
|
||||||
|
/// fault the whole range falls back to per-tag <see cref="ReadOneAsSnapshotAsync"/>
|
||||||
|
/// so a single bad slot doesn't poison N-1 good neighbours.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ReadCoalescedRangeAsync(
|
||||||
|
global::S7.Net.Plc plc,
|
||||||
|
S7BlockCoalescingPlanner.BlockReadRange range,
|
||||||
|
IReadOnlyList<string> fullReferences,
|
||||||
|
DataValueSnapshot[] results,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
byte[]? buf;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _totalBlockReads);
|
||||||
|
buf = await plc.ReadBytesAsync(MapArea(range.Area), range.DbNumber, range.StartByte, range.ByteCount, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Block read fault → fan out per-tag so a bad address in the block
|
||||||
|
// surfaces its own StatusCode and good neighbours can still retry
|
||||||
|
// through the per-tag fallback path.
|
||||||
|
foreach (var slice in range.Tags)
|
||||||
|
{
|
||||||
|
var tag = _tagsByName[fullReferences[slice.CallerIndex]];
|
||||||
|
results[slice.CallerIndex] = await ReadOneAsSnapshotAsync(plc, tag, now, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf is null || buf.Length != range.ByteCount)
|
||||||
|
{
|
||||||
|
// Short / truncated PDU — same fan-out semantics as a transport fault.
|
||||||
|
foreach (var slice in range.Tags)
|
||||||
|
{
|
||||||
|
results[slice.CallerIndex] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var slice in range.Tags)
|
||||||
|
{
|
||||||
|
var name = fullReferences[slice.CallerIndex];
|
||||||
|
var tag = _tagsByName[name];
|
||||||
|
var addr = _parsedByName[name];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = DecodeScalarFromBlock(buf, slice.OffsetInBlock, tag, addr);
|
||||||
|
results[slice.CallerIndex] = new DataValueSnapshot(value, 0u, now, now);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results[slice.CallerIndex] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null, BuildDiagnostics());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode one packable scalar from a coalesced byte buffer. Mirrors the
|
||||||
|
/// reinterpret table in <see cref="S7ReadPacker.DecodePackedValue"/> so the
|
||||||
|
/// coalesced and per-tag-batch paths produce identical .NET types for the
|
||||||
|
/// same wire bytes.
|
||||||
|
/// </summary>
|
||||||
|
private static object DecodeScalarFromBlock(byte[] buf, int offset, S7TagDefinition tag, S7ParsedAddress addr)
|
||||||
|
{
|
||||||
|
return (tag.DataType, addr.Size) switch
|
||||||
|
{
|
||||||
|
(S7DataType.Bool, S7Size.Bit) => ((buf[offset] >> addr.BitOffset) & 0x1) == 1,
|
||||||
|
(S7DataType.Byte, S7Size.Byte) => buf[offset],
|
||||||
|
(S7DataType.UInt16, S7Size.Word) => BinaryPrimitives.ReadUInt16BigEndian(buf.AsSpan(offset, 2)),
|
||||||
|
(S7DataType.Int16, S7Size.Word) => BinaryPrimitives.ReadInt16BigEndian(buf.AsSpan(offset, 2)),
|
||||||
|
(S7DataType.UInt32, S7Size.DWord) => BinaryPrimitives.ReadUInt32BigEndian(buf.AsSpan(offset, 4)),
|
||||||
|
(S7DataType.Int32, S7Size.DWord) => BinaryPrimitives.ReadInt32BigEndian(buf.AsSpan(offset, 4)),
|
||||||
|
(S7DataType.Float32, S7Size.DWord) =>
|
||||||
|
BitConverter.UInt32BitsToSingle(BinaryPrimitives.ReadUInt32BigEndian(buf.AsSpan(offset, 4))),
|
||||||
|
(S7DataType.Float64, S7Size.LWord) =>
|
||||||
|
BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(buf.AsSpan(offset, 8))),
|
||||||
|
_ => throw new System.IO.InvalidDataException(
|
||||||
|
$"S7 block-decode: tag '{tag.Name}' declared {tag.DataType} but address parsed Size={addr.Size}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the wire-level coalescing counters surfaced through
|
||||||
|
/// <see cref="DriverHealth.Diagnostics"/>. Names follow the
|
||||||
|
/// <c>"<DriverType>.<Counter>"</c> convention so the driver-diagnostics
|
||||||
|
/// RPC can render them in the Admin UI alongside Modbus / OPC UA Client
|
||||||
|
/// metrics without a per-driver special-case.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyDictionary<string, double> BuildDiagnostics() => new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["S7.TotalBlockReads"] = Interlocked.Read(ref _totalBlockReads),
|
||||||
|
["S7.TotalMultiVarBatches"] = Interlocked.Read(ref _totalMultiVarBatches),
|
||||||
|
["S7.TotalSingleReads"] = Interlocked.Read(ref _totalSingleReads),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one packed batch via <c>Plc.ReadMultipleVarsAsync</c>. On batch
|
||||||
|
/// success each <c>DataItem.Value</c> decodes into its tag's snapshot
|
||||||
|
/// slot; on batch failure each tag in the batch falls back to
|
||||||
|
/// <see cref="ReadOneAsSnapshotAsync"/> so the failure fans out per-tag instead
|
||||||
|
/// of poisoning the whole batch with one StatusCode.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ReadBatchAsync(
|
||||||
|
global::S7.Net.Plc plc,
|
||||||
|
IReadOnlyList<int> batchIndexes,
|
||||||
|
IReadOnlyList<string> fullReferences,
|
||||||
|
DataValueSnapshot[] results,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var items = new List<global::S7.Net.Types.DataItem>(batchIndexes.Count);
|
||||||
|
foreach (var idx in batchIndexes)
|
||||||
|
{
|
||||||
|
var name = fullReferences[idx];
|
||||||
|
items.Add(S7ReadPacker.BuildDataItem(_tagsByName[name], _parsedByName[name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _totalMultiVarBatches);
|
||||||
|
var responses = await plc.ReadMultipleVarsAsync(items, ct).ConfigureAwait(false);
|
||||||
|
// S7.Net mutates the input list in place and also returns it; iterate by
|
||||||
|
// index against the input list so we are agnostic to either contract.
|
||||||
|
for (var k = 0; k < batchIndexes.Count; k++)
|
||||||
|
{
|
||||||
|
var idx = batchIndexes[k];
|
||||||
|
var tag = _tagsByName[fullReferences[idx]];
|
||||||
|
var raw = (responses != null && k < responses.Count ? responses[k] : items[k]).Value;
|
||||||
|
if (raw is null)
|
||||||
|
{
|
||||||
|
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var decoded = S7ReadPacker.DecodePackedValue(tag, raw);
|
||||||
|
results[idx] = new DataValueSnapshot(decoded, 0u, now, now);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results[idx] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null, BuildDiagnostics());
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Batch-level fault: most likely a single bad address poisoned the
|
||||||
|
// multi-var response. Fall back to ReadOneAsync per tag in the batch so
|
||||||
|
// good tags still surface a value and the offender gets its own StatusCode.
|
||||||
|
foreach (var idx in batchIndexes)
|
||||||
|
{
|
||||||
|
var tag = _tagsByName[fullReferences[idx]];
|
||||||
|
results[idx] = await ReadOneAsSnapshotAsync(plc, tag, now, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-tag read wrapped as a <see cref="DataValueSnapshot"/> with the same
|
||||||
|
/// exception-to-StatusCode mapping the legacy per-tag loop applied. Shared
|
||||||
|
/// between the fallback path and the post-batch retry path so the failure
|
||||||
|
/// surface stays identical.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<DataValueSnapshot> ReadOneAsSnapshotAsync(
|
||||||
|
global::S7.Net.Plc plc, S7TagDefinition tag, DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _totalSingleReads);
|
||||||
|
var value = await ReadOneAsync(plc, tag, ct).ConfigureAwait(false);
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
return new DataValueSnapshot(value, 0u, now, now);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
return new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||||
|
}
|
||||||
|
catch (global::S7.Net.PlcException pex)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||||
|
return new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
return new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var addr = _parsedByName[tag.Name];
|
var addr = _parsedByName[tag.Name];
|
||||||
|
|
||||||
|
// 1-D array path: one byte-range read covering N×elementBytes, sliced client-side.
|
||||||
|
// Init-time validation guarantees only fixed-width element types reach here.
|
||||||
|
if (tag.ElementCount is int n && n > 1)
|
||||||
|
{
|
||||||
|
var elemBytes = ArrayElementBytes(tag.DataType);
|
||||||
|
var totalBytes = checked(n * elemBytes);
|
||||||
|
if (addr.Size == S7Size.Bit)
|
||||||
|
throw new System.IO.InvalidDataException(
|
||||||
|
$"S7 Read type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
|
||||||
|
$"parsed as bit-access; arrays require byte-addressing");
|
||||||
|
|
||||||
|
var arrBytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, totalBytes, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (arrBytes is null || arrBytes.Length != totalBytes)
|
||||||
|
throw new System.IO.InvalidDataException(
|
||||||
|
$"S7.Net returned {arrBytes?.Length ?? 0} bytes for array '{tag.Address}' (n={n}), expected {totalBytes}");
|
||||||
|
|
||||||
|
return SliceArray(arrBytes, tag.DataType, n, elemBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// String-shaped types (STRING/WSTRING/CHAR/WCHAR): S7.Net's string-keyed ReadAsync
|
||||||
|
// has no syntax for these, so the driver issues a raw byte read and decodes via
|
||||||
|
// S7StringCodec. Wire order is big-endian for the WSTRING/WCHAR UTF-16 payload.
|
||||||
|
if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar)
|
||||||
|
{
|
||||||
|
if (addr.Size == S7Size.Bit)
|
||||||
|
throw new System.IO.InvalidDataException(
|
||||||
|
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||||
|
$"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)");
|
||||||
|
|
||||||
|
var (area, dbNum, off) = (addr.Area, addr.DbNumber, addr.ByteOffset);
|
||||||
|
switch (tag.DataType)
|
||||||
|
{
|
||||||
|
case S7DataType.Char:
|
||||||
|
{
|
||||||
|
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 1, ct).ConfigureAwait(false);
|
||||||
|
if (b is null || b.Length != 1)
|
||||||
|
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for CHAR '{tag.Address}', expected 1");
|
||||||
|
return S7StringCodec.DecodeChar(b);
|
||||||
|
}
|
||||||
|
case S7DataType.WChar:
|
||||||
|
{
|
||||||
|
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 2, ct).ConfigureAwait(false);
|
||||||
|
if (b is null || b.Length != 2)
|
||||||
|
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WCHAR '{tag.Address}', expected 2");
|
||||||
|
return S7StringCodec.DecodeWChar(b);
|
||||||
|
}
|
||||||
|
case S7DataType.String:
|
||||||
|
{
|
||||||
|
var max = tag.StringLength;
|
||||||
|
var size = S7StringCodec.StringBufferSize(max);
|
||||||
|
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false);
|
||||||
|
if (b is null || b.Length != size)
|
||||||
|
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for STRING '{tag.Address}', expected {size}");
|
||||||
|
return S7StringCodec.DecodeString(b, max);
|
||||||
|
}
|
||||||
|
case S7DataType.WString:
|
||||||
|
{
|
||||||
|
var max = tag.StringLength;
|
||||||
|
var size = S7StringCodec.WStringBufferSize(max);
|
||||||
|
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false);
|
||||||
|
if (b is null || b.Length != size)
|
||||||
|
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WSTRING '{tag.Address}', expected {size}");
|
||||||
|
return S7StringCodec.DecodeWString(b, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date/time-shaped types (DTL/DT/S5TIME/TIME/TOD/DATE): S7.Net has no native size
|
||||||
|
// suffix for any of these, so the driver issues a raw byte read at the address's
|
||||||
|
// ByteOffset and decodes via S7DateTimeCodec. All require byte-addressing — bit-
|
||||||
|
// access against a date/time tag is a config bug worth surfacing as a hard error.
|
||||||
|
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time
|
||||||
|
or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||||
|
{
|
||||||
|
if (addr.Size == S7Size.Bit)
|
||||||
|
throw new System.IO.InvalidDataException(
|
||||||
|
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||||
|
$"parsed as bit-access; date/time types require byte-addressing");
|
||||||
|
|
||||||
|
int size = tag.DataType switch
|
||||||
|
{
|
||||||
|
S7DataType.Dtl => S7DateTimeCodec.DtlSize,
|
||||||
|
S7DataType.DateAndTime => S7DateTimeCodec.DtSize,
|
||||||
|
S7DataType.S5Time => S7DateTimeCodec.S5TimeSize,
|
||||||
|
S7DataType.Time => S7DateTimeCodec.TimeSize,
|
||||||
|
S7DataType.TimeOfDay => S7DateTimeCodec.TodSize,
|
||||||
|
S7DataType.Date => S7DateTimeCodec.DateSize,
|
||||||
|
_ => throw new InvalidOperationException(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var b = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, size, ct).ConfigureAwait(false);
|
||||||
|
if (b is null || b.Length != size)
|
||||||
|
throw new System.IO.InvalidDataException(
|
||||||
|
$"S7.Net returned {b?.Length ?? 0} bytes for {tag.DataType} '{tag.Address}', expected {size}");
|
||||||
|
|
||||||
|
return tag.DataType switch
|
||||||
|
{
|
||||||
|
S7DataType.Dtl => S7DateTimeCodec.DecodeDtl(b),
|
||||||
|
S7DataType.DateAndTime => S7DateTimeCodec.DecodeDt(b),
|
||||||
|
// S5TIME/TIME/TOD surface as Int32 ms — DriverDataType has no Duration type;
|
||||||
|
// OPC UA clients see a millisecond integer matching the IEC-1131 convention.
|
||||||
|
S7DataType.S5Time => (int)S7DateTimeCodec.DecodeS5Time(b).TotalMilliseconds,
|
||||||
|
S7DataType.Time => (int)S7DateTimeCodec.DecodeTime(b).TotalMilliseconds,
|
||||||
|
S7DataType.TimeOfDay => (int)S7DateTimeCodec.DecodeTod(b).TotalMilliseconds,
|
||||||
|
S7DataType.Date => S7DateTimeCodec.DecodeDate(b),
|
||||||
|
_ => throw new InvalidOperationException(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 64-bit types: S7.Net's string-based ReadAsync has no LWord size suffix, so issue an
|
||||||
|
// 8-byte ReadBytesAsync and convert big-endian in-process. Wire order on S7 is BE.
|
||||||
|
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
|
||||||
|
{
|
||||||
|
if (addr.Size != S7Size.LWord)
|
||||||
|
throw new System.IO.InvalidDataException(
|
||||||
|
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||||
|
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
|
||||||
|
|
||||||
|
var bytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, 8, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (bytes is null || bytes.Length != 8)
|
||||||
|
throw new System.IO.InvalidDataException($"S7.Net returned {bytes?.Length ?? 0} bytes for '{tag.Address}', expected 8");
|
||||||
|
return tag.DataType switch
|
||||||
|
{
|
||||||
|
S7DataType.Int64 => BinaryPrimitives.ReadInt64BigEndian(bytes),
|
||||||
|
S7DataType.UInt64 => BinaryPrimitives.ReadUInt64BigEndian(bytes),
|
||||||
|
S7DataType.Float64 => BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes)),
|
||||||
|
_ => throw new InvalidOperationException(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||||||
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
||||||
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||||||
@@ -238,10 +674,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
||||||
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||||
|
|
||||||
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
|
|
||||||
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
|
|
||||||
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
|
|
||||||
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
|
|
||||||
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
||||||
|
|
||||||
_ => throw new System.IO.InvalidDataException(
|
_ => throw new System.IO.InvalidDataException(
|
||||||
@@ -250,6 +682,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Map driver-internal <see cref="S7Area"/> to S7.Net's <see cref="global::S7.Net.DataType"/>.</summary>
|
||||||
|
private static global::S7.Net.DataType MapArea(S7Area area) => area switch
|
||||||
|
{
|
||||||
|
S7Area.DataBlock => global::S7.Net.DataType.DataBlock,
|
||||||
|
S7Area.Memory => global::S7.Net.DataType.Memory,
|
||||||
|
S7Area.Input => global::S7.Net.DataType.Input,
|
||||||
|
S7Area.Output => global::S7.Net.DataType.Output,
|
||||||
|
S7Area.Timer => global::S7.Net.DataType.Timer,
|
||||||
|
S7Area.Counter => global::S7.Net.DataType.Counter,
|
||||||
|
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
|
||||||
|
};
|
||||||
|
|
||||||
// ---- IWritable ----
|
// ---- IWritable ----
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||||
@@ -299,6 +743,102 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
|
|
||||||
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// 1-D array path: pack all N elements into a single buffer then push via WriteBytesAsync.
|
||||||
|
// Init-time validation guarantees only fixed-width element types reach here.
|
||||||
|
if (tag.ElementCount is int n && n > 1)
|
||||||
|
{
|
||||||
|
var addr = _parsedByName[tag.Name];
|
||||||
|
if (addr.Size == S7Size.Bit)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"S7 Write type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
|
||||||
|
$"parsed as bit-access; arrays require byte-addressing");
|
||||||
|
if (value is null)
|
||||||
|
throw new ArgumentNullException(nameof(value));
|
||||||
|
var elemBytes = ArrayElementBytes(tag.DataType);
|
||||||
|
var buf = PackArray(value, tag.DataType, n, elemBytes, tag.Name);
|
||||||
|
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// String-shaped types: encode via S7StringCodec then push via WriteBytesAsync. The
|
||||||
|
// codec rejects out-of-range lengths and non-ASCII for CHAR — we let the resulting
|
||||||
|
// ArgumentException bubble out so the WriteAsync caller maps it to BadInternalError.
|
||||||
|
if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar)
|
||||||
|
{
|
||||||
|
var addr = _parsedByName[tag.Name];
|
||||||
|
if (addr.Size == S7Size.Bit)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||||
|
$"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)");
|
||||||
|
|
||||||
|
byte[] payload = tag.DataType switch
|
||||||
|
{
|
||||||
|
S7DataType.Char => S7StringCodec.EncodeChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))),
|
||||||
|
S7DataType.WChar => S7StringCodec.EncodeWChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))),
|
||||||
|
S7DataType.String => S7StringCodec.EncodeString(Convert.ToString(value) ?? string.Empty, tag.StringLength),
|
||||||
|
S7DataType.WString => S7StringCodec.EncodeWString(Convert.ToString(value) ?? string.Empty, tag.StringLength),
|
||||||
|
_ => throw new InvalidOperationException(),
|
||||||
|
};
|
||||||
|
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date/time-shaped types: encode via S7DateTimeCodec and push as raw bytes. S5TIME /
|
||||||
|
// TIME / TOD accept an integer-ms input (matching the read surface); DTL / DT / DATE
|
||||||
|
// accept a DateTime. ArgumentException from the codec bubbles to BadInternalError.
|
||||||
|
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time
|
||||||
|
or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||||
|
{
|
||||||
|
var addr = _parsedByName[tag.Name];
|
||||||
|
if (addr.Size == S7Size.Bit)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||||
|
$"parsed as bit-access; date/time types require byte-addressing");
|
||||||
|
if (value is null)
|
||||||
|
throw new ArgumentNullException(nameof(value));
|
||||||
|
|
||||||
|
byte[] payload = tag.DataType switch
|
||||||
|
{
|
||||||
|
S7DataType.Dtl => S7DateTimeCodec.EncodeDtl(Convert.ToDateTime(value)),
|
||||||
|
S7DataType.DateAndTime => S7DateTimeCodec.EncodeDt(Convert.ToDateTime(value)),
|
||||||
|
S7DataType.S5Time => S7DateTimeCodec.EncodeS5Time(value is TimeSpan ts1 ? ts1 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))),
|
||||||
|
S7DataType.Time => S7DateTimeCodec.EncodeTime(value is TimeSpan ts2 ? ts2 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))),
|
||||||
|
S7DataType.TimeOfDay => S7DateTimeCodec.EncodeTod(value is TimeSpan ts3 ? ts3 : TimeSpan.FromMilliseconds(Convert.ToInt64(value))),
|
||||||
|
S7DataType.Date => S7DateTimeCodec.EncodeDate(Convert.ToDateTime(value)),
|
||||||
|
_ => throw new InvalidOperationException(),
|
||||||
|
};
|
||||||
|
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 64-bit types: S7.Net has no LWord-aware WriteAsync(string, object) overload, so emit
|
||||||
|
// the value as 8 big-endian bytes via WriteBytesAsync. Wire order on S7 is BE so a
|
||||||
|
// BinaryPrimitives.Write*BigEndian round-trips with the matching ReadOneAsync path.
|
||||||
|
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
|
||||||
|
{
|
||||||
|
var addr = _parsedByName[tag.Name];
|
||||||
|
if (addr.Size != S7Size.LWord)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||||
|
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
|
||||||
|
|
||||||
|
var buf = new byte[8];
|
||||||
|
switch (tag.DataType)
|
||||||
|
{
|
||||||
|
case S7DataType.Int64:
|
||||||
|
BinaryPrimitives.WriteInt64BigEndian(buf, Convert.ToInt64(value));
|
||||||
|
break;
|
||||||
|
case S7DataType.UInt64:
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(buf, Convert.ToUInt64(value));
|
||||||
|
break;
|
||||||
|
case S7DataType.Float64:
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(buf, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(value)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
||||||
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
||||||
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
|
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
|
||||||
@@ -313,10 +853,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
||||||
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
|
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
|
||||||
|
|
||||||
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
|
|
||||||
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
|
|
||||||
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
|
|
||||||
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
|
|
||||||
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
||||||
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
||||||
};
|
};
|
||||||
@@ -334,11 +870,12 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
var folder = builder.Folder("S7", "S7");
|
var folder = builder.Folder("S7", "S7");
|
||||||
foreach (var t in _options.Tags)
|
foreach (var t in _options.Tags)
|
||||||
{
|
{
|
||||||
|
var isArr = t.ElementCount is int ec && ec > 1;
|
||||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||||
FullName: t.Name,
|
FullName: t.Name,
|
||||||
DriverDataType: MapDataType(t.DataType),
|
DriverDataType: MapDataType(t.DataType),
|
||||||
IsArray: false,
|
IsArray: isArr,
|
||||||
ArrayDim: null,
|
ArrayDim: isArr ? (uint)t.ElementCount!.Value : null,
|
||||||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||||
IsHistorized: false,
|
IsHistorized: false,
|
||||||
IsAlarm: false,
|
IsAlarm: false,
|
||||||
@@ -347,16 +884,198 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="t"/> can be used as an array element. Variable-width string
|
||||||
|
/// types and BOOL (packed-bit layout) are rejected — both need bespoke addressing
|
||||||
|
/// beyond a flat <c>N × elementBytes</c> byte-range read and ship as a follow-up.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsArrayElementSupported(S7DataType t) => t is
|
||||||
|
S7DataType.Byte or
|
||||||
|
S7DataType.Int16 or S7DataType.UInt16 or
|
||||||
|
S7DataType.Int32 or S7DataType.UInt32 or
|
||||||
|
S7DataType.Int64 or S7DataType.UInt64 or
|
||||||
|
S7DataType.Float32 or S7DataType.Float64 or
|
||||||
|
S7DataType.Date or S7DataType.Time or S7DataType.TimeOfDay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// On-wire bytes per array element for the supported fixed-width element types. DATE
|
||||||
|
/// is a 16-bit days-since-1990 counter, TIME and TOD are 32-bit ms counters.
|
||||||
|
/// </summary>
|
||||||
|
internal static int ArrayElementBytes(S7DataType t) => t switch
|
||||||
|
{
|
||||||
|
S7DataType.Byte => 1,
|
||||||
|
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Date => 2,
|
||||||
|
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32
|
||||||
|
or S7DataType.Time or S7DataType.TimeOfDay => 4,
|
||||||
|
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
|
||||||
|
_ => throw new InvalidOperationException($"S7 array element bytes undefined for {t}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice a flat S7 byte buffer into a typed array using the existing big-endian scalar
|
||||||
|
/// codec for each element. Returns the typed array boxed as <c>object</c> so the
|
||||||
|
/// <see cref="DataValueSnapshot"/> surface can carry it without further conversion.
|
||||||
|
/// </summary>
|
||||||
|
internal static object SliceArray(byte[] bytes, S7DataType t, int n, int elemBytes)
|
||||||
|
{
|
||||||
|
switch (t)
|
||||||
|
{
|
||||||
|
case S7DataType.Byte:
|
||||||
|
{
|
||||||
|
var a = new byte[n];
|
||||||
|
Buffer.BlockCopy(bytes, 0, a, 0, n);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.Int16:
|
||||||
|
{
|
||||||
|
var a = new short[n];
|
||||||
|
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.UInt16:
|
||||||
|
{
|
||||||
|
var a = new ushort[n];
|
||||||
|
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.Int32:
|
||||||
|
{
|
||||||
|
var a = new int[n];
|
||||||
|
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.UInt32:
|
||||||
|
{
|
||||||
|
var a = new uint[n];
|
||||||
|
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.Int64:
|
||||||
|
{
|
||||||
|
var a = new long[n];
|
||||||
|
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.UInt64:
|
||||||
|
{
|
||||||
|
var a = new ulong[n];
|
||||||
|
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.Float32:
|
||||||
|
{
|
||||||
|
var a = new float[n];
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
a[i] = BitConverter.UInt32BitsToSingle(BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4)));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.Float64:
|
||||||
|
{
|
||||||
|
var a = new double[n];
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
a[i] = BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8)));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.Date:
|
||||||
|
{
|
||||||
|
var a = new DateTime[n];
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
a[i] = S7DateTimeCodec.DecodeDate(bytes.AsSpan(i * elemBytes, 2));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.Time:
|
||||||
|
{
|
||||||
|
// Surface as Int32 ms — matches the scalar Time read path (driver-specs §5).
|
||||||
|
var a = new int[n];
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
a[i] = (int)S7DateTimeCodec.DecodeTime(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case S7DataType.TimeOfDay:
|
||||||
|
{
|
||||||
|
var a = new int[n];
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
a[i] = (int)S7DateTimeCodec.DecodeTod(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"S7 array slice undefined for {t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pack a caller-supplied array (object) into the on-wire S7 byte layout for
|
||||||
|
/// <paramref name="elementType"/>. Accepts both the strongly-typed array
|
||||||
|
/// (<c>short[]</c>, <c>int[]</c>, ...) and a generic <c>System.Array</c> / <c>IEnumerable</c>
|
||||||
|
/// so OPC UA Variant-boxed values flow through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
internal static byte[] PackArray(object value, S7DataType elementType, int n, int elemBytes, string tagName)
|
||||||
|
{
|
||||||
|
if (value is not System.Collections.IEnumerable enumerable)
|
||||||
|
throw new ArgumentException($"S7 Write tag '{tagName}' is array but value is not enumerable (got {value.GetType().Name})", nameof(value));
|
||||||
|
|
||||||
|
var buf = new byte[n * elemBytes];
|
||||||
|
var i = 0;
|
||||||
|
foreach (var raw in enumerable)
|
||||||
|
{
|
||||||
|
if (i >= n)
|
||||||
|
throw new ArgumentException($"S7 Write tag '{tagName}': value has more than ElementCount={n} elements", nameof(value));
|
||||||
|
var span = buf.AsSpan(i * elemBytes, elemBytes);
|
||||||
|
switch (elementType)
|
||||||
|
{
|
||||||
|
case S7DataType.Byte: span[0] = Convert.ToByte(raw); break;
|
||||||
|
case S7DataType.Int16: BinaryPrimitives.WriteInt16BigEndian(span, Convert.ToInt16(raw)); break;
|
||||||
|
case S7DataType.UInt16: BinaryPrimitives.WriteUInt16BigEndian(span, Convert.ToUInt16(raw)); break;
|
||||||
|
case S7DataType.Int32: BinaryPrimitives.WriteInt32BigEndian(span, Convert.ToInt32(raw)); break;
|
||||||
|
case S7DataType.UInt32: BinaryPrimitives.WriteUInt32BigEndian(span, Convert.ToUInt32(raw)); break;
|
||||||
|
case S7DataType.Int64: BinaryPrimitives.WriteInt64BigEndian(span, Convert.ToInt64(raw)); break;
|
||||||
|
case S7DataType.UInt64: BinaryPrimitives.WriteUInt64BigEndian(span, Convert.ToUInt64(raw)); break;
|
||||||
|
case S7DataType.Float32: BinaryPrimitives.WriteUInt32BigEndian(span, BitConverter.SingleToUInt32Bits(Convert.ToSingle(raw))); break;
|
||||||
|
case S7DataType.Float64: BinaryPrimitives.WriteUInt64BigEndian(span, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(raw))); break;
|
||||||
|
case S7DataType.Date:
|
||||||
|
S7DateTimeCodec.EncodeDate(Convert.ToDateTime(raw)).CopyTo(span);
|
||||||
|
break;
|
||||||
|
case S7DataType.Time:
|
||||||
|
S7DateTimeCodec.EncodeTime(raw is TimeSpan ts ? ts : TimeSpan.FromMilliseconds(Convert.ToInt32(raw))).CopyTo(span);
|
||||||
|
break;
|
||||||
|
case S7DataType.TimeOfDay:
|
||||||
|
S7DateTimeCodec.EncodeTod(raw is TimeSpan tod ? tod : TimeSpan.FromMilliseconds(Convert.ToInt64(raw))).CopyTo(span);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"S7 array pack undefined for {elementType}");
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i != n)
|
||||||
|
throw new ArgumentException($"S7 Write tag '{tagName}': value had {i} elements, expected ElementCount={n}", nameof(value));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
private static DriverDataType MapDataType(S7DataType t) => t switch
|
private static DriverDataType MapDataType(S7DataType t) => t switch
|
||||||
{
|
{
|
||||||
S7DataType.Bool => DriverDataType.Boolean,
|
S7DataType.Bool => DriverDataType.Boolean,
|
||||||
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
|
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
|
||||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
|
S7DataType.Int16 => DriverDataType.Int16,
|
||||||
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
|
S7DataType.UInt16 => DriverDataType.UInt16,
|
||||||
|
S7DataType.Int32 => DriverDataType.Int32,
|
||||||
|
S7DataType.UInt32 => DriverDataType.UInt32,
|
||||||
|
S7DataType.Int64 => DriverDataType.Int64,
|
||||||
|
S7DataType.UInt64 => DriverDataType.UInt64,
|
||||||
S7DataType.Float32 => DriverDataType.Float32,
|
S7DataType.Float32 => DriverDataType.Float32,
|
||||||
S7DataType.Float64 => DriverDataType.Float64,
|
S7DataType.Float64 => DriverDataType.Float64,
|
||||||
S7DataType.String => DriverDataType.String,
|
S7DataType.String => DriverDataType.String,
|
||||||
|
S7DataType.WString => DriverDataType.String,
|
||||||
|
S7DataType.Char => DriverDataType.String,
|
||||||
|
S7DataType.WChar => DriverDataType.String,
|
||||||
S7DataType.DateTime => DriverDataType.DateTime,
|
S7DataType.DateTime => DriverDataType.DateTime,
|
||||||
|
S7DataType.Dtl => DriverDataType.DateTime,
|
||||||
|
S7DataType.DateAndTime => DriverDataType.DateTime,
|
||||||
|
S7DataType.Date => DriverDataType.DateTime,
|
||||||
|
// S5TIME/TIME/TOD have no Duration type in DriverDataType — surface as Int32 ms
|
||||||
|
// (matching the IEC-1131 representation).
|
||||||
|
S7DataType.S5Time => DriverDataType.Int32,
|
||||||
|
S7DataType.Time => DriverDataType.Int32,
|
||||||
|
S7DataType.TimeOfDay => DriverDataType.Int32,
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ public sealed class S7DriverOptions
|
|||||||
/// Running ↔ Stopped transitions.
|
/// Running ↔ Stopped transitions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public S7ProbeOptions Probe { get; init; } = new();
|
public S7ProbeOptions Probe { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Block-read coalescing gap-merge threshold (bytes). When two same-DB tags are
|
||||||
|
/// within this many bytes of each other the planner folds them into a single
|
||||||
|
/// <c>Plc.ReadBytesAsync</c> request and slices the response client-side. The
|
||||||
|
/// default <see cref="S7BlockCoalescingPlanner.DefaultGapMergeBytes"/> = 16 bytes
|
||||||
|
/// trades a minor over-fetch for one fewer PDU round-trip — over-fetching 16
|
||||||
|
/// bytes is cheaper than the ~30-byte S7 request frame.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Raise the threshold for chatty PLCs where PDU round-trips dominate latency
|
||||||
|
/// (S7-1200 with default 240-byte PDU); lower it when DBs are sparsely populated
|
||||||
|
/// so the over-fetch cost outweighs the saved PDU. Setting to 0 disables gap
|
||||||
|
/// merging entirely — only literally adjacent ranges (gap == 0) coalesce.
|
||||||
|
/// STRING / WSTRING / CHAR / WCHAR / structured-timestamp / array tags always
|
||||||
|
/// opt out of merging regardless of this knob.
|
||||||
|
/// </remarks>
|
||||||
|
public int BlockCoalescingGapBytes { get; init; } = S7BlockCoalescingPlanner.DefaultGapMergeBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class S7ProbeOptions
|
public sealed class S7ProbeOptions
|
||||||
@@ -95,13 +113,23 @@ public sealed class S7ProbeOptions
|
|||||||
/// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output)
|
/// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output)
|
||||||
/// coils that drive edge-triggered routines in the PLC program.
|
/// coils that drive edge-triggered routines in the PLC program.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="ElementCount">
|
||||||
|
/// Optional 1-D array length. <c>null</c> (or <c>1</c>) = scalar tag; <c>> 1</c> = array.
|
||||||
|
/// The driver issues one byte-range read covering <c>ElementCount × bytes-per-element</c>
|
||||||
|
/// and slices client-side via the existing scalar codec. Multi-dim arrays are deferred;
|
||||||
|
/// array-of-UDT lands with PR-S7-D2. Variable-width element types
|
||||||
|
/// (STRING/WSTRING/CHAR/WCHAR) and BOOL (packed bits) are rejected at init time —
|
||||||
|
/// they need bespoke layout handling and are tracked as a follow-up. Capped at 8000 to
|
||||||
|
/// keep the byte-range request inside a single S7 PDU envelope.
|
||||||
|
/// </param>
|
||||||
public sealed record S7TagDefinition(
|
public sealed record S7TagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string Address,
|
string Address,
|
||||||
S7DataType DataType,
|
S7DataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
int StringLength = 254,
|
int StringLength = 254,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
int? ElementCount = null);
|
||||||
|
|
||||||
public enum S7DataType
|
public enum S7DataType
|
||||||
{
|
{
|
||||||
@@ -116,5 +144,23 @@ public enum S7DataType
|
|||||||
Float32,
|
Float32,
|
||||||
Float64,
|
Float64,
|
||||||
String,
|
String,
|
||||||
|
/// <summary>S7 WSTRING: 4-byte header (max-len + actual-len, both UInt16 big-endian) followed by N×2 UTF-16BE bytes; total wire length = 4 + 2 × StringLength.</summary>
|
||||||
|
WString,
|
||||||
|
/// <summary>S7 CHAR: single ASCII byte.</summary>
|
||||||
|
Char,
|
||||||
|
/// <summary>S7 WCHAR: two bytes UTF-16 big-endian.</summary>
|
||||||
|
WChar,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
/// <summary>S7 DTL — 12-byte structured timestamp with year/mon/day/dow/h/m/s/ns; year range 1970-2554.</summary>
|
||||||
|
Dtl,
|
||||||
|
/// <summary>S7 DATE_AND_TIME (DT) — 8-byte BCD timestamp; year range 1990-2089.</summary>
|
||||||
|
DateAndTime,
|
||||||
|
/// <summary>S7 S5TIME — 16-bit BCD duration with 2-bit timebase; range 0..9990s. Surfaced as Int32 ms.</summary>
|
||||||
|
S5Time,
|
||||||
|
/// <summary>S7 TIME — signed Int32 ms big-endian. Surfaced as Int32 ms (negative durations allowed).</summary>
|
||||||
|
Time,
|
||||||
|
/// <summary>S7 TIME_OF_DAY (TOD) — UInt32 ms since midnight big-endian; range 0..86399999. Surfaced as Int32 ms.</summary>
|
||||||
|
TimeOfDay,
|
||||||
|
/// <summary>S7 DATE — UInt16 days since 1990-01-01 big-endian. Surfaced as DateTime.</summary>
|
||||||
|
Date,
|
||||||
}
|
}
|
||||||
|
|||||||
190
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadPacker.cs
Normal file
190
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadPacker.cs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
using S7.Net;
|
||||||
|
using S7.Net.Types;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Multi-variable PDU packer for S7 reads. Replaces the per-tag <c>Plc.ReadAsync</c>
|
||||||
|
/// loop with batched <c>Plc.ReadMultipleVarsAsync</c> calls so that N scalar tags fit
|
||||||
|
/// into ⌈N / 19⌉ PDU round-trips on a default 240-byte negotiated PDU instead of N.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Packing budget</b>: Siemens S7 read response budget is
|
||||||
|
/// <c>negotiatedPduSize - 18 - 12·N</c>, where the 18 bytes cover the response
|
||||||
|
/// header / parameter headers and 12 bytes per item carry the per-variable item
|
||||||
|
/// response (return code + data header + value). For a 240-byte PDU the absolute
|
||||||
|
/// ceiling is ~19 items per request before the response overflows; we apply that
|
||||||
|
/// as a conservative cap regardless of negotiated PDU since S7.Net does not
|
||||||
|
/// expose the negotiated size and 240 is the default for every CPU family.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Packable types only</b>: only fixed-width scalars where the wire layout
|
||||||
|
/// maps 1-to-1 onto an <see cref="VarType"/> the multi-var path natively decodes
|
||||||
|
/// (Bool, Byte, Int16/UInt16, Int32/UInt32, Float32, Float64). Strings, dates,
|
||||||
|
/// arrays, 64-bit ints, and UDT-shaped types stay on the per-tag
|
||||||
|
/// <c>ReadOneAsync</c> path because their decode requires
|
||||||
|
/// <c>Plc.ReadBytesAsync</c> + bespoke codec rather than a single
|
||||||
|
/// <see cref="DataItem"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class S7ReadPacker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default negotiated S7 PDU size (bytes). Every S7 CPU family negotiates 240 by
|
||||||
|
/// default; the extended-PDU 480 / 960 byte settings need an explicit COTP
|
||||||
|
/// parameter that S7.Net does not expose. Stay conservative.
|
||||||
|
/// </summary>
|
||||||
|
internal const int DefaultPduSize = 240;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-item response overhead in bytes — return code + data type code + length
|
||||||
|
/// field. The S7 spec calls this 4 bytes minimum but rounds up to 12 once the
|
||||||
|
/// payload alignment + worst-case 8-byte LReal value field are included.
|
||||||
|
/// </summary>
|
||||||
|
internal const int PerItemResponseBytes = 12;
|
||||||
|
|
||||||
|
/// <summary>Fixed response-header bytes regardless of item count.</summary>
|
||||||
|
internal const int ResponseHeaderBytes = 18;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum items per PDU at the default 240-byte negotiated size. Derived from
|
||||||
|
/// <c>floor((240 - 18) / 12) = 18.5</c> rounded down to 18 plus 1 for a
|
||||||
|
/// response-header slack the S7 spec rounds up; the practical Siemens limit
|
||||||
|
/// documented in TIA Portal is 19 items per <c>PUT</c>/<c>GET</c> call so we cap
|
||||||
|
/// at 19 and rely on the budget calculation only when a non-default PDU is in
|
||||||
|
/// play.
|
||||||
|
/// </summary>
|
||||||
|
internal const int MaxItemsPerPdu240 = 19;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compute how many items can fit in one <c>Plc.ReadMultipleVarsAsync</c>
|
||||||
|
/// call at the given negotiated PDU size, capped at the practical Siemens
|
||||||
|
/// ceiling of 19 items.
|
||||||
|
/// </summary>
|
||||||
|
internal static int ItemBudget(int negotiatedPduSize)
|
||||||
|
{
|
||||||
|
if (negotiatedPduSize <= ResponseHeaderBytes + PerItemResponseBytes)
|
||||||
|
return 1;
|
||||||
|
var byBudget = (negotiatedPduSize - ResponseHeaderBytes) / PerItemResponseBytes;
|
||||||
|
return Math.Min(byBudget, MaxItemsPerPdu240);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if the tag can be packed into a single <see cref="DataItem"/> for
|
||||||
|
/// <c>Plc.ReadMultipleVarsAsync</c>. Returns false for everything that
|
||||||
|
/// needs a custom byte-range decode (strings, dates, arrays, UDTs, 64-bit ints
|
||||||
|
/// where S7.Net's <see cref="VarType"/> has no entry).
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsPackable(S7TagDefinition tag, S7ParsedAddress addr)
|
||||||
|
{
|
||||||
|
if (tag.ElementCount is int n && n > 1) return false; // arrays go through ReadOneAsync
|
||||||
|
return tag.DataType switch
|
||||||
|
{
|
||||||
|
S7DataType.Bool when addr.Size == S7Size.Bit => true,
|
||||||
|
S7DataType.Byte when addr.Size == S7Size.Byte => true,
|
||||||
|
S7DataType.Int16 or S7DataType.UInt16 when addr.Size == S7Size.Word => true,
|
||||||
|
S7DataType.Int32 or S7DataType.UInt32 when addr.Size == S7Size.DWord => true,
|
||||||
|
S7DataType.Float32 when addr.Size == S7Size.DWord => true,
|
||||||
|
S7DataType.Float64 when addr.Size == S7Size.LWord => true,
|
||||||
|
// Int64 / UInt64 have no native VarType; S7.Net's multi-var path can't decode
|
||||||
|
// them without falling back to byte-range reads. Route to ReadOneAsync.
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a <see cref="DataItem"/> for a packable tag. <see cref="VarType"/> is
|
||||||
|
/// chosen so that S7.Net's multi-var path decodes the wire bytes into a .NET type
|
||||||
|
/// this driver can reinterpret without a second PLC round-trip
|
||||||
|
/// (Word→ushort, DWord→uint, etc.).
|
||||||
|
/// </summary>
|
||||||
|
internal static DataItem BuildDataItem(S7TagDefinition tag, S7ParsedAddress addr)
|
||||||
|
{
|
||||||
|
var dataType = MapArea(addr.Area);
|
||||||
|
var varType = tag.DataType switch
|
||||||
|
{
|
||||||
|
S7DataType.Bool => VarType.Bit,
|
||||||
|
S7DataType.Byte => VarType.Byte,
|
||||||
|
// Int16 read via Word (UInt16 wire) and reinterpreted to short in
|
||||||
|
// DecodePackedValue; gives identical wire behaviour to the single-tag path.
|
||||||
|
S7DataType.Int16 => VarType.Word,
|
||||||
|
S7DataType.UInt16 => VarType.Word,
|
||||||
|
S7DataType.Int32 => VarType.DWord,
|
||||||
|
S7DataType.UInt32 => VarType.DWord,
|
||||||
|
S7DataType.Float32 => VarType.Real,
|
||||||
|
S7DataType.Float64 => VarType.LReal,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"S7ReadPacker: tag '{tag.Name}' DataType {tag.DataType} is not packable; IsPackable check skipped"),
|
||||||
|
};
|
||||||
|
return new DataItem
|
||||||
|
{
|
||||||
|
DataType = dataType,
|
||||||
|
VarType = varType,
|
||||||
|
DB = addr.DbNumber,
|
||||||
|
StartByteAdr = addr.ByteOffset,
|
||||||
|
BitAdr = (byte)addr.BitOffset,
|
||||||
|
Count = 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert the boxed value S7.Net's multi-var path returns into the .NET type
|
||||||
|
/// declared by <paramref name="tag"/>. Mirrors the reinterpret table in
|
||||||
|
/// <c>S7Driver.ReadOneAsync</c> so packed reads and single-tag reads produce
|
||||||
|
/// identical snapshots for the same input.
|
||||||
|
/// </summary>
|
||||||
|
internal static object DecodePackedValue(S7TagDefinition tag, object raw)
|
||||||
|
{
|
||||||
|
return (tag.DataType, raw) switch
|
||||||
|
{
|
||||||
|
(S7DataType.Bool, bool b) => b,
|
||||||
|
(S7DataType.Byte, byte by) => by,
|
||||||
|
(S7DataType.UInt16, ushort u16) => u16,
|
||||||
|
(S7DataType.Int16, ushort u16) => unchecked((short)u16),
|
||||||
|
(S7DataType.UInt32, uint u32) => u32,
|
||||||
|
(S7DataType.Int32, uint u32) => unchecked((int)u32),
|
||||||
|
(S7DataType.Float32, float f) => f,
|
||||||
|
(S7DataType.Float64, double d) => d,
|
||||||
|
// S7.Net occasionally hands back the underlying integer type for Real/LReal
|
||||||
|
// when the bytes were marshalled raw — reinterpret defensively.
|
||||||
|
(S7DataType.Float32, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||||
|
(S7DataType.Float64, ulong u64) => BitConverter.UInt64BitsToDouble(u64),
|
||||||
|
_ => throw new System.IO.InvalidDataException(
|
||||||
|
$"S7ReadPacker: tag '{tag.Name}' declared {tag.DataType} but multi-var returned {raw.GetType().Name}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bin-pack <paramref name="indices"/> into batches of at most
|
||||||
|
/// <paramref name="itemBudget"/> items. Order within each batch matches the
|
||||||
|
/// input order so the per-item response from S7.Net maps back 1-to-1.
|
||||||
|
/// </summary>
|
||||||
|
internal static List<List<int>> BinPack(IReadOnlyList<int> indices, int itemBudget)
|
||||||
|
{
|
||||||
|
var batches = new List<List<int>>();
|
||||||
|
var current = new List<int>(itemBudget);
|
||||||
|
foreach (var idx in indices)
|
||||||
|
{
|
||||||
|
current.Add(idx);
|
||||||
|
if (current.Count >= itemBudget)
|
||||||
|
{
|
||||||
|
batches.Add(current);
|
||||||
|
current = new List<int>(itemBudget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.Count > 0) batches.Add(current);
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataType MapArea(S7Area area) => area switch
|
||||||
|
{
|
||||||
|
S7Area.DataBlock => DataType.DataBlock,
|
||||||
|
S7Area.Memory => DataType.Memory,
|
||||||
|
S7Area.Input => DataType.Input,
|
||||||
|
S7Area.Output => DataType.Output,
|
||||||
|
S7Area.Timer => DataType.Timer,
|
||||||
|
S7Area.Counter => DataType.Counter,
|
||||||
|
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
166
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs
Normal file
166
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte-level codecs for the four Siemens S7 string-shaped types: STRING, WSTRING,
|
||||||
|
/// CHAR, WCHAR. Pulled out of <see cref="S7Driver"/> so the encoding rules are
|
||||||
|
/// unit-testable against golden byte vectors without standing up a Plc instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Wire formats (all big-endian, matching S7's native byte order):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <b>STRING</b>: 2-byte header (<c>maxLen</c> byte, <c>actualLen</c> byte) +
|
||||||
|
/// N ASCII bytes. Total slot size on the PLC = <c>2 + maxLen</c>. Bytes past
|
||||||
|
/// <c>actualLen</c> are unspecified — the codec ignores them on read.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>WSTRING</b>: 4-byte header (<c>maxLen</c> UInt16 BE, <c>actualLen</c>
|
||||||
|
/// UInt16 BE) + N × 2 UTF-16BE bytes. Total slot size on the PLC =
|
||||||
|
/// <c>4 + 2 × maxLen</c>.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>CHAR</b>: 1 ASCII byte.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>WCHAR</b>: 2 UTF-16BE bytes.
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Header-bug clamp</b>: certain S7 firmware revisions write
|
||||||
|
/// <c>actualLen > maxLen</c> (observed with NULL-padded buffers from older
|
||||||
|
/// CP-modules). On <i>read</i> the codec clamps the effective length so it never
|
||||||
|
/// walks past the wire buffer. On <i>write</i> the codec rejects the input
|
||||||
|
/// outright — silently truncating produces silent data loss.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class S7StringCodec
|
||||||
|
{
|
||||||
|
/// <summary>Buffer size for a STRING tag with the given declared <paramref name="maxLen"/>.</summary>
|
||||||
|
public static int StringBufferSize(int maxLen) => 2 + maxLen;
|
||||||
|
|
||||||
|
/// <summary>Buffer size for a WSTRING tag with the given declared <paramref name="maxLen"/>.</summary>
|
||||||
|
public static int WStringBufferSize(int maxLen) => 4 + (2 * maxLen);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode an S7 STRING wire buffer into a .NET string. <paramref name="bytes"/>
|
||||||
|
/// must be exactly <c>2 + maxLen</c> long. <c>actualLen</c> is clamped to the
|
||||||
|
/// declared <paramref name="maxLen"/> if the firmware reported an out-of-spec
|
||||||
|
/// value (header-bug tolerance).
|
||||||
|
/// </summary>
|
||||||
|
public static string DecodeString(ReadOnlySpan<byte> bytes, int maxLen)
|
||||||
|
{
|
||||||
|
if (maxLen is < 1 or > 254)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 STRING max length must be 1-254");
|
||||||
|
var expected = StringBufferSize(maxLen);
|
||||||
|
if (bytes.Length != expected)
|
||||||
|
throw new InvalidDataException($"S7 STRING expected {expected} bytes, got {bytes.Length}");
|
||||||
|
|
||||||
|
// bytes[0] = declared max-length (advisory; we trust the caller-provided maxLen).
|
||||||
|
// bytes[1] = actual length. Clamp on read — firmware bug fallback.
|
||||||
|
int actual = bytes[1];
|
||||||
|
if (actual > maxLen) actual = maxLen;
|
||||||
|
if (actual == 0) return string.Empty;
|
||||||
|
return Encoding.ASCII.GetString(bytes.Slice(2, actual));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode a .NET string into an S7 STRING wire buffer of length
|
||||||
|
/// <c>2 + maxLen</c>. ASCII only — non-ASCII characters are encoded as <c>?</c>
|
||||||
|
/// by <see cref="Encoding.ASCII"/>. Throws if <paramref name="value"/> is longer
|
||||||
|
/// than <paramref name="maxLen"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] EncodeString(string value, int maxLen)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
if (maxLen is < 1 or > 254)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 STRING max length must be 1-254");
|
||||||
|
if (value.Length > maxLen)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"S7 STRING value of length {value.Length} exceeds declared max {maxLen}", nameof(value));
|
||||||
|
|
||||||
|
var buf = new byte[StringBufferSize(maxLen)];
|
||||||
|
buf[0] = (byte)maxLen;
|
||||||
|
buf[1] = (byte)value.Length;
|
||||||
|
Encoding.ASCII.GetBytes(value, 0, value.Length, buf, 2);
|
||||||
|
// Trailing bytes [2 + value.Length .. end] left as 0x00; S7 PLCs treat them as
|
||||||
|
// don't-care because actualLen bounds the readable region.
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode an S7 WSTRING wire buffer into a .NET string. <paramref name="bytes"/>
|
||||||
|
/// must be exactly <c>4 + 2 × maxLen</c> long. <c>actualLen</c> is clamped to
|
||||||
|
/// <paramref name="maxLen"/> on read.
|
||||||
|
/// </summary>
|
||||||
|
public static string DecodeWString(ReadOnlySpan<byte> bytes, int maxLen)
|
||||||
|
{
|
||||||
|
if (maxLen < 1)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 WSTRING max length must be >= 1");
|
||||||
|
var expected = WStringBufferSize(maxLen);
|
||||||
|
if (bytes.Length != expected)
|
||||||
|
throw new InvalidDataException($"S7 WSTRING expected {expected} bytes, got {bytes.Length}");
|
||||||
|
|
||||||
|
// Header is two UInt16 BE: declared max-len and actual-len (both in characters).
|
||||||
|
int actual = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(2, 2));
|
||||||
|
if (actual > maxLen) actual = maxLen;
|
||||||
|
if (actual == 0) return string.Empty;
|
||||||
|
return Encoding.BigEndianUnicode.GetString(bytes.Slice(4, actual * 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode a .NET string into an S7 WSTRING wire buffer of length
|
||||||
|
/// <c>4 + 2 × maxLen</c>. Throws if <paramref name="value"/> has more than
|
||||||
|
/// <paramref name="maxLen"/> UTF-16 code units.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] EncodeWString(string value, int maxLen)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
if (maxLen < 1)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 WSTRING max length must be >= 1");
|
||||||
|
if (value.Length > maxLen)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"S7 WSTRING value of length {value.Length} exceeds declared max {maxLen}", nameof(value));
|
||||||
|
|
||||||
|
var buf = new byte[WStringBufferSize(maxLen)];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), (ushort)maxLen);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), (ushort)value.Length);
|
||||||
|
if (value.Length > 0)
|
||||||
|
Encoding.BigEndianUnicode.GetBytes(value, 0, value.Length, buf, 4);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decode a single S7 CHAR (one ASCII byte).</summary>
|
||||||
|
public static char DecodeChar(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != 1)
|
||||||
|
throw new InvalidDataException($"S7 CHAR expected 1 byte, got {bytes.Length}");
|
||||||
|
return (char)bytes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a single ASCII char into an S7 CHAR (one byte). Non-ASCII rejected.</summary>
|
||||||
|
public static byte[] EncodeChar(char value)
|
||||||
|
{
|
||||||
|
if (value > 0x7F)
|
||||||
|
throw new ArgumentException($"S7 CHAR value '{value}' (U+{(int)value:X4}) is not ASCII", nameof(value));
|
||||||
|
return [(byte)value];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decode a single S7 WCHAR (two bytes UTF-16 big-endian).</summary>
|
||||||
|
public static char DecodeWChar(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != 2)
|
||||||
|
throw new InvalidDataException($"S7 WCHAR expected 2 bytes, got {bytes.Length}");
|
||||||
|
return (char)BinaryPrimitives.ReadUInt16BigEndian(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a single char into an S7 WCHAR (two bytes UTF-16 big-endian).</summary>
|
||||||
|
public static byte[] EncodeWChar(char value)
|
||||||
|
{
|
||||||
|
var buf = new byte[2];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf, value);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
using TwinCAT;
|
using TwinCAT;
|
||||||
using TwinCAT.Ads;
|
using TwinCAT.Ads;
|
||||||
|
using TwinCAT.Ads.SumCommand;
|
||||||
using TwinCAT.Ads.TypeSystem;
|
using TwinCAT.Ads.TypeSystem;
|
||||||
using TwinCAT.TypeSystem;
|
using TwinCAT.TypeSystem;
|
||||||
|
|
||||||
@@ -24,40 +26,182 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
private readonly AdsClient _client = new();
|
private readonly AdsClient _client = new();
|
||||||
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
||||||
|
|
||||||
|
// Per-parent-symbol RMW locks. Keys are bounded by the writable-bit-tag cardinality
|
||||||
|
// and are intentionally never removed — a leaking-but-bounded dictionary is simpler
|
||||||
|
// than tracking liveness, matching the AbCip / Modbus / FOCAS pattern from #181.
|
||||||
|
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitWriteLocks = new();
|
||||||
|
|
||||||
|
// PR 2.2 — handle cache. Per-tag read/write resolves a symbolic path to an ADS
|
||||||
|
// variable handle once, then issues every subsequent op against the handle. Smaller
|
||||||
|
// AMS payloads (4-byte handle vs N-byte path) + skips name resolution in the runtime.
|
||||||
|
// Lifetime is process-scoped: cleared on reconnect (EnsureConnected path), wiped on
|
||||||
|
// a Symbol-Version-Invalid retry, and disposed on Dispose. PR 2.3 will wire a
|
||||||
|
// proactive Symbol Version invalidation listener so stale handles after an online
|
||||||
|
// change get evicted before the next read fails — until then, operators can call
|
||||||
|
// FlushOptionalCachesAsync to wipe manually.
|
||||||
|
private readonly ConcurrentDictionary<string, uint> _handleCache = new();
|
||||||
|
private bool _wasConnected;
|
||||||
|
private readonly object _connectionStateGate = new();
|
||||||
|
|
||||||
|
// PR 2.3 — proactive Symbol-Version invalidation listener. The Beckhoff stack
|
||||||
|
// surfaces a high-level <see cref="AdsClient.AdsSymbolVersionChanged"/> event
|
||||||
|
// (built on top of the SymbolVersion ADS notification, IndexGroup 0xF008) that
|
||||||
|
// fires when the PLC's symbol table version counter increments — i.e. on full
|
||||||
|
// re-initialisations after a download / activate. Registered after the AMS
|
||||||
|
// session is up so the device server actually accepts the registration; we
|
||||||
|
// unregister + clear the handle on Dispose. _symbolVersionRegistered guards
|
||||||
|
// against double-registration if EnsureSymbolVersionListenerAsync is called
|
||||||
|
// re-entrantly through ConnectAsync on a reconnect.
|
||||||
|
//
|
||||||
|
// Spec deviation: the original PR 2.3 plan called for a raw
|
||||||
|
// AddDeviceNotificationAsync(AdsReservedIndexGroup.SymbolVersion, ...). Beckhoff
|
||||||
|
// wrap that in IAdsSymbolChangedProvider on AdsClient so we get a typed
|
||||||
|
// <see cref="AdsSymbolVersionChangedEventArgs"/> + Dispose-aware unregister
|
||||||
|
// for free — same wire effect, smaller surface area.
|
||||||
|
private bool _symbolVersionRegistered;
|
||||||
|
private long _symbolVersionBumps;
|
||||||
|
|
||||||
|
// Test-only counter — number of CreateVariableHandleAsync calls actually issued
|
||||||
|
// (i.e. cache misses). Integration tests assert this stays at the unique-symbol
|
||||||
|
// count after a second pass over the same set.
|
||||||
|
internal int HandleCreateCount;
|
||||||
|
|
||||||
|
/// <summary>Test-only — current size of the handle cache.</summary>
|
||||||
|
internal int HandleCacheCount => _handleCache.Count;
|
||||||
|
|
||||||
|
/// <summary>Test-only — total Symbol-Version bumps observed since process start.</summary>
|
||||||
|
internal long SymbolVersionBumps => Interlocked.Read(ref _symbolVersionBumps);
|
||||||
|
|
||||||
public AdsTwinCATClient()
|
public AdsTwinCATClient()
|
||||||
{
|
{
|
||||||
_client.AdsNotificationEx += OnAdsNotificationEx;
|
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||||||
|
_client.AdsSymbolVersionChanged += OnAdsSymbolVersionChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsConnected => _client.IsConnected;
|
public bool IsConnected => _client.IsConnected;
|
||||||
|
|
||||||
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
public async Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_client.IsConnected) return Task.CompletedTask;
|
if (_client.IsConnected)
|
||||||
|
{
|
||||||
|
// Idempotent. Still ensure the Symbol-Version listener is registered — first
|
||||||
|
// ConnectAsync may have lost the registration if the AMS session dropped.
|
||||||
|
await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
_client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
|
_client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
|
||||||
var netId = AmsNetId.Parse(address.NetId);
|
var netId = AmsNetId.Parse(address.NetId);
|
||||||
|
|
||||||
|
// PR 2.2 — a fresh AMS session invalidates every cached handle (handle space is
|
||||||
|
// per-session in the ADS device server). Clear before reconnect so any read that
|
||||||
|
// raced with a transient drop never reuses a stale handle from the prior session.
|
||||||
|
// Note: the handles for the prior session are gone with that session — no need to
|
||||||
|
// call DeleteVariableHandleAsync, which would just fail with a transport error.
|
||||||
|
var wasConnected = false;
|
||||||
|
lock (_connectionStateGate)
|
||||||
|
{
|
||||||
|
wasConnected = _wasConnected;
|
||||||
|
_wasConnected = false;
|
||||||
|
}
|
||||||
|
if (wasConnected || !_handleCache.IsEmpty)
|
||||||
|
_handleCache.Clear();
|
||||||
|
|
||||||
|
// PR 2.3 — a reconnect drops the device-side notification registration. Mark
|
||||||
|
// the listener as needing re-registration so EnsureSymbolVersionListenerAsync
|
||||||
|
// re-arms it against the new session.
|
||||||
|
_symbolVersionRegistered = false;
|
||||||
|
|
||||||
_client.Connect(netId, address.Port);
|
_client.Connect(netId, address.Port);
|
||||||
return Task.CompletedTask;
|
|
||||||
|
lock (_connectionStateGate) _wasConnected = _client.IsConnected;
|
||||||
|
|
||||||
|
// PR 2.3 — register the Symbol-Version listener now that the AMS session is up.
|
||||||
|
// Best-effort: a registration failure here doesn't fail the connect (the
|
||||||
|
// DeviceSymbolVersionInvalid evict-and-retry path from PR 2.2 stays as the safety
|
||||||
|
// net), it just means we won't get proactive cache invalidation until next reconnect.
|
||||||
|
await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 2.3 — register the Beckhoff <c>AdsSymbolVersionChanged</c> event listener
|
||||||
|
/// against the current AMS session. Idempotent: a second call while
|
||||||
|
/// <see cref="_symbolVersionRegistered"/> is <c>true</c> is a no-op so reconnect
|
||||||
|
/// paths can call this freely without double-arming. Failures swallowed because
|
||||||
|
/// the PR 2.2 reactive evict-and-retry path is still in place — proactive
|
||||||
|
/// invalidation is an optimisation, not a correctness requirement.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnsureSymbolVersionListenerAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_symbolVersionRegistered) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _client.RegisterSymbolVersionChangedAsync(OnAdsSymbolVersionChanged, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
_symbolVersionRegistered = true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort. The reactive evict-and-retry path (PR 2.2) catches the same
|
||||||
|
// staleness; this is just an optimisation that lets us preempt the wasted
|
||||||
|
// request that would otherwise come back DeviceSymbolVersionInvalid.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 2.3 — Beckhoff fires this when the PLC's symbol-version counter increments,
|
||||||
|
/// which happens on every full re-initialisation (download, activate-config, etc.).
|
||||||
|
/// Every cached handle is invalid against the new symbol table, so we wipe the
|
||||||
|
/// cache here. In-flight reads that already hold a handle will fall through to the
|
||||||
|
/// PR 2.2 <see cref="AdsErrorCode.DeviceSymbolVersionInvalid"/> evict-and-retry path,
|
||||||
|
/// which is exactly what we want — the proactive wipe just preempts the wasted
|
||||||
|
/// round-trip on the next read for any symbol that didn't already have an in-flight op.
|
||||||
|
/// </summary>
|
||||||
|
private void OnAdsSymbolVersionChanged(object? sender, AdsSymbolVersionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _symbolVersionBumps);
|
||||||
|
// Snapshot cache for best-effort wire-side cleanup, then clear so the next
|
||||||
|
// EnsureHandleAsync re-resolves. Wire deletes are fire-and-forget — the device
|
||||||
|
// server has already invalidated these handles, so the deletes typically just
|
||||||
|
// bounce back with an error code we don't care about.
|
||||||
|
var snapshot = _handleCache.ToArray();
|
||||||
|
_handleCache.Clear();
|
||||||
|
foreach (var kv in snapshot)
|
||||||
|
{
|
||||||
|
try { _ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None); }
|
||||||
|
catch { /* best-effort; the new symbol-table version makes these handles dead anyway */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(object? value, uint status)> ReadValueAsync(
|
public async Task<(object? value, uint status)> ReadValueAsync(
|
||||||
string symbolPath,
|
string symbolPath,
|
||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
|
int[]? arrayDimensions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var clrType = MapToClrType(type);
|
var clrType = MapToClrType(type);
|
||||||
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
var readType = IsWholeArray(arrayDimensions) ? clrType.MakeArrayType() : clrType;
|
||||||
|
|
||||||
|
// PR 2.2 — handle-based read. EnsureHandleAsync resolves through the cache;
|
||||||
|
// SymbolVersionInvalid evicts + retries once with a fresh handle.
|
||||||
|
var (rawValue, errorCode) = await ReadByHandleWithRetryAsync(symbolPath, readType, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
if (errorCode != AdsErrorCode.NoError)
|
||||||
|
return (null, TwinCATStatusMapper.MapAdsError((uint)errorCode));
|
||||||
|
|
||||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
var value = rawValue;
|
||||||
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
if (IsWholeArray(arrayDimensions))
|
||||||
|
{
|
||||||
|
value = PostProcessArray(type, value);
|
||||||
|
return (value, TwinCATStatusMapper.Good);
|
||||||
|
}
|
||||||
|
|
||||||
var value = result.Value;
|
|
||||||
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||||||
value = ExtractBit(value, bit);
|
value = ExtractBit(value, bit);
|
||||||
|
value = PostProcessIecTime(type, value);
|
||||||
|
|
||||||
return (value, TwinCATStatusMapper.Good);
|
return (value, TwinCATStatusMapper.Good);
|
||||||
}
|
}
|
||||||
@@ -67,25 +211,139 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve <paramref name="symbolPath"/> to a cached ADS variable handle (or create one
|
||||||
|
/// on first use) and dispatch a <see cref="AdsClient.ReadAnyAsync(uint, Type, CancellationToken)"/>.
|
||||||
|
/// On <see cref="AdsErrorCode.DeviceSymbolVersionInvalid"/> evicts the cached handle
|
||||||
|
/// + retries once with a freshly-created handle — covers the online-change race where
|
||||||
|
/// the symbol survives but its descriptor moves.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(object? value, AdsErrorCode errorCode)> ReadByHandleWithRetryAsync(
|
||||||
|
string symbolPath, Type readType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
var result = await _client.ReadAnyAsync(handle, readType, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (result.ErrorCode == AdsErrorCode.DeviceSymbolVersionInvalid)
|
||||||
|
{
|
||||||
|
EvictHandle(symbolPath);
|
||||||
|
handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
result = await _client.ReadAnyAsync(handle, readType, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return (result.Value, result.ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirror of <see cref="ReadByHandleWithRetryAsync"/> for writes. Returns the final
|
||||||
|
/// <see cref="AdsErrorCode"/>; the caller maps that to an OPC UA status.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<AdsErrorCode> WriteByHandleWithRetryAsync(
|
||||||
|
string symbolPath, object value, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
var result = await _client.WriteAnyAsync(handle, value, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (result.ErrorCode == AdsErrorCode.DeviceSymbolVersionInvalid)
|
||||||
|
{
|
||||||
|
EvictHandle(symbolPath);
|
||||||
|
handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
result = await _client.WriteAnyAsync(handle, value, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return result.ErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lookup-or-create the cached ADS handle for <paramref name="symbolPath"/>. The
|
||||||
|
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> guarantees publication safety,
|
||||||
|
/// but two concurrent callers on a cold key may both call
|
||||||
|
/// <see cref="AdsClient.CreateVariableHandleAsync(string, CancellationToken)"/>.
|
||||||
|
/// The loser's handle leaks for the lifetime of the process — acceptable cost
|
||||||
|
/// given how narrow the race window is, and matched by the libplctag / S7 driver
|
||||||
|
/// handle-cache patterns.
|
||||||
|
/// </summary>
|
||||||
|
internal async ValueTask<uint> EnsureHandleAsync(string symbolPath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_handleCache.TryGetValue(symbolPath, out var existing))
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
Interlocked.Increment(ref HandleCreateCount);
|
||||||
|
var result = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||||
|
throw new AdsErrorException(
|
||||||
|
$"CreateVariableHandleAsync failed for '{symbolPath}'", result.ErrorCode);
|
||||||
|
|
||||||
|
// GetOrAdd on a hit returns the winning handle; a loser-side DeleteVariableHandle here
|
||||||
|
// would race against an in-flight read using that handle elsewhere in this method, so
|
||||||
|
// we accept the small leak (one-time, per cold key) instead.
|
||||||
|
return _handleCache.GetOrAdd(symbolPath, result.Handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evict a single cached handle. Best-effort delete on the wire — the runtime may
|
||||||
|
/// already have invalidated the handle (Symbol-Version-Invalid path), so we swallow
|
||||||
|
/// transport / ADS errors here.
|
||||||
|
/// </summary>
|
||||||
|
private void EvictHandle(string symbolPath)
|
||||||
|
{
|
||||||
|
if (!_handleCache.TryRemove(symbolPath, out var handle)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Fire-and-forget delete — the cache key is gone, the wire-side cleanup is
|
||||||
|
// strictly courtesy. If the device server is in a state where the handle is
|
||||||
|
// already dead, the delete will fail and we don't care.
|
||||||
|
_ = _client.DeleteVariableHandleAsync(handle, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWholeArray(int[]? arrayDimensions) =>
|
||||||
|
arrayDimensions is { Length: > 0 } && arrayDimensions.All(d => d > 0);
|
||||||
|
|
||||||
|
/// <summary>Apply per-element IEC TIME/DATE post-processing to a flat array result.</summary>
|
||||||
|
private static object? PostProcessArray(TwinCATDataType type, object? value)
|
||||||
|
{
|
||||||
|
if (value is not Array arr) return value;
|
||||||
|
var elementProjector = type switch
|
||||||
|
{
|
||||||
|
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
|
||||||
|
or TwinCATDataType.Date or TwinCATDataType.DateTime
|
||||||
|
=> (Func<object?, object?>)(v => PostProcessIecTime(type, v)),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (elementProjector is null) return arr;
|
||||||
|
// IEC time post-processing changes the CLR element type (uint -> TimeSpan / DateTime).
|
||||||
|
// Project into an object[] so the array element type matches the projected values.
|
||||||
|
var projected = new object?[arr.Length];
|
||||||
|
for (var i = 0; i < arr.Length; i++)
|
||||||
|
projected[i] = elementProjector(arr.GetValue(i));
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<uint> WriteValueAsync(
|
public async Task<uint> WriteValueAsync(
|
||||||
string symbolPath,
|
string symbolPath,
|
||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
|
int[]? arrayDimensions,
|
||||||
object? value,
|
object? value,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (bitIndex is int && type == TwinCATDataType.Bool)
|
if (IsWholeArray(arrayDimensions))
|
||||||
throw new NotSupportedException(
|
return TwinCATStatusMapper.BadNotSupported; // PR-1.4 ships read-only whole-array
|
||||||
"BOOL-within-word writes require read-modify-write; tracked in task #181.");
|
|
||||||
|
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||||
|
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var converted = ConvertForWrite(type, value);
|
var converted = ConvertForWrite(type, value);
|
||||||
var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken)
|
// PR 2.2 — handle-based write with SymbolVersionInvalid evict-and-retry.
|
||||||
|
var errorCode = await WriteByHandleWithRetryAsync(symbolPath, converted, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
return result.ErrorCode == AdsErrorCode.NoError
|
return errorCode == AdsErrorCode.NoError
|
||||||
? TwinCATStatusMapper.Good
|
? TwinCATStatusMapper.Good
|
||||||
: TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode);
|
: TwinCATStatusMapper.MapAdsError((uint)errorCode);
|
||||||
}
|
}
|
||||||
catch (AdsErrorException ex)
|
catch (AdsErrorException ex)
|
||||||
{
|
{
|
||||||
@@ -93,6 +351,71 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-modify-write a single bit within an integer parent word. <paramref name="symbolPath"/>
|
||||||
|
/// is the bit-selector path (e.g. <c>Flags.3</c>); the parent is the same path with the
|
||||||
|
/// <c>.N</c> suffix stripped and is read/written as a UDINT — TwinCAT handles narrower
|
||||||
|
/// parents (BYTE/WORD) implicitly through the UDINT projection.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Concurrent bit writers against the same parent are serialised through a per-parent
|
||||||
|
/// <see cref="SemaphoreSlim"/> to prevent torn reads/writes. Mirrors the AbCip / Modbus /
|
||||||
|
/// FOCAS bit-RMW pattern.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<uint> WriteBitInWordAsync(
|
||||||
|
string symbolPath, int bit, object? value, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parentPath = TryGetParentSymbolPath(symbolPath);
|
||||||
|
if (parentPath is null) return TwinCATStatusMapper.BadNotSupported;
|
||||||
|
|
||||||
|
var setBit = Convert.ToBoolean(value);
|
||||||
|
var rmwLock = _bitWriteLocks.GetOrAdd(parentPath, _ => new SemaphoreSlim(1, 1));
|
||||||
|
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// PR 2.2 — RMW round-trip flows through the same handle cache so that the
|
||||||
|
// parent word's resolved handle is reused on subsequent bit writes too.
|
||||||
|
var (rawCurrent, readErr) = await ReadByHandleWithRetryAsync(parentPath, typeof(uint), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (readErr != AdsErrorCode.NoError)
|
||||||
|
return TwinCATStatusMapper.MapAdsError((uint)readErr);
|
||||||
|
|
||||||
|
var current = Convert.ToUInt32(rawCurrent ?? 0u);
|
||||||
|
var updated = ApplyBit(current, bit, setBit);
|
||||||
|
|
||||||
|
var writeErr = await WriteByHandleWithRetryAsync(parentPath, updated, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return writeErr == AdsErrorCode.NoError
|
||||||
|
? TwinCATStatusMapper.Good
|
||||||
|
: TwinCATStatusMapper.MapAdsError((uint)writeErr);
|
||||||
|
}
|
||||||
|
catch (AdsErrorException ex)
|
||||||
|
{
|
||||||
|
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
rmwLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strip the trailing <c>.N</c> bit selector from a TwinCAT symbol path. Returns
|
||||||
|
/// <c>null</c> when the path has no parent (single segment / leading dot).
|
||||||
|
/// </summary>
|
||||||
|
internal static string? TryGetParentSymbolPath(string symbolPath)
|
||||||
|
{
|
||||||
|
var dot = symbolPath.LastIndexOf('.');
|
||||||
|
return dot <= 0 ? null : symbolPath.Substring(0, dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Set or clear bit <paramref name="bit"/> in <paramref name="word"/>.</summary>
|
||||||
|
internal static uint ApplyBit(uint word, int bit, bool setBit)
|
||||||
|
{
|
||||||
|
var mask = 1u << bit;
|
||||||
|
return setBit ? (word | mask) : (word & ~mask);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -143,6 +466,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
var value = args.Value;
|
var value = args.Value;
|
||||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||||
value = ExtractBit(value, bit);
|
value = ExtractBit(value, bit);
|
||||||
|
value = PostProcessIecTime(reg.Type, value);
|
||||||
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
|
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,12 +490,50 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
foreach (ISymbol symbol in loader.Symbols)
|
foreach (ISymbol symbol in loader.Symbols)
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested) yield break;
|
if (cancellationToken.IsCancellationRequested) yield break;
|
||||||
var mapped = MapSymbolTypeName(symbol.DataType?.Name);
|
var mapped = ResolveSymbolDataType(symbol.DataType);
|
||||||
var readOnly = !IsSymbolWritable(symbol);
|
var readOnly = !IsSymbolWritable(symbol);
|
||||||
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
|
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve an IEC atomic <see cref="TwinCATDataType"/> for a TwinCAT symbol's data type.
|
||||||
|
/// ENUMs surface as their underlying integer (the enum's <c>BaseType</c>); ALIAS chains
|
||||||
|
/// are walked recursively via <see cref="IAliasType.BaseType"/> until an atomic primitive
|
||||||
|
/// is reached. POINTER / REFERENCE / INTERFACE / UNION / STRUCT / FB / array types remain
|
||||||
|
/// out of scope and surface as <c>null</c> so the caller skips them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Recursion is bounded at <see cref="MaxAliasDepth"/> as a defence against pathological
|
||||||
|
/// cycles in the type graph — TwinCAT shouldn't emit those, but this is cheap insurance.
|
||||||
|
/// </remarks>
|
||||||
|
internal const int MaxAliasDepth = 16;
|
||||||
|
|
||||||
|
internal static TwinCATDataType? ResolveSymbolDataType(IDataType? dataType)
|
||||||
|
{
|
||||||
|
var current = dataType;
|
||||||
|
for (var depth = 0; current is not null && depth < MaxAliasDepth; depth++)
|
||||||
|
{
|
||||||
|
switch (current.Category)
|
||||||
|
{
|
||||||
|
case DataTypeCategory.Primitive:
|
||||||
|
case DataTypeCategory.String:
|
||||||
|
return MapSymbolTypeName(current.Name);
|
||||||
|
case DataTypeCategory.Enum:
|
||||||
|
case DataTypeCategory.Alias:
|
||||||
|
// IEnumType : IAliasType, so BaseType walk handles both. For an enum the
|
||||||
|
// base type is the underlying integer; for alias chains it's the next link.
|
||||||
|
if (current is IAliasType alias) { current = alias.BaseType; continue; }
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
// POINTER / REFERENCE / INTERFACE / UNION / STRUCT / ARRAY / FB / Program —
|
||||||
|
// explicitly out of scope at this PR.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
|
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
|
||||||
{
|
{
|
||||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||||
@@ -203,13 +565,180 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||||||
|
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (reads.Count == 0) return Array.Empty<(object?, uint)>();
|
||||||
|
|
||||||
|
// PR 2.2 deviation: bulk path stays on symbolic Sum-command (SumInstancePathAnyTypeRead /
|
||||||
|
// SumWriteBySymbolPath). Beckhoff also exposes SumHandleRead / SumWriteByHandle, but
|
||||||
|
// wiring the cached handles into them changes the request layout substantially +
|
||||||
|
// would either need to reuse handles created on the per-tag path (tying lifetimes)
|
||||||
|
// or maintain a parallel handle batch — neither pulls weight against PR 2.1's already
|
||||||
|
// 10-20× win. Tracked as a follow-up for the Phase-2 perf sweep.
|
||||||
|
// Build the (path, AnyTypeSpecifier) request envelope. SumInstancePathAnyTypeRead
|
||||||
|
// batches all paths into a single ADS Sum-read round-trip (IndexGroup 0xF080 = read
|
||||||
|
// multiple items by symbol name with ANY-type marshalling).
|
||||||
|
var typeSpecs = new List<(string instancePath, AnyTypeSpecifier spec)>(reads.Count);
|
||||||
|
foreach (var r in reads)
|
||||||
|
typeSpecs.Add((r.SymbolPath, BuildAnyTypeSpecifier(r.Type, r.StringLength)));
|
||||||
|
|
||||||
|
var sumCmd = new SumInstancePathAnyTypeRead(_client, typeSpecs);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sumResult = await sumCmd.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// ResultSumValues2.ValueResults is a per-item array with Source / Value /
|
||||||
|
// ErrorCode. Even when the overall ADS request succeeds, individual sub-items can
|
||||||
|
// carry their own ADS error (e.g. SymbolNotFound).
|
||||||
|
var output = new (object? value, uint status)[reads.Count];
|
||||||
|
var valueResults = sumResult.ValueResults;
|
||||||
|
for (var i = 0; i < reads.Count; i++)
|
||||||
|
{
|
||||||
|
var vr = valueResults[i];
|
||||||
|
if (vr.ErrorCode != 0)
|
||||||
|
{
|
||||||
|
output[i] = (null, TwinCATStatusMapper.MapAdsError((uint)vr.ErrorCode));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var raw = vr.Value;
|
||||||
|
output[i] = (PostProcessIecTime(reads[i].Type, raw), TwinCATStatusMapper.Good);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
catch (AdsErrorException ex)
|
||||||
|
{
|
||||||
|
// Whole-batch failure (no symbol-server ack, router unreachable, etc.). Map the
|
||||||
|
// overall ADS status onto every entry so callers see uniform status — partial-
|
||||||
|
// success marshalling lives in the success branch above.
|
||||||
|
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||||
|
var failed = new (object? value, uint status)[reads.Count];
|
||||||
|
for (var i = 0; i < reads.Count; i++) failed[i] = (null, status);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<uint>> WriteValuesAsync(
|
||||||
|
IReadOnlyList<TwinCATBulkWriteItem> writes, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (writes.Count == 0) return Array.Empty<uint>();
|
||||||
|
|
||||||
|
// SumWriteBySymbolPath internally requests symbol handles + issues a single sum-write
|
||||||
|
// (IndexGroup 0xF081) carrying all values. One AMS round-trip for N writes.
|
||||||
|
var paths = new List<string>(writes.Count);
|
||||||
|
var values = new object[writes.Count];
|
||||||
|
for (var i = 0; i < writes.Count; i++)
|
||||||
|
{
|
||||||
|
paths.Add(writes[i].SymbolPath);
|
||||||
|
values[i] = ConvertForWrite(writes[i].Type, writes[i].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sumCmd = new SumWriteBySymbolPath(_client, paths);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await sumCmd.WriteAsync(values, cancellationToken).ConfigureAwait(false);
|
||||||
|
var output = new uint[writes.Count];
|
||||||
|
var subErrors = result.SubErrors;
|
||||||
|
for (var i = 0; i < writes.Count; i++)
|
||||||
|
{
|
||||||
|
// SubErrors can be null when the overall request failed before sub-dispatch —
|
||||||
|
// surface the OverallError on every slot in that case.
|
||||||
|
var code = subErrors is { Length: > 0 } && i < subErrors.Length
|
||||||
|
? (uint)subErrors[i]
|
||||||
|
: (uint)result.ErrorCode;
|
||||||
|
output[i] = TwinCATStatusMapper.MapAdsError(code);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
catch (AdsErrorException ex)
|
||||||
|
{
|
||||||
|
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||||
|
var failed = new uint[writes.Count];
|
||||||
|
for (var i = 0; i < writes.Count; i++) failed[i] = status;
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an <see cref="AnyTypeSpecifier"/> for one bulk-read entry. STRING uses ASCII +
|
||||||
|
/// the supplied <paramref name="stringLength"/>; WSTRING uses Unicode (UTF-16). All other
|
||||||
|
/// types resolve to a primitive CLR type via <see cref="MapToClrType"/>. IEC time/date
|
||||||
|
/// symbols flow as their underlying UDINT (matching the per-tag path in
|
||||||
|
/// <see cref="ReadValueAsync"/>) and are post-processed CLR-side after the sum-read.
|
||||||
|
/// </summary>
|
||||||
|
private static AnyTypeSpecifier BuildAnyTypeSpecifier(TwinCATDataType type, int stringLength) =>
|
||||||
|
type switch
|
||||||
|
{
|
||||||
|
TwinCATDataType.String => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.ASCII),
|
||||||
|
TwinCATDataType.WString => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.Unicode),
|
||||||
|
_ => new AnyTypeSpecifier(MapToClrType(type)),
|
||||||
|
};
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
||||||
|
|
||||||
|
// PR 2.3 — unregister the Symbol-Version listener. Best-effort: by the time we're
|
||||||
|
// disposing, the AMS session is already shutting down so the device server may
|
||||||
|
// refuse the unregister. Either way, AdsClient.Dispose tears the underlying
|
||||||
|
// notification subscription down regardless.
|
||||||
|
if (_symbolVersionRegistered)
|
||||||
|
{
|
||||||
|
try { _client.UnregisterSymbolVersionChanged(OnAdsSymbolVersionChanged); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
_symbolVersionRegistered = false;
|
||||||
|
}
|
||||||
|
_client.AdsSymbolVersionChanged -= OnAdsSymbolVersionChanged;
|
||||||
_notifications.Clear();
|
_notifications.Clear();
|
||||||
|
|
||||||
|
// PR 2.2 — release every cached handle on the wire as a good citizen. Best-effort
|
||||||
|
// and bounded to a short window so a hung router doesn't block process shutdown:
|
||||||
|
// each delete is fire-and-forget, errors swallowed. The session itself is about to
|
||||||
|
// tear down anyway, so the device server will reclaim everything regardless.
|
||||||
|
foreach (var kv in _handleCache)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Per-entry failures are expected on a closing connection.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_handleCache.Clear();
|
||||||
|
|
||||||
_client.Dispose();
|
_client.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 2.2 — flush all process-scoped optional caches (handle cache today). A
|
||||||
|
/// proactive Symbol Version invalidation listener arrives in PR 2.3 — until then,
|
||||||
|
/// operators / 2.3-aware callers can wipe the cache manually after a known online
|
||||||
|
/// change.
|
||||||
|
/// </summary>
|
||||||
|
public Task FlushOptionalCachesAsync()
|
||||||
|
{
|
||||||
|
// Best-effort delete on the wire — a held handle won't survive a redeploy anyway,
|
||||||
|
// but cleaning up matches the Dispose convention.
|
||||||
|
var snapshot = _handleCache.ToArray();
|
||||||
|
_handleCache.Clear();
|
||||||
|
foreach (var kv in snapshot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class NotificationRegistration(
|
private sealed class NotificationRegistration(
|
||||||
string symbolPath,
|
string symbolPath,
|
||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
@@ -249,7 +778,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
_ => typeof(int),
|
_ => 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.Bool => Convert.ToBoolean(value),
|
||||||
TwinCATDataType.SInt => Convert.ToSByte(value),
|
TwinCATDataType.SInt => Convert.ToSByte(value),
|
||||||
@@ -263,11 +792,79 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
TwinCATDataType.Real => Convert.ToSingle(value),
|
TwinCATDataType.Real => Convert.ToSingle(value),
|
||||||
TwinCATDataType.LReal => Convert.ToDouble(value),
|
TwinCATDataType.LReal => Convert.ToDouble(value),
|
||||||
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
|
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
|
||||||
TwinCATDataType.Time or TwinCATDataType.Date
|
// IEC durations (TIME / TOD) accept TimeSpan / Duration-as-Double-ms / raw UDINT.
|
||||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value),
|
// IEC timestamps (DATE / DT) accept DateTime (UTC) / raw UDINT seconds-since-epoch.
|
||||||
|
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DurationToUDInt(value),
|
||||||
|
TwinCATDataType.Date or TwinCATDataType.DateTime => DateTimeToUDInt(value),
|
||||||
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
|
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// IEC 61131-3 epoch is 1970-01-01 UTC for DATE / DT; TIME / TOD are unsigned ms counters.
|
||||||
|
private static readonly DateTime IecEpochUtc = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert the raw UDINT wire value for IEC TIME/DATE/DT/TOD into the native CLR type
|
||||||
|
/// surfaced upstream — TimeSpan for durations, DateTime (UTC) for timestamps. Other
|
||||||
|
/// types pass through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
internal static object? PostProcessIecTime(TwinCATDataType type, object? value)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
var raw = TryGetUInt32(value);
|
||||||
|
if (raw is null) return value;
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
// TIME / TOD — UDINT milliseconds.
|
||||||
|
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
|
||||||
|
=> TimeSpan.FromMilliseconds(raw.Value),
|
||||||
|
// DT — UDINT seconds since 1970-01-01 UTC.
|
||||||
|
TwinCATDataType.DateTime
|
||||||
|
=> IecEpochUtc.AddSeconds(raw.Value),
|
||||||
|
// DATE — UDINT seconds since 1970-01-01 UTC, but TwinCAT runtimes pin the time
|
||||||
|
// component to midnight; pass through the same conversion so we get a date-only
|
||||||
|
// value at midnight UTC.
|
||||||
|
TwinCATDataType.Date
|
||||||
|
=> IecEpochUtc.AddSeconds(raw.Value),
|
||||||
|
_ => value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint? TryGetUInt32(object value) => value switch
|
||||||
|
{
|
||||||
|
uint u => u,
|
||||||
|
int i when i >= 0 => (uint)i,
|
||||||
|
ushort us => (uint)us,
|
||||||
|
short s when s >= 0 => (uint)s,
|
||||||
|
long l when l >= 0 && l <= uint.MaxValue => (uint)l,
|
||||||
|
ulong ul when ul <= uint.MaxValue => (uint)ul,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static uint DurationToUDInt(object? value) => value switch
|
||||||
|
{
|
||||||
|
TimeSpan ts => (uint)Math.Max(0, ts.TotalMilliseconds),
|
||||||
|
// OPC UA Duration on the wire is a Double in milliseconds.
|
||||||
|
double d => (uint)Math.Max(0, d),
|
||||||
|
float f => (uint)Math.Max(0, f),
|
||||||
|
_ => Convert.ToUInt32(value),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static uint DateTimeToUDInt(object? value)
|
||||||
|
{
|
||||||
|
if (value is DateTime dt)
|
||||||
|
{
|
||||||
|
var utc = dt.Kind == DateTimeKind.Unspecified
|
||||||
|
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
|
||||||
|
: dt.ToUniversalTime();
|
||||||
|
var seconds = (long)(utc - IecEpochUtc).TotalSeconds;
|
||||||
|
if (seconds < 0 || seconds > uint.MaxValue)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value),
|
||||||
|
"DATE/DT value out of UDINT epoch range (1970-01-01..2106-02-07 UTC).");
|
||||||
|
return (uint)seconds;
|
||||||
|
}
|
||||||
|
return Convert.ToUInt32(value);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
|
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
|
||||||
{
|
{
|
||||||
short s => (s & (1 << bit)) != 0,
|
short s => (s & (1 << bit)) != 0,
|
||||||
|
|||||||
@@ -22,25 +22,64 @@ public interface ITwinCATClient : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
||||||
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
|
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
|
||||||
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good).
|
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good). When
|
||||||
|
/// <paramref name="arrayDimensions"/> is non-null + non-empty, the symbol is treated
|
||||||
|
/// as a whole-array read and the boxed value is a flat 1-D CLR
|
||||||
|
/// <see cref="Array"/> sized to <c>product(arrayDimensions)</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<(object? value, uint status)> ReadValueAsync(
|
Task<(object? value, uint status)> ReadValueAsync(
|
||||||
string symbolPath,
|
string symbolPath,
|
||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
|
int[]? arrayDimensions,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
||||||
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
||||||
|
/// <paramref name="arrayDimensions"/> mirrors <see cref="ReadValueAsync"/>; PR-1.4
|
||||||
|
/// ships read-only whole-array support so writers may surface <c>BadNotSupported</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<uint> WriteValueAsync(
|
Task<uint> WriteValueAsync(
|
||||||
string symbolPath,
|
string symbolPath,
|
||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
|
int[]? arrayDimensions,
|
||||||
object? value,
|
object? value,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk-read N scalar symbols in a single AMS request via Beckhoff's ADS Sum-command
|
||||||
|
/// family (IndexGroup <c>0xF080..0xF084</c>). The result is a parallel array preserving
|
||||||
|
/// <paramref name="reads"/> ordering — element <c>i</c>'s outcome maps to request <c>i</c>.
|
||||||
|
/// Empty input returns an empty result without a wire round-trip.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>This is the throughput-optimised path used by <see cref="TwinCATDriver.ReadAsync"/>
|
||||||
|
/// to replace the per-tag <see cref="ReadValueAsync"/> loop — one ADS sum-read for N
|
||||||
|
/// symbols beats N individual round-trips by ~10× on the typical PLC link.</para>
|
||||||
|
///
|
||||||
|
/// <para>Whole-array reads + bit-extracted BOOL reads stay on the per-tag path because
|
||||||
|
/// the Sum-command surface only marshals scalars + bitIndex needs CLR-side post-processing.
|
||||||
|
/// Callers should pre-filter or fall back as appropriate.</para>
|
||||||
|
/// </remarks>
|
||||||
|
Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||||||
|
IReadOnlyList<TwinCATBulkReadItem> reads,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk-write N scalar symbols in a single AMS request via Beckhoff's
|
||||||
|
/// <c>SumWriteBySymbolPath</c>. Result is a parallel status array preserving
|
||||||
|
/// <paramref name="writes"/> ordering. Empty input returns an empty result.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Whole-array writes + bit-RMW writes are not in scope for the bulk path — those continue
|
||||||
|
/// through the per-tag <see cref="WriteValueAsync"/> path. The driver layer pre-filters.
|
||||||
|
/// </remarks>
|
||||||
|
Task<IReadOnlyList<uint>> WriteValuesAsync(
|
||||||
|
IReadOnlyList<TwinCATBulkWriteItem> writes,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
|
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
|
||||||
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
|
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
|
||||||
@@ -75,6 +114,16 @@ public interface ITwinCATClient : IDisposable
|
|||||||
/// decide whether to drill in via their own walker.
|
/// decide whether to drill in via their own walker.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
|
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 2.2 — wipe process-scoped optional caches (today: the ADS variable-handle
|
||||||
|
/// cache backing per-tag reads / writes). Surfaces so operators + the future PR
|
||||||
|
/// 2.3 Symbol-Version invalidation listener can flush stale handles after a known
|
||||||
|
/// online change without forcing a full reconnect. Safe to call mid-traffic — in-flight
|
||||||
|
/// reads continue to use the handles they already hold; the next read for a symbol
|
||||||
|
/// will re-resolve. Best-effort wire-side delete; failures are swallowed.
|
||||||
|
/// </summary>
|
||||||
|
Task FlushOptionalCachesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
|
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
|
||||||
@@ -98,3 +147,19 @@ public interface ITwinCATClientFactory
|
|||||||
{
|
{
|
||||||
ITwinCATClient Create();
|
ITwinCATClient Create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One element of an <see cref="ITwinCATClient.ReadValuesAsync"/> request — the symbol path
|
||||||
|
/// + the IEC type for marshalling. Strings carry an explicit <paramref name="StringLength"/> for
|
||||||
|
/// fixed-size <c>STRING(n)</c> declarations (defaults to <c>80</c> matching IEC 61131-3).</summary>
|
||||||
|
public sealed record TwinCATBulkReadItem(
|
||||||
|
string SymbolPath,
|
||||||
|
TwinCATDataType Type,
|
||||||
|
int StringLength = 80);
|
||||||
|
|
||||||
|
/// <summary>One element of an <see cref="ITwinCATClient.WriteValuesAsync"/> request.
|
||||||
|
/// Mirror of <see cref="TwinCATBulkReadItem"/> with the value to push.</summary>
|
||||||
|
public sealed record TwinCATBulkWriteItem(
|
||||||
|
string SymbolPath,
|
||||||
|
TwinCATDataType Type,
|
||||||
|
object? Value,
|
||||||
|
int StringLength = 80);
|
||||||
|
|||||||
@@ -37,12 +37,16 @@ public static class TwinCATDataTypeExtensions
|
|||||||
TwinCATDataType.SInt or TwinCATDataType.USInt
|
TwinCATDataType.SInt or TwinCATDataType.USInt
|
||||||
or TwinCATDataType.Int or TwinCATDataType.UInt
|
or TwinCATDataType.Int or TwinCATDataType.UInt
|
||||||
or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32,
|
or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32,
|
||||||
TwinCATDataType.LInt or TwinCATDataType.ULInt => DriverDataType.Int32, // matches Int64 gap
|
TwinCATDataType.LInt => DriverDataType.Int64,
|
||||||
|
TwinCATDataType.ULInt => DriverDataType.UInt64,
|
||||||
TwinCATDataType.Real => DriverDataType.Float32,
|
TwinCATDataType.Real => DriverDataType.Float32,
|
||||||
TwinCATDataType.LReal => DriverDataType.Float64,
|
TwinCATDataType.LReal => DriverDataType.Float64,
|
||||||
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
|
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
|
||||||
TwinCATDataType.Time or TwinCATDataType.Date
|
// IEC 61131-3 TIME / TOD are durations (ms); DATE / DT are absolute timestamps.
|
||||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.Int32,
|
// The wire form is UDINT but the driver post-processes into TimeSpan / DateTime so the
|
||||||
|
// address space surfaces native UA Duration / DateTime instead of opaque integers.
|
||||||
|
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DriverDataType.Duration,
|
||||||
|
TwinCATDataType.Date or TwinCATDataType.DateTime => DriverDataType.DateTime,
|
||||||
TwinCATDataType.Structure => DriverDataType.String,
|
TwinCATDataType.Structure => DriverDataType.String,
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
|
|
||||||
// ---- IReadable ----
|
// ---- IReadable ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the supplied tag references in as few AMS round-trips as possible.
|
||||||
|
/// Tags resolved to the same <c>DeviceHostAddress</c> are bucketed + sent as one
|
||||||
|
/// ADS Sum-read (<see cref="ITwinCATClient.ReadValuesAsync"/>) — N tags in one
|
||||||
|
/// request beats N individual <c>ReadValueAsync</c> calls by ~10× for typical PLC
|
||||||
|
/// loads. Tags with bit-extracted BOOL or whole-array shape stay on the per-tag
|
||||||
|
/// path because the sum-read surface only marshals scalars.
|
||||||
|
/// </summary>
|
||||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -115,6 +123,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var results = new DataValueSnapshot[fullReferences.Count];
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
|
|
||||||
|
// Resolve tag definitions + bucket bulk-eligible reads by device. Anything that
|
||||||
|
// doesn't fit the bulk surface (unknown ref, bit BOOL, whole-array) is processed
|
||||||
|
// through the per-tag path inline so we still return a full result array in
|
||||||
|
// request order.
|
||||||
|
var bulkBuckets = new Dictionary<string, List<(int origIndex, string symbol, TwinCATTagDefinition def, int? bitIndex)>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
for (var i = 0; i < fullReferences.Count; i++)
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
{
|
{
|
||||||
var reference = fullReferences[i];
|
var reference = fullReferences[i];
|
||||||
@@ -123,31 +137,66 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out _))
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||||
|
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||||
|
var bitIndex = parsed?.BitIndex;
|
||||||
|
var isWholeArray = def.ArrayDimensions is { Length: > 0 };
|
||||||
|
var isBitBool = bitIndex is int && def.DataType == TwinCATDataType.Bool;
|
||||||
|
|
||||||
|
if (isWholeArray || isBitBool)
|
||||||
|
{
|
||||||
|
// Per-tag fallback path — preserves bit-extract / whole-array logic in
|
||||||
|
// AdsTwinCATClient.ReadValueAsync.
|
||||||
|
results[i] = await ReadOneAsync(reference, def, symbolName, bitIndex, cancellationToken, now)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bulkBuckets.TryGetValue(def.DeviceHostAddress, out var bucket))
|
||||||
|
{
|
||||||
|
bucket = new List<(int, string, TwinCATTagDefinition, int?)>();
|
||||||
|
bulkBuckets[def.DeviceHostAddress] = bucket;
|
||||||
|
}
|
||||||
|
bucket.Add((i, symbolName, def, bitIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One sum-read per device bucket. Ordering inside a bucket is preserved by the
|
||||||
|
// (origIndex, ...) tuple — the result array entry comes from the parallel index.
|
||||||
|
foreach (var (hostAddress, bucket) in bulkBuckets)
|
||||||
|
{
|
||||||
|
if (!_devices.TryGetValue(hostAddress, out var device)) continue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
var items = new TwinCATBulkReadItem[bucket.Count];
|
||||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
for (var k = 0; k < bucket.Count; k++)
|
||||||
var (value, status) = await client.ReadValueAsync(
|
items[k] = new TwinCATBulkReadItem(bucket[k].symbol, bucket[k].def.DataType);
|
||||||
symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
var bulk = await client.ReadValuesAsync(items, cancellationToken).ConfigureAwait(false);
|
||||||
if (status == TwinCATStatusMapper.Good)
|
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
for (var k = 0; k < bucket.Count; k++)
|
||||||
else
|
{
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
var (origIndex, _, def, _) = bucket[k];
|
||||||
$"ADS status {status:X8} reading {reference}");
|
var (value, status) = bulk[k];
|
||||||
|
results[origIndex] = new DataValueSnapshot(value, status, now, now);
|
||||||
|
if (status == TwinCATStatusMapper.Good)
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
else
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||||
|
$"ADS status {status:X8} reading {fullReferences[origIndex]}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { throw; }
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
foreach (var (origIndex, _, _, _) in bucket)
|
||||||
|
results[origIndex] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,14 +204,53 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<DataValueSnapshot> ReadOneAsync(
|
||||||
|
string reference, TwinCATTagDefinition def, string symbolName, int? bitIndex,
|
||||||
|
CancellationToken cancellationToken, DateTime timestamp)
|
||||||
|
{
|
||||||
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||||
|
return new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, timestamp);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
|
var (value, status) = await client.ReadValueAsync(
|
||||||
|
symbolName, def.DataType, bitIndex, def.ArrayDimensions, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (status == TwinCATStatusMapper.Good)
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, timestamp, null);
|
||||||
|
else
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||||
|
$"ADS status {status:X8} reading {reference}");
|
||||||
|
|
||||||
|
return new DataValueSnapshot(value, status, timestamp, timestamp);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- IWritable ----
|
// ---- IWritable ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write the supplied requests, bucketing scalar writes by device + dispatching
|
||||||
|
/// each bucket as one ADS Sum-write. Bit-RMW BOOL writes + whole-array writes use
|
||||||
|
/// the per-tag <see cref="ITwinCATClient.WriteValueAsync"/> path so the per-parent
|
||||||
|
/// RMW lock stays in play.
|
||||||
|
/// </summary>
|
||||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(writes);
|
ArgumentNullException.ThrowIfNull(writes);
|
||||||
var results = new WriteResult[writes.Count];
|
var results = new WriteResult[writes.Count];
|
||||||
|
|
||||||
|
// Bucket scalar writes by device. Bit-BOOL + whole-array writes route through the
|
||||||
|
// per-tag fallback below.
|
||||||
|
var bulkBuckets = new Dictionary<string, List<(int origIndex, string symbol, TwinCATTagDefinition def, object? value)>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
for (var i = 0; i < writes.Count; i++)
|
for (var i = 0; i < writes.Count; i++)
|
||||||
{
|
{
|
||||||
var w = writes[i];
|
var w = writes[i];
|
||||||
@@ -176,38 +264,68 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable);
|
results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out _))
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||||
|
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||||
|
var bitIndex = parsed?.BitIndex;
|
||||||
|
var isWholeArray = def.ArrayDimensions is { Length: > 0 };
|
||||||
|
var isBitBool = bitIndex is int && def.DataType == TwinCATDataType.Bool;
|
||||||
|
|
||||||
|
if (isWholeArray || isBitBool)
|
||||||
|
{
|
||||||
|
results[i] = await WriteOneAsync(def, symbolName, bitIndex, w.Value, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bulkBuckets.TryGetValue(def.DeviceHostAddress, out var bucket))
|
||||||
|
{
|
||||||
|
bucket = new List<(int, string, TwinCATTagDefinition, object?)>();
|
||||||
|
bulkBuckets[def.DeviceHostAddress] = bucket;
|
||||||
|
}
|
||||||
|
bucket.Add((i, symbolName, def, w.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (hostAddress, bucket) in bulkBuckets)
|
||||||
|
{
|
||||||
|
if (!_devices.TryGetValue(hostAddress, out var device)) continue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
var items = new TwinCATBulkWriteItem[bucket.Count];
|
||||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
for (var k = 0; k < bucket.Count; k++)
|
||||||
var status = await client.WriteValueAsync(
|
items[k] = new TwinCATBulkWriteItem(bucket[k].symbol, bucket[k].def.DataType, bucket[k].value);
|
||||||
symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
|
|
||||||
results[i] = new WriteResult(status);
|
var bulk = await client.WriteValuesAsync(items, cancellationToken).ConfigureAwait(false);
|
||||||
|
for (var k = 0; k < bucket.Count; k++)
|
||||||
|
results[bucket[k].origIndex] = new WriteResult(bulk[k]);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { throw; }
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (NotSupportedException nse)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
foreach (var (origIndex, _, _, _) in bucket)
|
||||||
|
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||||
}
|
}
|
||||||
catch (OverflowException)
|
catch (OverflowException)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
foreach (var (origIndex, _, _, _) in bucket)
|
||||||
|
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException nse)
|
||||||
|
{
|
||||||
|
foreach (var (origIndex, _, _, _) in bucket)
|
||||||
|
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
foreach (var (origIndex, _, _, _) in bucket)
|
||||||
|
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +333,40 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<WriteResult> WriteOneAsync(
|
||||||
|
TwinCATTagDefinition def, string symbolName, int? bitIndex, object? value, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||||
|
return new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
|
var status = await client.WriteValueAsync(
|
||||||
|
symbolName, def.DataType, bitIndex, def.ArrayDimensions, value, cancellationToken).ConfigureAwait(false);
|
||||||
|
return new WriteResult(status);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (NotSupportedException nse)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||||
|
return new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||||
|
{
|
||||||
|
return new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||||
|
}
|
||||||
|
catch (OverflowException)
|
||||||
|
{
|
||||||
|
return new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
return new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- ITagDiscovery ----
|
// ---- ITagDiscovery ----
|
||||||
|
|
||||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
@@ -231,11 +383,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||||
foreach (var tag in tagsForDevice)
|
foreach (var tag in tagsForDevice)
|
||||||
{
|
{
|
||||||
|
var (isArray, arrayDim) = ResolveArrayShape(tag.ArrayDimensions);
|
||||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||||
FullName: tag.Name,
|
FullName: tag.Name,
|
||||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||||
IsArray: false,
|
IsArray: isArray,
|
||||||
ArrayDim: null,
|
ArrayDim: arrayDim,
|
||||||
SecurityClass: tag.Writable
|
SecurityClass: tag.Writable
|
||||||
? SecurityClassification.Operate
|
? SecurityClassification.Operate
|
||||||
: SecurityClassification.ViewOnly,
|
: SecurityClassification.ViewOnly,
|
||||||
@@ -310,6 +463,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
{
|
{
|
||||||
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
|
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
|
||||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
|
||||||
|
// Whole-array tags don't fit the per-element AdsNotificationEx callback shape —
|
||||||
|
// skip the native path so the OPC UA layer falls through to a polled snapshot.
|
||||||
|
if (def.ArrayDimensions is { Length: > 0 }) continue;
|
||||||
|
|
||||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||||
@@ -428,6 +584,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
return device.Client;
|
return device.Client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Project a TwinCAT <see cref="TwinCATTagDefinition.ArrayDimensions"/> shape onto the
|
||||||
|
/// core <see cref="DriverAttributeInfo"/> 1-D surface. Multi-dim arrays flatten to the
|
||||||
|
/// product element count — the OPC UA address-space layer surfaces the rank via its own
|
||||||
|
/// <c>ArrayDimensions</c> metadata at variable build time.
|
||||||
|
/// </summary>
|
||||||
|
internal static (bool isArray, uint? arrayDim) ResolveArrayShape(int[]? dimensions)
|
||||||
|
{
|
||||||
|
if (dimensions is null || dimensions.Length == 0) return (false, null);
|
||||||
|
long product = 1;
|
||||||
|
foreach (var d in dimensions)
|
||||||
|
{
|
||||||
|
if (d <= 0) return (false, null); // invalid shape; surface as scalar to fail safe
|
||||||
|
product *= d;
|
||||||
|
if (product > uint.MaxValue) return (true, uint.MaxValue);
|
||||||
|
}
|
||||||
|
return (true, (uint)product);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ public sealed record TwinCATDeviceOptions(
|
|||||||
string? DeviceName = null);
|
string? DeviceName = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT
|
/// One TwinCAT-backed OPC UA variable. <c>SymbolPath</c> is the full TwinCAT symbolic name
|
||||||
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>).
|
/// (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>). When
|
||||||
|
/// <c>ArrayDimensions</c> is non-null + non-empty the symbol is treated as a whole-array
|
||||||
|
/// read of <c>product(dims)</c> elements rather than a single scalar — PR-1.4 ships read-
|
||||||
|
/// only whole-array support; multi-dim shapes flatten to the product on the wire and the
|
||||||
|
/// OPC UA layer reflects the rank via its own <c>ArrayDimensions</c> metadata.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record TwinCATTagDefinition(
|
public sealed record TwinCATTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
@@ -52,7 +56,8 @@ public sealed record TwinCATTagDefinition(
|
|||||||
string SymbolPath,
|
string SymbolPath,
|
||||||
TwinCATDataType DataType,
|
TwinCATDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
int[]? ArrayDimensions = null);
|
||||||
|
|
||||||
public sealed class TwinCATProbeOptions
|
public sealed class TwinCATProbeOptions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests"/>
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests"/>
|
||||||
|
<!-- PR 2.2 — integration tier needs visibility into AdsTwinCATClient + its
|
||||||
|
HandleCreateCount / HandleCacheCount counters to assert the live cache. -->
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
|
NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
|
||||||
BrowseName = new QualifiedName(browseName, NamespaceIndex),
|
BrowseName = new QualifiedName(browseName, NamespaceIndex),
|
||||||
DisplayName = new LocalizedText(displayName),
|
DisplayName = new LocalizedText(displayName),
|
||||||
|
// Per Task #231 — surface the driver-supplied tag description as the OPC UA
|
||||||
|
// Description attribute on the Variable node. Drivers that don't carry
|
||||||
|
// descriptions pass null, leaving Description unset (the stack defaults to
|
||||||
|
// an empty LocalizedText, matching prior behaviour).
|
||||||
|
Description = string.IsNullOrEmpty(attributeInfo.Description)
|
||||||
|
? null
|
||||||
|
: new LocalizedText(attributeInfo.Description),
|
||||||
DataType = MapDataType(attributeInfo.DriverDataType),
|
DataType = MapDataType(attributeInfo.DriverDataType),
|
||||||
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
|
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
|
||||||
// Historized attributes get the HistoryRead access bit so the stack dispatches
|
// Historized attributes get the HistoryRead access bit so the stack dispatches
|
||||||
@@ -310,6 +317,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
DriverDataType.Float64 => DataTypeIds.Double,
|
DriverDataType.Float64 => DataTypeIds.Double,
|
||||||
DriverDataType.String => DataTypeIds.String,
|
DriverDataType.String => DataTypeIds.String,
|
||||||
DriverDataType.DateTime => DataTypeIds.DateTime,
|
DriverDataType.DateTime => DataTypeIds.DateTime,
|
||||||
|
DriverDataType.Duration => DataTypeIds.Duration,
|
||||||
_ => DataTypeIds.BaseDataType,
|
_ => DataTypeIds.BaseDataType,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.2 — wall-clock comparison of Symbolic vs Logical reads on a running
|
||||||
|
/// <c>ab_server</c> (or a real ControlLogix). Skipped when <c>ab_server</c> isn't
|
||||||
|
/// reachable, same gating rule as <see cref="AbCipReadSmokeTests"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>This is a <em>scaffold</em>: it builds + runs against the existing test fixture,
|
||||||
|
/// but the libplctag .NET 1.5.x wrapper does not yet expose a public knob for instance-ID
|
||||||
|
/// addressing (see <c>docs/drivers/AbCip-Performance.md</c> §"Addressing mode"). On a live
|
||||||
|
/// fixture the two paths therefore measure the same wire behaviour today; the assertion
|
||||||
|
/// just sanity-checks that both modes complete + produce well-formed snapshots, with timing
|
||||||
|
/// emitted to the test output for inspection. When the wrapper exposes the attribute
|
||||||
|
/// publicly (or libplctag native gains hot-update of cip_addr) the assertion can be
|
||||||
|
/// tightened to require Logical < Symbolic on N-tag scans.</para>
|
||||||
|
///
|
||||||
|
/// <para>Marked <c>[Trait("Category", "Bench")]</c> so a future <c>--filter</c> rule can
|
||||||
|
/// opt out of bench tests in CI runs that only want the smoke set.</para>
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Bench")]
|
||||||
|
[Trait("Requires", "AbServer")]
|
||||||
|
public sealed class AbCipAddressingModeBenchTests
|
||||||
|
{
|
||||||
|
[AbServerFact]
|
||||||
|
public async Task Symbolic_and_Logical_modes_both_read_seeded_DInt_and_emit_timing()
|
||||||
|
{
|
||||||
|
var profile = KnownProfiles.ControlLogix;
|
||||||
|
var fixture = new AbServerFixture(profile);
|
||||||
|
await fixture.InitializeAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
|
||||||
|
var symElapsed = await ReadOnceAsync(deviceUri, profile.Family, AddressingMode.Symbolic);
|
||||||
|
var logElapsed = await ReadOnceAsync(deviceUri, profile.Family, AddressingMode.Logical);
|
||||||
|
|
||||||
|
// Wall-clock timing is captured for human inspection; the assertion just confirms
|
||||||
|
// both completed. The actual symbolic-vs-logical comparison is qualitative until
|
||||||
|
// the libplctag wrapper exposes logical-segment addressing publicly — see class doc.
|
||||||
|
Console.WriteLine($"Symbolic read elapsed: {symElapsed.TotalMilliseconds:F2} ms");
|
||||||
|
Console.WriteLine($"Logical read elapsed: {logElapsed.TotalMilliseconds:F2} ms");
|
||||||
|
|
||||||
|
symElapsed.ShouldBeGreaterThan(TimeSpan.Zero);
|
||||||
|
logElapsed.ShouldBeGreaterThan(TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await fixture.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<TimeSpan> ReadOnceAsync(string deviceUri, AbCipPlcFamily family, AddressingMode mode)
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(deviceUri, family, AddressingMode: mode)],
|
||||||
|
Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
}, $"drv-bench-{mode}");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
return sw.Elapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — golden-box-tier MultiPacket read-strategy test against Logix Emulate.
|
||||||
|
/// Exercises the sparse-UDT case the strategy is designed for: a 50-member UDT instance
|
||||||
|
/// where the OPC UA client subscribed to 5 members. Asserts the driver routes the read
|
||||||
|
/// through the MultiPacket planner (<see cref="AbCipDriver.DeviceState.MultiPacketGroupsExecuted"/>
|
||||||
|
/// counter increments) and returns Good StatusCodes for every member.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c> for
|
||||||
|
/// the L5X export that seeds this; ship the project once Emulate is on the integration
|
||||||
|
/// host):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>UDT <c>Tank_50</c> with 50 DINT members <c>M0</c>..<c>M49</c> — a deliberately
|
||||||
|
/// oversized UDT so a 5-member subscription is sparse enough for the
|
||||||
|
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/> default of 0.25 to
|
||||||
|
/// pick MultiPacket.</item>
|
||||||
|
/// <item>Controller-scope tag <c>Tank1 : Tank_50</c> with each <c>M{i}</c> seeded to
|
||||||
|
/// <c>i * 10</c> so each subscribed member returns a distinct value.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. With the default ab_server the
|
||||||
|
/// test skips cleanly — ab_server lacks UDT / Multi-Service-Packet emulation depth so a
|
||||||
|
/// wire-level pass against it would be vacuous regardless. Note: the libplctag .NET
|
||||||
|
/// wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling, so the
|
||||||
|
/// driver's MultiPacket runtime today issues N member reads sequentially. The planner-tier
|
||||||
|
/// dispatch is what's under test here — the wire-level bundling lands when the upstream
|
||||||
|
/// wrapper exposes the 0x0A service primitive (see
|
||||||
|
/// <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy").</para>
|
||||||
|
/// </remarks>
|
||||||
|
[Collection("AbServerEmulate")]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Tier", "Emulate")]
|
||||||
|
public sealed class AbCipEmulateMultiPacketReadTests
|
||||||
|
{
|
||||||
|
[AbServerFact]
|
||||||
|
public async Task Sparse_5_of_50_member_subscription_dispatches_through_MultiPacket()
|
||||||
|
{
|
||||||
|
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
|
||||||
|
|
||||||
|
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"AB_SERVER_ENDPOINT must be set to the Logix Emulate instance " +
|
||||||
|
"(e.g. '10.0.0.42:44818') when AB_SERVER_PROFILE=emulate.");
|
||||||
|
|
||||||
|
// Build a 50-member declared UDT — the planner needs the full member set to compute
|
||||||
|
// the subscribed-fraction in the Auto heuristic and to place MultiPacket member offsets.
|
||||||
|
var members = new AbCipStructureMember[50];
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt);
|
||||||
|
|
||||||
|
var options = new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(
|
||||||
|
HostAddress: $"ab://{endpoint}/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
ReadStrategy: ReadStrategy.MultiPacket)],
|
||||||
|
Tags = [
|
||||||
|
new AbCipTagDefinition(
|
||||||
|
Name: "Tank1",
|
||||||
|
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||||
|
TagPath: "Tank1",
|
||||||
|
DataType: AbCipDataType.Structure,
|
||||||
|
Members: members),
|
||||||
|
],
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-multipacket-smoke");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Sparse pick: 5 of 50 = 0.10 < default threshold 0.25 → MultiPacket planner. Force
|
||||||
|
// the strategy explicitly above so the test isn't sensitive to threshold drift.
|
||||||
|
var refs = new[] { "Tank1.M0", "Tank1.M3", "Tank1.M7", "Tank1.M22", "Tank1.M49" };
|
||||||
|
var snapshots = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snapshots.Count.ShouldBe(5);
|
||||||
|
foreach (var s in snapshots) s.StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
|
||||||
|
// Plan-stats counter assertion — the device-level counter increments once per parent
|
||||||
|
// UDT routed through the MultiPacket path. Sibling counter for WholeUdt must stay zero.
|
||||||
|
var deviceState = drv.GetDeviceState($"ab://{endpoint}/1,0");
|
||||||
|
deviceState.ShouldNotBeNull();
|
||||||
|
deviceState!.MultiPacketGroupsExecuted.ShouldBeGreaterThan(0);
|
||||||
|
deviceState.WholeUdtGroupsExecuted.ShouldBe(0);
|
||||||
|
|
||||||
|
// Sanity-check the seeded values land at the right indices: M{i} == i * 10 in the
|
||||||
|
// emulate fixture's startup routine.
|
||||||
|
Convert.ToInt32(snapshots[0].Value).ShouldBe(0);
|
||||||
|
Convert.ToInt32(snapshots[1].Value).ShouldBe(30);
|
||||||
|
Convert.ToInt32(snapshots[2].Value).ShouldBe(70);
|
||||||
|
Convert.ToInt32(snapshots[3].Value).ShouldBe(220);
|
||||||
|
Convert.ToInt32(snapshots[4].Value).ShouldBe(490);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.2 — coverage for the per-device <c>AddressingMode</c> toggle.
|
||||||
|
/// Asserts (a) <see cref="AddressingMode.Auto"/> resolves to
|
||||||
|
/// <see cref="AddressingMode.Symbolic"/> at the device level, (b) explicit
|
||||||
|
/// <see cref="AddressingMode.Logical"/> threads through every
|
||||||
|
/// <see cref="AbCipTagCreateParams"/> the driver builds, (c) Logical against an unsupported
|
||||||
|
/// family (Micro800) emits a warning + falls back to Symbolic, (d) the Driver-config DTO
|
||||||
|
/// round-trips the mode, and (e) family compatibility is captured by
|
||||||
|
/// <see cref="AbCipPlcFamilyProfile.SupportsLogicalAddressing"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipAddressingModeTests
|
||||||
|
{
|
||||||
|
// ---- Auto resolves to Symbolic ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Default_AddressingMode_resolves_to_Symbolic_on_DeviceState()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Auto_AddressingMode_resolves_to_Symbolic_on_DeviceState()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
AddressingMode: AddressingMode.Auto),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Logical threads through to AbCipTagCreateParams ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Logical_AddressingMode_threads_through_into_create_params()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
AddressingMode: AddressingMode.Logical),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", tagFactory: factory,
|
||||||
|
enumeratorFactory: new EmptyEnumeratorFactoryStub());
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Logical);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Symbolic_AddressingMode_explicitly_set_threads_through()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
AddressingMode: AddressingMode.Symbolic),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", tagFactory: factory);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Symbolic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Logical against unsupported family falls back with warning ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Logical_on_Micro800_falls_back_to_Symbolic_with_warning()
|
||||||
|
{
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.6/",
|
||||||
|
PlcFamily: AbCipPlcFamily.Micro800,
|
||||||
|
AddressingMode: AddressingMode.Logical),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
OnWarning = warnings.Add,
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState("ab://10.0.0.6/")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
|
||||||
|
warnings.ShouldHaveSingleItem();
|
||||||
|
warnings[0].ShouldContain("Micro800");
|
||||||
|
warnings[0].ShouldContain("Logical");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Logical_on_Micro800_carries_Symbolic_into_create_params()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.6/",
|
||||||
|
PlcFamily: AbCipPlcFamily.Micro800,
|
||||||
|
AddressingMode: AddressingMode.Logical),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.6/", "Speed", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
OnWarning = _ => { },
|
||||||
|
}, "drv-1", tagFactory: factory);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Symbolic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Logical_on_ControlLogix_does_not_warn()
|
||||||
|
{
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
AddressingMode: AddressingMode.Logical),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
OnWarning = warnings.Add,
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
warnings.ShouldBeEmpty();
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Logical);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Family-profile compatibility flags ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Family_profiles_advertise_logical_support_correctly()
|
||||||
|
{
|
||||||
|
AbCipPlcFamilyProfile.ControlLogix.SupportsLogicalAddressing.ShouldBeTrue();
|
||||||
|
AbCipPlcFamilyProfile.CompactLogix.SupportsLogicalAddressing.ShouldBeTrue();
|
||||||
|
AbCipPlcFamilyProfile.GuardLogix.SupportsLogicalAddressing.ShouldBeTrue();
|
||||||
|
AbCipPlcFamilyProfile.Micro800.SupportsLogicalAddressing.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DTO round-trip ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DTO_round_trips_AddressingMode_Logical_through_config_json()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"AddressingMode": "Logical"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Probe": { "Enabled": false }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||||
|
await drv.InitializeAsync(json, CancellationToken.None);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Logical);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DTO_round_trips_AddressingMode_Symbolic_through_config_json()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"AddressingMode": "Symbolic"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Probe": { "Enabled": false }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||||
|
await drv.InitializeAsync(json, CancellationToken.None);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DTO_omitted_AddressingMode_falls_back_to_Auto_then_Symbolic()
|
||||||
|
{
|
||||||
|
// No AddressingMode in JSON → DTO field is null → factory parses fallback Auto →
|
||||||
|
// device-level resolution lands on Symbolic.
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Probe": { "Enabled": false }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||||
|
await drv.InitializeAsync(json, CancellationToken.None);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Logical-mode triggers a one-time symbol walk ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Logical_mode_first_read_triggers_symbol_walk_once()
|
||||||
|
{
|
||||||
|
var enumStub = new RecordingEnumeratorFactory(
|
||||||
|
new AbCipDiscoveredTag("Speed", null, AbCipDataType.DInt, false),
|
||||||
|
new AbCipDiscoveredTag("Counter", null, AbCipDataType.DInt, false));
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
AddressingMode: AddressingMode.Logical),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Counter", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", tagFactory: factory, enumeratorFactory: enumStub);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
// First read fires the walk
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
// Second read must NOT walk again
|
||||||
|
await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||||
|
|
||||||
|
enumStub.CreateCount.ShouldBe(1);
|
||||||
|
var device = drv.GetDeviceState("ab://10.0.0.5/1,0")!;
|
||||||
|
device.LogicalWalkComplete.ShouldBeTrue();
|
||||||
|
device.LogicalInstanceMap.ShouldContainKey("Speed");
|
||||||
|
device.LogicalInstanceMap.ShouldContainKey("Counter");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Symbolic_mode_does_not_trigger_symbol_walk()
|
||||||
|
{
|
||||||
|
var enumStub = new RecordingEnumeratorFactory();
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
AddressingMode: AddressingMode.Symbolic),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", tagFactory: factory, enumeratorFactory: enumStub);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
enumStub.CreateCount.ShouldBe(0);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.LogicalWalkComplete.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stubs ----
|
||||||
|
|
||||||
|
private sealed class EmptyEnumeratorFactoryStub : IAbCipTagEnumeratorFactory
|
||||||
|
{
|
||||||
|
public IAbCipTagEnumerator Create() => new EmptyStub();
|
||||||
|
|
||||||
|
private sealed class EmptyStub : IAbCipTagEnumerator
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||||
|
AbCipTagCreateParams deviceParams,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||||
|
{
|
||||||
|
private readonly AbCipDiscoveredTag[] _seed;
|
||||||
|
public int CreateCount;
|
||||||
|
|
||||||
|
public RecordingEnumeratorFactory(params AbCipDiscoveredTag[] seed) => _seed = seed;
|
||||||
|
|
||||||
|
public IAbCipTagEnumerator Create()
|
||||||
|
{
|
||||||
|
CreateCount++;
|
||||||
|
return new SeededStub(_seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SeededStub(AbCipDiscoveredTag[] seed) : IAbCipTagEnumerator
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||||
|
AbCipTagCreateParams deviceParams,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var tag in seed)
|
||||||
|
yield return tag;
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipArrayReadPlannerTests
|
||||||
|
{
|
||||||
|
private const string Device = "ab://10.0.0.5/1,0";
|
||||||
|
|
||||||
|
private static AbCipTagCreateParams BaseParams(string tagName) => new(
|
||||||
|
Gateway: "10.0.0.5",
|
||||||
|
Port: 44818,
|
||||||
|
CipPath: "1,0",
|
||||||
|
LibplctagPlcAttribute: "controllogix",
|
||||||
|
TagName: tagName,
|
||||||
|
Timeout: TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryBuild_emits_single_tag_create_with_element_count()
|
||||||
|
{
|
||||||
|
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..15]", AbCipDataType.DInt);
|
||||||
|
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||||
|
|
||||||
|
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..15]"));
|
||||||
|
|
||||||
|
plan.ShouldNotBeNull();
|
||||||
|
plan.ElementType.ShouldBe(AbCipDataType.DInt);
|
||||||
|
plan.Stride.ShouldBe(4);
|
||||||
|
plan.Slice.Count.ShouldBe(16);
|
||||||
|
plan.CreateParams.ElementCount.ShouldBe(16);
|
||||||
|
// Anchored at the slice start; libplctag reads N consecutive elements from there.
|
||||||
|
plan.CreateParams.TagName.ShouldBe("Data[0]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryBuild_returns_null_when_path_has_no_slice()
|
||||||
|
{
|
||||||
|
var def = new AbCipTagDefinition("Plain", Device, "Data[3]", AbCipDataType.DInt);
|
||||||
|
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||||
|
|
||||||
|
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[3]")).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AbCipDataType.Bool)]
|
||||||
|
[InlineData(AbCipDataType.String)]
|
||||||
|
[InlineData(AbCipDataType.Structure)]
|
||||||
|
public void TryBuild_returns_null_for_unsupported_element_types(AbCipDataType type)
|
||||||
|
{
|
||||||
|
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
|
||||||
|
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||||
|
|
||||||
|
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]")).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AbCipDataType.SInt, 1)]
|
||||||
|
[InlineData(AbCipDataType.Int, 2)]
|
||||||
|
[InlineData(AbCipDataType.DInt, 4)]
|
||||||
|
[InlineData(AbCipDataType.Real, 4)]
|
||||||
|
[InlineData(AbCipDataType.LInt, 8)]
|
||||||
|
[InlineData(AbCipDataType.LReal, 8)]
|
||||||
|
public void TryBuild_uses_natural_stride_per_element_type(AbCipDataType type, int expectedStride)
|
||||||
|
{
|
||||||
|
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
|
||||||
|
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||||
|
|
||||||
|
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
|
||||||
|
plan.Stride.ShouldBe(expectedStride);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_walks_buffer_at_element_stride()
|
||||||
|
{
|
||||||
|
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..3]", AbCipDataType.DInt);
|
||||||
|
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||||
|
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
|
||||||
|
|
||||||
|
var fake = new FakeAbCipTag(plan.CreateParams);
|
||||||
|
// Stride == 4 for DInt, so offsets 0/4/8/12 hold the four element values.
|
||||||
|
fake.ValuesByOffset[0] = 100;
|
||||||
|
fake.ValuesByOffset[4] = 200;
|
||||||
|
fake.ValuesByOffset[8] = 300;
|
||||||
|
fake.ValuesByOffset[12] = 400;
|
||||||
|
|
||||||
|
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
|
||||||
|
|
||||||
|
decoded.Length.ShouldBe(4);
|
||||||
|
decoded.ShouldBe(new object?[] { 100, 200, 300, 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_preserves_slice_count_for_real_arrays()
|
||||||
|
{
|
||||||
|
var def = new AbCipTagDefinition("FloatSlice", Device, "Floats[2..5]", AbCipDataType.Real);
|
||||||
|
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||||
|
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Floats[2]"))!;
|
||||||
|
|
||||||
|
var fake = new FakeAbCipTag(plan.CreateParams);
|
||||||
|
fake.ValuesByOffset[0] = 1.5f;
|
||||||
|
fake.ValuesByOffset[4] = 2.5f;
|
||||||
|
fake.ValuesByOffset[8] = 3.5f;
|
||||||
|
fake.ValuesByOffset[12] = 4.5f;
|
||||||
|
|
||||||
|
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
|
||||||
|
|
||||||
|
decoded.ShouldBe(new object?[] { 1.5f, 2.5f, 3.5f, 4.5f });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.1 — coverage for the per-device CIP <c>ConnectionSize</c> override.
|
||||||
|
/// Asserts (a) the value flows from <see cref="AbCipDeviceOptions"/> into every
|
||||||
|
/// <see cref="AbCipTagCreateParams"/> the driver builds, (b) the family default kicks in
|
||||||
|
/// when the override is unset, (c) values outside the Kepware-supported range are rejected
|
||||||
|
/// at <c>InitializeAsync</c>, and (d) the legacy-firmware warning fires when a CompactLogix
|
||||||
|
/// narrow-cap device is configured above 511 bytes.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipConnectionSizeTests
|
||||||
|
{
|
||||||
|
// ---- options threading ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Custom_ConnectionSize_flows_from_device_options_into_create_params()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
ConnectionSize: 1500),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition(
|
||||||
|
Name: "Speed",
|
||||||
|
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
TagPath: "Speed",
|
||||||
|
DataType: AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", tagFactory: factory);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unset_ConnectionSize_falls_back_to_ControlLogix_family_default()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", tagFactory: factory);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Speed"].CreationParams.ConnectionSize
|
||||||
|
.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
|
||||||
|
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(4002);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unset_ConnectionSize_falls_back_to_CompactLogix_family_default()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.CompactLogix)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", tagFactory: factory);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(504);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unset_ConnectionSize_falls_back_to_Micro800_family_default()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.6/", AbCipPlcFamily.Micro800)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.6/", "Speed", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", tagFactory: factory);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(488);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- range validation ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(499)]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(4003)]
|
||||||
|
[InlineData(10000)]
|
||||||
|
public async Task Out_of_range_ConnectionSize_throws_at_InitializeAsync(int badSize)
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
ConnectionSize: badSize),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||||
|
ex.Message.ShouldContain("ConnectionSize");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(500)]
|
||||||
|
[InlineData(504)]
|
||||||
|
[InlineData(2000)]
|
||||||
|
[InlineData(4002)]
|
||||||
|
public async Task In_range_ConnectionSize_initialises_cleanly(int goodSize)
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
ConnectionSize: goodSize),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(goodSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- legacy-firmware warning ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Oversized_ConnectionSize_on_CompactLogix_emits_legacy_warning()
|
||||||
|
{
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.CompactLogix,
|
||||||
|
ConnectionSize: 1500),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
OnWarning = warnings.Add,
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
warnings.ShouldHaveSingleItem();
|
||||||
|
warnings[0].ShouldContain("CompactLogix");
|
||||||
|
warnings[0].ShouldContain("1500");
|
||||||
|
warnings[0].ShouldContain("Forward Open");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Within_legacy_cap_on_CompactLogix_does_not_warn()
|
||||||
|
{
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.CompactLogix,
|
||||||
|
ConnectionSize: 504),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
OnWarning = warnings.Add,
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
warnings.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Oversized_ConnectionSize_on_ControlLogix_does_not_warn()
|
||||||
|
{
|
||||||
|
// ControlLogix profile default is 4002 (Large Forward Open) — the warning is only
|
||||||
|
// meaningful when the family default is in the legacy-cap bucket. FW20+ ControlLogix
|
||||||
|
// happily accepts 1500-byte connections, so no warning fires.
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||||
|
ConnectionSize: 1500),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
OnWarning = warnings.Add,
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
warnings.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Oversized_ConnectionSize_on_Micro800_emits_legacy_warning()
|
||||||
|
{
|
||||||
|
// Micro800 default is 488 (well under the legacy cap), so any over-511 override
|
||||||
|
// triggers the same family-mismatch warning.
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions(
|
||||||
|
HostAddress: "ab://10.0.0.6/",
|
||||||
|
PlcFamily: AbCipPlcFamily.Micro800,
|
||||||
|
ConnectionSize: 1000),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
OnWarning = warnings.Add,
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
warnings.ShouldHaveSingleItem();
|
||||||
|
warnings[0].ShouldContain("Micro800");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DeviceState resolved ConnectionSize ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeviceState_ConnectionSize_reflects_override_when_set()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix, ConnectionSize: 2000),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeviceState_ConnectionSize_reflects_family_default_when_unset()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.CompactLogix)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(504);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AbCipConnectionSize constants ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constants_match_documented_Kepware_range()
|
||||||
|
{
|
||||||
|
AbCipConnectionSize.Min.ShouldBe(500);
|
||||||
|
AbCipConnectionSize.Max.ShouldBe(4002);
|
||||||
|
AbCipConnectionSize.LegacyFirmwareCap.ShouldBe(511);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DriverConfig DTO path (DriverFactoryRegistry-bound deployments) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_factory_threads_ConnectionSize_through_config_json()
|
||||||
|
{
|
||||||
|
// The bootstrapper-driven path deserialises driver config from JSON in the central
|
||||||
|
// DB (sp_PublishGeneration → DriverInstance.DriverConfig). The DTO must surface
|
||||||
|
// ConnectionSize so production deployments don't lose the override at the wire.
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"ConnectionSize": 1500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Probe": { "Enabled": false }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||||
|
// CreateInstance returns a fully-built driver; we kick InitializeAsync to surface the
|
||||||
|
// resolved DeviceState.ConnectionSize.
|
||||||
|
await drv.InitializeAsync(json, CancellationToken.None);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task #231 — verifies that tag/member descriptions parsed from L5K and L5X exports thread
|
||||||
|
/// through <see cref="AbCipTagDefinition.Description"/> /
|
||||||
|
/// <see cref="AbCipStructureMember.Description"/> + land on
|
||||||
|
/// <see cref="DriverAttributeInfo.Description"/> on the produced address-space variables, so
|
||||||
|
/// downstream OPC UA Variable nodes carry the source-project comment as their Description
|
||||||
|
/// attribute.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipDescriptionThreadingTests
|
||||||
|
{
|
||||||
|
private const string DeviceHost = "ab://10.0.0.5/1,0";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void L5kParser_captures_member_description_from_attribute_block()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
DATATYPE MyUdt
|
||||||
|
MEMBER Speed : DINT (Description := "Belt speed in RPM");
|
||||||
|
END_DATATYPE
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var member = doc.DataTypes.Single().Members.Single();
|
||||||
|
member.Name.ShouldBe("Speed");
|
||||||
|
member.Description.ShouldBe("Belt speed in RPM");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void L5xParser_captures_member_description_child_node()
|
||||||
|
{
|
||||||
|
const string xml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<RSLogix5000Content>
|
||||||
|
<Controller>
|
||||||
|
<DataTypes>
|
||||||
|
<DataType Name="MyUdt">
|
||||||
|
<Members>
|
||||||
|
<Member Name="Speed" DataType="DINT" Dimension="0" ExternalAccess="Read/Write">
|
||||||
|
<Description><![CDATA[Belt speed in RPM]]></Description>
|
||||||
|
</Member>
|
||||||
|
</Members>
|
||||||
|
</DataType>
|
||||||
|
</DataTypes>
|
||||||
|
</Controller>
|
||||||
|
</RSLogix5000Content>
|
||||||
|
""";
|
||||||
|
var doc = L5xParser.Parse(new StringL5kSource(xml));
|
||||||
|
|
||||||
|
doc.DataTypes.Single().Members.Single().Description.ShouldBe("Belt speed in RPM");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void L5kIngest_threads_tag_and_member_descriptions_into_AbCipTagDefinition()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
DATATYPE MotorBlock
|
||||||
|
MEMBER Speed : DINT (Description := "Setpoint RPM");
|
||||||
|
MEMBER Status : DINT;
|
||||||
|
END_DATATYPE
|
||||||
|
TAG
|
||||||
|
Motor1 : MotorBlock (Description := "Conveyor motor 1") := [];
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
var tag = result.Tags.Single();
|
||||||
|
tag.Description.ShouldBe("Conveyor motor 1");
|
||||||
|
tag.Members.ShouldNotBeNull();
|
||||||
|
var members = tag.Members!.ToDictionary(m => m.Name);
|
||||||
|
members["Speed"].Description.ShouldBe("Setpoint RPM");
|
||||||
|
members["Status"].Description.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_atomic_tag()
|
||||||
|
{
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(DeviceHost)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition(
|
||||||
|
Name: "Speed",
|
||||||
|
DeviceHostAddress: DeviceHost,
|
||||||
|
TagPath: "Motor1.Speed",
|
||||||
|
DataType: AbCipDataType.DInt,
|
||||||
|
Description: "Belt speed in RPM"),
|
||||||
|
new AbCipTagDefinition(
|
||||||
|
Name: "NoDescription",
|
||||||
|
DeviceHostAddress: DeviceHost,
|
||||||
|
TagPath: "X",
|
||||||
|
DataType: AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
|
||||||
|
.ShouldBe("Belt speed in RPM");
|
||||||
|
// Tags without descriptions leave Info.Description null (back-compat path).
|
||||||
|
builder.Variables.Single(v => v.BrowseName == "NoDescription").Info.Description
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_UDT_members()
|
||||||
|
{
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(DeviceHost)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition(
|
||||||
|
Name: "Motor1",
|
||||||
|
DeviceHostAddress: DeviceHost,
|
||||||
|
TagPath: "Motor1",
|
||||||
|
DataType: AbCipDataType.Structure,
|
||||||
|
Members:
|
||||||
|
[
|
||||||
|
new AbCipStructureMember(
|
||||||
|
Name: "Speed",
|
||||||
|
DataType: AbCipDataType.DInt,
|
||||||
|
Description: "Setpoint RPM"),
|
||||||
|
new AbCipStructureMember(
|
||||||
|
Name: "Status",
|
||||||
|
DataType: AbCipDataType.DInt),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
|
||||||
|
.ShouldBe("Setpoint RPM");
|
||||||
|
builder.Variables.Single(v => v.BrowseName == "Status").Info.Description
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||||
|
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||||
|
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{ Folders.Add((browseName, displayName)); return this; }
|
||||||
|
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||||
|
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||||
|
|
||||||
|
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||||
|
|
||||||
|
private sealed class Handle(string fullRef) : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => fullRef;
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullSink : IAlarmConditionSink
|
||||||
|
{
|
||||||
|
public void OnTransition(AlarmEventArgs args) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,55 @@ public sealed class AbCipDriverReadTests
|
|||||||
p.TagName.ShouldBe("Program:P.Counter");
|
p.TagName.ShouldBe("Program:P.Counter");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Slice_tag_reads_one_array_and_decodes_n_elements()
|
||||||
|
{
|
||||||
|
// PR abcip-1.3 — `Data[0..3]` slice routes through AbCipArrayReadPlanner: one libplctag
|
||||||
|
// tag-create at TagName="Data[0]" with ElementCount=4, single PLC read, contiguous
|
||||||
|
// buffer decoded at element stride into one snapshot whose Value is an object?[].
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("DataSlice", "ab://10.0.0.5/1,0", "Data[0..3]", AbCipDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = p =>
|
||||||
|
{
|
||||||
|
var t = new FakeAbCipTag(p);
|
||||||
|
t.ValuesByOffset[0] = 10;
|
||||||
|
t.ValuesByOffset[4] = 20;
|
||||||
|
t.ValuesByOffset[8] = 30;
|
||||||
|
t.ValuesByOffset[12] = 40;
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["DataSlice"], CancellationToken.None);
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
var values = snapshots.Single().Value.ShouldBeOfType<object?[]>();
|
||||||
|
values.ShouldBe(new object?[] { 10, 20, 30, 40 });
|
||||||
|
|
||||||
|
// Exactly ONE libplctag tag was created — anchored at the slice start with
|
||||||
|
// ElementCount=4. Without the planner this would have been four scalar reads.
|
||||||
|
factory.Tags.Count.ShouldBe(1);
|
||||||
|
factory.Tags.ShouldContainKey("Data[0]");
|
||||||
|
factory.Tags["Data[0]"].CreationParams.ElementCount.ShouldBe(4);
|
||||||
|
factory.Tags["Data[0]"].ReadCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Slice_tag_with_unsupported_element_type_returns_BadNotSupported()
|
||||||
|
{
|
||||||
|
// BOOL slices can't be laid out from the declaration alone (Logix packs BOOLs into a
|
||||||
|
// hidden host byte). The planner refuses; the driver surfaces BadNotSupported instead
|
||||||
|
// of attempting a best-effort decode.
|
||||||
|
var (drv, _) = NewDriver(
|
||||||
|
new AbCipTagDefinition("BoolSlice", "ab://10.0.0.5/1,0", "Flags[0..7]", AbCipDataType.Bool));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["BoolSlice"], CancellationToken.None);
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||||
|
snapshots.Single().Value.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Cancellation_propagates_from_read()
|
public async Task Cancellation_propagates_from_read()
|
||||||
{
|
{
|
||||||
@@ -211,4 +260,79 @@ public sealed class AbCipDriverReadTests
|
|||||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||||
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PR abcip-1.2 — STRINGnn variant decoding. Threading <see cref="AbCipTagDefinition.StringLength"/>
|
||||||
|
// through libplctag's StringMaxCapacity attribute lets STRING_20 / STRING_40 / STRING_80 UDTs
|
||||||
|
// decode against the right DATA-array size; null preserves the default 82-byte STRING.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StringLength_threads_into_TagCreateParams_StringMaxCapacity()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Banner", "ab://10.0.0.5/1,0", "Banner", AbCipDataType.String,
|
||||||
|
StringLength: 40));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = p => new FakeAbCipTag(p) { Value = "hello" };
|
||||||
|
|
||||||
|
await drv.ReadAsync(["Banner"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Banner"].CreationParams.StringMaxCapacity.ShouldBe(40);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StringLength_null_leaves_StringMaxCapacity_null_for_back_compat()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("LegacyStr", "ab://10.0.0.5/1,0", "LegacyStr", AbCipDataType.String));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = p => new FakeAbCipTag(p) { Value = "world" };
|
||||||
|
|
||||||
|
await drv.ReadAsync(["LegacyStr"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["LegacyStr"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StringLength_ignored_for_non_String_data_types()
|
||||||
|
{
|
||||||
|
// StringLength on a DINT-typed tag must not flow into StringMaxCapacity — libplctag would
|
||||||
|
// otherwise re-shape the buffer and corrupt the read. EnsureTagRuntimeAsync gates on the
|
||||||
|
// declared DataType.
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt,
|
||||||
|
StringLength: 80));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = p => new FakeAbCipTag(p) { Value = 7 };
|
||||||
|
|
||||||
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Speed"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UDT_member_StringLength_threads_through_to_member_runtime()
|
||||||
|
{
|
||||||
|
// STRINGnn members of a UDT — declaration-driven fan-out copies StringLength from
|
||||||
|
// AbCipStructureMember onto the synthesised member AbCipTagDefinition; the per-member
|
||||||
|
// runtime then receives the right StringMaxCapacity.
|
||||||
|
var udt = new AbCipTagDefinition(
|
||||||
|
Name: "Recipe",
|
||||||
|
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
TagPath: "Recipe",
|
||||||
|
DataType: AbCipDataType.Structure,
|
||||||
|
Members: [
|
||||||
|
new AbCipStructureMember("Name", AbCipDataType.String, StringLength: 20),
|
||||||
|
new AbCipStructureMember("Description", AbCipDataType.String, StringLength: 80),
|
||||||
|
new AbCipStructureMember("Code", AbCipDataType.DInt),
|
||||||
|
]);
|
||||||
|
var (drv, factory) = NewDriver(udt);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = p => new FakeAbCipTag(p) { Value = "x" };
|
||||||
|
|
||||||
|
await drv.ReadAsync(["Recipe.Name", "Recipe.Description", "Recipe.Code"], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Recipe.Name"].CreationParams.StringMaxCapacity.ShouldBe(20);
|
||||||
|
factory.Tags["Recipe.Description"].CreationParams.StringMaxCapacity.ShouldBe(80);
|
||||||
|
factory.Tags["Recipe.Code"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,8 +124,11 @@ public sealed class AbCipDriverTests
|
|||||||
{
|
{
|
||||||
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||||
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||||
|
AbCipDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
|
||||||
|
AbCipDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
|
||||||
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||||
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||||
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||||
|
AbCipDataType.Dt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-1.4 — multi-tag write packing. Validates that <see cref="AbCipDriver.WriteAsync"/>
|
||||||
|
/// groups writes by device, dispatches packable writes for request-packing-capable
|
||||||
|
/// families concurrently, falls back to sequential writes on Micro800, keeps BOOL-RMW
|
||||||
|
/// writes on the per-parent semaphore path, and fans per-tag StatusCodes out to the
|
||||||
|
/// correct positions on partial failures.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipMultiWritePackingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Writes_get_grouped_by_device()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||||
|
],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("A1", "ab://10.0.0.5/1,0", "A1", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("A2", "ab://10.0.0.5/1,0", "A2", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("B1", "ab://10.0.0.6/1,0", "B1", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[
|
||||||
|
new WriteRequest("A1", 1),
|
||||||
|
new WriteRequest("B1", 100),
|
||||||
|
new WriteRequest("A2", 2),
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Count.ShouldBe(3);
|
||||||
|
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
// Per-device handles materialised — A1/A2 share device A, B1 lives on device B.
|
||||||
|
factory.Tags["A1"].CreationParams.Gateway.ShouldBe("10.0.0.5");
|
||||||
|
factory.Tags["A2"].CreationParams.Gateway.ShouldBe("10.0.0.5");
|
||||||
|
factory.Tags["B1"].CreationParams.Gateway.ShouldBe("10.0.0.6");
|
||||||
|
factory.Tags["A1"].WriteCount.ShouldBe(1);
|
||||||
|
factory.Tags["A2"].WriteCount.ShouldBe(1);
|
||||||
|
factory.Tags["B1"].WriteCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ControlLogix_packs_concurrently_within_a_device()
|
||||||
|
{
|
||||||
|
// ControlLogix has SupportsRequestPacking=true → a multi-write batch is dispatched in
|
||||||
|
// parallel. The fake's WriteAsync gates on a TaskCompletionSource so we can prove that
|
||||||
|
// both writes are in flight at the same time before either completes.
|
||||||
|
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var inFlight = 0;
|
||||||
|
var maxInFlight = 0;
|
||||||
|
var factory = new FakeAbCipTagFactory
|
||||||
|
{
|
||||||
|
Customise = p => new GatedWriteFake(p, gate, () =>
|
||||||
|
{
|
||||||
|
var current = Interlocked.Increment(ref inFlight);
|
||||||
|
var observed = maxInFlight;
|
||||||
|
while (current > observed
|
||||||
|
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
|
||||||
|
observed = maxInFlight;
|
||||||
|
}, () => Interlocked.Decrement(ref inFlight)),
|
||||||
|
};
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var writeTask = drv.WriteAsync(
|
||||||
|
[
|
||||||
|
new WriteRequest("A", 1),
|
||||||
|
new WriteRequest("B", 2),
|
||||||
|
new WriteRequest("C", 3),
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
// Wait until all three writes have entered WriteAsync simultaneously, then release.
|
||||||
|
await WaitForAsync(() => Volatile.Read(ref inFlight) >= 3, TimeSpan.FromSeconds(2));
|
||||||
|
gate.SetResult(0);
|
||||||
|
|
||||||
|
var results = await writeTask;
|
||||||
|
results.Count.ShouldBe(3);
|
||||||
|
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
maxInFlight.ShouldBeGreaterThanOrEqualTo(2,
|
||||||
|
"ControlLogix supports request packing — packable writes should run concurrently within the device.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Micro800_falls_back_to_sequential_writes()
|
||||||
|
{
|
||||||
|
// Micro800 has SupportsRequestPacking=false → writes go one-at-a-time; the gated fake
|
||||||
|
// never sees more than one in-flight at a time.
|
||||||
|
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
gate.SetResult(0); // No need to gate — we just observe concurrency.
|
||||||
|
var inFlight = 0;
|
||||||
|
var maxInFlight = 0;
|
||||||
|
var factory = new FakeAbCipTagFactory
|
||||||
|
{
|
||||||
|
Customise = p => new GatedWriteFake(p, gate, () =>
|
||||||
|
{
|
||||||
|
var current = Interlocked.Increment(ref inFlight);
|
||||||
|
var observed = maxInFlight;
|
||||||
|
while (current > observed
|
||||||
|
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
|
||||||
|
observed = maxInFlight;
|
||||||
|
}, () => Interlocked.Decrement(ref inFlight)),
|
||||||
|
};
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/", AbCipPlcFamily.Micro800)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("A", "ab://10.0.0.5/", "A", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("B", "ab://10.0.0.5/", "B", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("C", "ab://10.0.0.5/", "C", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[
|
||||||
|
new WriteRequest("A", 1),
|
||||||
|
new WriteRequest("B", 2),
|
||||||
|
new WriteRequest("C", 3),
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Count.ShouldBe(3);
|
||||||
|
results.ShouldAllBe(r => r.StatusCode == AbCipStatusMapper.Good);
|
||||||
|
maxInFlight.ShouldBe(1,
|
||||||
|
"Micro800 disables request packing — writes must execute sequentially.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bit_in_dint_writes_still_route_through_RMW_path()
|
||||||
|
{
|
||||||
|
// BOOL-with-bitIndex must hit the per-parent RMW semaphore — it must NOT go through
|
||||||
|
// the packable per-tag runtime path. We prove this by checking that:
|
||||||
|
// (a) the per-tag "bit-selector" runtime is never created (it would throw via
|
||||||
|
// LibplctagTagRuntime's NotSupportedException had the bypass happened);
|
||||||
|
// (b) the parent-DINT runtime got both a Read and a Write.
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool),
|
||||||
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[
|
||||||
|
new WriteRequest("Flag3", true),
|
||||||
|
new WriteRequest("Speed", 99),
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Count.ShouldBe(2);
|
||||||
|
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
|
||||||
|
// Parent runtime created lazily for Flags (no .3 suffix) — drove the RMW.
|
||||||
|
factory.Tags.ShouldContainKey("Flags");
|
||||||
|
factory.Tags["Flags"].ReadCount.ShouldBe(1);
|
||||||
|
factory.Tags["Flags"].WriteCount.ShouldBe(1);
|
||||||
|
// Speed went through the packable path.
|
||||||
|
factory.Tags["Speed"].WriteCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Per_tag_status_code_fan_out_works_on_partial_failure()
|
||||||
|
{
|
||||||
|
// Mix Good + BadTimeout + BadNotWritable + BadNodeIdUnknown across two devices to
|
||||||
|
// exercise the original-index preservation through the per-device plan + concurrent
|
||||||
|
// dispatch.
|
||||||
|
var factory = new FakeAbCipTagFactory
|
||||||
|
{
|
||||||
|
Customise = p => p.TagName == "B"
|
||||||
|
? new FakeAbCipTag(p) { Status = -5 /* timeout */ }
|
||||||
|
: new FakeAbCipTag(p),
|
||||||
|
};
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||||
|
],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("RO", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false),
|
||||||
|
new AbCipTagDefinition("C", "ab://10.0.0.6/1,0", "C", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[
|
||||||
|
new WriteRequest("A", 1),
|
||||||
|
new WriteRequest("B", 2),
|
||||||
|
new WriteRequest("RO", 3),
|
||||||
|
new WriteRequest("UnknownTag", 4),
|
||||||
|
new WriteRequest("C", 5),
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Count.ShouldBe(5);
|
||||||
|
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||||
|
results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||||
|
results[3].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
|
results[4].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (!predicate())
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow >= deadline)
|
||||||
|
throw new TimeoutException("predicate did not become true within timeout");
|
||||||
|
await Task.Delay(10).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test fake whose <see cref="WriteAsync"/> blocks on a shared
|
||||||
|
/// <see cref="TaskCompletionSource"/> so the test can observe how many writes are
|
||||||
|
/// simultaneously in flight inside the driver.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class GatedWriteFake : FakeAbCipTag
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<int> _gate;
|
||||||
|
private readonly Action _onEnter;
|
||||||
|
private readonly Action _onExit;
|
||||||
|
|
||||||
|
public GatedWriteFake(AbCipTagCreateParams p, TaskCompletionSource<int> gate,
|
||||||
|
Action onEnter, Action onExit) : base(p)
|
||||||
|
{
|
||||||
|
_gate = gate;
|
||||||
|
_onEnter = onEnter;
|
||||||
|
_onExit = onExit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task WriteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
_onEnter();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _gate.Task.ConfigureAwait(false);
|
||||||
|
await base.WriteAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_onExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.3 — coverage for the per-device <see cref="ReadStrategy"/> selector. Three
|
||||||
|
/// resolution layers under test: (a) <see cref="AbCipDriver.ResolveReadStrategy"/> at
|
||||||
|
/// device init (MultiPacket-against-Micro800 fall-back, plain pass-through otherwise),
|
||||||
|
/// (b) <see cref="AbCipMultiPacketReadPlanner.ChooseStrategyForGroup"/> sparsity heuristic
|
||||||
|
/// (Auto-mode dispatch), (c) end-to-end <see cref="AbCipDriver.ReadAsync"/> dispatch
|
||||||
|
/// verified by the per-device WholeUdt / MultiPacket counters.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipReadStrategyTests
|
||||||
|
{
|
||||||
|
private const string Device = "ab://10.0.0.5/1,0";
|
||||||
|
private const string Micro = "ab://10.0.0.6/";
|
||||||
|
|
||||||
|
// ---- Device init resolution ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Default_ReadStrategy_resolves_to_Auto_on_DeviceState()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task User_forced_WholeUdt_passes_through_init()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix,
|
||||||
|
ReadStrategy: ReadStrategy.WholeUdt)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task User_forced_MultiPacket_on_ControlLogix_passes_through_init()
|
||||||
|
{
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix,
|
||||||
|
ReadStrategy: ReadStrategy.MultiPacket)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
OnWarning = warnings.Add,
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket);
|
||||||
|
warnings.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task User_forced_MultiPacket_on_Micro800_falls_back_to_WholeUdt_with_warning()
|
||||||
|
{
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800,
|
||||||
|
ReadStrategy: ReadStrategy.MultiPacket)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
OnWarning = warnings.Add,
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
|
||||||
|
warnings.ShouldHaveSingleItem();
|
||||||
|
warnings[0].ShouldContain("Micro800");
|
||||||
|
warnings[0].ShouldContain("Multi-Service Packet");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Auto_on_Micro800_stays_Auto_at_init_planner_caps_to_WholeUdt_per_batch()
|
||||||
|
{
|
||||||
|
// Auto resolution does not warn on non-packing families — the per-batch planner caps
|
||||||
|
// the strategy to WholeUdt at dispatch time. Keeping Auto here means a future PR can
|
||||||
|
// change the family-cap policy in one place without touching device init.
|
||||||
|
var warnings = new List<string>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800,
|
||||||
|
ReadStrategy: ReadStrategy.Auto)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
OnWarning = warnings.Add,
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.Auto);
|
||||||
|
warnings.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Heuristic ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heuristic_picks_MultiPacket_when_subscribed_fraction_below_threshold()
|
||||||
|
{
|
||||||
|
// 5 of 50 subscribed = 0.10, threshold = 0.25 → MultiPacket
|
||||||
|
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(5, 50, 0.25).ShouldBe(ReadStrategy.MultiPacket);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heuristic_picks_WholeUdt_when_subscribed_fraction_above_threshold()
|
||||||
|
{
|
||||||
|
// 40 of 50 subscribed = 0.80, threshold = 0.25 → WholeUdt
|
||||||
|
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(40, 50, 0.25).ShouldBe(ReadStrategy.WholeUdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heuristic_at_threshold_boundary_picks_WholeUdt()
|
||||||
|
{
|
||||||
|
// Strictly less than → MultiPacket; equal → WholeUdt. Deterministic boundary behaviour
|
||||||
|
// so tests can pin exact picks without hand-wringing about float comparison drift.
|
||||||
|
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(10, 40, 0.25).ShouldBe(ReadStrategy.WholeUdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heuristic_with_zero_total_members_defaults_to_WholeUdt()
|
||||||
|
{
|
||||||
|
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 0, 0.25).ShouldBe(ReadStrategy.WholeUdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heuristic_clamps_threshold_below_zero_to_zero()
|
||||||
|
{
|
||||||
|
// Negative threshold collapses to "never MultiPacket" — even a 0-of-N read picks WholeUdt.
|
||||||
|
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 10, -0.5).ShouldBe(ReadStrategy.WholeUdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heuristic_clamps_threshold_above_one_to_one()
|
||||||
|
{
|
||||||
|
// Threshold > 1 saturates so any subscribed fraction triggers MultiPacket.
|
||||||
|
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(9, 10, 5.0).ShouldBe(ReadStrategy.MultiPacket);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver-level dispatch / counters ----
|
||||||
|
|
||||||
|
private static AbCipTagDefinition BuildLargeUdt(string name, int memberCount)
|
||||||
|
{
|
||||||
|
var members = new AbCipStructureMember[memberCount];
|
||||||
|
for (var i = 0; i < memberCount; i++)
|
||||||
|
members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt);
|
||||||
|
return new AbCipTagDefinition(name, Device, name, AbCipDataType.Structure, Members: members);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbCipDriverOptions BuildOptions(ReadStrategy strategy, double threshold = 0.25,
|
||||||
|
AbCipPlcFamily family = AbCipPlcFamily.ControlLogix, params AbCipTagDefinition[] tags)
|
||||||
|
{
|
||||||
|
var host = family == AbCipPlcFamily.Micro800 ? Micro : Device;
|
||||||
|
// Re-bind tag DeviceHostAddress when family flips so single-test reuse keeps
|
||||||
|
// working — the supplied tags are built against Device by default.
|
||||||
|
var rebuiltTags = tags.Select(t => new AbCipTagDefinition(
|
||||||
|
t.Name, host, t.TagPath, t.DataType, t.Writable, t.WriteIdempotent,
|
||||||
|
t.Members, t.SafetyTag, t.StringLength, t.Description)).ToArray();
|
||||||
|
return new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(host, family, ReadStrategy: strategy,
|
||||||
|
MultiPacketSparsityThreshold: threshold)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
Tags = rebuiltTags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Auto_with_sparse_subscription_dispatches_through_MultiPacket()
|
||||||
|
{
|
||||||
|
// 5 subscribed of 50 = 0.10 < 0.25 → MultiPacket
|
||||||
|
var udt = BuildLargeUdt("Tank", 50);
|
||||||
|
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt);
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray();
|
||||||
|
await drv.ReadAsync(refs, CancellationToken.None);
|
||||||
|
|
||||||
|
var state = drv.GetDeviceState(Device)!;
|
||||||
|
state.MultiPacketGroupsExecuted.ShouldBe(1);
|
||||||
|
state.WholeUdtGroupsExecuted.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Auto_with_dense_subscription_dispatches_through_WholeUdt()
|
||||||
|
{
|
||||||
|
// 40 subscribed of 50 = 0.80 > 0.25 → WholeUdt
|
||||||
|
var udt = BuildLargeUdt("Tank", 50);
|
||||||
|
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt);
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray();
|
||||||
|
await drv.ReadAsync(refs, CancellationToken.None);
|
||||||
|
|
||||||
|
var state = drv.GetDeviceState(Device)!;
|
||||||
|
state.WholeUdtGroupsExecuted.ShouldBe(1);
|
||||||
|
state.MultiPacketGroupsExecuted.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task User_forced_MultiPacket_dispatches_through_MultiPacket_regardless_of_density()
|
||||||
|
{
|
||||||
|
// 40-of-50 dense reads still hit MultiPacket when the user forces it.
|
||||||
|
var udt = BuildLargeUdt("Tank", 50);
|
||||||
|
var options = BuildOptions(ReadStrategy.MultiPacket, threshold: 0.25, tags: udt);
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray();
|
||||||
|
await drv.ReadAsync(refs, CancellationToken.None);
|
||||||
|
|
||||||
|
var state = drv.GetDeviceState(Device)!;
|
||||||
|
state.MultiPacketGroupsExecuted.ShouldBe(1);
|
||||||
|
state.WholeUdtGroupsExecuted.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task User_forced_WholeUdt_dispatches_through_WholeUdt_regardless_of_sparsity()
|
||||||
|
{
|
||||||
|
// 1 sparse read of 50 still hits WholeUdt when the user forces it. Note: the WholeUdt
|
||||||
|
// planner demotes 1-member groups to fallback because a single member doesn't beat the
|
||||||
|
// whole-UDT-buffer cost. Verify ReadCount on the parent's runtime stays zero — the
|
||||||
|
// member runtime did the work.
|
||||||
|
var udt = BuildLargeUdt("Tank", 50);
|
||||||
|
var options = BuildOptions(ReadStrategy.WholeUdt, threshold: 0.25, tags: udt);
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.ReadAsync(["Tank.M0"], CancellationToken.None);
|
||||||
|
|
||||||
|
var state = drv.GetDeviceState(Device)!;
|
||||||
|
state.MultiPacketGroupsExecuted.ShouldBe(0);
|
||||||
|
// 1-member groups skip WholeUdt grouping per the existing planner contract — the
|
||||||
|
// counter increments only when the planner emits a group, not for the per-tag fallback.
|
||||||
|
state.WholeUdtGroupsExecuted.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Threshold_tunable_higher_value_picks_MultiPacket_for_denser_reads()
|
||||||
|
{
|
||||||
|
// 12 of 50 = 0.24, threshold = 0.5 → MultiPacket (would have been WholeUdt at 0.25).
|
||||||
|
var udt = BuildLargeUdt("Tank", 50);
|
||||||
|
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.5, tags: udt);
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var refs = Enumerable.Range(0, 12).Select(i => $"Tank.M{i}").ToArray();
|
||||||
|
await drv.ReadAsync(refs, CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState(Device)!.MultiPacketGroupsExecuted.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Auto_on_Micro800_caps_to_WholeUdt_even_when_sparse()
|
||||||
|
{
|
||||||
|
// Family doesn't support request packing → Auto must NEVER pick MultiPacket.
|
||||||
|
var udt = BuildLargeUdt("Tank", 50);
|
||||||
|
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25,
|
||||||
|
family: AbCipPlcFamily.Micro800, tags: udt);
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray();
|
||||||
|
await drv.ReadAsync(refs, CancellationToken.None);
|
||||||
|
|
||||||
|
var state = drv.GetDeviceState(Micro)!;
|
||||||
|
state.MultiPacketGroupsExecuted.ShouldBe(0);
|
||||||
|
state.WholeUdtGroupsExecuted.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Family-profile compatibility ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Family_profiles_advertise_request_packing_correctly()
|
||||||
|
{
|
||||||
|
AbCipPlcFamilyProfile.ControlLogix.SupportsRequestPacking.ShouldBeTrue();
|
||||||
|
AbCipPlcFamilyProfile.CompactLogix.SupportsRequestPacking.ShouldBeTrue();
|
||||||
|
AbCipPlcFamilyProfile.GuardLogix.SupportsRequestPacking.ShouldBeTrue();
|
||||||
|
AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DTO round-trip ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DTO_round_trips_ReadStrategy_MultiPacket_through_config_json()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"ReadStrategy": "MultiPacket",
|
||||||
|
"MultiPacketSparsityThreshold": 0.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Probe": { "Enabled": false }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||||
|
await drv.InitializeAsync(json, CancellationToken.None);
|
||||||
|
|
||||||
|
var state = drv.GetDeviceState(Device)!;
|
||||||
|
state.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket);
|
||||||
|
state.Options.MultiPacketSparsityThreshold.ShouldBe(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DTO_round_trips_ReadStrategy_WholeUdt_through_config_json()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"ReadStrategy": "WholeUdt"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Probe": { "Enabled": false }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||||
|
await drv.InitializeAsync(json, CancellationToken.None);
|
||||||
|
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DTO_omitted_ReadStrategy_falls_back_to_Auto_with_default_threshold()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Probe": { "Enabled": false }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||||
|
await drv.InitializeAsync(json, CancellationToken.None);
|
||||||
|
|
||||||
|
var state = drv.GetDeviceState(Device)!;
|
||||||
|
state.ReadStrategy.ShouldBe(ReadStrategy.Auto);
|
||||||
|
state.Options.MultiPacketSparsityThreshold.ShouldBe(0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Planner output shape (sanity) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultiPacketPlanner_groups_subscribed_members_by_parent()
|
||||||
|
{
|
||||||
|
var udt = BuildLargeUdt("Tank", 50);
|
||||||
|
var tagsByName = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Tank"] = udt,
|
||||||
|
};
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
tagsByName[$"Tank.M{i}"] = new AbCipTagDefinition(
|
||||||
|
$"Tank.M{i}", Device, $"Tank.M{i}", AbCipDataType.DInt);
|
||||||
|
}
|
||||||
|
|
||||||
|
var refs = new[] { "Tank.M0", "Tank.M3", "Tank.M7" };
|
||||||
|
var plan = AbCipMultiPacketReadPlanner.Build(refs, tagsByName);
|
||||||
|
|
||||||
|
plan.Batches.Count.ShouldBe(1);
|
||||||
|
plan.Batches[0].ParentName.ShouldBe("Tank");
|
||||||
|
plan.Batches[0].Members.Count.ShouldBe(3);
|
||||||
|
plan.Fallbacks.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultiPacketPlanner_does_not_demote_singletons_unlike_WholeUdt_planner()
|
||||||
|
{
|
||||||
|
// A 1-of-N read is the canonical sparse case — MultiPacket emits a Batch with one
|
||||||
|
// member where WholeUdt would demote to fallback. This is the load-bearing difference.
|
||||||
|
var udt = BuildLargeUdt("Tank", 50);
|
||||||
|
var tagsByName = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Tank"] = udt,
|
||||||
|
["Tank.M0"] = new AbCipTagDefinition("Tank.M0", Device, "Tank.M0", AbCipDataType.DInt),
|
||||||
|
};
|
||||||
|
|
||||||
|
var plan = AbCipMultiPacketReadPlanner.Build(["Tank.M0"], tagsByName);
|
||||||
|
|
||||||
|
plan.Batches.Count.ShouldBe(1);
|
||||||
|
plan.Batches[0].Members.Count.ShouldBe(1);
|
||||||
|
plan.Fallbacks.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
141
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs
Normal file
141
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #233 — RebrowseAsync forces a re-walk of the controller symbol table without
|
||||||
|
/// restarting the driver. Tests cover the call-counting contract (each invocation issues
|
||||||
|
/// a fresh enumeration pass), the IDriverControl interface implementation, and that the
|
||||||
|
/// UDT template cache is dropped so stale shapes don't survive a program-download.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipRebrowseTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RebrowseAsync_runs_enumerator_once_per_call()
|
||||||
|
{
|
||||||
|
var factory = new CountingEnumeratorFactory(
|
||||||
|
new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false));
|
||||||
|
|
||||||
|
await using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
EnableControllerBrowse = true,
|
||||||
|
}, "drv-1", enumeratorFactory: factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
|
||||||
|
factory.CreateCount.ShouldBe(1);
|
||||||
|
factory.EnumerationCount.ShouldBe(1);
|
||||||
|
|
||||||
|
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
|
||||||
|
factory.CreateCount.ShouldBe(2);
|
||||||
|
factory.EnumerationCount.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RebrowseAsync_emits_discovered_tags_through_supplied_builder()
|
||||||
|
{
|
||||||
|
var factory = new CountingEnumeratorFactory(
|
||||||
|
new AbCipDiscoveredTag("NewTag", null, AbCipDataType.DInt, ReadOnly: false));
|
||||||
|
|
||||||
|
await using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
EnableControllerBrowse = true,
|
||||||
|
}, "drv-1", enumeratorFactory: factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
await drv.RebrowseAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Variables.Select(v => v.Info.FullName).ShouldContain("NewTag");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RebrowseAsync_clears_template_cache()
|
||||||
|
{
|
||||||
|
await using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.TemplateCache.Put("ab://10.0.0.5/1,0", 42, new AbCipUdtShape("T", 4, []));
|
||||||
|
drv.TemplateCache.Count.ShouldBe(1);
|
||||||
|
|
||||||
|
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
|
||||||
|
|
||||||
|
drv.TemplateCache.Count.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AbCipDriver_implements_IDriverControl()
|
||||||
|
{
|
||||||
|
await using var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||||
|
drv.ShouldBeAssignableTo<IDriverControl>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||||
|
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||||
|
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{ Folders.Add((browseName, displayName)); return this; }
|
||||||
|
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||||
|
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||||
|
|
||||||
|
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||||
|
|
||||||
|
private sealed class Handle(string fullRef) : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => fullRef;
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||||
|
}
|
||||||
|
private sealed class NullSink : IAlarmConditionSink
|
||||||
|
{
|
||||||
|
public void OnTransition(AlarmEventArgs args) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks both <see cref="Create"/> calls (one per discovery / rebrowse pass) and
|
||||||
|
/// <see cref="EnumerationCount"/> (incremented when the resulting enumerator is
|
||||||
|
/// actually iterated). Two consecutive RebrowseAsync calls must bump both counters.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CountingEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||||
|
{
|
||||||
|
private readonly AbCipDiscoveredTag[] _tags;
|
||||||
|
public int CreateCount { get; private set; }
|
||||||
|
public int EnumerationCount { get; private set; }
|
||||||
|
|
||||||
|
public CountingEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
|
||||||
|
|
||||||
|
public IAbCipTagEnumerator Create()
|
||||||
|
{
|
||||||
|
CreateCount++;
|
||||||
|
return new CountingEnumerator(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CountingEnumerator(CountingEnumeratorFactory outer) : IAbCipTagEnumerator
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||||
|
AbCipTagCreateParams deviceParams,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
outer.EnumerationCount++;
|
||||||
|
await Task.CompletedTask;
|
||||||
|
foreach (var t in outer._tags) yield return t;
|
||||||
|
}
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,61 @@ public sealed class AbCipTagPathTests
|
|||||||
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
|
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slice_basic_inclusive_range()
|
||||||
|
{
|
||||||
|
var p = AbCipTagPath.TryParse("Data[0..15]");
|
||||||
|
p.ShouldNotBeNull();
|
||||||
|
p.Slice.ShouldNotBeNull();
|
||||||
|
p.Slice!.Start.ShouldBe(0);
|
||||||
|
p.Slice.End.ShouldBe(15);
|
||||||
|
p.Slice.Count.ShouldBe(16);
|
||||||
|
p.BitIndex.ShouldBeNull();
|
||||||
|
p.Segments.Single().Name.ShouldBe("Data");
|
||||||
|
p.Segments.Single().Subscripts.ShouldBeEmpty();
|
||||||
|
p.ToLibplctagName().ShouldBe("Data[0..15]");
|
||||||
|
// Slice array name omits the `..End` so libplctag sees an anchored read at the start
|
||||||
|
// index; pair with ElementCount to cover the whole range.
|
||||||
|
p.ToLibplctagSliceArrayName().ShouldBe("Data[0]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slice_with_program_scope_and_member_chain()
|
||||||
|
{
|
||||||
|
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors.Data[3..7]");
|
||||||
|
p.ShouldNotBeNull();
|
||||||
|
p.ProgramScope.ShouldBe("MainProgram");
|
||||||
|
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Data"]);
|
||||||
|
p.Slice!.Start.ShouldBe(3);
|
||||||
|
p.Slice.End.ShouldBe(7);
|
||||||
|
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors.Data[3..7]");
|
||||||
|
p.ToLibplctagSliceArrayName().ShouldBe("Program:MainProgram.Motors.Data[3]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slice_zero_length_single_element_allowed()
|
||||||
|
{
|
||||||
|
// [5..5] is a one-element slice — degenerate but legal (a single read of one element).
|
||||||
|
var p = AbCipTagPath.TryParse("Data[5..5]");
|
||||||
|
p.ShouldNotBeNull();
|
||||||
|
p.Slice!.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Data[5..3]")] // M < N
|
||||||
|
[InlineData("Data[-1..5]")] // negative start
|
||||||
|
[InlineData("Data[0..15].Member")] // slice + sub-element
|
||||||
|
[InlineData("Data[0..15].3")] // slice + bit index
|
||||||
|
[InlineData("Data[0..15,1]")] // slice cannot be multi-dim
|
||||||
|
[InlineData("Data[0..15,2..3]")] // multi-dim slice not supported
|
||||||
|
[InlineData("Data[..5]")] // missing start
|
||||||
|
[InlineData("Data[5..]")] // missing end
|
||||||
|
[InlineData("Data[a..5]")] // non-numeric start
|
||||||
|
public void Invalid_slice_shapes_return_null(string input)
|
||||||
|
{
|
||||||
|
AbCipTagPath.TryParse(input).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToLibplctagName_recomposes_round_trip()
|
public void ToLibplctagName_recomposes_round_trip()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -167,6 +167,83 @@ public sealed class AbCipUdtMemberTests
|
|||||||
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
|
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AOI_typed_tag_groups_members_under_directional_subfolders()
|
||||||
|
{
|
||||||
|
// PR abcip-2.6 — when any member carries a non-Local AoiQualifier, the tag is treated
|
||||||
|
// as an AOI instance: Input / Output / InOut members get grouped under sub-folders so
|
||||||
|
// the browse tree mirrors Studio 5000's AOI parameter tabs. Plain UDT tags (every member
|
||||||
|
// Local) keep the pre-2.6 flat layout.
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition(
|
||||||
|
Name: "Valve_001",
|
||||||
|
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
TagPath: "Valve_001",
|
||||||
|
DataType: AbCipDataType.Structure,
|
||||||
|
Members:
|
||||||
|
[
|
||||||
|
new AbCipStructureMember("Cmd", AbCipDataType.Bool, AoiQualifier: AoiQualifier.Input),
|
||||||
|
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false, AoiQualifier: AoiQualifier.Output),
|
||||||
|
new AbCipStructureMember("Buffer", AbCipDataType.DInt, AoiQualifier: AoiQualifier.InOut),
|
||||||
|
new AbCipStructureMember("LocalVar", AbCipDataType.DInt, AoiQualifier: AoiQualifier.Local),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
// Sub-folders for each directional bucket land in the recorder; the AOI parent folder
|
||||||
|
// and the Local member's lack of a sub-folder confirm only directional members get
|
||||||
|
// bucketed. Folder names are intentionally simple (Inputs / Outputs / InOut) — clients
|
||||||
|
// that browse "Valve_001/Inputs/Cmd" see exactly that path.
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldContain("Valve_001");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldContain("Inputs");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldContain("Outputs");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldContain("InOut");
|
||||||
|
|
||||||
|
// Variables emitted under the right full names — full reference still {Tag}.{Member}
|
||||||
|
// so the read/write paths stay unchanged from the flat-UDT case.
|
||||||
|
var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList();
|
||||||
|
variables.ShouldContain(("Cmd", "Valve_001.Cmd"));
|
||||||
|
variables.ShouldContain(("Status", "Valve_001.Status"));
|
||||||
|
variables.ShouldContain(("Buffer", "Valve_001.Buffer"));
|
||||||
|
variables.ShouldContain(("LocalVar", "Valve_001.LocalVar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Plain_UDT_keeps_flat_layout_when_every_member_is_Local()
|
||||||
|
{
|
||||||
|
// Plain UDTs (no Usage attributes anywhere) stay on the pre-2.6 flat layout — no
|
||||||
|
// Inputs/Outputs/InOut sub-folders should appear since there are no directional members.
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Tank1", "ab://10.0.0.5/1,0", "Tank1", AbCipDataType.Structure,
|
||||||
|
Members:
|
||||||
|
[
|
||||||
|
new AbCipStructureMember("Level", AbCipDataType.Real),
|
||||||
|
new AbCipStructureMember("Pressure", AbCipDataType.Real),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Inputs");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Outputs");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("InOut");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UDT_members_mixed_with_flat_tags_coexist()
|
public async Task UDT_members_mixed_with_flat_tags_coexist()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class CsvTagImporterTests
|
||||||
|
{
|
||||||
|
private const string DeviceHost = "ab://10.10.10.1/0,1";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Imports_Kepware_format_controller_tag_with_RW_access()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Respect Data Type,Client Access,Scan Rate,Description,Scaling
|
||||||
|
Motor1_Speed,Motor1_Speed,DINT,1,Read/Write,100,Drive speed setpoint,None
|
||||||
|
""";
|
||||||
|
|
||||||
|
var importer = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost };
|
||||||
|
var result = importer.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Count.ShouldBe(1);
|
||||||
|
var t = result.Tags[0];
|
||||||
|
t.Name.ShouldBe("Motor1_Speed");
|
||||||
|
t.TagPath.ShouldBe("Motor1_Speed");
|
||||||
|
t.DataType.ShouldBe(AbCipDataType.DInt);
|
||||||
|
t.Writable.ShouldBeTrue();
|
||||||
|
t.Description.ShouldBe("Drive speed setpoint");
|
||||||
|
t.DeviceHostAddress.ShouldBe(DeviceHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_Only_access_yields_non_writable_tag()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Client Access,Description
|
||||||
|
Sensor,Sensor,REAL,Read Only,Pressure sensor
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().Writable.ShouldBeFalse();
|
||||||
|
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Real);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Blank_rows_and_section_markers_are_skipped()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
; Kepware Server Tag Export
|
||||||
|
|
||||||
|
Tag Name,Address,Data Type,Client Access
|
||||||
|
|
||||||
|
; group: Motors
|
||||||
|
Motor1,Motor1,DINT,Read/Write
|
||||||
|
|
||||||
|
Motor2,Motor2,DINT,Read/Write
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Count.ShouldBe(2);
|
||||||
|
result.Tags.Select(t => t.Name).ShouldBe(["Motor1", "Motor2"]);
|
||||||
|
result.SkippedBlankCount.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Quoted_field_with_embedded_comma_is_parsed()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Client Access,Description
|
||||||
|
Motor1,Motor1,DINT,Read/Write,"Speed, RPM"
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().Description.ShouldBe("Speed, RPM");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Quoted_field_with_escaped_quote_is_parsed()
|
||||||
|
{
|
||||||
|
const string csv = "Tag Name,Address,Data Type,Client Access,Description\r\n"
|
||||||
|
+ "Tag1,Tag1,DINT,Read Only,\"He said \"\"hi\"\"\"\r\n";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().Description.ShouldBe("He said \"hi\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NamePrefix_is_applied()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Client Access
|
||||||
|
Speed,Speed,DINT,Read/Write
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter
|
||||||
|
{
|
||||||
|
DefaultDeviceHostAddress = DeviceHost,
|
||||||
|
NamePrefix = "PLC1_",
|
||||||
|
}.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
|
||||||
|
result.Tags.Single().TagPath.ShouldBe("Speed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_data_type_falls_through_as_Structure()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Client Access
|
||||||
|
Mystery,Mystery,SomeUnknownType,Read/Write
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Throws_when_DefaultDeviceHostAddress_missing()
|
||||||
|
{
|
||||||
|
const string csv = "Tag Name,Address,Data Type,Client Access\nA,A,DINT,Read/Write\n";
|
||||||
|
|
||||||
|
Should.Throw<InvalidOperationException>(() => new CsvTagImporter().Import(csv));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_load_export_reparse_is_stable()
|
||||||
|
{
|
||||||
|
var original = new[]
|
||||||
|
{
|
||||||
|
new AbCipTagDefinition("Motor1", DeviceHost, "Motor1", AbCipDataType.DInt,
|
||||||
|
Writable: true, Description: "Drive speed"),
|
||||||
|
new AbCipTagDefinition("Sensor", DeviceHost, "Sensor", AbCipDataType.Real,
|
||||||
|
Writable: false, Description: "Pressure, kPa"),
|
||||||
|
new AbCipTagDefinition("Tag3", DeviceHost, "Program:Main.Tag3", AbCipDataType.Bool,
|
||||||
|
Writable: true, Description: null),
|
||||||
|
};
|
||||||
|
|
||||||
|
var csv = CsvTagExporter.ToCsv(original);
|
||||||
|
var reparsed = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv).Tags;
|
||||||
|
|
||||||
|
reparsed.Count.ShouldBe(original.Length);
|
||||||
|
for (var i = 0; i < original.Length; i++)
|
||||||
|
{
|
||||||
|
reparsed[i].Name.ShouldBe(original[i].Name);
|
||||||
|
reparsed[i].TagPath.ShouldBe(original[i].TagPath);
|
||||||
|
reparsed[i].DataType.ShouldBe(original[i].DataType);
|
||||||
|
reparsed[i].Writable.ShouldBe(original[i].Writable);
|
||||||
|
reparsed[i].Description.ShouldBe(original[i].Description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reordered_columns_are_honoured_via_header_lookup()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Description,Address,Tag Name,Client Access,Data Type
|
||||||
|
Drive speed,Motor1,Motor1,Read/Write,DINT
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
var t = result.Tags.Single();
|
||||||
|
t.Name.ShouldBe("Motor1");
|
||||||
|
t.TagPath.ShouldBe("Motor1");
|
||||||
|
t.DataType.ShouldBe(AbCipDataType.DInt);
|
||||||
|
t.Description.ShouldBe("Drive speed");
|
||||||
|
t.Writable.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
250
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal file
250
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class L5kIngestTests
|
||||||
|
{
|
||||||
|
private const string DeviceHost = "ab://10.10.10.1/0,1";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Atomic_controller_scope_tag_becomes_AbCipTagDefinition()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
TAG
|
||||||
|
Motor1_Speed : DINT (ExternalAccess := Read/Write) := 0;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var ingest = new L5kIngest { DefaultDeviceHostAddress = DeviceHost };
|
||||||
|
var result = ingest.Ingest(doc);
|
||||||
|
|
||||||
|
result.Tags.Count.ShouldBe(1);
|
||||||
|
var tag = result.Tags[0];
|
||||||
|
tag.Name.ShouldBe("Motor1_Speed");
|
||||||
|
tag.DeviceHostAddress.ShouldBe(DeviceHost);
|
||||||
|
tag.TagPath.ShouldBe("Motor1_Speed");
|
||||||
|
tag.DataType.ShouldBe(AbCipDataType.DInt);
|
||||||
|
tag.Writable.ShouldBeTrue();
|
||||||
|
tag.Members.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_scope_tag_uses_Program_prefix_and_compound_name()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
PROGRAM MainProgram
|
||||||
|
TAG
|
||||||
|
StepIndex : DINT := 0;
|
||||||
|
END_TAG
|
||||||
|
END_PROGRAM
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
result.Tags.Count.ShouldBe(1);
|
||||||
|
result.Tags[0].Name.ShouldBe("MainProgram.StepIndex");
|
||||||
|
result.Tags[0].TagPath.ShouldBe("Program:MainProgram.StepIndex");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Alias_tag_is_skipped()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
TAG
|
||||||
|
Real : DINT := 0;
|
||||||
|
Aliased : DINT (AliasFor := "Real");
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
result.SkippedAliasCount.ShouldBe(1);
|
||||||
|
result.Tags.Count.ShouldBe(1);
|
||||||
|
result.Tags.ShouldAllBe(t => t.Name != "Aliased");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExternalAccess_None_tag_is_skipped()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
TAG
|
||||||
|
Hidden : DINT (ExternalAccess := None) := 0;
|
||||||
|
Visible : DINT := 0;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
result.SkippedNoAccessCount.ShouldBe(1);
|
||||||
|
result.Tags.Single().Name.ShouldBe("Visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExternalAccess_ReadOnly_tag_becomes_non_writable()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
TAG
|
||||||
|
Sensor : REAL (ExternalAccess := Read Only) := 0.0;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
result.Tags.Single().Writable.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UDT_typed_tag_picks_up_member_layout_from_DATATYPE_block()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
DATATYPE TankUDT
|
||||||
|
MEMBER Level : REAL := 0.0;
|
||||||
|
MEMBER Active : BOOL := 0;
|
||||||
|
END_DATATYPE
|
||||||
|
TAG
|
||||||
|
Tank1 : TankUDT := [0.0, 0];
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
var tag = result.Tags.Single();
|
||||||
|
tag.Name.ShouldBe("Tank1");
|
||||||
|
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||||
|
tag.Members.ShouldNotBeNull();
|
||||||
|
tag.Members!.Count.ShouldBe(2);
|
||||||
|
tag.Members[0].Name.ShouldBe("Level");
|
||||||
|
tag.Members[0].DataType.ShouldBe(AbCipDataType.Real);
|
||||||
|
tag.Members[1].Name.ShouldBe("Active");
|
||||||
|
tag.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_datatype_falls_through_as_structure_with_no_members()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
TAG
|
||||||
|
Mystery : SomeUnknownType := 0;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
var tag = result.Tags.Single();
|
||||||
|
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||||
|
tag.Members.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ingest_throws_when_DefaultDeviceHostAddress_missing()
|
||||||
|
{
|
||||||
|
var doc = new L5kDocument(new[] { new L5kTag("X", "DINT", null, null, null, null) }, Array.Empty<L5kDataType>());
|
||||||
|
|
||||||
|
Should.Throw<InvalidOperationException>(() => new L5kIngest().Ingest(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
|
||||||
|
{
|
||||||
|
// PR abcip-2.6 — L5K AOI parameters carry a Usage := Input / Output / InOut attribute.
|
||||||
|
// Ingest must map those values onto AbCipStructureMember.AoiQualifier so the discovery
|
||||||
|
// layer can group AOI members under sub-folders. Plain DATATYPE members get Local.
|
||||||
|
const string body = """
|
||||||
|
ADD_ON_INSTRUCTION_DEFINITION ValveAoi
|
||||||
|
PARAMETERS
|
||||||
|
PARAMETER Cmd : BOOL (Usage := Input) := 0;
|
||||||
|
PARAMETER Status : DINT (Usage := Output) := 0;
|
||||||
|
PARAMETER Buffer : DINT (Usage := InOut) := 0;
|
||||||
|
PARAMETER Local1 : DINT := 0;
|
||||||
|
END_PARAMETERS
|
||||||
|
END_ADD_ON_INSTRUCTION_DEFINITION
|
||||||
|
DATATYPE PlainUdt
|
||||||
|
MEMBER Speed : DINT := 0;
|
||||||
|
END_DATATYPE
|
||||||
|
TAG
|
||||||
|
Valve_001 : ValveAoi;
|
||||||
|
Tank1 : PlainUdt;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
var aoiTag = result.Tags.Single(t => t.Name == "Valve_001");
|
||||||
|
aoiTag.Members.ShouldNotBeNull();
|
||||||
|
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Local1").AoiQualifier.ShouldBe(AoiQualifier.Local);
|
||||||
|
|
||||||
|
// Plain UDT members default to Local — no Usage attribute to map.
|
||||||
|
var plainTag = result.Tags.Single(t => t.Name == "Tank1");
|
||||||
|
plainTag.Members.ShouldNotBeNull();
|
||||||
|
plainTag.Members!.Single().AoiQualifier.ShouldBe(AoiQualifier.Local);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void L5x_AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
|
||||||
|
{
|
||||||
|
// Same mapping as the L5K case above, exercised through the L5X parser to confirm both
|
||||||
|
// formats land at the same downstream representation.
|
||||||
|
const string body = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<RSLogix5000Content>
|
||||||
|
<Controller Name="C">
|
||||||
|
<AddOnInstructionDefinitions>
|
||||||
|
<AddOnInstructionDefinition Name="MyAoi">
|
||||||
|
<Parameters>
|
||||||
|
<Parameter Name="Cmd" DataType="BOOL" Usage="Input" />
|
||||||
|
<Parameter Name="Status" DataType="DINT" Usage="Output" />
|
||||||
|
<Parameter Name="Buffer" DataType="DINT" Usage="InOut" />
|
||||||
|
</Parameters>
|
||||||
|
</AddOnInstructionDefinition>
|
||||||
|
</AddOnInstructionDefinitions>
|
||||||
|
<Tags>
|
||||||
|
<Tag Name="Valve_001" TagType="Base" DataType="MyAoi" />
|
||||||
|
</Tags>
|
||||||
|
</Controller>
|
||||||
|
</RSLogix5000Content>
|
||||||
|
""";
|
||||||
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
var aoiTag = result.Tags.Single();
|
||||||
|
aoiTag.Members.ShouldNotBeNull();
|
||||||
|
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NamePrefix_is_applied_to_imported_tags()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
TAG
|
||||||
|
Speed : DINT := 0;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest
|
||||||
|
{
|
||||||
|
DefaultDeviceHostAddress = DeviceHost,
|
||||||
|
NamePrefix = "PLC1_",
|
||||||
|
}.Ingest(doc);
|
||||||
|
|
||||||
|
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
|
||||||
|
result.Tags.Single().TagPath.ShouldBe("Speed"); // path on the PLC stays unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
198
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal file
198
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class L5kParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Controller_scope_TAG_block_parses_name_datatype_externalaccess()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
TAG
|
||||||
|
Motor1_Speed : DINT (Description := "Motor 1 set point", ExternalAccess := Read/Write) := 0;
|
||||||
|
Tank_Level : REAL (ExternalAccess := Read Only) := 0.0;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
doc.Tags.Count.ShouldBe(2);
|
||||||
|
doc.Tags[0].Name.ShouldBe("Motor1_Speed");
|
||||||
|
doc.Tags[0].DataType.ShouldBe("DINT");
|
||||||
|
doc.Tags[0].ProgramScope.ShouldBeNull();
|
||||||
|
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
||||||
|
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
|
||||||
|
doc.Tags[0].AliasFor.ShouldBeNull();
|
||||||
|
|
||||||
|
doc.Tags[1].Name.ShouldBe("Tank_Level");
|
||||||
|
doc.Tags[1].DataType.ShouldBe("REAL");
|
||||||
|
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_scope_TAG_block_carries_program_name()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
PROGRAM MainProgram (Class := Standard)
|
||||||
|
TAG
|
||||||
|
StepIndex : DINT := 0;
|
||||||
|
Running : BOOL := 0;
|
||||||
|
END_TAG
|
||||||
|
END_PROGRAM
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
doc.Tags.Count.ShouldBe(2);
|
||||||
|
doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram");
|
||||||
|
doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Alias_tag_is_flagged()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
TAG
|
||||||
|
Motor1 : DINT := 0;
|
||||||
|
Motor1_Alias : DINT (AliasFor := "Motor1", ExternalAccess := Read/Write);
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var alias = doc.Tags.Single(t => t.Name == "Motor1_Alias");
|
||||||
|
alias.AliasFor.ShouldBe("Motor1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DATATYPE_block_collects_member_lines()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
DATATYPE TankUDT (FamilyType := NoFamily)
|
||||||
|
MEMBER Level : REAL (ExternalAccess := Read/Write) := 0.0;
|
||||||
|
MEMBER Pressure : REAL := 0.0;
|
||||||
|
MEMBER Active : BOOL := 0;
|
||||||
|
END_DATATYPE
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
doc.DataTypes.Count.ShouldBe(1);
|
||||||
|
var udt = doc.DataTypes[0];
|
||||||
|
udt.Name.ShouldBe("TankUDT");
|
||||||
|
udt.Members.Count.ShouldBe(3);
|
||||||
|
udt.Members[0].Name.ShouldBe("Level");
|
||||||
|
udt.Members[0].DataType.ShouldBe("REAL");
|
||||||
|
udt.Members[0].ExternalAccess.ShouldBe("Read/Write");
|
||||||
|
udt.Members[1].Name.ShouldBe("Pressure");
|
||||||
|
udt.Members[2].Name.ShouldBe("Active");
|
||||||
|
udt.Members[2].DataType.ShouldBe("BOOL");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DATATYPE_member_with_array_dim_keeps_type_clean()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
DATATYPE BatchUDT
|
||||||
|
MEMBER Recipe : DINT[16] := 0;
|
||||||
|
MEMBER Name : STRING := "";
|
||||||
|
END_DATATYPE
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var udt = doc.DataTypes[0];
|
||||||
|
var recipe = udt.Members.First(m => m.Name == "Recipe");
|
||||||
|
recipe.DataType.ShouldBe("DINT");
|
||||||
|
recipe.ArrayDim.ShouldBe(16);
|
||||||
|
|
||||||
|
var nameMember = udt.Members.First(m => m.Name == "Name");
|
||||||
|
nameMember.DataType.ShouldBe("STRING");
|
||||||
|
nameMember.ArrayDim.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Block_comments_are_stripped_before_parsing()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
(* This is a long
|
||||||
|
multi-line comment with TAG and END_TAG inside, parser must skip *)
|
||||||
|
TAG
|
||||||
|
Real_Tag : DINT := 0;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
doc.Tags.Count.ShouldBe(1);
|
||||||
|
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_sections_are_skipped_silently()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
CONFIG SomeConfig (Class := Standard)
|
||||||
|
ConfigData := 0;
|
||||||
|
END_CONFIG
|
||||||
|
MOTION_GROUP Motion1
|
||||||
|
Member := whatever;
|
||||||
|
END_MOTION_GROUP
|
||||||
|
TAG
|
||||||
|
Real_Tag : DINT := 0;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
doc.Tags.Count.ShouldBe(1);
|
||||||
|
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AOI_definition_block_collects_parameters_with_Usage()
|
||||||
|
{
|
||||||
|
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION blocks with PARAMETER entries carrying
|
||||||
|
// Usage := Input / Output / InOut. The parser surfaces them as L5kDataType members so
|
||||||
|
// AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
||||||
|
const string body = """
|
||||||
|
ADD_ON_INSTRUCTION_DEFINITION MyValveAoi (Revision := "1.0")
|
||||||
|
PARAMETERS
|
||||||
|
PARAMETER Cmd : BOOL (Usage := Input) := 0;
|
||||||
|
PARAMETER Status : DINT (Usage := Output, ExternalAccess := Read Only) := 0;
|
||||||
|
PARAMETER Buffer : DINT (Usage := InOut) := 0;
|
||||||
|
PARAMETER Internal : DINT := 0;
|
||||||
|
END_PARAMETERS
|
||||||
|
LOCAL_TAGS
|
||||||
|
Working : DINT := 0;
|
||||||
|
END_LOCAL_TAGS
|
||||||
|
END_ADD_ON_INSTRUCTION_DEFINITION
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
|
||||||
|
aoi.Members.Count.ShouldBe(4);
|
||||||
|
aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input");
|
||||||
|
aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output");
|
||||||
|
aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut");
|
||||||
|
aoi.Members.Single(m => m.Name == "Internal").Usage.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Multi_line_TAG_entry_is_concatenated()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
TAG
|
||||||
|
Motor1 : DINT (Description := "Long description spanning",
|
||||||
|
ExternalAccess := Read/Write) := 0;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
doc.Tags.Count.ShouldBe(1);
|
||||||
|
doc.Tags[0].Description.ShouldBe("Long description spanning");
|
||||||
|
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user