Compare commits

...

58 Commits

Author SHA1 Message Date
dohertj2 2266dd9ad5 Merge pull request '[twincat] TwinCAT — ENUM and ALIAS at discovery' (#345) from auto/twincat/1.5 into auto/driver-gaps 2026-04-25 17:51:08 -04:00
Joseph Doherty 0df14ab94a Auto: twincat-1.5 — ENUM/ALIAS discovery
Resolve TwinCAT symbol data types via the IDataType chain instead of a
flat name match. ALIAS chains walk BaseType recursively (depth-capped at
16 against pathological cycles); ENUM surfaces its underlying integer
base type. POINTER / REFERENCE / INTERFACE / UNION / STRUCT / ARRAY / FB
remain explicitly out of scope and surface as null.

Closes #309
2026-04-25 17:48:45 -04:00
dohertj2 448a97d67f Merge pull request '[twincat] TwinCAT — Whole-array reads' (#344) from auto/twincat/1.4 into auto/driver-gaps 2026-04-25 17:38:38 -04:00
Joseph Doherty b699052324 Auto: twincat-1.4 — whole-array reads
Surface int[]? ArrayDimensions on TwinCATTagDefinition + thread it through
ITwinCATClient.ReadValueAsync / WriteValueAsync. When non-null + non-empty,
AdsTwinCATClient issues a single ADS read against the symbol with
clrType.MakeArrayType() and returns the flat 1-D CLR Array; for IEC TIME /
DATE / DT / TOD element types we project per-element to the native
TimeSpan / DateTime so consumers see consistent types regardless of rank.

DiscoverAsync surfaces IsArray=true + ArrayDim=product(dims) onto
DriverAttributeInfo via a new ResolveArrayShape helper. Multi-dim shapes
flatten to the product on the wire — DriverAttributeInfo.ArrayDim is
single-uint today and the OPC UA layer reflects rank via its own metadata.

Native ADS notification subscriptions skip whole-array tags so the OPC UA
layer falls through to a polled snapshot — the per-element AdsNotificationEx
callback shape doesn't fit a flat array. Whole-array WRITES are out of
scope for this PR — AdsTwinCATClient.WriteValueAsync returns
BadNotSupported when ArrayDimensions is set.

Tests: TwinCATArrayReadTests covers ResolveArrayShape (null / empty /
single-dim / multi-dim flatten / non-positive defensive), DiscoverAsync
emitting IsArray + ArrayDim for declared array tags, single-dim + multi-dim
fake-client read fan-out, and the BadNotSupported gate on whole-array
writes. Existing 137 unit tests still pass — total now 143.

Closes #308
2026-04-25 17:36:15 -04:00
dohertj2 e6a55add20 Merge pull request '[twincat] TwinCAT — Bit-indexed BOOL writes (RMW)' (#343) from auto/twincat/1.3 into auto/driver-gaps 2026-04-25 17:25:21 -04:00
Joseph Doherty fcf89618cd Auto: twincat-1.3 — bit-indexed BOOL writes (RMW)
Replace the NotSupportedException at AdsTwinCATClient.WriteValueAsync
for bit-indexed BOOL writes with a read-modify-write path:

  1. Strip the trailing .N selector from the symbol path.
  2. Read the parent as UDINT.
  3. Set or clear bit N via the standard mask.
  4. Write the parent back.

Concurrent bit writers against the same parent serialise through a
per-parent SemaphoreSlim cached in a ConcurrentDictionary (never
removed — bounded by writable-bit-tag cardinality). Mirrors the AbCip /
Modbus / FOCAS bit-RMW pattern shipped in #181 pass 1.

The path-stripping (TryGetParentSymbolPath) and mask helper (ApplyBit)
are exposed as internal statics so tests can pin the pure logic without
needing a real ADS target. The FakeTwinCATClient mirrors the same RMW
semantics so driver-level round-trip tests assert the parent-word state.

Closes #307
2026-04-25 17:22:59 -04:00
dohertj2 f83c467647 Merge pull request '[twincat] TwinCAT — Native UA TIME/DATE/DT/TOD' (#342) from auto/twincat/1.2 into auto/driver-gaps 2026-04-25 17:16:39 -04:00
Joseph Doherty 80b2d7f8c3 Auto: twincat-1.2 — native UA TIME/DATE/DT/TOD
IEC 61131-3 TIME/TOD now surface as TimeSpan (UA Duration); DATE/DT
surface as DateTime (UTC). The wire form stays UDINT — AdsTwinCATClient
post-processes raw values in ReadValueAsync and OnAdsNotificationEx,
and accepts native CLR types in ConvertForWrite. Added Duration to
DriverDataType (back-compat: existing switches default to BaseDataType
for unknown enum values) and mapped it to DataTypeIds.Duration in
DriverNodeManager.

Closes #306
2026-04-25 17:14:12 -04:00
dohertj2 8286255ae5 Merge pull request '[twincat] TwinCAT — Int64 fidelity for LINT/ULINT' (#341) from auto/twincat/1.1 into auto/driver-gaps 2026-04-25 17:06:55 -04:00
Joseph Doherty 615ab25680 Auto: twincat-1.1 — Int64 fidelity for LINT/ULINT
Map LInt/ULInt to DriverDataType.Int64/UInt64 instead of truncating
to Int32. AdsTwinCATClient.MapToClrType already returns long/ulong
so the wire-level read returns the correct boxed types.

Closes #305
2026-04-25 17:04:43 -04:00
dohertj2 545cc74ec8 Merge pull request '[s7] S7 — LOGO!/S7-200 V-memory parser' (#340) from auto/s7/PR-S7-A5 into auto/driver-gaps 2026-04-25 17:00:59 -04:00
Joseph Doherty e5122c546b Auto: s7-a5 — LOGO!/S7-200 V-memory parser
Add CPU-aware overload S7AddressParser.Parse(string, CpuType?) that
accepts the V area letter for S7-200 / S7-200 Smart / LOGO! 0BA8 and
maps it to DataBlock DB1. V is rejected on S7-300/400/1200/1500 and on
the legacy CPU-agnostic Parse(string) overload. Width suffixes mirror
M/I/Q (VB/VW/VD/V0.0). S7Driver passes _options.CpuType so live tag
config picks up family-aware parsing.

Tests cover S7200/S7200Smart/Logo0BA8 positive cases, modern-family
rejection, and CPU-agnostic rejection.

Closes #291
2026-04-25 16:58:34 -04:00
dohertj2 6737edbad2 Merge pull request '[s7] S7 — Array tags (ValueRank=1)' (#339) from auto/s7/PR-S7-A4 into auto/driver-gaps 2026-04-25 16:51:34 -04:00
Joseph Doherty ce98c2ada3 Auto: s7-a4 — array tags (ValueRank=1)
- S7TagDefinition gets optional ElementCount; >1 marks the tag as a 1-D array.
- ReadOneAsync / WriteOneAsync: one byte-range Read/WriteBytesAsync covering
  N × elementBytes, sliced/packed client-side via the existing big-endian scalar
  codecs and S7DateTimeCodec.
- DiscoverAsync surfaces IsArray=true and ArrayDim=ElementCount → ValueRank=1.
- Init-time validation (now ahead of TCP open) caps ElementCount at 8000 and
  rejects unsupported element types: STRING/WSTRING/CHAR/WCHAR (variable-width)
  and BOOL (packed-bit layout) — both follow-ups.
- Supported element types: Byte, Int16/UInt16, Int32/UInt32, Int64/UInt64,
  Float32, Float64, Date, Time, TimeOfDay.

Closes #290
2026-04-25 16:49:02 -04:00
dohertj2 676eebd5e4 Merge pull request '[s7] S7 — DTL/DT/S5TIME/TIME/TOD/DATE codecs' (#338) from auto/s7/PR-S7-A3 into auto/driver-gaps 2026-04-25 16:40:02 -04:00
Joseph Doherty 2b66cec582 Auto: s7-a3 — DTL/DT/S5TIME/TIME/TOD/DATE codecs
Adds S7DateTimeCodec static class implementing the six Siemens S7 date/time
wire formats:

  - DTL (12 bytes): UInt16 BE year + month/day/dow/h/m/s + UInt32 BE nanos
  - DATE_AND_TIME (8 bytes BCD): yy/mm/dd/hh/mm/ss + 3-digit BCD ms + dow
  - S5TIME (16 bits): 2-bit timebase + 3-digit BCD count → TimeSpan
  - TIME (Int32 ms BE, signed) → TimeSpan, allows negative durations
  - TOD (UInt32 ms BE, 0..86399999) → TimeSpan since midnight
  - DATE (UInt16 BE days since 1990-01-01) → DateTime

Mirrors the S7StringCodec pattern from PR-S7-A2 — codecs operate on raw byte
spans so each format can be locked with golden-byte unit tests without a
live PLC. New S7DataType members (Dtl, DateAndTime, S5Time, Time, TimeOfDay,
Date) are wired into S7Driver.ReadOneAsync/WriteOneAsync via byte-level
ReadBytesAsync/WriteBytesAsync calls — S7.Net's string-keyed Read/Write
overloads have no syntax for these widths.

Uninitialized PLC buffers (all-zero year+month for DTL/DT) reject as
InvalidDataException → BadOutOfRange to operators, rather than decoding as
year-0001 garbage.

S5TIME / TIME / TOD surface as Int32 ms (DriverDataType has no Duration);
DTL / DT / DATE surface as DriverDataType.DateTime.

Test coverage: 30 new golden-vector + round-trip + rejection tests,
including the all-zero buffer rejection paths and BCD-nibble validation.
Build clean, 115/115 S7 tests pass.

Closes #289

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:37:39 -04:00
dohertj2 b751c1c096 Merge pull request '[s7] S7 — STRING/WSTRING/CHAR/WCHAR' (#337) from auto/s7/PR-S7-A2 into auto/driver-gaps 2026-04-25 16:28:22 -04:00
Joseph Doherty 316f820eff Auto: s7-a2 — STRING/WSTRING/CHAR/WCHAR
Closes the NotSupportedException cliff for S7 string-shaped types.

- S7DataType gains WString, Char, WChar members alongside the existing
  String entry.
- New S7StringCodec encodes/decodes the four wire formats:
    STRING  : 2-byte header (max-len + actual-len bytes) + N ASCII bytes
              -> total 2 + max_len.
    WSTRING : 4-byte header (max-len + actual-len UInt16 BE) + N×2
              UTF-16BE bytes -> total 4 + 2 × max_len.
    CHAR    : 1 ASCII byte (rejects non-ASCII on encode).
    WCHAR   : 2 UTF-16BE bytes.
  Header-bug clamp: actualLen > maxLen is silently clamped on read so
  firmware quirks don't walk past the wire buffer; rejected on write
  to avoid silent truncation.
- S7Driver.ReadOneAsync / WriteOneAsync issue ReadBytesAsync /
  WriteBytesAsync against the parsed Area / DbNumber / ByteOffset and
  honour S7TagDefinition.StringLength (default 254 = S7 STRING max).
- MapDataType returns DriverDataType.String for the three new enum
  members so OPC UA discovery surfaces them as scalar strings.

Tests: 21 new cases on S7StringCodec covering golden-byte vectors,
encode/decode round-trips, the firmware-bug header-clamp, ASCII-only
guard on CHAR, and the StringLength default. 85/85 passing.

Closes #288
2026-04-25 16:26:05 -04:00
dohertj2 38eb909f69 Merge pull request '[s7] S7 — 64-bit scalar types (LInt/ULInt/LReal/LWord)' (#336) from auto/s7/PR-S7-A1 into auto/driver-gaps 2026-04-25 16:18:40 -04:00
Joseph Doherty d1699af609 Auto: s7-a1 — 64-bit scalar types
Closes the NotSupportedException cliff for S7 Float64/Int64/UInt64.

- S7Size enum gains LWord (8 bytes); parser accepts DBLD/DBL on data
  blocks and LD on M/I/Q (e.g. DB1.DBLD0, DB1.DBL8, MLD0, ILD8, QLD16).
- S7Driver.ReadOneAsync / WriteOneAsync issue ReadBytesAsync /
  WriteBytesAsync for 64-bit types and convert big-endian via
  System.Buffers.Binary.BinaryPrimitives. S7's wire format is BE.
- Internal MapArea(S7Area) helper translates to S7.Net DataType.
- MapDataType now surfaces native DriverDataType for Int16/UInt16/
  UInt32/Int64/UInt64 instead of collapsing them all to Int32.

Tests: parser theories cover DBLD/DBL/MLD/ILD/QLD; discovery test
asserts the 64-bit DriverDataType mapping. 64/64 passing.

Closes #287
2026-04-25 16:16:23 -04:00
dohertj2 c6c694b69e Merge pull request '[opcuaclient] OpcUaClient — CRL/revocation handling' (#335) from auto/opcuaclient/5 into auto/driver-gaps 2026-04-25 16:08:21 -04:00
Joseph Doherty 4a3860ae92 Auto: opcuaclient-5 — CRL/revocation handling
Adds explicit revoked-vs-untrusted distinction to the OpcUaClient driver's
server-cert validation hook, plus three new knobs on a new
OpcUaCertificateValidationOptions sub-record:

  RejectSHA1SignedCertificates  (default true — SHA-1 is OPC UA spec-deprecated;
                                 this is a deliberately tighter default)
  RejectUnknownRevocationStatus (default false — keeps brownfield deployments
                                 without CRL infrastructure working)
  MinimumCertificateKeySize     (default 2048)

The validator hook now runs whether or not AutoAcceptCertificates is set:
revoked / issuer-revoked certs are always rejected with a distinct
"REVOKED" log line; SHA-1 + small-key certs are rejected per policy;
unknown-revocation gates on the new flag; untrusted still honours
AutoAccept.

Decision pipeline factored into a static EvaluateCertificateValidation
helper with a CertificateValidationDecision record so unit tests cover
all branches without needing to spin up an SDK CertificateValidator.

CRL files themselves: the OPC UA SDK reads them automatically from the
crl/ subdir of each cert store — no driver-side wiring needed.
Documented on the new options record.

Tests (12 new) cover defaults, every branch of the decision pipeline,
SHA-1 detection (custom X509SignatureGenerator since .NET 10's
CreateSelfSigned refuses SHA-1), and key-size detection. All 127
OpcUaClient unit tests still pass.

Closes #277

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:05:50 -04:00
dohertj2 d57e24a7fa Merge pull request '[opcuaclient] OpcUaClient — Diagnostics counters' (#334) from auto/opcuaclient/4 into auto/driver-gaps 2026-04-25 15:56:21 -04:00
Joseph Doherty bb1ab47b68 Auto: opcuaclient-4 — diagnostics counters
Per-driver counters surfaced via DriverHealth.Diagnostics for the
driver-diagnostics RPC. New OpcUaClientDiagnostics tracks
PublishRequestCount, NotificationCount, NotificationsPerSecond (5s-half-life
EWMA), MissingPublishRequestCount, DroppedNotificationCount,
SessionResetCount and LastReconnectUtcTicks via Interlocked on the hot path.

DriverHealth gains an optional IReadOnlyDictionary<string,double>?
Diagnostics parameter (defaulted null for back-compat with the seven other
drivers' constructors). OpcUaClientDriver wires Session.Notification +
Session.PublishError on connect and on reconnect-complete (recording a
session-reset there); GetHealth snapshots the counters on every poll so the
RPC sees fresh values without a tick source.

Tests: 11 new OpcUaClientDiagnosticsTests cover counter increments, EWMA
convergence, snapshot shape, GetHealth integration, and DriverHealth
back-compat. Full OpcUaClient.Tests 115/115 green.

Closes #276

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:53:57 -04:00
dohertj2 a04ba2af7a Merge pull request '[opcuaclient] OpcUaClient — Honor server OperationLimits' (#333) from auto/opcuaclient/3 into auto/driver-gaps 2026-04-25 15:41:17 -04:00
Joseph Doherty 494fdf2358 Auto: opcuaclient-3 — honor server OperationLimits
Closes #275
2026-04-25 15:38:55 -04:00
dohertj2 9f1e033e83 Merge pull request '[opcuaclient] OpcUaClient — Per-tag advanced subscription tuning incl. deadband' (#332) from auto/opcuaclient/2 into auto/driver-gaps 2026-04-25 15:27:51 -04:00
Joseph Doherty fae00749ca Auto: opcuaclient-2 — per-tag advanced subscription tuning
Closes #274
2026-04-25 15:25:20 -04:00
dohertj2 bf200e813e Merge pull request '[opcuaclient] OpcUaClient — Per-subscription tuning' (#331) from auto/opcuaclient/1 into auto/driver-gaps 2026-04-25 15:11:23 -04:00
Joseph Doherty 7209364c35 Auto: opcuaclient-1 — per-subscription tuning
Closes #273
2026-04-25 15:09:08 -04:00
dohertj2 8314c273e7 Merge pull request '[focas] FOCAS — Figure scaling + diagnostics' (#330) from auto/focas/F1-f into auto/driver-gaps 2026-04-25 15:04:05 -04:00
Joseph Doherty 1abf743a9f Auto: focas-f1f — figure scaling + diagnostics
Closes #262
2026-04-25 15:01:37 -04:00
dohertj2 63a79791cd Merge pull request '[focas] FOCAS — Operator messages + block text' (#329) from auto/focas/F1-e into auto/driver-gaps 2026-04-25 14:51:46 -04:00
Joseph Doherty cc757855e6 Auto: focas-f1e — operator messages + block text
Closes #261
2026-04-25 14:49:11 -04:00
dohertj2 84913638b1 Merge pull request '[focas] FOCAS — Tool number + work coordinate offsets' (#328) from auto/focas/F1-d into auto/driver-gaps 2026-04-25 14:40:17 -04:00
Joseph Doherty 9ec92a9082 Auto: focas-f1d — Tool number + work coordinate offsets
Closes #260
2026-04-25 14:37:51 -04:00
dohertj2 49fc23adc6 Merge pull request '[focas] FOCAS — Modal codes + overrides' (#327) from auto/focas/F1-c into auto/driver-gaps 2026-04-25 14:29:12 -04:00
Joseph Doherty 3c2c4f29ea Auto: focas-f1c — Modal codes + overrides
Closes #259

Adds Modal/ + Override/ fixed-tree subfolders per FOCAS device, mirroring the
pattern established by Status/ (#257) and Production/ (#258): cached snapshots
refreshed on the probe tick, served from cache on read, no extra wire traffic
on top of user-driven tag reads.

Modal/ surfaces the four universally-present aux modal codes M/S/T/B from
cnc_modal(type=100..103) as Int16. **G-group decoding (groups 1..21) is deferred
to a follow-up** — the FWLIB ODBMDL union differs per series + group and the
issue body explicitly permits this scoping. Adds the cnc_modal P/Invoke +
ODBMDL struct + a generic int16 cnc_rdparam helper so the follow-up can add
G-groups without further wire-level scaffolding.

Override/ surfaces Feed/Rapid/Spindle/Jog from cnc_rdparam at MTB-specific
parameter numbers (FocasDeviceOptions.OverrideParameters; defaults to 30i:
6010/6011/6014/6015). Per-field nullable params let a deployment hide overrides
their MTB doesn't wire up; passing OverrideParameters=null suppresses the entire
Override/ subfolder for that device.

6 unit tests cover discovery shape, omitted Override folder when unconfigured,
partial Override field selection, cached-snapshot reads (Modal + Override),
BadCommunicationError before first refresh, and the FwlibFocasClient
disconnected short-circuit.
2026-04-25 14:26:48 -04:00
dohertj2 ae7cc15178 Merge pull request '[focas] FOCAS — Parts count + cycle time' (#326) from auto/focas/F1-b into auto/driver-gaps 2026-04-25 14:17:17 -04:00
Joseph Doherty 3d9697b918 Auto: focas-f1b — parts count + cycle time
Closes #258
2026-04-25 14:14:54 -04:00
dohertj2 329e222aa2 Merge pull request '[focas] FOCAS — ODBST status flags as fixed-tree nodes' (#325) from auto/focas/F1-a into auto/driver-gaps 2026-04-25 14:07:42 -04:00
Joseph Doherty 551494d223 Auto: focas-f1a — ODBST status flags as fixed-tree nodes
Closes #257
2026-04-25 14:05:12 -04:00
dohertj2 5b4925e61a Merge pull request '[ablegacy] AbLegacy — Indirect/indexed addressing parser' (#324) from auto/ablegacy/4 into auto/driver-gaps 2026-04-25 13:53:28 -04:00
Joseph Doherty 4ff4cc5899 Auto: ablegacy-4 — indirect/indexed addressing parser
Closes #247
2026-04-25 13:51:03 -04:00
dohertj2 b95eaacc05 Merge pull request '[ablegacy] AbLegacy — Sub-element bit semantics' (#323) from auto/ablegacy/3 into auto/driver-gaps 2026-04-25 13:44:21 -04:00
Joseph Doherty c89f5bb3b9 Auto: ablegacy-3 — sub-element bit semantics
Closes #246
2026-04-25 13:41:52 -04:00
dohertj2 07235d3b66 Merge pull request '[ablegacy] AbLegacy — MicroLogix function-file letters' (#322) from auto/ablegacy/2 into auto/driver-gaps 2026-04-25 13:34:46 -04:00
Joseph Doherty f2bc36349e Auto: ablegacy-2 — MicroLogix function-file letters
Closes #245
2026-04-25 13:32:23 -04:00
dohertj2 ccf2e3a9c0 Merge pull request '[ablegacy] AbLegacy — PLC-5 octal I/O addressing' (#321) from auto/ablegacy/1 into auto/driver-gaps 2026-04-25 13:26:54 -04:00
Joseph Doherty 8f7265186d Auto: ablegacy-1 — PLC-5 octal I/O addressing
Closes #244
2026-04-25 13:25:22 -04:00
dohertj2 651d6c005c Merge pull request '[abcip] AbCip — CIP multi-tag write packing' (#320) from auto/abcip/1.4 into auto/driver-gaps 2026-04-25 13:16:49 -04:00
Joseph Doherty 36b2929780 Auto: abcip-1.4 — CIP multi-tag write packing
Group writes by device through new AbCipMultiWritePlanner; for families that
support CIP request packing (ControlLogix / CompactLogix / GuardLogix) the
packable writes for one device are dispatched concurrently so libplctag's
native scheduler can coalesce them onto one Multi-Service Packet (0x0A).
Micro800 keeps SupportsRequestPacking=false and falls back to per-tag
sequential writes. BOOL-within-DINT writes are excluded from packing and
continue to go through the per-parent RMW semaphore so two concurrent bit
writes against the same DINT cannot lose one another's update.

The libplctag .NET wrapper does not expose a Multi-Service Packet construction
API at the per-Tag surface (each Tag is one CIP service), so this PR uses
client-side coalescing — concurrent Task.WhenAll dispatch per device — rather
than building raw CIP frames. The native libplctag scheduler does pack
concurrent same-connection writes when the family allows it, which gives the
round-trip reduction #228 calls for without ballooning the diff.

Per-tag StatusCodes preserve caller order across success, transport failure,
non-writable tags, unknown references, and unknown devices, including in
mixed concurrent batches.

Closes #228
2026-04-25 13:14:28 -04:00
dohertj2 345ac97c43 Merge pull request '[abcip] AbCip — Array-slice read addressing Tag[0..N]' (#319) from auto/abcip/1.3 into auto/driver-gaps 2026-04-25 13:06:11 -04:00
Joseph Doherty 767ac4aec5 Auto: abcip-1.3 — array-slice read addressing
Closes #227
2026-04-25 13:03:45 -04:00
dohertj2 29edd835a3 Merge pull request '[abcip] AbCip — STRINGnn variant decoding' (#318) from auto/abcip/1.2 into auto/driver-gaps 2026-04-25 12:55:34 -04:00
Joseph Doherty d78a471e90 Auto: abcip-1.2 — STRINGnn variant decoding
Closes #226

Adds nullable StringLength to AbCipTagDefinition + AbCipStructureMember
so STRING_20 / STRING_40 / STRING_80 UDT variants decode against the
right DATA-array capacity. The configured length threads through a new
StringMaxCapacity field on AbCipTagCreateParams and lands on the
libplctag Tag.StringMaxCapacity attribute (verified property on
libplctag 1.5.2). Null leaves libplctag's default 82-byte STRING in
place for back-compat. Driver gates on DataType == String so a stray
StringLength on a DINT tag doesn't reshape that buffer. UDT member
fan-out copies StringLength from the AbCipStructureMember onto the
synthesised member tag definition.

Tests: 4 new in AbCipDriverReadTests covering threaded StringMaxCapacity,
the null back-compat path, the non-String gate, and the UDT-member fan-out.
2026-04-25 12:53:20 -04:00
dohertj2 1d9e40236b Merge pull request '[abcip] AbCip — LINT/ULINT 64-bit fidelity' (#317) from auto/abcip/1.1 into auto/driver-gaps 2026-04-25 12:47:17 -04:00
Joseph Doherty 2e6228a243 Auto: abcip-1.1 — LINT/ULINT 64-bit fidelity
Closes #225
2026-04-25 12:44:43 -04:00
69 changed files with 9319 additions and 278 deletions
@@ -25,7 +25,7 @@ public enum DriverCapability
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
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,
/// <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>
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="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="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(
DriverState State,
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>
public enum DriverState
@@ -20,7 +20,29 @@ public interface ISubscribable
TimeSpan publishingInterval,
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);
/// <summary>
@@ -30,7 +52,7 @@ public interface ISubscribable
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
{
/// <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>
/// <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="Snapshot">New value + quality + timestamps.</param>
public sealed record DataChangeEventArgs(
ISubscriptionHandle SubscriptionHandle,
string FullReference,
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,
}
@@ -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);
@@ -50,11 +50,12 @@ public static class AbCipDataTypeExtensions
AbCipDataType.Bool => DriverDataType.Boolean,
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => 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.LReal => DriverDataType.Float64,
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
_ => DriverDataType.Int32,
};
+254 -75
View File
@@ -134,7 +134,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
TagPath: $"{tag.TagPath}.{member.Name}",
DataType: member.DataType,
Writable: member.Writable,
WriteIdempotent: member.WriteIdempotent);
WriteIdempotent: member.WriteIdempotent,
StringLength: member.StringLength);
_tagsByName[memberTag.Name] = memberTag;
}
}
@@ -357,6 +358,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return;
}
// PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to
// AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one
// Rockwell array read; the contiguous buffer is decoded at element stride into a
// single snapshot whose Value is an object[] of the N elements.
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
if (parsedPath?.Slice is not null)
{
await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false);
return;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
@@ -372,8 +384,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return;
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
var bitIndex = parsedPath?.BitIndex;
var value = runtime.DecodeValue(def.DataType, bitIndex);
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
@@ -390,6 +401,89 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>
/// PR abcip-1.3 — slice read path. Builds an <see cref="AbCipArrayReadPlan"/> from the
/// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so
/// repeat reads reuse the same libplctag handle), issues one PLC array read, and
/// decodes the contiguous buffer into <c>object?[]</c> at element stride. Unsupported
/// element types fall back to <see cref="AbCipStatusMapper.BadNotSupported"/>.
/// </summary>
private async Task ReadSliceAsync(
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var baseParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsedPath.ToLibplctagName(),
Timeout: _options.Timeout);
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
if (plan is null)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadNotSupported, null, now);
return;
}
try
{
var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, ct)
.ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading slice {def.Name}");
return;
}
var values = AbCipArrayReadPlanner.Decode(plan, runtime);
results[fb.OriginalIndex] = new DataValueSnapshot(values, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
/// <summary>
/// Idempotently materialise a slice-read runtime. Slice runtimes share the device's
/// <see cref="DeviceState.Runtimes"/> dict keyed by the tag's full name so repeated
/// reads reuse the same libplctag handle without re-creating the native tag every poll.
/// </summary>
private async Task<IAbCipTagRuntime> EnsureSliceRuntimeAsync(
DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct)
{
if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing;
var runtime = _tagFactory.Create(createParams);
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.Runtimes[tagName] = runtime;
return runtime;
}
/// <summary>
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
@@ -451,100 +545,184 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- IWritable ----
/// <summary>
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
/// and the resilience pipeline (layered above the driver) decides whether to replay.
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
/// Write each request in the batch. Writes are NOT auto-retried by the driver — per
/// plan decisions #44, #45, #143 the caller opts in via
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> and the resilience pipeline (layered
/// above the driver) decides whether to replay. Non-writable configurations surface as
/// <c>BadNotWritable</c>; type-conversion failures as <c>BadTypeMismatch</c>; transport
/// errors as <c>BadCommunicationError</c>.
/// </summary>
/// <remarks>
/// PR abcip-1.4 — multi-tag write packing. Writes are grouped by device via
/// <see cref="AbCipMultiWritePlanner"/>. Devices whose family
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> is <c>true</c> dispatch
/// their packable writes concurrently so libplctag's native scheduler can coalesce them
/// onto one CIP Multi-Service Packet (0x0A) per round-trip; Micro800 (no packing) still
/// issues writes one-at-a-time. BOOL-within-DINT writes always go through the RMW path
/// under a per-parent semaphore, regardless of the family flag, because two concurrent
/// RMWs on the same DINT could lose one another's update. Per-tag StatusCodes are
/// preserved in the caller's input order on partial failures.
/// </remarks>
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
var now = DateTime.UtcNow;
for (var i = 0; i < writes.Count; i++)
var plans = AbCipMultiWritePlanner.Build(
writes, _tagsByName, _devices,
reportPreflight: (idx, code) => results[idx] = new WriteResult(code));
foreach (var plan in plans)
{
var w = writes[i];
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
if (!def.Writable || def.SafetyTag)
{
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
try
{
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
// Bit-RMW writes always serialise per-parent — never packed.
foreach (var entry in plan.BitRmw)
results[entry.OriginalIndex] = new WriteResult(
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
// per-parent lock prevents two concurrent bit writes to the same DINT from
// losing one another's update.
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
if (plan.Packable.Count == 0) continue;
if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1)
{
// Concurrent dispatch — libplctag's native scheduler packs same-connection writes
// into one Multi-Service Packet when the family supports it.
var tasks = new Task<(int idx, uint code)>[plan.Packable.Count];
for (var i = 0; i < plan.Packable.Count; i++)
{
results[i] = new WriteResult(
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
.ConfigureAwait(false));
if (results[i].StatusCode == AbCipStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
continue;
var entry = plan.Packable[i];
tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken);
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
var status = runtime.GetStatus();
results[i] = new WriteResult(status == 0
? AbCipStatusMapper.Good
: AbCipStatusMapper.MapLibplctagStatus(status));
if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null);
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
foreach (var (idx, code) in outcomes)
results[idx] = new WriteResult(code);
}
catch (OperationCanceledException)
else
{
throw;
}
catch (NotSupportedException nse)
{
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (FormatException fe)
{
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
}
catch (InvalidCastException ice)
{
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
}
catch (OverflowException oe)
{
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
}
catch (Exception ex)
{
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
// Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential.
foreach (var entry in plan.Packable)
{
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
.ConfigureAwait(false);
results[entry.OriginalIndex] = new WriteResult(code.code);
}
}
}
return results;
}
/// <summary>
/// Execute one packable write — encode the value into the per-tag runtime, flush, and
/// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the
/// pre-1.4 per-tag loop so callers see no behaviour change for individual writes.
/// </summary>
private async Task<(int idx, uint code)> ExecutePackableWriteAsync(
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
{
var def = entry.Definition;
var w = entry.Request;
var now = DateTime.UtcNow;
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
runtime.EncodeValue(def.DataType, entry.ParsedPath?.BitIndex, w.Value);
await runtime.WriteAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status == 0)
{
_health = new DriverHealth(DriverState.Healthy, now, null);
return (entry.OriginalIndex, AbCipStatusMapper.Good);
}
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
}
catch (OperationCanceledException)
{
throw;
}
catch (NotSupportedException nse)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadNotSupported);
}
catch (FormatException fe)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
}
catch (InvalidCastException ice)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
}
catch (OverflowException oe)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadOutOfRange);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadCommunicationError);
}
}
/// <summary>
/// Execute one BOOL-within-DINT write through <see cref="WriteBitInDIntAsync"/>, with
/// the same exception-mapping fan-out as the pre-1.4 per-tag loop. Bit RMWs cannot be
/// packed because two concurrent writes against the same parent DINT would race their
/// read-modify-write windows.
/// </summary>
private async Task<uint> ExecuteBitRmwWriteAsync(
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
{
try
{
var bit = entry.ParsedPath!.BitIndex!.Value;
var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct)
.ConfigureAwait(false);
if (code == AbCipStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
return code;
}
catch (OperationCanceledException)
{
throw;
}
catch (NotSupportedException nse)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
return AbCipStatusMapper.BadNotSupported;
}
catch (FormatException fe)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
return AbCipStatusMapper.BadTypeMismatch;
}
catch (InvalidCastException ice)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
return AbCipStatusMapper.BadTypeMismatch;
}
catch (OverflowException oe)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
return AbCipStatusMapper.BadOutOfRange;
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return AbCipStatusMapper.BadCommunicationError;
}
}
/// <summary>
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
@@ -633,7 +811,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout));
Timeout: _options.Timeout,
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -92,6 +92,13 @@ public sealed record AbCipDeviceOptions(
/// 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
/// 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>
public sealed record AbCipTagDefinition(
string Name,
string DeviceHostAddress,
@@ -100,7 +107,8 @@ public sealed record AbCipTagDefinition(
bool Writable = true,
bool WriteIdempotent = false,
IReadOnlyList<AbCipStructureMember>? Members = null,
bool SafetyTag = false);
bool SafetyTag = false,
int? StringLength = null);
/// <summary>
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
@@ -112,7 +120,8 @@ public sealed record AbCipStructureMember(
string Name,
AbCipDataType DataType,
bool Writable = true,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
int? StringLength = null);
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
public enum AbCipPlcFamily
@@ -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(
string? ProgramScope,
IReadOnlyList<AbCipTagPathSegment> Segments,
int? BitIndex)
int? BitIndex,
AbCipTagPathSlice? Slice = null)
{
/// <summary>Rebuild the canonical Logix tag string.</summary>
public string ToLibplctagName()
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
if (seg.Subscripts.Count > 0)
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);
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>
/// 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
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
}
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('[');
if (bracketIdx < 0)
{
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
var name = part[..bracketIdx];
if (!IsValidIdent(name)) return null;
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>();
foreach (var tok in inner.Split(','))
{
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
}
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)
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
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,20 @@ public interface IAbCipTagFactory
/// <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="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>
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
string CipPath,
string LibplctagPlcAttribute,
string TagName,
TimeSpan Timeout);
TimeSpan Timeout,
int? StringMaxCapacity = null,
int? ElementCount = null);
@@ -24,6 +24,17 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
Name = p.TagName,
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;
}
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
@@ -50,7 +61,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
AbCipDataType.Real => _tag.GetFloat32(offset),
AbCipDataType.LReal => _tag.GetFloat64(offset),
AbCipDataType.String => _tag.GetString(offset),
AbCipDataType.Dt => _tag.GetInt32(offset),
AbCipDataType.Dt => _tag.GetInt64(offset),
AbCipDataType.Structure => null,
_ => null,
};
@@ -105,7 +116,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
break;
case AbCipDataType.Dt:
_tag.SetInt32(0, Convert.ToInt32(value));
_tag.SetInt64(0, Convert.ToInt64(value));
break;
case AbCipDataType.Structure:
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
@@ -30,35 +32,87 @@ public sealed record AbLegacyAddress(
int? FileNumber,
int WordNumber,
int? BitIndex,
string? SubElement)
string? SubElement,
AbLegacyAddress? IndirectFileSource = null,
AbLegacyAddress? IndirectWordSource = null)
{
/// <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()
{
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
var wordPart = $"{file}:{WordNumber}";
// Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
// 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}";
if (SubElement is not null) wordPart += $".{SubElement}";
if (BitIndex is not null) wordPart += $"/{BitIndex}";
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;
var src = value.Trim();
// BitIndex: trailing /N
int? bitIndex = null;
var slashIdx = src.IndexOf('/');
if (slashIdx >= 0)
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
// BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
// 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;
bitIndex = bit;
bitText = src[(slashIdx + 1)..];
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.)
// 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;
var dotIdx = src.LastIndexOf('.');
var dotIdx = LastIndexOfTopLevel(src, '.');
if (dotIdx >= 0)
{
var candidate = src[(dotIdx + 1)..];
@@ -69,29 +123,139 @@ public sealed record AbLegacyAddress(
}
}
var colonIdx = src.IndexOf(':');
var colonIdx = IndexOfTopLevel(src, ':');
if (colonIdx <= 0) return null;
var filePart = src[..colonIdx];
var wordPart = src[(colonIdx + 1)..];
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
// 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;
var letterEnd = 1;
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
var letter = filePart[..letterEnd].ToUpperInvariant();
int? fileNumber = null;
AbLegacyAddress? indirectFile = null;
if (letterEnd < filePart.Length)
{
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
fileNumber = fn;
var fileTail = filePart[letterEnd..];
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.
if (!IsKnownFileLetter(letter)) return null;
// Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only.
if (!IsKnownFileLetter(letter))
{
if (!IsFunctionFileLetter(letter) || profile?.SupportsFunctionFiles != true) return null;
}
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
// 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;
}
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord);
}
/// <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
@@ -99,4 +263,14 @@ 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,
_ => 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,
};
}
@@ -26,6 +26,72 @@ public enum AbLegacyDataType
CounterElement,
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
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>
/// 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>
@@ -40,6 +106,106 @@ public static class AbLegacyDataTypeExtensions
AbLegacyDataType.String => DriverDataType.String,
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
AbLegacyDataType.MicroLogixFunctionFile => 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(),
},
_ => 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,
},
_ => 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",
_ => false,
};
}
}
@@ -140,8 +140,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
continue;
}
var parsed = AbLegacyAddress.TryParse(def.Address);
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
// 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);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
@@ -186,7 +191,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
@@ -247,12 +261,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
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);
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
DriverDataType: effectiveType,
IsArray: false,
ArrayDim: null,
SecurityClass: tag.Writable
SecurityClass: tag.Writable && !plcSetBit
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
@@ -413,10 +434,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
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(
$"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.");
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
@@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions(
/// <summary>
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
/// </summary>
public sealed record AbLegacyTagDefinition(
string Name,
@@ -40,8 +40,14 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
AbLegacyDataType.Long => _tag.GetInt32(0),
AbLegacyDataType.Float => _tag.GetFloat32(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
or AbLegacyDataType.ControlElement => _tag.GetInt32(0),
or AbLegacyDataType.ControlElement => bitIndex is int statusBit
? _tag.GetBit(statusBit)
: _tag.GetInt32(0),
_ => null,
};
@@ -9,7 +9,9 @@ public sealed record AbLegacyPlcFamilyProfile(
string DefaultCipPath,
int MaxTagBytes,
bool SupportsStringFile,
bool SupportsLongFile)
bool SupportsLongFile,
bool OctalIoAddressing,
bool SupportsFunctionFiles)
{
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
{
@@ -25,21 +27,27 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0",
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
SupportsStringFile: true, // ST file available SLC 5/04+
SupportsLongFile: true); // L file available SLC 5/05+
SupportsLongFile: true, // L file available SLC 5/05+
OctalIoAddressing: false, // SLC500 I:/O: indices are decimal in RSLogix 500
SupportsFunctionFiles: false); // SLC500 has no function files
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
LibplctagPlcAttribute: "micrologix",
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
MaxTagBytes: 232,
SupportsStringFile: true,
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
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
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
LibplctagPlcAttribute: "plc5",
DefaultCipPath: "1,0",
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
SupportsStringFile: true,
SupportsLongFile: false); // PLC-5 predates L files
SupportsLongFile: false, // PLC-5 predates L files
OctalIoAddressing: true, // RSLogix 5 displays I:/O: word + bit indices as octal
SupportsFunctionFiles: false);
/// <summary>
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
@@ -51,7 +59,9 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0",
MaxTagBytes: 240,
SupportsStringFile: true,
SupportsLongFile: true);
SupportsLongFile: true,
OctalIoAddressing: false, // Logix natively uses decimal arrays even via the PCCC bridge
SupportsFunctionFiles: false);
}
/// <summary>Which PCCC PLC family the device is.</summary>
@@ -106,6 +106,27 @@ public static class FocasCapabilityMatrix
_ => 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)
{
var (min, max) = MacroRange(series);
@@ -24,8 +24,96 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _modalNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _toolingNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _messagesNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _currentBlockNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _diagnosticsNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>
/// Names of the 9 fixed-tree <c>Status/</c> child nodes per device, mirroring the 9
/// fields of Fanuc's <c>cnc_rdcncstat</c> ODBST struct (issue #257). Order matters for
/// deterministic discovery output.
/// </summary>
private static readonly string[] StatusFieldNames =
[
"Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy",
];
/// <summary>
/// Names of the 4 fixed-tree <c>Production/</c> child nodes per device — parts
/// produced/required/total via <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time
/// seconds (issue #258). Order matters for deterministic discovery output.
/// </summary>
private static readonly string[] ProductionFieldNames =
[
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
];
/// <summary>
/// Names of the active modal aux-code child nodes per device — M/S/T/B from
/// <c>cnc_modal(type=100..103)</c> (issue #259). G-group decoding is a deferred
/// follow-up because the FWLIB <c>ODBMDL</c> union varies per series + group.
/// </summary>
private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"];
/// <summary>
/// Names of the four operator-override child nodes per device — Feed / Rapid /
/// Spindle / Jog from <c>cnc_rdparam</c> with MTB-specific parameter numbers
/// (issue #259). A device whose <c>FocasOverrideParameters</c> entry is null for a
/// given field has the matching node omitted from the address space.
/// </summary>
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
/// <summary>
/// Names of the standard work-coordinate offset slots surfaced under
/// <c>Offsets/</c> per device — G54..G59 from <c>cnc_rdzofs(n=1..6)</c>
/// (issue #260). Extended G54.1 P1..P48 surfaces are deferred to a follow-up
/// PR because <c>cnc_rdzofsr</c> uses a different range surface.
/// </summary>
private static readonly string[] WorkOffsetSlotNames =
[
"G54", "G55", "G56", "G57", "G58", "G59",
];
/// <summary>
/// Axis columns surfaced under each <c>Offsets/{slot}/</c> folder. Per the F1-d
/// plan a fixed 3-axis (X/Y/Z) view is used; lathes / mills with extra rotational
/// offsets get those columns exposed as 0.0 until a follow-up extends the surface.
/// </summary>
private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"];
/// <summary>
/// Names of the five fixed-tree <c>Diagnostics/</c> child nodes per device — runtime
/// counters surfaced for operator visibility (issue #262). Order matters for
/// deterministic discovery output.
/// <list type="bullet">
/// <item><c>ReadCount</c> (Int64) — successful probe ticks since init</item>
/// <item><c>ReadFailureCount</c> (Int64) — failed probe ticks since init</item>
/// <item><c>LastErrorMessage</c> (String) — text of the last probe / read failure</item>
/// <item><c>LastSuccessfulRead</c> (DateTime) — UTC timestamp of the last good probe tick</item>
/// <item><c>ReconnectCount</c> (Int64) — wire reconnects observed since init</item>
/// </list>
/// </summary>
private static readonly string[] DiagnosticsFieldNames =
[
"ReadCount", "ReadFailureCount", "LastErrorMessage", "LastSuccessfulRead", "ReconnectCount",
];
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -76,6 +164,67 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_tagsByName[tag.Name] = tag;
}
// Per-device fixed-tree Status nodes — issue #257. Names are deterministic so
// ReadAsync can dispatch on the synthetic full-reference without extra metadata.
foreach (var device in _devices.Values)
{
foreach (var field in StatusFieldNames)
_statusNodesByName[StatusReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
foreach (var field in ProductionFieldNames)
_productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
foreach (var field in ModalFieldNames)
_modalNodesByName[ModalReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
if (device.Options.OverrideParameters is { } op)
{
foreach (var field in OverrideFieldNames)
{
if (OverrideParamFor(op, field) is null) continue;
_overrideNodesByName[OverrideReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
}
}
// Tooling/CurrentTool — single Int16 node per device (issue #260). Tool
// life + active offset index are deferred per the F1-d plan; they need
// ODBTLIFE* unions whose shape varies per series.
if (FocasCapabilityMatrix.SupportsTooling(device.Options.Series))
{
_toolingNodesByName[ToolingReferenceFor(device.Options.HostAddress, "CurrentTool")] =
device.Options.HostAddress;
}
// Offsets/{G54..G59}/{X|Y|Z} — fixed 3-axis view of the standard work-
// coordinate offsets (issue #260). Capability matrix gates by series so
// legacy CNCs that don't support cnc_rdzofs don't produce the subtree.
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Options.Series))
{
foreach (var slot in WorkOffsetSlotNames)
foreach (var axis in WorkOffsetAxisNames)
{
_offsetNodesByName[OffsetReferenceFor(device.Options.HostAddress, slot, axis)] =
(device.Options.HostAddress, slot, axis);
}
}
// Messages/External/Latest + Program/CurrentBlock — single String nodes per
// device backed by cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe
// tick (issue #261). Permissive across series (no capability gate yet).
_messagesNodesByName[MessagesLatestReferenceFor(device.Options.HostAddress)] =
device.Options.HostAddress;
_currentBlockNodesByName[CurrentBlockReferenceFor(device.Options.HostAddress)] =
device.Options.HostAddress;
// Diagnostics/{ReadCount, ReadFailureCount, LastErrorMessage,
// LastSuccessfulRead, ReconnectCount} — runtime counters surfaced for
// operator visibility (issue #262). Permissive across all CNC series.
foreach (var field in DiagnosticsFieldNames)
_diagnosticsNodesByName[DiagnosticsReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
}
if (_options.Probe.Enabled)
{
foreach (var state in _devices.Values)
@@ -113,6 +262,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
_devices.Clear();
_tagsByName.Clear();
_statusNodesByName.Clear();
_productionNodesByName.Clear();
_modalNodesByName.Clear();
_overrideNodesByName.Clear();
_toolingNodesByName.Clear();
_offsetNodesByName.Clear();
_messagesNodesByName.Clear();
_currentBlockNodesByName.Clear();
_diagnosticsNodesByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -136,6 +294,73 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
// Fixed-tree Status/ nodes — served from the per-device cached ODBST struct
// refreshed on the probe tick (issue #257). No wire call here.
if (_statusNodesByName.TryGetValue(reference, out var statusKey))
{
results[i] = ReadStatusField(statusKey.Host, statusKey.Field, now);
continue;
}
// Fixed-tree Production/ nodes — served from the per-device cached production
// snapshot refreshed on the probe tick (issue #258). No wire call here.
if (_productionNodesByName.TryGetValue(reference, out var prodKey))
{
results[i] = ReadProductionField(prodKey.Host, prodKey.Field, now);
continue;
}
// Fixed-tree Modal/ + Override/ nodes — served from per-device cached snapshots
// refreshed on the probe tick (issue #259). Same cache-or-Bad policy as Status/.
if (_modalNodesByName.TryGetValue(reference, out var modalKey))
{
results[i] = ReadModalField(modalKey.Host, modalKey.Field, now);
continue;
}
if (_overrideNodesByName.TryGetValue(reference, out var overrideKey))
{
results[i] = ReadOverrideField(overrideKey.Host, overrideKey.Field, now);
continue;
}
// Fixed-tree Tooling/CurrentTool — served from cached cnc_rdtnum snapshot
// refreshed on the probe tick (issue #260). No wire call here.
if (_toolingNodesByName.TryGetValue(reference, out var toolingHost))
{
results[i] = ReadToolingField(toolingHost, "CurrentTool", now);
continue;
}
// Fixed-tree Offsets/{slot}/{axis} — served from cached cnc_rdzofs(1..6)
// snapshot refreshed on the probe tick (issue #260). No wire call here.
if (_offsetNodesByName.TryGetValue(reference, out var offsetKey))
{
results[i] = ReadOffsetField(offsetKey.Host, offsetKey.Slot, offsetKey.Axis, now);
continue;
}
// Fixed-tree Messages/External/Latest + Program/CurrentBlock — served from
// cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe tick (issue #261).
if (_messagesNodesByName.TryGetValue(reference, out var messagesHost))
{
results[i] = ReadMessagesLatestField(messagesHost, now);
continue;
}
if (_currentBlockNodesByName.TryGetValue(reference, out var blockHost))
{
results[i] = ReadCurrentBlockField(blockHost, now);
continue;
}
// Fixed-tree Diagnostics/ nodes — runtime counters maintained by the probe
// loop (issue #262). No wire call here.
if (_diagnosticsNodesByName.TryGetValue(reference, out var diagKey))
{
results[i] = ReadDiagnosticsField(diagKey.Host, diagKey.Field, now);
continue;
}
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
@@ -257,10 +482,267 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent));
}
// Fixed-tree Status/ subfolder — 9 read-only Int16 nodes mirroring the ODBST
// fields (issue #257). Cached on the probe tick + served from DeviceState.LastStatus.
var statusFolder = deviceFolder.Folder("Status", "Status");
foreach (var field in StatusFieldNames)
{
var fullRef = StatusReferenceFor(device.HostAddress, field);
statusFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Production/ subfolder — 4 read-only Int32 nodes: parts produced /
// required / total + cycle-time seconds (issue #258). Cached on the probe tick
// + served from DeviceState.LastProduction.
var productionFolder = deviceFolder.Folder("Production", "Production");
foreach (var field in ProductionFieldNames)
{
var fullRef = ProductionReferenceFor(device.HostAddress, field);
productionFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int32,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Modal/ subfolder — 4 read-only Int16 nodes for the universally-
// present aux modal codes M/S/T/B from cnc_modal(type=100..103). G-group
// surfaces are deferred to a follow-up because the FWLIB ODBMDL union varies
// per series + group (issue #259, plan PR F1-c).
var modalFolder = deviceFolder.Folder("Modal", "Modal");
foreach (var field in ModalFieldNames)
{
var fullRef = ModalReferenceFor(device.HostAddress, field);
modalFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Override/ subfolder — Feed / Rapid / Spindle / Jog from
// cnc_rdparam at MTB-specific parameter numbers (issue #259). Suppressed when
// OverrideParameters is null; per-field nodes whose parameter is null are
// omitted so a deployment can hide overrides their MTB doesn't wire up.
if (device.OverrideParameters is { } overrideParams)
{
var overrideFolder = deviceFolder.Folder("Override", "Override");
foreach (var field in OverrideFieldNames)
{
if (OverrideParamFor(overrideParams, field) is null) continue;
var fullRef = OverrideReferenceFor(device.HostAddress, field);
overrideFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
// Fixed-tree Tooling/ subfolder — single Int16 CurrentTool node from
// cnc_rdtnum (issue #260). Tool life + active offset index are deferred
// per the F1-d plan because the FWLIB ODBTLIFE* unions vary per series.
if (FocasCapabilityMatrix.SupportsTooling(device.Series))
{
var toolingFolder = deviceFolder.Folder("Tooling", "Tooling");
var toolingRef = ToolingReferenceFor(device.HostAddress, "CurrentTool");
toolingFolder.Variable("CurrentTool", "CurrentTool", new DriverAttributeInfo(
FullName: toolingRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Offsets/ subfolder — G54..G59 each with X/Y/Z Float64 axes
// from cnc_rdzofs(n=1..6) (issue #260). Capability matrix gates the surface
// by series so legacy controllers without cnc_rdzofs support don't expose
// dead nodes. Extended G54.1 P1..P48 surfaces are deferred to a follow-up.
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Series))
{
var offsetsFolder = deviceFolder.Folder("Offsets", "Offsets");
foreach (var slot in WorkOffsetSlotNames)
{
var slotFolder = offsetsFolder.Folder(slot, slot);
foreach (var axis in WorkOffsetAxisNames)
{
var fullRef = OffsetReferenceFor(device.HostAddress, slot, axis);
slotFolder.Variable(axis, axis, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
}
// Fixed-tree Messages/External/Latest — single String node per device backed
// by cnc_rdopmsg3 across the four FANUC operator-message classes (issue #261).
// The issue body permits this minimal "latest message" surface in the first
// cut over a full ring-buffer of all four slots.
var messagesFolder = deviceFolder.Folder("Messages", "Messages");
var externalFolder = messagesFolder.Folder("External", "External");
var messagesRef = MessagesLatestReferenceFor(device.HostAddress);
externalFolder.Variable("Latest", "Latest", new DriverAttributeInfo(
FullName: messagesRef,
DriverDataType: DriverDataType.String,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
// Fixed-tree Program/CurrentBlock — single String node per device backed by
// cnc_rdactpt (issue #261). Trim-stable round-trip per the issue body.
var programFolder = deviceFolder.Folder("Program", "Program");
var blockRef = CurrentBlockReferenceFor(device.HostAddress);
programFolder.Variable("CurrentBlock", "CurrentBlock", new DriverAttributeInfo(
FullName: blockRef,
DriverDataType: DriverDataType.String,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
// Fixed-tree Diagnostics/ subfolder — 5 read-only counters surfaced for
// operator visibility (issue #262). ReadCount / ReadFailureCount /
// ReconnectCount are Int64; LastErrorMessage is String;
// LastSuccessfulRead is DateTime. Permissive across CNC series — every
// device gets the same shape.
var diagnosticsFolder = deviceFolder.Folder("Diagnostics", "Diagnostics");
foreach (var field in DiagnosticsFieldNames)
{
var fullRef = DiagnosticsReferenceFor(device.HostAddress, field);
diagnosticsFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DiagnosticsFieldType(field),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
return Task.CompletedTask;
}
private static DriverDataType DiagnosticsFieldType(string field) => field switch
{
"ReadCount" or "ReadFailureCount" or "ReconnectCount" => DriverDataType.Int64,
"LastErrorMessage" => DriverDataType.String,
"LastSuccessfulRead" => DriverDataType.DateTime,
_ => DriverDataType.String,
};
private static string StatusReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Status/{field}";
private static string ProductionReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Production/{field}";
private static string ModalReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Modal/{field}";
private static string OverrideReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Override/{field}";
private static string ToolingReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Tooling/{field}";
private static string OffsetReferenceFor(string hostAddress, string slot, string axis) =>
$"{hostAddress}::Offsets/{slot}/{axis}";
private static string MessagesLatestReferenceFor(string hostAddress) =>
$"{hostAddress}::Messages/External/Latest";
private static string CurrentBlockReferenceFor(string hostAddress) =>
$"{hostAddress}::Program/CurrentBlock";
private static string DiagnosticsReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Diagnostics/{field}";
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
{
"Feed" => p.FeedParam,
"Rapid" => p.RapidParam,
"Spindle" => p.SpindleParam,
"Jog" => p.JogParam,
_ => null,
};
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
{
"Tmmode" => s.Tmmode,
"Aut" => s.Aut,
"Run" => s.Run,
"Motion" => s.Motion,
"Mstb" => s.Mstb,
"EmergencyStop" => s.EmergencyStop,
"Alarm" => s.Alarm,
"Edit" => s.Edit,
"Dummy" => s.Dummy,
_ => null,
};
private static int? PickProductionField(FocasProductionInfo p, string field) => field switch
{
"PartsProduced" => p.PartsProduced,
"PartsRequired" => p.PartsRequired,
"PartsTotal" => p.PartsTotal,
"CycleTimeSeconds" => p.CycleTimeSeconds,
_ => null,
};
private static short? PickModalField(FocasModalInfo m, string field) => field switch
{
"MCode" => m.MCode,
"SCode" => m.SCode,
"TCode" => m.TCode,
"BCode" => m.BCode,
_ => null,
};
private static short? PickOverrideField(FocasOverrideInfo o, string field) => field switch
{
"Feed" => o.Feed,
"Rapid" => o.Rapid,
"Spindle" => o.Spindle,
"Jog" => o.Jog,
_ => null,
};
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
@@ -283,13 +765,123 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
while (!ct.IsCancellationRequested)
{
var success = false;
string? failureMessage = null;
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
success = await client.ProbeAsync(ct).ConfigureAwait(false);
if (success)
{
// Refresh figure-scaling cache once per session (issue #262). The
// increment system rarely changes mid-session; re-reading every probe
// tick would waste a wire call. Best-effort — null result leaves the
// previous good map in place.
if (state.FigureScaling is null)
{
var fig = await client.GetFigureScalingAsync(ct).ConfigureAwait(false);
if (fig is not null) state.FigureScaling = fig;
}
// Refresh the cached ODBST status snapshot on every probe tick — this is
// what the Status/ fixed-tree nodes serve from. Best-effort: a null result
// (older IFocasClient impls without GetStatusAsync) just leaves the cache
// unchanged so the previous good snapshot keeps serving until refreshed.
var snapshot = await client.GetStatusAsync(ct).ConfigureAwait(false);
if (snapshot is not null)
{
state.LastStatus = snapshot;
state.LastStatusUtc = DateTime.UtcNow;
}
// Refresh the cached production snapshot too — same best-effort policy
// as Status/: a null result leaves the previous good snapshot in place
// so reads keep serving until the next successful refresh (issue #258).
var production = await client.GetProductionAsync(ct).ConfigureAwait(false);
if (production is not null)
{
state.LastProduction = production;
state.LastProductionUtc = DateTime.UtcNow;
}
// Modal aux M/S/T/B + per-device operator overrides — same best-effort
// policy as Status/ + Production/. Override snapshot is suppressed when
// the device has no OverrideParameters configured (issue #259).
var modal = await client.GetModalAsync(ct).ConfigureAwait(false);
if (modal is not null)
{
state.LastModal = modal;
state.LastModalUtc = DateTime.UtcNow;
}
if (state.Options.OverrideParameters is { } overrideParams)
{
var ov = await client.GetOverrideAsync(overrideParams, ct).ConfigureAwait(false);
if (ov is not null)
{
state.LastOverride = ov;
state.LastOverrideUtc = DateTime.UtcNow;
}
}
// Tooling/CurrentTool + Offsets/{G54..G59}/{X|Y|Z} — same best-
// effort policy as the other fixed-tree caches (issue #260). A
// null result leaves the previous good snapshot in place so reads
// keep serving until the next successful refresh.
if (FocasCapabilityMatrix.SupportsTooling(state.Options.Series))
{
var tooling = await client.GetToolingAsync(ct).ConfigureAwait(false);
if (tooling is not null)
{
state.LastTooling = tooling;
state.LastToolingUtc = DateTime.UtcNow;
}
}
if (FocasCapabilityMatrix.SupportsWorkOffsets(state.Options.Series))
{
var offsets = await client.GetWorkOffsetsAsync(ct).ConfigureAwait(false);
if (offsets is not null)
{
state.LastWorkOffsets = offsets;
state.LastWorkOffsetsUtc = DateTime.UtcNow;
}
}
// Operator messages + currently-executing block — same best-effort
// policy as the other fixed-tree caches (issue #261). A null result
// leaves the previous good snapshot in place so reads keep serving
// until the next successful refresh.
var messages = await client.GetOperatorMessagesAsync(ct).ConfigureAwait(false);
if (messages is not null)
{
state.LastMessages = messages;
state.LastMessagesUtc = DateTime.UtcNow;
}
var block = await client.GetCurrentBlockAsync(ct).ConfigureAwait(false);
if (block is not null)
{
state.LastCurrentBlock = block;
state.LastCurrentBlockUtc = DateTime.UtcNow;
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* connect-failure path already disposed + cleared the client */ }
catch (Exception ex)
{
failureMessage = ex.Message;
/* connect-failure path already disposed + cleared the client */
}
// Diagnostics counters refreshed per probe tick (issue #262). Successful
// ticks bump ReadCount + LastSuccessfulRead; failed ticks bump
// ReadFailureCount + LastErrorMessage. The reconnect counter is bumped in
// EnsureConnectedAsync's connect path so a wedged probe doesn't double-count.
if (success)
{
Interlocked.Increment(ref state.ReadCount);
state.LastSuccessfulReadUtc = DateTime.UtcNow;
}
else
{
Interlocked.Increment(ref state.ReadFailureCount);
if (!string.IsNullOrEmpty(failureMessage))
state.LastErrorMessage = failureMessage;
}
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
@@ -298,6 +890,161 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
private DataValueSnapshot ReadStatusField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastStatus is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickStatusField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastStatusUtc, now);
}
private DataValueSnapshot ReadProductionField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastProduction is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickProductionField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((int)value, FocasStatusMapper.Good,
device.LastProductionUtc, now);
}
private DataValueSnapshot ReadModalField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastModal is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickModalField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastModalUtc, now);
}
private DataValueSnapshot ReadOverrideField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastOverride is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickOverrideField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastOverrideUtc, now);
}
private DataValueSnapshot ReadToolingField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastTooling is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
return field switch
{
"CurrentTool" => new DataValueSnapshot(snap.CurrentTool, FocasStatusMapper.Good,
device.LastToolingUtc, now),
_ => new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now),
};
}
private DataValueSnapshot ReadOffsetField(string hostAddress, string slot, string axis, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastWorkOffsets is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var match = snap.Offsets.FirstOrDefault(o =>
string.Equals(o.Name, slot, StringComparison.OrdinalIgnoreCase));
if (match is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
var value = axis switch
{
"X" => (double?)match.X,
"Y" => match.Y,
"Z" => match.Z,
_ => null,
};
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot(value.Value, FocasStatusMapper.Good,
device.LastWorkOffsetsUtc, now);
}
private DataValueSnapshot ReadMessagesLatestField(string hostAddress, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastMessages is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
// Snapshot is the trimmed list of active classes. "Latest" surfaces the last
// (most-recent) entry — the issue body permits this minimal "latest message"
// surface in lieu of a full ring buffer of all 4 classes.
var latest = snap.Messages.Count == 0
? string.Empty
: snap.Messages[snap.Messages.Count - 1].Text;
return new DataValueSnapshot(latest, FocasStatusMapper.Good,
device.LastMessagesUtc, now);
}
private DataValueSnapshot ReadCurrentBlockField(string hostAddress, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastCurrentBlock is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
return new DataValueSnapshot(snap.Text, FocasStatusMapper.Good,
device.LastCurrentBlockUtc, now);
}
private DataValueSnapshot ReadDiagnosticsField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
// Diagnostics counters are always Good — they're driver-internal state, not wire
// reads. LastSuccessfulRead surfaces DateTime.MinValue before the first probe
// tick rather than null because OPC UA's DateTime variant has no "unset" sentinel
// a generic client can interpret (issue #262).
object? value = field switch
{
"ReadCount" => Interlocked.Read(ref device.ReadCount),
"ReadFailureCount" => Interlocked.Read(ref device.ReadFailureCount),
"ReconnectCount" => Interlocked.Read(ref device.ReconnectCount),
"LastErrorMessage" => device.LastErrorMessage ?? string.Empty,
"LastSuccessfulRead" => device.LastSuccessfulReadUtc,
_ => null,
};
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
}
/// <summary>
/// Apply <c>cnc_getfigure</c>-derived decimal scaling to a raw position value.
/// Returns <paramref name="raw"/> divided by <c>10^decimalPlaces</c> when the
/// device has a cached scaling entry for <paramref name="axisName"/> AND
/// <see cref="FocasFixedTreeOptions.ApplyFigureScaling"/> is on; otherwise
/// returns the raw value as a <c>double</c>. Forward-looking — surfaced for
/// future PRs that wire up <c>Axes/{name}/AbsolutePosition</c> etc. so they
/// don't need to re-derive the policy (issue #262).
/// </summary>
internal double ApplyFigureScaling(string hostAddress, string axisName, long raw)
{
if (!_options.FixedTree.ApplyFigureScaling) return raw;
if (!_devices.TryGetValue(hostAddress, out var device)) return raw;
if (device.FigureScaling is not { } map) return raw;
if (!map.TryGetValue(axisName, out var dec) || dec <= 0) return raw;
return raw / Math.Pow(10.0, dec);
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
@@ -324,6 +1071,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
{
if (device.Client is { IsConnected: true } c) return c;
// Reconnect counter bumps before the connect call — a successful first connect
// counts as one "establishment" so the field is non-zero from session start
// (issue #262, mirrors the convention from the AbCip / TwinCAT diagnostics).
Interlocked.Increment(ref device.ReconnectCount);
device.Client ??= _clientFactory.Create();
try
{
@@ -352,6 +1103,90 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
public CancellationTokenSource? ProbeCts { get; set; }
/// <summary>
/// Cached <c>cnc_rdcncstat</c> snapshot, refreshed on every probe tick. Reads of
/// the per-device <c>Status/&lt;field&gt;</c> fixed-tree nodes serve from this cache
/// so they don't pile extra wire traffic on top of the user-driven tag reads.
/// </summary>
public FocasStatusInfo? LastStatus { get; set; }
public DateTime LastStatusUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time snapshot, refreshed on
/// every probe tick. Reads of the per-device <c>Production/&lt;field&gt;</c>
/// fixed-tree nodes serve from this cache so they don't pile extra wire traffic
/// on top of the user-driven tag reads (issue #258).
/// </summary>
public FocasProductionInfo? LastProduction { get; set; }
public DateTime LastProductionUtc { get; set; }
/// <summary>
/// Cached <c>cnc_modal</c> M/S/T/B snapshot, refreshed on every probe tick.
/// Reads of the per-device <c>Modal/&lt;field&gt;</c> nodes serve from this cache
/// so they don't pile extra wire traffic on top of user-driven reads (issue #259).
/// </summary>
public FocasModalInfo? LastModal { get; set; }
public DateTime LastModalUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdparam</c> override snapshot, refreshed on every probe tick.
/// Suppressed when the device's <see cref="FocasDeviceOptions.OverrideParameters"/>
/// is null (no <c>Override/</c> nodes are exposed in that case — issue #259).
/// </summary>
public FocasOverrideInfo? LastOverride { get; set; }
public DateTime LastOverrideUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdtnum</c> snapshot — current tool number — refreshed on
/// every probe tick. Reads of <c>Tooling/CurrentTool</c> serve from this
/// cache so they don't pile extra wire traffic on top of user-driven
/// reads (issue #260).
/// </summary>
public FocasToolingInfo? LastTooling { get; set; }
public DateTime LastToolingUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdzofs(1..6)</c> snapshot — G54..G59 work-coordinate
/// offsets — refreshed on every probe tick. Reads of
/// <c>Offsets/{slot}/{X|Y|Z}</c> serve from this cache (issue #260).
/// </summary>
public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; }
public DateTime LastWorkOffsetsUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdopmsg3</c> snapshot — active operator messages across
/// the four FANUC classes — refreshed on every probe tick. Reads of
/// <c>Messages/External/Latest</c> serve from this cache (issue #261).
/// </summary>
public FocasOperatorMessagesInfo? LastMessages { get; set; }
public DateTime LastMessagesUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdactpt</c> snapshot — currently-executing block text —
/// refreshed on every probe tick. Reads of <c>Program/CurrentBlock</c>
/// serve from this cache (issue #261).
/// </summary>
public FocasCurrentBlockInfo? LastCurrentBlock { get; set; }
public DateTime LastCurrentBlockUtc { get; set; }
/// <summary>
/// Cached per-axis decimal-place counts from <c>cnc_getfigure</c> (issue #262).
/// Populated once per session (the increment system rarely changes mid-run);
/// served by <see cref="FocasDriver.ApplyFigureScaling"/> when a future PR
/// surfaces position values that need scaling. Keys are axis names (or
/// fallback <c>"axis{n}"</c> until <c>cnc_rdaxisname</c> integration lands).
/// </summary>
public IReadOnlyDictionary<string, int>? FigureScaling { get; set; }
// Diagnostics counters per device — surfaced under Diagnostics/ subtree (issue
// #262). Public fields rather than properties so Interlocked.Increment can
// operate on them directly. Long-typed for the OPC UA Int64 surface.
public long ReadCount;
public long ReadFailureCount;
public long ReconnectCount;
public string? LastErrorMessage;
public DateTime LastSuccessfulReadUtc;
public void DisposeClient()
{
Client?.Dispose();
@@ -11,17 +11,49 @@ public sealed class FocasDriverOptions
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
public FocasProbeOptions Probe { get; init; } = new();
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>
/// 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>
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
/// <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>
public sealed record FocasDeviceOptions(
string HostAddress,
string? DeviceName = null,
FocasCncSeries Series = FocasCncSeries.Unknown);
FocasCncSeries Series = FocasCncSeries.Unknown,
FocasOverrideParameters? OverrideParameters = null);
/// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
@@ -137,6 +137,256 @@ internal sealed class FwlibFocasClient : IFocasClient
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 ----
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
@@ -88,6 +88,104 @@ internal static class FwlibNative
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
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);
// ---- 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 ----
/// <summary>
@@ -129,6 +227,121 @@ internal static class FwlibNative
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>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBST
@@ -48,8 +48,236 @@ public interface IFocasClient : IDisposable
/// responds with any valid status.
/// </summary>
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>
/// 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>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{
@@ -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;
}
}
@@ -1,3 +1,4 @@
using System.Security.Cryptography.X509Certificates;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
@@ -58,6 +59,23 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
private readonly OpcUaClientDriverOptions _options = options;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>
/// Per-driver diagnostic counters (publish/notification rates, missing-publish,
/// dropped-notification, session-reset). Surfaced through
/// <see cref="DriverHealth.Diagnostics"/> for the <c>driver-diagnostics</c> RPC.
/// Hot-path increments use <see cref="Interlocked"/>; the read path snapshots.
/// </summary>
private readonly OpcUaClientDiagnostics _diagnostics = new();
/// <summary>Test seam — exposes the live counters for unit tests.</summary>
internal OpcUaClientDiagnostics DiagnosticsForTest => _diagnostics;
/// <summary>Wired to <see cref="ISession.Notification"/> in <see cref="WireSessionDiagnostics"/>; cached so we can unwire in <see cref="ShutdownAsync"/> + on reconnect.</summary>
private NotificationEventHandler? _notificationHandler;
/// <summary>Wired to <see cref="ISession.PublishError"/>; cached so we can unwire on reconnect/shutdown.</summary>
private PublishErrorEventHandler? _publishErrorHandler;
/// <summary>Active OPC UA session. Null until <see cref="InitializeAsync"/> returns cleanly.</summary>
internal ISession? Session { get; private set; }
@@ -75,6 +93,31 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
/// </summary>
private SessionReconnectHandler? _reconnectHandler;
/// <summary>
/// Cached server-advertised OperationLimits, fetched lazily on first batch op and
/// refreshed on reconnect. Null until the first successful fetch; null components
/// mean "fetch hasn't completed yet, fall through to single-call". Per spec, a 0
/// limit means "no limit" — we surface that as <c>uint?</c>=null too so the
/// chunking helper has a single sentinel for "don't chunk".
/// </summary>
private OperationLimitsCache? _operationLimits;
private readonly SemaphoreSlim _operationLimitsLock = new(1, 1);
/// <summary>
/// Snapshot of the four OperationLimits the driver chunks against. Stored as
/// <c>uint?</c> so callers can distinguish "not yet fetched" / "no limit"
/// (null) from "limit = N" (Some(N)). Spec sentinel 0 is normalized to null at
/// fetch time so the chunking helper has a single "don't chunk" sentinel.
/// </summary>
internal sealed record OperationLimitsCache(
uint? MaxNodesPerRead,
uint? MaxNodesPerWrite,
uint? MaxNodesPerBrowse,
uint? MaxNodesPerHistoryReadData);
/// <summary>Test seam — exposes the cached limits so unit tests can assert fetch behaviour.</summary>
internal OperationLimitsCache? OperationLimitsForTest => _operationLimits;
public string DriverInstanceId => driverInstanceId;
public string DriverType => "OpcUaClient";
@@ -126,6 +169,8 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
_keepAliveHandler = OnKeepAlive;
session.KeepAlive += _keepAliveHandler;
WireSessionDiagnostics(session);
Session = session;
_connectedEndpointUrl = connectedUrl;
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
@@ -204,17 +249,11 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
// Attach a cert-validator handler that honours the AutoAccept flag. Without this,
// AutoAcceptUntrustedCertificates on the config alone isn't always enough in newer
// SDK versions — the validator raises an event the app has to handle.
if (_options.AutoAcceptCertificates)
{
config.CertificateValidator.CertificateValidation += (s, e) =>
{
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
e.Accept = true;
};
}
// Attach a cert-validator handler. The SDK's AutoAcceptUntrustedCertificates flag
// alone isn't always enough in newer SDK versions — the validator raises an event
// the app has to handle. We also use this hook to enforce the
// CertificateValidation policy (revoked, SHA-1, key size) regardless of AutoAccept.
config.CertificateValidator.CertificateValidation += OnCertificateValidation;
// Ensure an application certificate exists. The SDK auto-generates one if missing.
app.ApplicationConfiguration = config;
@@ -224,6 +263,128 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
return config;
}
/// <summary>
/// Cert-validator callback. Funnels into <see cref="EvaluateCertificateValidation"/>
/// for testability — the static helper takes the cert + status code + options and
/// returns the decision, which this method then applies to the SDK's event args.
/// </summary>
private void OnCertificateValidation(object sender, Opc.Ua.CertificateValidationEventArgs e)
{
var decision = EvaluateCertificateValidation(
e.Certificate,
e.Error.StatusCode,
_options.AutoAcceptCertificates,
_options.CertificateValidation);
if (decision.LogMessage is { Length: > 0 })
{
// Use the SDK's trace surface — no driver-side ILogger is plumbed today, and the
// SDK trace is already wired up by the host. Warning level for rejections so
// operators surface them without code changes. The non-telemetry overload is
// marked obsolete in the latest SDK; suppress locally to keep the gateway-driver
// surface free of an ITelemetryContext plumb-through (parity with the same
// pattern in BuildApplicationConfigurationAsync).
#pragma warning disable CS0618
Opc.Ua.Utils.LogWarning(
"OpcUaClient[{0}] cert-validation: {1} (subject={2}, status=0x{3:X8})",
driverInstanceId, decision.LogMessage,
e.Certificate?.Subject ?? "<null>",
(uint)e.Error.StatusCode.Code);
#pragma warning restore CS0618
}
e.Accept = decision.Accept;
}
/// <summary>
/// Cert-validation decision pipeline. Pulled out as a static helper so unit tests can
/// drive each branch without standing up an OPC UA SDK <c>CertificateValidator</c>.
/// Order matters: revoked &gt; SHA-1 &gt; key-size &gt; revocation-unknown &gt; auto-accept-untrusted.
/// </summary>
/// <param name="cert">Server certificate the SDK is asking us to validate. May be null in pathological cases.</param>
/// <param name="status">The SDK's validation result. <c>Good</c> = no failure to inspect.</param>
/// <param name="autoAcceptUntrusted">Mirror of <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/>.</param>
/// <param name="opts">The cert-validation knobs.</param>
internal static CertificateValidationDecision EvaluateCertificateValidation(
System.Security.Cryptography.X509Certificates.X509Certificate2? cert,
Opc.Ua.StatusCode status,
bool autoAcceptUntrusted,
OpcUaCertificateValidationOptions opts)
{
// Revoked certs are always a hard fail — never auto-accept regardless of flags.
if (status.Code == Opc.Ua.StatusCodes.BadCertificateRevoked)
return new CertificateValidationDecision(false, "REVOKED server certificate — rejecting");
if (status.Code == Opc.Ua.StatusCodes.BadCertificateIssuerRevoked)
return new CertificateValidationDecision(false, "REVOKED issuer certificate — rejecting");
// SHA-1 signature detection runs even when the SDK didn't surface a status —
// we want to reject SHA-1 certs on policy, not just when the SDK happens to flag them.
if (opts.RejectSHA1SignedCertificates && IsSha1Signed(cert))
return new CertificateValidationDecision(false, "SHA-1 signed certificate rejected by policy");
// Key-size check: only meaningful for RSA keys; ECC bypasses.
if (cert is not null && TryGetRsaKeySize(cert, out var keyBits) && keyBits < opts.MinimumCertificateKeySize)
return new CertificateValidationDecision(false,
$"RSA key size {keyBits} bits below minimum {opts.MinimumCertificateKeySize}");
// Unknown revocation status — reject only if policy says so.
if (status.Code == Opc.Ua.StatusCodes.BadCertificateRevocationUnknown
|| status.Code == Opc.Ua.StatusCodes.BadCertificateIssuerRevocationUnknown)
{
if (opts.RejectUnknownRevocationStatus)
return new CertificateValidationDecision(false, "revocation status unknown (no/stale CRL) — rejecting per policy");
return new CertificateValidationDecision(true, "revocation status unknown (no/stale CRL) — accepting per policy");
}
// Untrusted: SDK couldn't chain the cert to a trusted issuer. Honour AutoAccept.
if (status.Code == Opc.Ua.StatusCodes.BadCertificateUntrusted)
{
if (autoAcceptUntrusted) return new CertificateValidationDecision(true, null);
return new CertificateValidationDecision(false, "untrusted certificate — rejecting (AutoAcceptCertificates=false)");
}
// Anything else is an SDK-level failure — let the SDK's default disposition stand
// (don't accept by default; surface the status code in the log).
if (status.Code != Opc.Ua.StatusCodes.Good)
return new CertificateValidationDecision(false, $"validation failed (status=0x{(uint)status.Code:X8})");
return new CertificateValidationDecision(true, null);
}
/// <summary>
/// True when the cert's signature algorithm OID matches a SHA-1 RSA signature
/// (<c>1.2.840.113549.1.1.5</c>) or a SHA-1 ECDSA signature (<c>1.2.840.10045.4.1</c>).
/// Friendly-name prefix match is unreliable across .NET runtimes, so we use OIDs.
/// </summary>
internal static bool IsSha1Signed(System.Security.Cryptography.X509Certificates.X509Certificate2? cert)
{
if (cert is null) return false;
var oid = cert.SignatureAlgorithm?.Value;
return oid is "1.2.840.113549.1.1.5" // sha1RSA
or "1.2.840.10045.4.1"; // sha1ECDSA
}
/// <summary>
/// Read the RSA public key size in bits if the cert has an RSA key. Returns false for
/// non-RSA (ECC, DSA) certs so the key-size check is skipped on them.
/// </summary>
internal static bool TryGetRsaKeySize(
System.Security.Cryptography.X509Certificates.X509Certificate2 cert,
out int keyBits)
{
using var rsa = cert.GetRSAPublicKey();
if (rsa is null) { keyBits = 0; return false; }
keyBits = rsa.KeySize;
return true;
}
/// <summary>
/// Outcome of <see cref="EvaluateCertificateValidation"/>. <see cref="LogMessage"/>
/// is null when the decision is silently "accept (Good)" — no need to log healthy
/// validations.
/// </summary>
internal readonly record struct CertificateValidationDecision(bool Accept, string? LogMessage);
/// <summary>
/// Resolve the ordered failover candidate list. <c>EndpointUrls</c> wins when
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
@@ -422,17 +583,27 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
}
_keepAliveHandler = null;
UnwireSessionDiagnostics(Session);
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
try { Session?.Dispose(); } catch { }
Session = null;
_connectedEndpointUrl = null;
_operationLimits = null;
TransitionTo(HostState.Unknown);
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
public DriverHealth GetHealth() => _health;
public DriverHealth GetHealth()
{
// Snapshot the counters into the optional Diagnostics dictionary on every poll —
// the RPC reads through GetHealth so we can't lazy-cache without a tick source.
// The snapshot is O(7) so the per-poll cost is negligible compared to the RPC plumbing.
var h = _health;
return new DriverHealth(h.State, h.LastSuccessfulRead, h.LastError, _diagnostics.Snapshot());
}
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -442,6 +613,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var session = RequireSession();
await EnsureOperationLimitsFetchedAsync(cancellationToken).ConfigureAwait(false);
var results = new DataValueSnapshot[fullReferences.Count];
var now = DateTime.UtcNow;
@@ -463,31 +635,44 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
if (toSend.Count == 0) return results;
// Honor server's MaxNodesPerRead — chunk large batches so a single ReadAsync stays
// under the cap. cap=null means "no limit" (sentinel for both 0-from-server and
// not-yet-fetched), in which case ChunkBy yields the input as a single slice and
// the wire path collapses to one SDK call.
var readCap = _operationLimits?.MaxNodesPerRead;
var indexMapList = indexMap; // close over for catch
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
var resp = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Both,
nodesToRead: toSend,
ct: cancellationToken).ConfigureAwait(false);
var values = resp.Results;
for (var w = 0; w < values.Count; w++)
var wireOffset = 0;
foreach (var chunk in ChunkBy(toSend, readCap))
{
var r = indexMap[w];
var dv = values[w];
// Preserve the upstream StatusCode verbatim — including Bad codes per
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
// clients can detect stale upstream data.
results[r] = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
var chunkColl = new ReadValueIdCollection(chunk.Count);
for (var i = 0; i < chunk.Count; i++) chunkColl.Add(chunk.Array![chunk.Offset + i]);
var resp = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Both,
nodesToRead: chunkColl,
ct: cancellationToken).ConfigureAwait(false);
var values = resp.Results;
for (var w = 0; w < values.Count; w++)
{
var r = indexMapList[wireOffset + w];
var dv = values[w];
// Preserve the upstream StatusCode verbatim — including Bad codes per
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
// clients can detect stale upstream data.
results[r] = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
}
wireOffset += chunk.Count;
}
_health = new DriverHealth(DriverState.Healthy, now, null);
}
@@ -497,9 +682,9 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
// tag in this batch. Per-tag StatusCode stays BadCommunicationError (not
// BadInternalError) so operators distinguish "upstream unreachable" from
// "driver bug".
for (var w = 0; w < indexMap.Count; w++)
for (var w = 0; w < indexMapList.Count; w++)
{
var r = indexMap[w];
var r = indexMapList[w];
results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
}
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
@@ -515,6 +700,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
IReadOnlyList<Core.Abstractions.WriteRequest> writes, CancellationToken cancellationToken)
{
var session = RequireSession();
await EnsureOperationLimitsFetchedAsync(cancellationToken).ConfigureAwait(false);
var results = new WriteResult[writes.Count];
var toSend = new WriteValueCollection();
@@ -537,24 +723,34 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
if (toSend.Count == 0) return results;
// Honor server's MaxNodesPerWrite — same chunking pattern as ReadAsync. cap=null
// collapses to a single wire call.
var writeCap = _operationLimits?.MaxNodesPerWrite;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
var resp = await session.WriteAsync(
requestHeader: null,
nodesToWrite: toSend,
ct: cancellationToken).ConfigureAwait(false);
var codes = resp.Results;
for (var w = 0; w < codes.Count; w++)
var wireOffset = 0;
foreach (var chunk in ChunkBy(toSend, writeCap))
{
var r = indexMap[w];
// Pass upstream WriteResult StatusCode through verbatim. Success codes
// include Good (0) and any warning-level Good* variants; anything with
// the severity bits set is a Bad.
results[r] = new WriteResult(codes[w].Code);
var chunkColl = new WriteValueCollection(chunk.Count);
for (var i = 0; i < chunk.Count; i++) chunkColl.Add(chunk.Array![chunk.Offset + i]);
var resp = await session.WriteAsync(
requestHeader: null,
nodesToWrite: chunkColl,
ct: cancellationToken).ConfigureAwait(false);
var codes = resp.Results;
for (var w = 0; w < codes.Count; w++)
{
var r = indexMap[wireOffset + w];
// Pass upstream WriteResult StatusCode through verbatim. Success codes
// include Good (0) and any warning-level Good* variants; anything with
// the severity bits set is a Bad.
results[r] = new WriteResult(codes[w].Code);
}
wireOffset += chunk.Count;
}
}
catch (Exception)
@@ -591,6 +787,81 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
private ISession RequireSession() =>
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
/// <summary>
/// Lazily fetch <c>Server.ServerCapabilities.OperationLimits</c> from the upstream
/// server and cache them on the driver. Idempotent — called from every batch op,
/// no-ops once a successful fetch has populated the cache. The cache is cleared on
/// reconnect (see <see cref="OnReconnectComplete"/>) so a server with redrawn
/// capabilities doesn't run forever with stale caps.
/// </summary>
/// <remarks>
/// Uses <see cref="Session.FetchOperationLimitsAsync(CancellationToken)"/> when the
/// active session is a concrete <see cref="Session"/> (always true in production —
/// the SDK's session factory returns Session). Falls back gracefully on any fetch
/// failure: callers see <see cref="_operationLimits"/> remain null and fall through
/// to single-call behaviour. Per OPC UA Part 5, a server reporting 0 for any
/// OperationLimits attribute means "no limit"; we normalize that to <c>null</c> so
/// the chunking helper has a single sentinel.
/// </remarks>
private async Task EnsureOperationLimitsFetchedAsync(CancellationToken ct)
{
if (_operationLimits is not null) return;
await _operationLimitsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_operationLimits is not null) return;
if (Session is not Session concrete) return;
try
{
await concrete.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
var ol = concrete.OperationLimits;
if (ol is null) return;
_operationLimits = new OperationLimitsCache(
MaxNodesPerRead: NormalizeLimit(ol.MaxNodesPerRead),
MaxNodesPerWrite: NormalizeLimit(ol.MaxNodesPerWrite),
MaxNodesPerBrowse: NormalizeLimit(ol.MaxNodesPerBrowse),
MaxNodesPerHistoryReadData: NormalizeLimit(ol.MaxNodesPerHistoryReadData));
}
catch
{
// Fetch failed — leave cache null so we re-attempt on the next batch op.
// Single-call behaviour applies in the meantime; never block traffic on a
// capability discovery glitch.
}
}
finally { _operationLimitsLock.Release(); }
}
/// <summary>Spec sentinel: 0 = "no limit". Normalize to null for the chunking helper.</summary>
private static uint? NormalizeLimit(uint raw) => raw == 0 ? null : raw;
/// <summary>
/// Split <paramref name="source"/> into contiguous slices of at most <paramref name="cap"/>
/// items. Returns the input as a single slice when the cap is null (no limit),
/// 0, or larger than the input — the spec sentinel + the no-cap path collapse onto
/// the same single-call branch so the wire path stays a single SDK invocation when
/// the server doesn't impose a limit.
/// </summary>
internal static IEnumerable<ArraySegment<T>> ChunkBy<T>(IReadOnlyList<T> source, uint? cap)
{
if (source.Count == 0) yield break;
var array = source as T[] ?? source.ToArray();
if (cap is null or 0 || (uint)array.Length <= cap.Value)
{
yield return new ArraySegment<T>(array, 0, array.Length);
yield break;
}
var size = checked((int)cap.Value);
for (var offset = 0; offset < array.Length; offset += size)
{
var len = Math.Min(size, array.Length - offset);
yield return new ArraySegment<T>(array, offset, len);
}
}
// ---- ITagDiscovery ----
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
@@ -851,29 +1122,43 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
// ---- ISubscribable ----
public async Task<ISubscriptionHandle> SubscribeAsync(
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
// Route the simple-string overload through the per-tag overload with all knobs at
// their defaults. Single code path for subscription create — keeps the wire-side
// identical for callers that don't need per-tag tuning.
var specs = new MonitoredTagSpec[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
specs[i] = new MonitoredTagSpec(fullReferences[i]);
return SubscribeAsync(specs, publishingInterval, cancellationToken);
}
public async Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<MonitoredTagSpec> tags, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var session = RequireSession();
var id = Interlocked.Increment(ref _nextSubscriptionId);
var handle = new OpcUaSubscriptionHandle(id);
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
// bandwidth on every subscription create.
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
? 50
// Floor the publishing interval — OPC UA servers routinely negotiate
// minimum-supported intervals up anyway, but sending sub-floor values wastes
// negotiation bandwidth on every subscription create. Floor is configurable via
// OpcUaSubscriptionDefaults.MinPublishingIntervalMs (default 50ms).
var subDefaults = _options.Subscriptions;
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(subDefaults.MinPublishingIntervalMs)
? subDefaults.MinPublishingIntervalMs
: (int)publishingInterval.TotalMilliseconds;
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-sub-{id}",
PublishingInterval = intervalMs,
KeepAliveCount = 10,
LifetimeCount = 1000,
MaxNotificationsPerPublish = 0,
KeepAliveCount = (uint)subDefaults.KeepAliveCount,
LifetimeCount = subDefaults.LifetimeCount,
MaxNotificationsPerPublish = subDefaults.MaxNotificationsPerPublish,
PublishingEnabled = true,
Priority = 0,
Priority = subDefaults.Priority,
TimestampsToReturn = TimestampsToReturn.Both,
});
@@ -883,29 +1168,28 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
session.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
foreach (var fullRef in fullReferences)
foreach (var spec in tags)
{
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
// The tag string is routed through MonitoredItem.Handle so the Notification
// handler can identify which tag changed without an extra lookup.
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
{
DisplayName = fullRef,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = intervalMs,
QueueSize = 1,
DiscardOldest = true,
})
{
Handle = fullRef,
};
item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
subscription.AddItem(item);
if (!TryParseNodeId(session, spec.TagName, out var nodeId)) continue;
var monItem = BuildMonitoredItem(spec, nodeId, intervalMs);
monItem.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
subscription.AddItem(monItem);
}
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
try
{
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Opc.Ua.ServiceResultException sre)
{
// PercentDeadband requires the server to expose EURange on the variable; if
// it isn't set the server returns BadFilterNotAllowed during item creation.
// We swallow the exception here so other items in the batch still get created
// — per-item failure surfaces through MonitoredItem.Status.Error rather than
// tearing down the whole subscription.
if (sre.StatusCode != StatusCodes.BadFilterNotAllowed) throw;
}
_subscriptions[id] = new RemoteSubscription(subscription, handle);
}
finally { _gate.Release(); }
@@ -913,6 +1197,84 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
return handle;
}
/// <summary>
/// Map a <see cref="MonitoredTagSpec"/> to a SDK <see cref="MonitoredItem"/> with the
/// per-tag knobs applied. Defaults match the original hard-coded values
/// (Reporting / SamplingInterval=publishInterval / QueueSize=1 / DiscardOldest=true)
/// so a spec with all knobs <c>null</c> behaves identically to the legacy path.
/// </summary>
internal static MonitoredItem BuildMonitoredItem(MonitoredTagSpec spec, NodeId nodeId, int defaultIntervalMs)
{
var sampling = spec.SamplingIntervalMs.HasValue ? (int)spec.SamplingIntervalMs.Value : defaultIntervalMs;
var queueSize = spec.QueueSize ?? 1u;
var discardOldest = spec.DiscardOldest ?? true;
var monitoringMode = spec.MonitoringMode is { } mm ? MapMonitoringMode(mm) : MonitoringMode.Reporting;
var filter = BuildDataChangeFilter(spec.DataChangeFilter);
var options = new MonitoredItemOptions
{
DisplayName = spec.TagName,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
MonitoringMode = monitoringMode,
SamplingInterval = sampling,
QueueSize = queueSize,
DiscardOldest = discardOldest,
Filter = filter,
};
return new MonitoredItem(telemetry: null!, options)
{
// The tag string is routed through MonitoredItem.Handle so the Notification
// handler can identify which tag changed without an extra lookup.
Handle = spec.TagName,
};
}
/// <summary>
/// Build the OPC UA <see cref="DataChangeFilter"/> from a <see cref="DataChangeFilterSpec"/>,
/// or return <c>null</c> if the caller didn't supply a filter. PercentDeadband requires
/// server-side EURange — if the server rejects with BadFilterNotAllowed, the caller's
/// <c>SubscribeAsync</c> swallows it so other items in the batch still get created.
/// </summary>
internal static DataChangeFilter? BuildDataChangeFilter(DataChangeFilterSpec? spec)
{
if (spec is null) return null;
return new DataChangeFilter
{
Trigger = MapTrigger(spec.Trigger),
DeadbandType = (uint)MapDeadbandType(spec.DeadbandType),
DeadbandValue = spec.DeadbandValue,
};
}
/// <summary>Map our SDK-free <see cref="SubscriptionMonitoringMode"/> to the OPC UA SDK's enum.</summary>
internal static MonitoringMode MapMonitoringMode(SubscriptionMonitoringMode mode) => mode switch
{
SubscriptionMonitoringMode.Disabled => MonitoringMode.Disabled,
SubscriptionMonitoringMode.Sampling => MonitoringMode.Sampling,
SubscriptionMonitoringMode.Reporting => MonitoringMode.Reporting,
_ => MonitoringMode.Reporting,
};
/// <summary>Map our <see cref="Core.Abstractions.DataChangeTrigger"/> to the SDK enum.</summary>
internal static Opc.Ua.DataChangeTrigger MapTrigger(Core.Abstractions.DataChangeTrigger trigger) => trigger switch
{
Core.Abstractions.DataChangeTrigger.Status => Opc.Ua.DataChangeTrigger.Status,
Core.Abstractions.DataChangeTrigger.StatusValue => Opc.Ua.DataChangeTrigger.StatusValue,
Core.Abstractions.DataChangeTrigger.StatusValueTimestamp => Opc.Ua.DataChangeTrigger.StatusValueTimestamp,
_ => Opc.Ua.DataChangeTrigger.StatusValue,
};
/// <summary>Map our <see cref="Core.Abstractions.DeadbandType"/> to the SDK enum.</summary>
internal static Opc.Ua.DeadbandType MapDeadbandType(Core.Abstractions.DeadbandType type) => type switch
{
Core.Abstractions.DeadbandType.None => Opc.Ua.DeadbandType.None,
Core.Abstractions.DeadbandType.Absolute => Opc.Ua.DeadbandType.Absolute,
Core.Abstractions.DeadbandType.Percent => Opc.Ua.DeadbandType.Percent,
_ => Opc.Ua.DeadbandType.None,
};
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not OpcUaSubscriptionHandle h) return;
@@ -975,15 +1337,16 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
// match in O(1) without re-parsing on every event.
var sourceFilter = new HashSet<string>(sourceNodeIds, StringComparer.Ordinal);
var alarmDefaults = _options.Subscriptions;
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-alarm-sub-{id}",
PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes
KeepAliveCount = 10,
LifetimeCount = 1000,
MaxNotificationsPerPublish = 0,
KeepAliveCount = (uint)alarmDefaults.KeepAliveCount,
LifetimeCount = alarmDefaults.LifetimeCount,
MaxNotificationsPerPublish = alarmDefaults.MaxNotificationsPerPublish,
PublishingEnabled = true,
Priority = 0,
Priority = alarmDefaults.AlarmsPriority,
TimestampsToReturn = TimestampsToReturn.Both,
});
@@ -1344,7 +1707,21 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
newSession.KeepAlive += _keepAliveHandler;
}
// Move the diagnostic event hooks (Notification + PublishError) onto the new
// session as well so counters keep flowing post-failover. Record this as a
// session-reset for the operator dashboard.
UnwireSessionDiagnostics(oldSession);
if (newSession is not null)
{
WireSessionDiagnostics(newSession);
_diagnostics.RecordSessionReset(DateTime.UtcNow);
}
Session = newSession;
// Drop cached OperationLimits so the next batch op refetches against the (potentially
// re-redeployed) upstream server. A zero-cost guard against a server whose published
// capabilities changed across the reconnect window.
_operationLimits = null;
_reconnectHandler?.Dispose();
_reconnectHandler = null;
@@ -1371,6 +1748,71 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
/// <summary>
/// Wire the diagnostic counters onto the supplied session — every publish-response
/// notification increments <c>NotificationCount</c> + samples the EWMA;
/// <see cref="ISession.PublishError"/> distinguishes missing-publish vs other publish
/// faults so operators can see whether the upstream is starving the client of publish
/// slots vs. failing notifications outright.
/// </summary>
private void WireSessionDiagnostics(ISession session)
{
_notificationHandler = OnSessionNotification;
_publishErrorHandler = OnSessionPublishError;
session.Notification += _notificationHandler;
session.PublishError += _publishErrorHandler;
}
private void UnwireSessionDiagnostics(ISession? session)
{
if (session is null) return;
if (_notificationHandler is not null)
{
try { session.Notification -= _notificationHandler; } catch { }
}
if (_publishErrorHandler is not null)
{
try { session.PublishError -= _publishErrorHandler; } catch { }
}
_notificationHandler = null;
_publishErrorHandler = null;
}
private void OnSessionNotification(ISession session, NotificationEventArgs e)
{
// Each publish response carries one NotificationMessage with N data-change /
// event notifications. Track both cardinalities: PublishRequestCount counts
// server publish responses delivered to us; NotificationCount counts the
// individual MonitoredItem changes inside them. The difference matters when
// diagnosing "many publishes, few changes" vs "few publishes, large bursts".
_diagnostics.IncrementPublishRequest();
var msg = e.NotificationMessage;
if (msg?.NotificationData is { Count: > 0 } data)
{
for (var i = 0; i < data.Count; i++)
{
_diagnostics.RecordNotification();
}
}
}
private void OnSessionPublishError(ISession session, PublishErrorEventArgs e)
{
// BadNoSubscription / BadSequenceNumberUnknown / BadMessageNotAvailable all surface
// as "the server expected to publish but couldn't" — bucket them as missing-publish
// for the operator. Other status codes (timeout, network) are dropped notifications.
var sc = e.Status?.StatusCode;
if (sc.HasValue && IsMissingPublishStatus(sc.Value))
_diagnostics.IncrementMissingPublishRequest();
else
_diagnostics.IncrementDroppedNotification();
}
private static bool IsMissingPublishStatus(StatusCode sc) =>
sc.Code == StatusCodes.BadNoSubscription
|| sc.Code == StatusCodes.BadSequenceNumberUnknown
|| sc.Code == StatusCodes.BadMessageNotAvailable;
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
@@ -1380,5 +1822,6 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
_gate.Dispose();
_operationLimitsLock.Dispose();
}
}
@@ -134,8 +134,106 @@ public sealed class OpcUaClientDriverOptions
/// browse forever.
/// </summary>
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>
/// 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>
public enum OpcUaSecurityMode
{
@@ -1,3 +1,5 @@
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
@@ -26,10 +28,12 @@ public enum S7Size
Byte, // B
Word, // W — 16-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>
/// 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>
/// <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>
@@ -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:
/// <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}.{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{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>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>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
/// </list>
@@ -69,7 +76,29 @@ public static class S7AddressParser
/// the offending input echoed in the message so operators can correlate to the tag
/// config that produced the fault.
/// </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))
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 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
case 'V': return ParseV(rest, address, cpuType);
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>
/// 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
/// would throw from <see cref="Parse"/>.
/// would throw from <see cref="Parse(string)"/>.
/// </summary>
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
{
result = Parse(address);
result = Parse(address, cpuType);
return true;
}
catch (FormatException)
@@ -130,18 +166,36 @@ public static class S7AddressParser
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
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];
var offsetStart = 3;
var size = sizeChar switch
// 64-bit suffixes are two-letter (LD or DBL-as-prefix). Detect them up front so the
// single-char switch below stays readable. "DBLD" is the symmetric extension of
// 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,
'B' => S7Size.Byte,
'W' => S7Size.Word,
'D' => S7Size.DWord,
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
};
size = S7Size.LWord;
offsetStart = 4;
}
else if (tail.Length >= 4 && tail[2] == 'L')
{
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);
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
@@ -156,23 +210,73 @@ public static class S7AddressParser
var first = rest[0];
S7Size size;
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;
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;
size = S7Size.LWord;
offsetStart = 2;
}
else
{
switch (first)
{
case 'B': size = S7Size.Byte; offsetStart = 1; 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);
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)
{
if (rest.Length == 0)
@@ -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 &gt; 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));
}
}
+466 -24
View File
@@ -1,3 +1,4 @@
using System.Buffers.Binary;
using S7.Net;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -53,6 +54,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>
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, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
@@ -84,6 +94,34 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
_health = new DriverHealth(DriverState.Initializing, null, null);
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);
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
@@ -97,18 +135,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
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);
// Kick off the probe loop once the connection is up. Initial HostState stays
@@ -220,6 +246,139 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
{
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
// 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
@@ -238,10 +397,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)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"),
_ => throw new System.IO.InvalidDataException(
@@ -250,6 +405,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 ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
@@ -299,6 +466,102 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
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
// 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
@@ -313,10 +576,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(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"),
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
};
@@ -334,11 +593,12 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
var folder = builder.Folder("S7", "S7");
foreach (var t in _options.Tags)
{
var isArr = t.ElementCount is int ec && ec > 1;
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: false,
ArrayDim: null,
IsArray: isArr,
ArrayDim: isArr ? (uint)t.ElementCount!.Value : null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
@@ -347,16 +607,198 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
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
{
S7DataType.Bool => DriverDataType.Boolean,
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
S7DataType.Int16 => DriverDataType.Int16,
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.Float64 => DriverDataType.Float64,
S7DataType.String => DriverDataType.String,
S7DataType.WString => DriverDataType.String,
S7DataType.Char => DriverDataType.String,
S7DataType.WChar => DriverDataType.String,
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,
};
@@ -95,13 +95,23 @@ public sealed class S7ProbeOptions
/// 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.
/// </param>
/// <param name="ElementCount">
/// Optional 1-D array length. <c>null</c> (or <c>1</c>) = scalar tag; <c>&gt; 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(
string Name,
string Address,
S7DataType DataType,
bool Writable = true,
int StringLength = 254,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
int? ElementCount = null);
public enum S7DataType
{
@@ -116,5 +126,23 @@ public enum S7DataType
Float32,
Float64,
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,
/// <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,
}
@@ -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 &gt; 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;
}
}
@@ -24,6 +24,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
private readonly AdsClient _client = 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();
public AdsTwinCATClient()
{
_client.AdsNotificationEx += OnAdsNotificationEx;
@@ -44,20 +49,30 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int[]? arrayDimensions,
CancellationToken cancellationToken)
{
try
{
var clrType = MapToClrType(type);
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
var readType = IsWholeArray(arrayDimensions) ? clrType.MakeArrayType() : clrType;
var result = await _client.ReadValueAsync(symbolPath, readType, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
var value = result.Value;
if (IsWholeArray(arrayDimensions))
{
value = PostProcessArray(type, value);
return (value, TwinCATStatusMapper.Good);
}
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit);
value = PostProcessIecTime(type, value);
return (value, TwinCATStatusMapper.Good);
}
@@ -67,16 +82,43 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
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(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int[]? arrayDimensions,
object? value,
CancellationToken cancellationToken)
{
if (bitIndex is int && type == TwinCATDataType.Bool)
throw new NotSupportedException(
"BOOL-within-word writes require read-modify-write; tracked in task #181.");
if (IsWholeArray(arrayDimensions))
return TwinCATStatusMapper.BadNotSupported; // PR-1.4 ships read-only whole-array
if (bitIndex is int bit && type == TwinCATDataType.Bool)
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
.ConfigureAwait(false);
try
{
@@ -93,6 +135,69 @@ 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
{
var read = await _client.ReadValueAsync(parentPath, typeof(uint), cancellationToken)
.ConfigureAwait(false);
if (read.ErrorCode != AdsErrorCode.NoError)
return TwinCATStatusMapper.MapAdsError((uint)read.ErrorCode);
var current = Convert.ToUInt32(read.Value ?? 0u);
var updated = ApplyBit(current, bit, setBit);
var write = await _client.WriteValueAsync(parentPath, updated, cancellationToken)
.ConfigureAwait(false);
return write.ErrorCode == AdsErrorCode.NoError
? TwinCATStatusMapper.Good
: TwinCATStatusMapper.MapAdsError((uint)write.ErrorCode);
}
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)
{
try
@@ -143,6 +248,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var value = args.Value;
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
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 */ }
}
@@ -166,12 +272,50 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
foreach (ISymbol symbol in loader.Symbols)
{
if (cancellationToken.IsCancellationRequested) yield break;
var mapped = MapSymbolTypeName(symbol.DataType?.Name);
var mapped = ResolveSymbolDataType(symbol.DataType);
var readOnly = !IsSymbolWritable(symbol);
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
{
"BOOL" or "BIT" => TwinCATDataType.Bool,
@@ -249,7 +393,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
_ => typeof(int),
};
private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
internal static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
{
TwinCATDataType.Bool => Convert.ToBoolean(value),
TwinCATDataType.SInt => Convert.ToSByte(value),
@@ -263,11 +407,79 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
TwinCATDataType.Real => Convert.ToSingle(value),
TwinCATDataType.LReal => Convert.ToDouble(value),
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value),
// IEC durations (TIME / TOD) accept TimeSpan / Duration-as-Double-ms / raw UDINT.
// 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."),
};
// 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
{
short s => (s & (1 << bit)) != 0,
@@ -22,22 +22,29 @@ public interface ITwinCATClient : IDisposable
/// <summary>
/// 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
/// <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>
Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int[]? arrayDimensions,
CancellationToken cancellationToken);
/// <summary>
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
/// (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>
Task<uint> WriteValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int[]? arrayDimensions,
object? value,
CancellationToken cancellationToken);
@@ -37,12 +37,16 @@ public static class TwinCATDataTypeExtensions
TwinCATDataType.SInt or TwinCATDataType.USInt
or TwinCATDataType.Int or TwinCATDataType.UInt
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.LReal => DriverDataType.Float64,
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.Int32,
// IEC 61131-3 TIME / TOD are durations (ms); DATE / DT are absolute timestamps.
// 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,
_ => DriverDataType.Int32,
};
@@ -135,7 +135,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
symbolName, def.DataType, parsed?.BitIndex, def.ArrayDimensions, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == TwinCATStatusMapper.Good)
@@ -188,7 +188,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
var status = await client.WriteValueAsync(
symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
symbolName, def.DataType, parsed?.BitIndex, def.ArrayDimensions, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(status);
}
catch (OperationCanceledException) { throw; }
@@ -231,11 +231,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
var (isArray, arrayDim) = ResolveArrayShape(tag.ArrayDimensions);
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
IsArray: isArray,
ArrayDim: arrayDim,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
@@ -310,6 +311,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
if (!_tagsByName.TryGetValue(reference, out var def)) 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 parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
@@ -428,6 +432,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
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 async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
@@ -43,8 +43,12 @@ public sealed record TwinCATDeviceOptions(
string? DeviceName = null);
/// <summary>
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>).
/// One TwinCAT-backed OPC UA variable. <c>SymbolPath</c> is the full TwinCAT symbolic name
/// (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>
public sealed record TwinCATTagDefinition(
string Name,
@@ -52,7 +56,8 @@ public sealed record TwinCATTagDefinition(
string SymbolPath,
TwinCATDataType DataType,
bool Writable = true,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
int[]? ArrayDimensions = null);
public sealed class TwinCATProbeOptions
{
@@ -310,6 +310,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
DriverDataType.Float64 => DataTypeIds.Double,
DriverDataType.String => DataTypeIds.String,
DriverDataType.DateTime => DataTypeIds.DateTime,
DriverDataType.Duration => DataTypeIds.Duration,
_ => DataTypeIds.BaseDataType,
};
@@ -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 });
}
}
@@ -165,6 +165,55 @@ public sealed class AbCipDriverReadTests
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]
public async Task Cancellation_propagates_from_read()
{
@@ -211,4 +260,79 @@ public sealed class AbCipDriverReadTests
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
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.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
AbCipDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
AbCipDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
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();
}
}
}
}
@@ -123,6 +123,61 @@ public sealed class AbCipTagPathTests
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]
public void ToLibplctagName_recomposes_round_trip()
{
@@ -1,6 +1,7 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
@@ -65,4 +66,271 @@ public sealed class AbLegacyAddressTests
a.ShouldNotBeNull();
a.ToLibplctagName().ShouldBe(input);
}
// ---- PLC-5 octal I:/O: addressing (Issue #244) ----
//
// RSLogix 5 displays I:/O: word + bit indices as octal. `I:001/17` means rack 1, bit 15
// (octal 17). Other PCCC families (SLC500, MicroLogix, LogixPccc) keep decimal indices.
// Non-I/O file letters are always decimal regardless of family.
[Theory]
[InlineData("I:001/17", 1, 15)] // octal 17 → bit 15
[InlineData("I:0/0", 0, 0)] // boundary: octal 0
[InlineData("O:1/2", 1, 2)] // octal 1, 2 happen to match decimal
[InlineData("I:010/10", 8, 8)] // octal 10 → 8 (both word + bit)
[InlineData("I:007/7", 7, 7)] // boundary: largest single octal digit
public void TryParse_Plc5_parses_io_indices_as_octal(string input, int expectedWord, int expectedBit)
{
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
a.ShouldNotBeNull();
a.WordNumber.ShouldBe(expectedWord);
a.BitIndex.ShouldBe(expectedBit);
}
[Theory]
[InlineData("I:8/0")] // word digit 8 illegal in octal
[InlineData("I:0/9")] // bit digit 9 illegal in octal
[InlineData("O:128/0")] // contains digit 8
[InlineData("I:0/18")] // bit field octal-illegal because of '8'
public void TryParse_Plc5_rejects_octal_invalid_io_digits(string input)
{
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
}
[Theory]
// Non-I/O files stay decimal even on PLC-5 (e.g. N7:8 is integer 7, word 8).
[InlineData("N7:8", 7, 8)]
[InlineData("F8:9", 8, 9)]
public void TryParse_Plc5_keeps_non_io_indices_decimal(string input, int? expectedFile, int expectedWord)
{
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
a.ShouldNotBeNull();
a.FileNumber.ShouldBe(expectedFile);
a.WordNumber.ShouldBe(expectedWord);
}
[Fact]
public void TryParse_Slc500_keeps_io_indices_decimal_back_compat()
{
// SLC500 has OctalIoAddressing=false — the digits are decimal as before.
var a = AbLegacyAddress.TryParse("I:10/15", AbLegacyPlcFamily.Slc500);
a.ShouldNotBeNull();
a.WordNumber.ShouldBe(10);
a.BitIndex.ShouldBe(15);
// Decimal '8' that PLC-5 would reject is fine on SLC500.
var b = AbLegacyAddress.TryParse("I:8/0", AbLegacyPlcFamily.Slc500);
b.ShouldNotBeNull();
b.WordNumber.ShouldBe(8);
}
[Fact]
public void TryParse_MicroLogix_and_LogixPccc_keep_io_indices_decimal()
{
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.LogixPccc).ShouldNotBeNull();
}
[Fact]
public void Plc5Profile_advertises_octal_io_addressing()
{
AbLegacyPlcFamilyProfile.Plc5.OctalIoAddressing.ShouldBeTrue();
AbLegacyPlcFamilyProfile.Slc500.OctalIoAddressing.ShouldBeFalse();
AbLegacyPlcFamilyProfile.MicroLogix.OctalIoAddressing.ShouldBeFalse();
AbLegacyPlcFamilyProfile.LogixPccc.OctalIoAddressing.ShouldBeFalse();
}
// ---- MicroLogix function-file letters (Issue #245) ----
//
// MicroLogix 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI function files. Other
// PCCC families (SLC500 / PLC-5 / LogixPccc) reject those file letters.
[Theory]
[InlineData("RTC:0.HR", "RTC", "HR")]
[InlineData("RTC:0.MIN", "RTC", "MIN")]
[InlineData("RTC:0.YR", "RTC", "YR")]
[InlineData("HSC:0.ACC", "HSC", "ACC")]
[InlineData("HSC:0.PRE", "HSC", "PRE")]
[InlineData("HSC:0.EN", "HSC", "EN")]
[InlineData("DLS:0.STR", "DLS", "STR")]
[InlineData("PTO:0.OF", "PTO", "OF")]
[InlineData("PWM:0.EN", "PWM", "EN")]
[InlineData("STI:0.SPM", "STI", "SPM")]
[InlineData("EII:0.PFN", "EII", "PFN")]
[InlineData("MMI:0.FT", "MMI", "FT")]
[InlineData("BHI:0.OS", "BHI", "OS")]
[InlineData("IOS:0.ID", "IOS", "ID")]
public void TryParse_MicroLogix_accepts_function_files(string input, string expectedLetter, string expectedSub)
{
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.MicroLogix);
a.ShouldNotBeNull();
a.FileLetter.ShouldBe(expectedLetter);
a.SubElement.ShouldBe(expectedSub);
}
[Theory]
[InlineData("RTC:0.HR")]
[InlineData("HSC:0.ACC")]
[InlineData("PTO:0.OF")]
[InlineData("BHI:0.OS")]
public void TryParse_Slc500_rejects_function_files(string input)
{
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Slc500).ShouldBeNull();
}
[Theory]
[InlineData("RTC:0.HR")]
[InlineData("HSC:0.ACC")]
public void TryParse_Plc5_and_LogixPccc_reject_function_files(string input)
{
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.LogixPccc).ShouldBeNull();
}
[Fact]
public void TryParse_Default_overload_rejects_function_files()
{
// Without a family the parser cannot allow MicroLogix-only letters — back-compat with
// the family-less overload from before #244.
AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull();
AbLegacyAddress.TryParse("HSC:0.ACC").ShouldBeNull();
}
[Fact]
public void MicroLogixProfile_advertises_function_file_support()
{
AbLegacyPlcFamilyProfile.MicroLogix.SupportsFunctionFiles.ShouldBeTrue();
AbLegacyPlcFamilyProfile.Slc500.SupportsFunctionFiles.ShouldBeFalse();
AbLegacyPlcFamilyProfile.Plc5.SupportsFunctionFiles.ShouldBeFalse();
AbLegacyPlcFamilyProfile.LogixPccc.SupportsFunctionFiles.ShouldBeFalse();
}
// ---- Indirect / indexed addressing (Issue #247) ----
//
// PLC-5 / SLC permit `N7:[N7:0]` (word number sourced from another address) and
// `N[N7:0]:5` (file number sourced from another address). Recursion is capped at 1 — the
// inner address must itself be a plain direct PCCC reference.
[Fact]
public void TryParse_accepts_indirect_word_source()
{
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
a.ShouldNotBeNull();
a.FileLetter.ShouldBe("N");
a.FileNumber.ShouldBe(7);
a.IndirectFileSource.ShouldBeNull();
a.IndirectWordSource.ShouldNotBeNull();
a.IndirectWordSource!.FileLetter.ShouldBe("N");
a.IndirectWordSource.FileNumber.ShouldBe(7);
a.IndirectWordSource.WordNumber.ShouldBe(0);
a.IsIndirect.ShouldBeTrue();
}
[Fact]
public void TryParse_accepts_indirect_file_source()
{
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
a.ShouldNotBeNull();
a.FileLetter.ShouldBe("N");
a.FileNumber.ShouldBeNull();
a.WordNumber.ShouldBe(5);
a.IndirectFileSource.ShouldNotBeNull();
a.IndirectFileSource!.FileLetter.ShouldBe("N");
a.IndirectFileSource.FileNumber.ShouldBe(7);
a.IndirectFileSource.WordNumber.ShouldBe(0);
a.IndirectWordSource.ShouldBeNull();
a.IsIndirect.ShouldBeTrue();
}
[Fact]
public void TryParse_accepts_both_indirect_file_and_word()
{
var a = AbLegacyAddress.TryParse("N[N7:0]:[N7:1]");
a.ShouldNotBeNull();
a.IndirectFileSource.ShouldNotBeNull();
a.IndirectWordSource.ShouldNotBeNull();
a.IndirectWordSource!.WordNumber.ShouldBe(1);
}
[Theory]
[InlineData("N[N[N7:0]:0]:5")] // depth-2 file source
[InlineData("N7:[N[N7:0]:0]")] // depth-2 word source
[InlineData("N7:[N7:[N7:0]]")] // depth-2 word source (nested word)
public void TryParse_rejects_depth_greater_than_one(string input)
{
AbLegacyAddress.TryParse(input).ShouldBeNull();
}
[Theory]
[InlineData("N7:[")] // unbalanced bracket
[InlineData("N7:]")] // unbalanced bracket
[InlineData("N[:5")] // empty inner file source
[InlineData("N7:[]")] // empty inner word source
[InlineData("N[X9:0]:5")] // unknown file letter inside
public void TryParse_rejects_malformed_indirect(string input)
{
AbLegacyAddress.TryParse(input).ShouldBeNull();
}
[Fact]
public void ToLibplctagName_reemits_indirect_word_source()
{
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
a.ShouldNotBeNull();
a.ToLibplctagName().ShouldBe("N7:[N7:0]");
}
[Fact]
public void ToLibplctagName_reemits_indirect_file_source()
{
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
a.ShouldNotBeNull();
a.ToLibplctagName().ShouldBe("N[N7:0]:5");
}
[Fact]
public void TryParse_indirect_with_bit_outside_brackets()
{
// Outer bit applies to the resolved word; inner address is still depth-1.
var a = AbLegacyAddress.TryParse("N7:[N7:0]/3");
a.ShouldNotBeNull();
a.BitIndex.ShouldBe(3);
a.IndirectWordSource.ShouldNotBeNull();
a.ToLibplctagName().ShouldBe("N7:[N7:0]/3");
}
[Fact]
public void TryParse_Plc5_indirect_inner_address_obeys_octal()
{
// Inner I:/O: indices on PLC-5 must obey octal rules even when nested in brackets.
var a = AbLegacyAddress.TryParse("N7:[I:010/10]", AbLegacyPlcFamily.Plc5);
a.ShouldNotBeNull();
a.IndirectWordSource.ShouldNotBeNull();
a.IndirectWordSource!.WordNumber.ShouldBe(8); // octal 010 → 8
a.IndirectWordSource.BitIndex.ShouldBe(8); // octal 10 → 8
// Octal-illegal digit '8' inside an inner I: address is rejected on PLC-5.
AbLegacyAddress.TryParse("N7:[I:8/0]", AbLegacyPlcFamily.Plc5).ShouldBeNull();
}
[Fact]
public void TryParse_indirect_inner_cannot_itself_be_indirect()
{
AbLegacyAddress.TryParse("N7:[N7:[N7:0]]").ShouldBeNull();
AbLegacyAddress.TryParse("N[N[N7:0]:5]:5").ShouldBeNull();
}
[Theory]
[InlineData("RTC", "HR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
[InlineData("RTC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
[InlineData("HSC", "ACC", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
[InlineData("HSC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
[InlineData("DLS", "STR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
[InlineData("DLS", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
[InlineData("PWM", "OUT", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
public void FunctionFile_subelement_catalogue_maps_to_expected_driver_type(
string letter, string sub, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType expected)
{
AbLegacyFunctionFile.SubElementType(letter, sub).ShouldBe(expected);
}
}
@@ -102,4 +102,96 @@ public sealed class AbLegacyDriverTests
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
}
[Theory]
[InlineData(AbLegacyDataType.TimerElement, "EN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.TimerElement, "TT", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.TimerElement, "DN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.TimerElement, "PRE", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.TimerElement, "ACC", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.CounterElement, "CU", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "CD", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "DN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "OV", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "UN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "PRE", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.CounterElement, "ACC", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.ControlElement, "EN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "EU", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "DN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "EM", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "ER", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "UL", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "IN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "FD", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "LEN", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.ControlElement, "POS", DriverDataType.Int32)]
public void EffectiveDriverDataType_resolves_subelements(
AbLegacyDataType dataType, string subElement, DriverDataType expected)
{
AbLegacyDataTypeExtensions.EffectiveDriverDataType(dataType, subElement).ShouldBe(expected);
}
[Fact]
public void EffectiveDriverDataType_unknown_subelement_falls_back_to_base()
{
// Permissive — keeps the driver from refusing tags whose sub-element we don't catalogue.
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, "BOGUS")
.ShouldBe(DriverDataType.Int32);
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, null)
.ShouldBe(DriverDataType.Int32);
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.Int, "DN")
.ShouldBe(DriverDataType.Int32);
}
[Theory]
[InlineData(AbLegacyDataType.TimerElement, "DN", 13)]
[InlineData(AbLegacyDataType.TimerElement, "TT", 14)]
[InlineData(AbLegacyDataType.TimerElement, "EN", 15)]
[InlineData(AbLegacyDataType.CounterElement, "UN", 10)]
[InlineData(AbLegacyDataType.CounterElement, "OV", 11)]
[InlineData(AbLegacyDataType.CounterElement, "DN", 12)]
[InlineData(AbLegacyDataType.CounterElement, "CD", 13)]
[InlineData(AbLegacyDataType.CounterElement, "CU", 14)]
[InlineData(AbLegacyDataType.ControlElement, "FD", 8)]
[InlineData(AbLegacyDataType.ControlElement, "IN", 9)]
[InlineData(AbLegacyDataType.ControlElement, "UL", 10)]
[InlineData(AbLegacyDataType.ControlElement, "ER", 11)]
[InlineData(AbLegacyDataType.ControlElement, "EM", 12)]
[InlineData(AbLegacyDataType.ControlElement, "DN", 13)]
[InlineData(AbLegacyDataType.ControlElement, "EU", 14)]
[InlineData(AbLegacyDataType.ControlElement, "EN", 15)]
public void StatusBitIndex_maps_to_standard_pccc_positions(
AbLegacyDataType dataType, string subElement, int expectedBit)
{
AbLegacyDataTypeExtensions.StatusBitIndex(dataType, subElement).ShouldBe(expectedBit);
}
[Fact]
public void StatusBitIndex_for_word_subelements_is_null()
{
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, "PRE").ShouldBeNull();
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.CounterElement, "ACC").ShouldBeNull();
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.ControlElement, "LEN").ShouldBeNull();
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, null).ShouldBeNull();
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.Int, "DN").ShouldBeNull();
}
[Theory]
[InlineData(AbLegacyDataType.TimerElement, "DN", true)]
[InlineData(AbLegacyDataType.TimerElement, "TT", true)]
[InlineData(AbLegacyDataType.TimerElement, "EN", false)] // operator-controllable
[InlineData(AbLegacyDataType.CounterElement, "DN", true)]
[InlineData(AbLegacyDataType.CounterElement, "OV", true)]
[InlineData(AbLegacyDataType.CounterElement, "UN", true)]
[InlineData(AbLegacyDataType.CounterElement, "CU", false)]
[InlineData(AbLegacyDataType.ControlElement, "DN", true)]
[InlineData(AbLegacyDataType.ControlElement, "ER", true)]
[InlineData(AbLegacyDataType.ControlElement, "EM", true)]
[InlineData(AbLegacyDataType.ControlElement, "EN", false)]
public void IsPlcSetStatusBit_classifies_writable_vs_status_bits(
AbLegacyDataType dataType, string subElement, bool expected)
{
AbLegacyDataTypeExtensions.IsPlcSetStatusBit(dataType, subElement).ShouldBe(expected);
}
}
@@ -256,4 +256,113 @@ public sealed class AbLegacyReadWriteTests
Value = value;
}
}
// ---- Timer / Counter / Control sub-element bit semantics (issue #246) ----
[Theory]
[InlineData("T4:0.DN", 13)]
[InlineData("T4:0.TT", 14)]
[InlineData("T4:0.EN", 15)]
public async Task Timer_status_bit_decodes_correct_position(string address, int bitPos)
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.TimerElement));
await drv.InitializeAsync("{}", CancellationToken.None);
// Seed a parent-word with only the target bit set.
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(true);
// The driver must have asked the runtime for the right bit position.
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
}
[Fact]
public async Task Timer_PRE_subelement_decodes_as_int_word()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("Pre", "ab://10.0.0.5/1,0", "T4:0.PRE", AbLegacyDataType.TimerElement));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5000 };
var snapshots = await drv.ReadAsync(["Pre"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(5000);
factory.Tags["T4:0.PRE"].LastDecodeBitIndex.ShouldBeNull();
}
[Theory]
[InlineData("C5:0.UN", 10)]
[InlineData("C5:0.OV", 11)]
[InlineData("C5:0.DN", 12)]
[InlineData("C5:0.CD", 13)]
[InlineData("C5:0.CU", 14)]
public async Task Counter_status_bit_decodes_correct_position(string address, int bitPos)
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.CounterElement));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(true);
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
}
[Theory]
[InlineData("R6:0.FD", 8)]
[InlineData("R6:0.IN", 9)]
[InlineData("R6:0.UL", 10)]
[InlineData("R6:0.ER", 11)]
[InlineData("R6:0.EM", 12)]
[InlineData("R6:0.DN", 13)]
[InlineData("R6:0.EU", 14)]
[InlineData("R6:0.EN", 15)]
public async Task Control_status_bit_decodes_correct_position(string address, int bitPos)
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.ControlElement));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(true);
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
}
[Fact]
public async Task Status_bit_returns_false_when_parent_word_bit_is_clear()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("Done", "ab://10.0.0.5/1,0", "T4:0.DN", AbLegacyDataType.TimerElement));
await drv.InitializeAsync("{}", CancellationToken.None);
// Bit 14 (TT) set, bit 13 (DN) clear.
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << 14 };
var snapshots = await drv.ReadAsync(["Done"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(false);
}
[Theory]
[InlineData("T4:0.DN", AbLegacyDataType.TimerElement)]
[InlineData("T4:0.TT", AbLegacyDataType.TimerElement)]
[InlineData("C5:0.DN", AbLegacyDataType.CounterElement)]
[InlineData("C5:0.OV", AbLegacyDataType.CounterElement)]
[InlineData("C5:0.UN", AbLegacyDataType.CounterElement)]
[InlineData("R6:0.ER", AbLegacyDataType.ControlElement)]
[InlineData("R6:0.EM", AbLegacyDataType.ControlElement)]
[InlineData("R6:0.DN", AbLegacyDataType.ControlElement)]
[InlineData("R6:0.FD", AbLegacyDataType.ControlElement)]
public async Task Writes_to_PLC_set_status_bits_return_BadNotWritable(
string address, AbLegacyDataType dataType)
{
var (drv, _) = NewDriver(
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, dataType));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("X", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
}
}
@@ -40,7 +40,25 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
}
public virtual int GetStatus() => Status;
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
public int? LastDecodeBitIndex { get; private set; }
public AbLegacyDataType? LastDecodeType { get; private set; }
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex)
{
LastDecodeType = type;
LastDecodeBitIndex = bitIndex;
// If the test seeded a parent-word value (ushort/short/int) and the driver asked for a
// specific status bit, mask it out so we can assert the correct bit reaches the client.
if (bitIndex is int bit && Value is not null and not bool)
{
try
{
var word = Convert.ToInt32(Value);
return ((word >> bit) & 1) != 0;
}
catch (Exception ex) when (ex is FormatException or InvalidCastException) { }
}
return Value;
}
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
public virtual void Dispose() => Disposed = true;
}
@@ -31,8 +31,10 @@ public sealed class FocasCapabilityTests
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
// Per-tag and Status/ fields can share a BrowseName ("Run", "Alarm") under different
// parent folders — disambiguate by FullName, which is unique per node.
builder.Variables.Single(v => v.Info.FullName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.Info.FullName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
// ---- ISubscribable ----
@@ -0,0 +1,273 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasFigureScalingDiagnosticsTests
{
private const string Host = "focas://10.0.0.7:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// per-axis figure scaling for the F1-f cache + diagnostics surface
/// (issue #262).
/// </summary>
private sealed class FigureAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public IReadOnlyDictionary<string, int>? Scaling { get; set; }
Task<IReadOnlyDictionary<string, int>?> IFocasClient.GetFigureScalingAsync(CancellationToken ct) =>
Task.FromResult(Scaling);
}
[Fact]
public async Task DiscoverAsync_emits_Diagnostics_subtree_with_five_counters()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-diag", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Diagnostics" && f.DisplayName == "Diagnostics");
var diagVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Diagnostics/")).ToList();
diagVars.Count.ShouldBe(5);
// Verify per-field types match the documented surface (Int64 counters,
// String error message, DateTime last-success timestamp).
diagVars.Single(v => v.BrowseName == "ReadCount")
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
diagVars.Single(v => v.BrowseName == "ReadFailureCount")
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
diagVars.Single(v => v.BrowseName == "ReconnectCount")
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
diagVars.Single(v => v.BrowseName == "LastErrorMessage")
.Info.DriverDataType.ShouldBe(DriverDataType.String);
diagVars.Single(v => v.BrowseName == "LastSuccessfulRead")
.Info.DriverDataType.ShouldBe(DriverDataType.DateTime);
foreach (var v in diagVars)
v.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task ReadAsync_publishes_diagnostics_counters_after_probe_ticks()
{
// Probe enabled — successful ticks bump ReadCount + LastSuccessfulRead;
// ReconnectCount bumps once on the initial connect (issue #262).
var fake = new FakeFocasClient { ProbeResult = true };
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
}, "drv-diag-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least 2 successful probe ticks so ReadCount > 0 deterministically.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
return snap.Value is long n && n >= 2;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Diagnostics/ReadCount",
$"{Host}::Diagnostics/ReadFailureCount",
$"{Host}::Diagnostics/ReconnectCount",
$"{Host}::Diagnostics/LastErrorMessage",
$"{Host}::Diagnostics/LastSuccessfulRead",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
((long)snaps[0].Value!).ShouldBeGreaterThanOrEqualTo(2);
((long)snaps[1].Value!).ShouldBe(0); // no failures on a healthy probe
((long)snaps[2].Value!).ShouldBe(1); // one initial connect
snaps[3].Value.ShouldBe(string.Empty);
((DateTime)snaps[4].Value!).ShouldBeGreaterThan(DateTime.MinValue);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_increments_ReadFailureCount_when_probe_returns_false()
{
// ProbeResult=false → success branch is skipped, ReadFailureCount bumps each
// tick. The connect itself succeeded so ReconnectCount is 1.
var fake = new FakeFocasClient { ProbeResult = false };
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
}, "drv-diag-fail", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadFailureCount"], CancellationToken.None)).Single();
return snap.Value is long n && n >= 2;
}, TimeSpan.FromSeconds(3));
var snaps = await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadCount", $"{Host}::Diagnostics/ReadFailureCount"],
CancellationToken.None);
((long)snaps[0].Value!).ShouldBe(0);
((long)snaps[1].Value!).ShouldBeGreaterThanOrEqualTo(2);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ApplyFigureScaling_divides_raw_position_by_ten_to_the_decimal_places()
{
// Cache populated via probe-tick GetFigureScalingAsync. ApplyFigureScaling
// default is true → rawValue / 10^dec for the named axis (issue #262).
var fake = new FigureAwareFakeFocasClient
{
Scaling = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["axis1"] = 3, // X-axis: 3 decimal places (mm * 1000)
["axis2"] = 4, // Y-axis: 4 decimal places
},
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
}, "drv-fig", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for the probe-tick path to populate the cache (one successful tick is
// enough — the figure-scaling read happens whenever the cache is null).
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
return snap.Value is long n && n >= 1;
}, TimeSpan.FromSeconds(3));
// 100000 / 10^3 = 100.0 mm
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100.0);
// 250000 / 10^4 = 25.0 mm
drv.ApplyFigureScaling(Host, "axis2", 250000).ShouldBe(25.0);
// Unknown axis → raw value passes through.
drv.ApplyFigureScaling(Host, "axis3", 42).ShouldBe(42.0);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ApplyFigureScaling_returns_raw_when_FixedTreeApplyFigureScaling_is_false()
{
// ApplyFigureScaling=false short-circuits before the cache lookup so the raw
// integer is published unchanged. Migration parity for deployments that already
// surfaced raw values from older drivers (issue #262).
var fake = new FigureAwareFakeFocasClient
{
Scaling = new Dictionary<string, int> { ["axis1"] = 3 },
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
FixedTree = new FocasFixedTreeOptions { ApplyFigureScaling = false },
}, "drv-fig-off", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
return snap.Value is long n && n >= 1;
}, TimeSpan.FromSeconds(3));
// Even though the cache has axis1 → 3 decimal places, ApplyFigureScaling=false
// means the raw value passes through unchanged.
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100000.0);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task FwlibFocasClient_GetFigureScaling_returns_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must
// short-circuit before P/Invoke so the driver leaves the cache untouched.
var client = new FwlibFocasClient();
(await client.GetFigureScalingAsync(CancellationToken.None)).ShouldBeNull();
}
[Fact]
public void DecodeFigureScaling_extracts_per_axis_decimal_places_from_buffer()
{
// Build an IODBAXIS-shaped buffer: 3 axes, decimal places = 3, 4, 0. Per
// fwlib32.h each axis entry is { short dec, short unit, short reserved,
// short reserved2 } = 8 bytes; we only read dec.
var buf = new byte[FwlibNative.MAX_AXIS * 8];
// Axis 1: dec=3
buf[0] = 3; buf[1] = 0;
// Axis 2: dec=4
buf[8] = 4; buf[9] = 0;
// Axis 3: dec=0 (already zero)
var map = FwlibFocasClient.DecodeFigureScaling(buf, count: 3);
map.Count.ShouldBe(3);
map["axis1"].ShouldBe(3);
map["axis2"].ShouldBe(4);
map["axis3"].ShouldBe(0);
// Out-of-range count clamps to MAX_AXIS so a malformed CNC reply doesn't
// overrun the buffer.
var clamped = FwlibFocasClient.DecodeFigureScaling(buf, count: 99);
clamped.Count.ShouldBe(FwlibNative.MAX_AXIS);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
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) { } }
}
}
@@ -0,0 +1,231 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasMessagesBlockTextFixedTreeTests
{
private const string Host = "focas://10.0.0.7:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// <see cref="FocasOperatorMessagesInfo"/> + <see cref="FocasCurrentBlockInfo"/>
/// snapshots for the F1-e Messages/External/Latest + Program/CurrentBlock
/// fixed-tree (issue #261).
/// </summary>
private sealed class MessagesAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasOperatorMessagesInfo? Messages { get; set; }
public FocasCurrentBlockInfo? CurrentBlock { get; set; }
Task<FocasOperatorMessagesInfo?> IFocasClient.GetOperatorMessagesAsync(CancellationToken ct) =>
Task.FromResult(Messages);
Task<FocasCurrentBlockInfo?> IFocasClient.GetCurrentBlockAsync(CancellationToken ct) =>
Task.FromResult(CurrentBlock);
}
[Fact]
public async Task DiscoverAsync_emits_Messages_External_Latest_and_Program_CurrentBlock_nodes()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-msg", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Messages" && f.DisplayName == "Messages");
builder.Folders.ShouldContain(f => f.BrowseName == "External" && f.DisplayName == "External");
builder.Folders.ShouldContain(f => f.BrowseName == "Program" && f.DisplayName == "Program");
var latest = builder.Variables.SingleOrDefault(v =>
v.Info.FullName == $"{Host}::Messages/External/Latest");
latest.BrowseName.ShouldBe("Latest");
latest.Info.DriverDataType.ShouldBe(DriverDataType.String);
latest.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
var block = builder.Variables.SingleOrDefault(v =>
v.Info.FullName == $"{Host}::Program/CurrentBlock");
block.BrowseName.ShouldBe("CurrentBlock");
block.Info.DriverDataType.ShouldBe(DriverDataType.String);
block.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task ReadAsync_serves_Messages_Latest_and_CurrentBlock_from_cached_snapshot()
{
var fake = new MessagesAwareFakeFocasClient
{
Messages = new FocasOperatorMessagesInfo(
[
new FocasOperatorMessage(2001, "OPMSG", "TOOL CHANGE READY"),
new FocasOperatorMessage(3010, "EXTERN", "DOOR OPEN"),
]),
CurrentBlock = new FocasCurrentBlockInfo("G01 X100. Y200. F500."),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-msg-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Program/CurrentBlock"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Messages/External/Latest",
$"{Host}::Program/CurrentBlock",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
// "Latest" surfaces the last entry in the message snapshot — issue #261 permits
// this minimal "latest message" surface in lieu of full ring-buffer coverage.
snaps[0].Value.ShouldBe("DOOR OPEN");
snaps[1].Value.ShouldBe("G01 X100. Y200. F500.");
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
{
// Probe disabled — neither cache populates; the nodes still resolve as known
// references but report Bad until the first poll. Mirrors the f1a/f1b/f1c/f1d
// policy.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-msg-empty", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task ReadAsync_publishes_empty_string_when_message_snapshot_is_empty()
{
// Empty snapshot (CNC reported no active messages) still publishes Good +
// empty string — operators distinguish "no messages" from "Bad" without
// having to read separate availability nodes.
var fake = new MessagesAwareFakeFocasClient
{
Messages = new FocasOperatorMessagesInfo([]),
CurrentBlock = new FocasCurrentBlockInfo(""),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-msg-empty-snap", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Messages/External/Latest"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var snaps = await drv.ReadAsync(
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
CancellationToken.None);
snaps[0].Value.ShouldBe(string.Empty);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[1].Value.ShouldBe(string.Empty);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task FwlibFocasClient_GetOperatorMessages_and_GetCurrentBlock_return_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must
// short-circuit before P/Invoke. Returns null → driver leaves the cache
// untouched, matching the policy in f1a/f1b/f1c/f1d.
var client = new FwlibFocasClient();
(await client.GetOperatorMessagesAsync(CancellationToken.None)).ShouldBeNull();
(await client.GetCurrentBlockAsync(CancellationToken.None)).ShouldBeNull();
}
[Fact]
public void TrimAnsiPadding_strips_trailing_nulls_and_spaces_for_round_trip()
{
// The CNC right-pads block text + opmsg bodies with NULs or spaces; the
// managed side trims them so the same message round-trips with stable text
// (issue #261). Stops at the first NUL so reused buffers don't leak old bytes.
var buf = new byte[16];
var bytes = System.Text.Encoding.ASCII.GetBytes("G01 X10 ");
Array.Copy(bytes, buf, bytes.Length);
FwlibFocasClient.TrimAnsiPadding(buf).ShouldBe("G01 X10");
// NUL-terminated mid-buffer with trailing spaces beyond the NUL — trim stops
// at the NUL so leftover bytes in the rest of the buffer are ignored.
var buf2 = new byte[32];
var bytes2 = System.Text.Encoding.ASCII.GetBytes("OPMSG TEXT");
Array.Copy(bytes2, buf2, bytes2.Length);
// After NUL the buffer has zeros — already invisible — but explicit space
// padding before the NUL should be trimmed.
var buf3 = new byte[32];
var bytes3 = System.Text.Encoding.ASCII.GetBytes("HELLO ");
Array.Copy(bytes3, buf3, bytes3.Length);
FwlibFocasClient.TrimAnsiPadding(buf2).ShouldBe("OPMSG TEXT");
FwlibFocasClient.TrimAnsiPadding(buf3).ShouldBe("HELLO");
// Empty buffer → empty string (no exception).
FwlibFocasClient.TrimAnsiPadding(new byte[8]).ShouldBe(string.Empty);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
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) { } }
}
}
@@ -0,0 +1,231 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasModalOverrideFixedTreeTests
{
private const string Host = "focas://10.0.0.6:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// <see cref="FocasModalInfo"/> + <see cref="FocasOverrideInfo"/> snapshots.
/// </summary>
private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasModalInfo? Modal { get; set; }
public FocasOverrideInfo? Override { get; set; }
public FocasOverrideParameters? LastOverrideParams { get; private set; }
Task<FocasModalInfo?> IFocasClient.GetModalAsync(CancellationToken ct) =>
Task.FromResult(Modal);
Task<FocasOverrideInfo?> IFocasClient.GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken ct)
{
LastOverrideParams = parameters;
return Task.FromResult(Override);
}
}
[Fact]
public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-modal", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal");
var modalVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Modal/")).ToList();
modalVars.Count.ShouldBe(4);
string[] expected = ["MCode", "SCode", "TCode", "BCode"];
foreach (var name in expected)
{
var node = modalVars.SingleOrDefault(v => v.BrowseName == name);
node.BrowseName.ShouldBe(name);
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Modal/{name}");
}
}
[Fact]
public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-no-overrides", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldNotContain(f => f.BrowseName == "Override");
builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/"));
}
[Fact]
public async Task DiscoverAsync_emits_only_configured_Override_fields()
{
// Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up.
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions(Host,
OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)),
],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-partial-overrides", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Override");
var overrideVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Override/")).ToList();
overrideVars.Count.ShouldBe(2);
overrideVars.ShouldContain(v => v.BrowseName == "Feed");
overrideVars.ShouldContain(v => v.BrowseName == "Rapid");
overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle");
overrideVars.ShouldNotContain(v => v.BrowseName == "Jog");
}
[Fact]
public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot()
{
var fake = new ModalAwareFakeFocasClient
{
Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0),
Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions(Host,
OverrideParameters: FocasOverrideParameters.Default),
],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-modal-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least one probe tick to populate both caches.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Modal/MCode"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Modal/MCode",
$"{Host}::Modal/SCode",
$"{Host}::Modal/TCode",
$"{Host}::Modal/BCode",
$"{Host}::Override/Feed",
$"{Host}::Override/Rapid",
$"{Host}::Override/Spindle",
$"{Host}::Override/Jog",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe((short)8);
snaps[1].Value.ShouldBe((short)1200);
snaps[2].Value.ShouldBe((short)101);
snaps[3].Value.ShouldBe((short)0);
snaps[4].Value.ShouldBe((short)100);
snaps[5].Value.ShouldBe((short)50);
snaps[6].Value.ShouldBe((short)110);
snaps[7].Value.ShouldBe((short)25);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
// The driver hands the device's configured override parameters to the wire client
// verbatim — defaulting to 30i numbers.
fake.LastOverrideParams.ShouldNotBeNull();
fake.LastOverrideParams!.FeedParam.ShouldBe<ushort?>(6010);
fake.LastOverrideParams.RapidParam.ShouldBe<ushort?>(6011);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
{
// Probe disabled — neither modal nor override caches populate; the nodes still
// resolve as known references but report Bad until the first successful poll.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions(Host,
OverrideParameters: FocasOverrideParameters.Default),
],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-empty-cache", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task FwlibFocasClient_GetModal_and_GetOverride_return_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must short-
// circuit before P/Invoke. Returns null → driver leaves the cache untouched.
var client = new FwlibFocasClient();
(await client.GetModalAsync(CancellationToken.None)).ShouldBeNull();
(await client.GetOverrideAsync(
FocasOverrideParameters.Default, CancellationToken.None)).ShouldBeNull();
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
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) { } }
}
}
@@ -0,0 +1,156 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasProductionFixedTreeTests
{
private const string Host = "focas://10.0.0.5:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
/// <see cref="FocasProductionInfo"/> snapshot from <c>GetProductionAsync</c>.
/// </summary>
private sealed class ProductionAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasProductionInfo? Production { get; set; }
Task<FocasProductionInfo?> IFocasClient.GetProductionAsync(CancellationToken ct) =>
Task.FromResult(Production);
}
[Fact]
public async Task DiscoverAsync_emits_Production_folder_with_4_Int32_nodes_per_device()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Production" && f.DisplayName == "Production");
var prodVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Production/")).ToList();
prodVars.Count.ShouldBe(4);
string[] expected = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"];
foreach (var name in expected)
{
var node = prodVars.SingleOrDefault(v => v.BrowseName == name);
node.BrowseName.ShouldBe(name);
node.Info.DriverDataType.ShouldBe(DriverDataType.Int32);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Production/{name}");
}
}
[Fact]
public async Task ReadAsync_serves_each_Production_field_from_cached_snapshot()
{
var fake = new ProductionAwareFakeFocasClient
{
Production = new FocasProductionInfo(
PartsProduced: 17,
PartsRequired: 100,
PartsTotal: 4242,
CycleTimeSeconds: 73),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least one probe tick to populate the cache.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Production/PartsProduced"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Production/PartsProduced",
$"{Host}::Production/PartsRequired",
$"{Host}::Production/PartsTotal",
$"{Host}::Production/CycleTimeSeconds",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe(17);
snaps[1].Value.ShouldBe(100);
snaps[2].Value.ShouldBe(4242);
snaps[3].Value.ShouldBe(73);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_production_cache_is_empty()
{
// Probe disabled — cache never populates; the production nodes still resolve as
// known references but report Bad until the first successful poll lands.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Production/PartsProduced"], CancellationToken.None);
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task FwlibFocasClient_GetProductionAsync_returns_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); calling GetProductionAsync on the
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
// in its current state.
var client = new FwlibFocasClient();
var result = await client.GetProductionAsync(CancellationToken.None);
result.ShouldBeNull();
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
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) { } }
}
}
@@ -0,0 +1,197 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasStatusFixedTreeTests
{
private const string Host = "focas://10.0.0.5:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
/// <see cref="FocasStatusInfo"/> snapshot from <see cref="GetStatusAsync"/>. Probe
/// keeps its existing boolean semantic so the back-compat path stays exercised.
/// </summary>
private sealed class StatusAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasStatusInfo? Status { get; set; }
// Shadow the default interface implementation with a real one. Explicit interface
// form so callers via IFocasClient hit this override; FakeFocasClient itself
// doesn't declare a virtual GetStatusAsync (the contract has a default impl).
Task<FocasStatusInfo?> IFocasClient.GetStatusAsync(CancellationToken ct) =>
Task.FromResult(Status);
}
[Fact]
public async Task DiscoverAsync_emits_Status_folder_with_9_Int16_nodes_per_device()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Status" && f.DisplayName == "Status");
var statusVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Status/")).ToList();
statusVars.Count.ShouldBe(9);
string[] expected = ["Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy"];
foreach (var name in expected)
{
var node = statusVars.SingleOrDefault(v => v.BrowseName == name);
node.BrowseName.ShouldBe(name);
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Status/{name}");
}
}
[Fact]
public async Task ReadAsync_serves_each_Status_field_from_cached_ODBST_snapshot()
{
var fake = new StatusAwareFakeFocasClient
{
Status = new FocasStatusInfo(
Dummy: 0, Tmmode: 1, Aut: 2, Run: 3, Motion: 4,
Mstb: 5, EmergencyStop: 1, Alarm: 7, Edit: 6),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least one probe tick to populate the cache.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Status/Tmmode",
$"{Host}::Status/Aut",
$"{Host}::Status/Run",
$"{Host}::Status/Motion",
$"{Host}::Status/Mstb",
$"{Host}::Status/EmergencyStop",
$"{Host}::Status/Alarm",
$"{Host}::Status/Edit",
$"{Host}::Status/Dummy",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe((short)1); // Tmmode
snaps[1].Value.ShouldBe((short)2); // Aut
snaps[2].Value.ShouldBe((short)3); // Run
snaps[3].Value.ShouldBe((short)4); // Motion
snaps[4].Value.ShouldBe((short)5); // Mstb
snaps[5].Value.ShouldBe((short)1); // EmergencyStop
snaps[6].Value.ShouldBe((short)7); // Alarm
snaps[7].Value.ShouldBe((short)6); // Edit
snaps[8].Value.ShouldBe((short)0); // Dummy
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_status_cache_is_empty()
{
// Probe disabled — cache never populates; the status nodes still resolve as
// known references but report Bad until the first successful poll lands.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Status/Tmmode"], CancellationToken.None);
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task Existing_boolean_probe_path_still_works_alongside_GetStatusAsync()
{
// Back-compat guard: ProbeAsync's existing boolean contract is preserved. A client
// that doesn't override GetStatusAsync (default null) leaves the cache untouched
// but the probe still flips host state to Running.
var fake = new FakeFocasClient { ProbeResult = true };
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => Task.FromResult(
drv.GetHostStatuses().Any(h => h.State == HostState.Running)),
TimeSpan.FromSeconds(3));
// No GetStatusAsync override → cache stays empty → status nodes report Bad,
// but the rest of the driver keeps functioning.
var snap = (await drv.ReadAsync(
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task FwlibFocasClient_GetStatusAsync_returns_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); calling GetStatusAsync on the
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
// in its current state.
var client = new FwlibFocasClient();
var result = await client.GetStatusAsync(CancellationToken.None);
result.ShouldBeNull();
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
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) { } }
}
}
@@ -0,0 +1,220 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasToolingOffsetsFixedTreeTests
{
private const string Host = "focas://10.0.0.7:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// <see cref="FocasToolingInfo"/> + <see cref="FocasWorkOffsetsInfo"/> snapshots
/// for the F1-d Tooling/CurrentTool + Offsets/ fixed-tree (issue #260).
/// </summary>
private sealed class ToolingAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasToolingInfo? Tooling { get; set; }
public FocasWorkOffsetsInfo? WorkOffsets { get; set; }
Task<FocasToolingInfo?> IFocasClient.GetToolingAsync(CancellationToken ct) =>
Task.FromResult(Tooling);
Task<FocasWorkOffsetsInfo?> IFocasClient.GetWorkOffsetsAsync(CancellationToken ct) =>
Task.FromResult(WorkOffsets);
}
[Fact]
public async Task DiscoverAsync_emits_Tooling_folder_with_CurrentTool_node()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-tooling", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Tooling" && f.DisplayName == "Tooling");
var toolingVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Tooling/")).ToList();
toolingVars.Count.ShouldBe(1);
var node = toolingVars.Single();
node.BrowseName.ShouldBe("CurrentTool");
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Tooling/CurrentTool");
}
[Fact]
public async Task DiscoverAsync_emits_Offsets_folder_with_G54_to_G59_each_with_3_axes()
{
// Six standard slots (G54..G59) * three axes (X/Y/Z) = 18 Float64 nodes per
// device. Extended G54.1 P1..P48 deferred per the F1-d plan.
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-offsets", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Offsets");
string[] expectedSlots = ["G54", "G55", "G56", "G57", "G58", "G59"];
foreach (var slot in expectedSlots)
builder.Folders.ShouldContain(f => f.BrowseName == slot);
var offsetVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Offsets/")).ToList();
offsetVars.Count.ShouldBe(6 * 3);
foreach (var slot in expectedSlots)
foreach (var axis in new[] { "X", "Y", "Z" })
{
var fullRef = $"{Host}::Offsets/{slot}/{axis}";
var node = offsetVars.SingleOrDefault(v => v.Info.FullName == fullRef);
node.Info.DriverDataType.ShouldBe(DriverDataType.Float64);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
}
[Fact]
public async Task ReadAsync_serves_Tooling_and_Offsets_fields_from_cached_snapshot()
{
var fake = new ToolingAwareFakeFocasClient
{
Tooling = new FocasToolingInfo(CurrentTool: 17),
WorkOffsets = new FocasWorkOffsetsInfo(
[
new FocasWorkOffset("G54", X: 100.5, Y: 200.25, Z: -50.0),
new FocasWorkOffset("G55", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G56", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G57", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G58", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G59", X: 1, Y: 2, Z: 3),
]),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-tooling-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least one probe tick to populate both caches.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Tooling/CurrentTool"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Tooling/CurrentTool",
$"{Host}::Offsets/G54/X",
$"{Host}::Offsets/G54/Y",
$"{Host}::Offsets/G54/Z",
$"{Host}::Offsets/G59/X",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe((short)17);
snaps[1].Value.ShouldBe(100.5);
snaps[2].Value.ShouldBe(200.25);
snaps[3].Value.ShouldBe(-50.0);
snaps[4].Value.ShouldBe(1.0);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
{
// Probe disabled — neither tooling nor offsets caches populate; the nodes
// still resolve as known references but report Bad until the first poll.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-empty-tooling", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Tooling/CurrentTool", $"{Host}::Offsets/G54/X"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task FwlibFocasClient_GetTooling_and_GetWorkOffsets_return_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must
// short-circuit before P/Invoke. Returns null → driver leaves the cache
// untouched, matching the policy in f1a/f1b/f1c.
var client = new FwlibFocasClient();
(await client.GetToolingAsync(CancellationToken.None)).ShouldBeNull();
(await client.GetWorkOffsetsAsync(CancellationToken.None)).ShouldBeNull();
}
[Fact]
public void DecodeOfsbAxis_applies_decimal_point_count_like_macro_decode()
{
// Layout per fwlib32.h: int data, short dec, short unit, short disp = 10 bytes.
// Three axes (X=12345 / dec=3 = 12.345; Y=-500 / dec=2 = -5.00; Z=0 / dec=0 = 0).
var buf = new byte[80];
WriteAxis(buf, 0, raw: 12345, dec: 3);
WriteAxis(buf, 1, raw: -500, dec: 2);
WriteAxis(buf, 2, raw: 0, dec: 0);
FwlibFocasClient.DecodeOfsbAxis(buf, 0).ShouldBe(12.345, tolerance: 1e-9);
FwlibFocasClient.DecodeOfsbAxis(buf, 1).ShouldBe(-5.0, tolerance: 1e-9);
FwlibFocasClient.DecodeOfsbAxis(buf, 2).ShouldBe(0.0, tolerance: 1e-9);
}
private static void WriteAxis(byte[] buf, int axisIndex, int raw, short dec)
{
var offset = axisIndex * 10;
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(buf.AsSpan(offset, 4), raw);
System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(buf.AsSpan(offset + 4, 2), dec);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
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) { } }
}
}
@@ -0,0 +1,242 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit coverage for the cert-validation knobs added in PR #277. Live revocation testing
/// requires standing up a CA + CRL; we cover the parts that are testable without one:
/// option defaults, the static decision pipeline, SHA-1 detection, and key-size checks.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientCertValidationTests
{
[Fact]
public void Defaults_match_documented_policy()
{
var opts = new OpcUaClientDriverOptions();
opts.CertificateValidation.RejectSHA1SignedCertificates.ShouldBeTrue(
"SHA-1 is spec-deprecated for OPC UA — default must be hard-fail.");
opts.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse(
"Default must allow brownfield deployments without CRL infrastructure.");
opts.CertificateValidation.MinimumCertificateKeySize.ShouldBe(2048);
}
[Fact]
public void Revoked_cert_is_rejected_even_when_AutoAccept_is_true()
{
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
cert,
new StatusCode(StatusCodes.BadCertificateRevoked),
autoAcceptUntrusted: true,
new OpcUaCertificateValidationOptions());
decision.Accept.ShouldBeFalse();
decision.LogMessage!.ShouldContain("REVOKED");
}
[Fact]
public void Issuer_revoked_is_rejected_even_when_AutoAccept_is_true()
{
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
cert,
new StatusCode(StatusCodes.BadCertificateIssuerRevoked),
autoAcceptUntrusted: true,
new OpcUaCertificateValidationOptions());
decision.Accept.ShouldBeFalse();
decision.LogMessage!.ShouldContain("REVOKED issuer");
}
[Fact]
public void RevocationUnknown_default_accepts_with_log_note()
{
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
cert,
new StatusCode(StatusCodes.BadCertificateRevocationUnknown),
autoAcceptUntrusted: false,
new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = false });
decision.Accept.ShouldBeTrue();
decision.LogMessage!.ShouldContain("revocation status unknown");
}
[Fact]
public void RevocationUnknown_with_strict_flag_rejects()
{
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
cert,
new StatusCode(StatusCodes.BadCertificateRevocationUnknown),
autoAcceptUntrusted: true,
new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = true });
decision.Accept.ShouldBeFalse();
decision.LogMessage!.ShouldContain("revocation status unknown");
}
[Fact]
public void Sha1_signed_cert_is_rejected_by_default()
{
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1);
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
cert,
new StatusCode(StatusCodes.Good),
autoAcceptUntrusted: false,
new OpcUaCertificateValidationOptions());
decision.Accept.ShouldBeFalse();
decision.LogMessage!.ShouldContain("SHA-1");
}
[Fact]
public void Sha1_acceptance_can_be_opted_back_into()
{
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1);
// Untrusted + auto-accept = let it through; SHA-1 must NOT be the failing reason.
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
cert,
new StatusCode(StatusCodes.BadCertificateUntrusted),
autoAcceptUntrusted: true,
new OpcUaCertificateValidationOptions { RejectSHA1SignedCertificates = false });
decision.Accept.ShouldBeTrue();
}
[Fact]
public void Small_rsa_key_is_rejected_below_minimum()
{
using var cert = CreateRsaCert(1024, HashAlgorithmName.SHA256);
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
cert,
new StatusCode(StatusCodes.Good),
autoAcceptUntrusted: false,
new OpcUaCertificateValidationOptions());
decision.Accept.ShouldBeFalse();
decision.LogMessage!.ShouldContain("1024");
}
[Fact]
public void TryGetRsaKeySize_reports_correct_bit_count()
{
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
OpcUaClientDriver.TryGetRsaKeySize(cert, out var bits).ShouldBeTrue();
bits.ShouldBe(2048);
}
[Fact]
public void IsSha1Signed_detects_sha1_signature()
{
using var sha1Cert = CreateRsaCert(2048, HashAlgorithmName.SHA1);
using var sha256Cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
OpcUaClientDriver.IsSha1Signed(sha1Cert).ShouldBeTrue();
OpcUaClientDriver.IsSha1Signed(sha256Cert).ShouldBeFalse();
OpcUaClientDriver.IsSha1Signed(null).ShouldBeFalse();
}
[Fact]
public void Untrusted_without_AutoAccept_is_rejected()
{
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
cert,
new StatusCode(StatusCodes.BadCertificateUntrusted),
autoAcceptUntrusted: false,
new OpcUaCertificateValidationOptions());
decision.Accept.ShouldBeFalse();
decision.LogMessage!.ShouldContain("untrusted");
}
[Fact]
public void Good_status_with_compliant_cert_accepts_silently()
{
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
cert,
new StatusCode(StatusCodes.Good),
autoAcceptUntrusted: false,
new OpcUaCertificateValidationOptions());
decision.Accept.ShouldBeTrue();
decision.LogMessage.ShouldBeNull("Good validations shouldn't emit log noise.");
}
private static X509Certificate2 CreateRsaCert(int keySize, HashAlgorithmName hash)
{
// .NET 10's CertificateRequest.CreateSelfSigned rejects SHA-1 outright. For the
// SHA-256 path we use the supported API; for SHA-1 we route through a custom
// X509SignatureGenerator that signs with SHA-1 OID so we can synthesise a SHA-1
// signed cert in-process without shipping a binary fixture.
var rsa = RSA.Create(keySize);
var req = new CertificateRequest(
new System.Security.Cryptography.X509Certificates.X500DistinguishedName(
"CN=OpcUaClientCertValidationTests"),
rsa,
hash == HashAlgorithmName.SHA1 ? HashAlgorithmName.SHA256 : hash,
RSASignaturePadding.Pkcs1);
if (hash == HashAlgorithmName.SHA1)
{
var generator = new Sha1RsaSignatureGenerator(rsa);
var serial = new byte[8];
System.Security.Cryptography.RandomNumberGenerator.Fill(serial);
var built = req.Create(
req.SubjectName,
generator,
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddHours(1),
serial);
// Combine cert + key so GetRSAPublicKey works downstream.
return built.CopyWithPrivateKey(rsa);
}
return req.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddHours(1));
}
/// <summary>
/// SHA-1 RSA signature generator. .NET 10's <see cref="X509SignatureGenerator.CreateForRSA"/>
/// refuses SHA-1; we subclass to emit the SHA-1 RSA algorithm identifier
/// (<c>1.2.840.113549.1.1.5</c>) and sign with SHA-1 explicitly. Test-only.
/// </summary>
private sealed class Sha1RsaSignatureGenerator : X509SignatureGenerator
{
private readonly RSA _rsa;
public Sha1RsaSignatureGenerator(RSA rsa) { _rsa = rsa; }
public override byte[] GetSignatureAlgorithmIdentifier(HashAlgorithmName hashAlgorithm)
{
// DER: SEQUENCE { OID 1.2.840.113549.1.1.5, NULL }
return new byte[]
{
0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x05, 0x05, 0x00,
};
}
public override byte[] SignData(byte[] data, HashAlgorithmName hashAlgorithm)
=> _rsa.SignData(data, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1);
protected override PublicKey BuildPublicKey() => PublicKey.CreateFromSubjectPublicKeyInfo(
_rsa.ExportSubjectPublicKeyInfo(), out _);
}
}
@@ -0,0 +1,163 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for the per-driver diagnostic counters surfaced via
/// <see cref="DriverHealth.Diagnostics"/> for the <c>driver-diagnostics</c> RPC
/// (task #276). Counters are exercised directly through the internal helper rather
/// than via a live SDK <c>ISession</c> because the SDK requires a connected upstream
/// to publish events and we want unit-level coverage of the math + the snapshot shape.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientDiagnosticsTests
{
[Fact]
public void Counters_default_to_zero()
{
var d = new OpcUaClientDiagnostics();
d.PublishRequestCount.ShouldBe(0);
d.NotificationCount.ShouldBe(0);
d.NotificationsPerSecond.ShouldBe(0);
d.MissingPublishRequestCount.ShouldBe(0);
d.DroppedNotificationCount.ShouldBe(0);
d.SessionResetCount.ShouldBe(0);
d.LastReconnectUtc.ShouldBeNull();
}
[Fact]
public void IncrementPublishRequest_bumps_total()
{
var d = new OpcUaClientDiagnostics();
d.IncrementPublishRequest();
d.IncrementPublishRequest();
d.IncrementPublishRequest();
d.PublishRequestCount.ShouldBe(3);
}
[Fact]
public void IncrementMissingPublishRequest_bumps_total()
{
var d = new OpcUaClientDiagnostics();
d.IncrementMissingPublishRequest();
d.IncrementMissingPublishRequest();
d.MissingPublishRequestCount.ShouldBe(2);
}
[Fact]
public void IncrementDroppedNotification_bumps_total()
{
var d = new OpcUaClientDiagnostics();
d.IncrementDroppedNotification();
d.DroppedNotificationCount.ShouldBe(1);
}
[Fact]
public void RecordNotification_grows_count_and_then_rate()
{
var d = new OpcUaClientDiagnostics();
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
// First sample seeds the EWMA — rate stays 0 until we have a delta.
d.RecordNotification(t0);
d.NotificationCount.ShouldBe(1);
d.NotificationsPerSecond.ShouldBe(0);
// 1 Hz steady state: 30 samples spaced 1s apart converge toward 1/s. With 5s half-life
// and alpha=0.5^(1/5)≈0.871, the EWMA approaches 1 - alpha^N — after 30 samples that's
// 1 - 0.871^30 ≈ 0.984.
for (var i = 1; i <= 30; i++)
d.RecordNotification(t0.AddSeconds(i));
d.NotificationCount.ShouldBe(31);
d.NotificationsPerSecond.ShouldBeInRange(0.95, 1.05, "EWMA at 5s half-life converges to ~1Hz after 30 samples");
}
[Fact]
public void RecordSessionReset_bumps_count_and_sets_last_reconnect()
{
var d = new OpcUaClientDiagnostics();
var t = new DateTime(2026, 4, 25, 12, 34, 56, DateTimeKind.Utc);
d.RecordSessionReset(t);
d.SessionResetCount.ShouldBe(1);
d.LastReconnectUtc.ShouldBe(t);
// Second reset overwrites timestamp + bumps count.
var t2 = t.AddMinutes(5);
d.RecordSessionReset(t2);
d.SessionResetCount.ShouldBe(2);
d.LastReconnectUtc.ShouldBe(t2);
}
[Fact]
public void Snapshot_emits_well_known_keys()
{
var d = new OpcUaClientDiagnostics();
d.IncrementPublishRequest();
d.RecordNotification(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
d.IncrementMissingPublishRequest();
d.IncrementDroppedNotification();
d.RecordSessionReset(new DateTime(2026, 4, 25, 0, 0, 0, DateTimeKind.Utc));
var snap = d.Snapshot();
snap.ShouldContainKey("PublishRequestCount");
snap["PublishRequestCount"].ShouldBe(1);
snap.ShouldContainKey("NotificationCount");
snap["NotificationCount"].ShouldBe(1);
snap.ShouldContainKey("NotificationsPerSecond");
snap.ShouldContainKey("MissingPublishRequestCount");
snap["MissingPublishRequestCount"].ShouldBe(1);
snap.ShouldContainKey("DroppedNotificationCount");
snap["DroppedNotificationCount"].ShouldBe(1);
snap.ShouldContainKey("SessionResetCount");
snap["SessionResetCount"].ShouldBe(1);
snap.ShouldContainKey("LastReconnectUtcTicks");
}
[Fact]
public void Snapshot_omits_LastReconnectUtcTicks_when_no_reset_recorded()
{
var d = new OpcUaClientDiagnostics();
d.Snapshot().ShouldNotContainKey("LastReconnectUtcTicks");
}
[Fact]
public void Driver_GetHealth_includes_diagnostics_dictionary()
{
// GetHealth must expose the snapshot to the RPC consumer even before any session
// has been opened — operators call it during startup to check counters baseline.
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "diag-test");
var health = drv.GetHealth();
health.Diagnostics.ShouldNotBeNull();
health.Diagnostics!.ShouldContainKey("PublishRequestCount");
health.Diagnostics["PublishRequestCount"].ShouldBe(0);
health.Diagnostics.ShouldContainKey("NotificationCount");
health.Diagnostics.ShouldContainKey("SessionResetCount");
}
[Fact]
public void Driver_health_diagnostics_reflect_internal_counters_after_increment()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "diag-test-2");
// Drive a counter through the test seam to prove the GetHealth snapshot is live,
// not a one-shot at construction.
drv.DiagnosticsForTest.IncrementPublishRequest();
drv.DiagnosticsForTest.IncrementPublishRequest();
var health = drv.GetHealth();
health.Diagnostics!["PublishRequestCount"].ShouldBe(2);
}
[Fact]
public void DriverHealth_default_diagnostics_is_null_but_DiagnosticsOrEmpty_is_empty()
{
// Back-compat: pre-existing call sites that construct DriverHealth with the
// 3-arg overload must keep working — the 4th param defaults to null.
var h = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
h.Diagnostics.ShouldBeNull();
h.DiagnosticsOrEmpty.ShouldBeEmpty();
}
}
@@ -67,6 +67,45 @@ public sealed class OpcUaClientDriverScaffoldTests
health.LastError.ShouldNotBeNull();
}
[Fact]
public void Default_subscription_tuning_matches_prior_hard_coded_values()
{
// PR #273: lifted hard-coded Subscription parameters into options; defaults MUST
// remain wire-identical so existing deployments see no behaviour change.
var subs = new OpcUaClientDriverOptions().Subscriptions;
subs.KeepAliveCount.ShouldBe(10);
subs.LifetimeCount.ShouldBe(1000u);
subs.MaxNotificationsPerPublish.ShouldBe(0u, "0 = unlimited per OPC UA spec");
subs.Priority.ShouldBe((byte)0);
subs.MinPublishingIntervalMs.ShouldBe(50);
subs.AlarmsPriority.ShouldBe((byte)1, "alarms get a higher priority than data tags so they aren't starved during bursts");
}
[Fact]
public void Subscription_defaults_are_overridable_via_options()
{
// Operators tuning a flaky-network deployment should be able to bump LifetimeCount /
// lower MaxNotificationsPerPublish without recompiling the driver. Verify the record
// is overridable end-to-end.
var opts = new OpcUaClientDriverOptions
{
Subscriptions = new OpcUaSubscriptionDefaults(
KeepAliveCount: 25,
LifetimeCount: 5000u,
MaxNotificationsPerPublish: 200u,
Priority: 7,
MinPublishingIntervalMs: 100,
AlarmsPriority: 9),
};
opts.Subscriptions.KeepAliveCount.ShouldBe(25);
opts.Subscriptions.LifetimeCount.ShouldBe(5000u);
opts.Subscriptions.MaxNotificationsPerPublish.ShouldBe(200u);
opts.Subscriptions.Priority.ShouldBe((byte)7);
opts.Subscriptions.MinPublishingIntervalMs.ShouldBe(100);
opts.Subscriptions.AlarmsPriority.ShouldBe((byte)9);
}
[Fact]
public async Task Reinitialize_against_unreachable_endpoint_re_throws()
{
@@ -0,0 +1,175 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for <see cref="MonitoredTagSpec"/> -> SDK <c>MonitoredItem</c> mapping.
/// Assertion-only — no live SDK session required, so the tests run on every CI without
/// a real OPC UA server fixture.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientMonitoredTagSpecTests
{
private static readonly NodeId SampleNodeId = new("Demo", 2);
[Fact]
public void BuildMonitoredItem_with_all_defaults_matches_legacy_hard_coded_values()
{
// Spec with every per-tag knob null should behave identically to the legacy
// string-only SubscribeAsync path: Reporting / SamplingInterval=publishInterval /
// QueueSize=1 / DiscardOldest=true / no filter.
var spec = new MonitoredTagSpec("ns=2;s=Demo");
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
item.SamplingInterval.ShouldBe(250);
item.QueueSize.ShouldBe(1u);
item.DiscardOldest.ShouldBeTrue();
item.MonitoringMode.ShouldBe(MonitoringMode.Reporting);
item.Filter.ShouldBeNull();
item.Handle.ShouldBe("ns=2;s=Demo",
"the tag string is routed through Handle so the Notification callback can identify the changed tag without re-parsing DisplayName");
}
[Fact]
public void BuildMonitoredItem_applies_per_tag_sampling_interval_independent_of_publish_interval()
{
// Per-tag SamplingInterval lets the server sample faster than it publishes — useful
// for events that change between publish ticks. If the spec sets it explicitly, the
// mapping uses that value, not the publish-interval default.
var spec = new MonitoredTagSpec("ns=2;s=Fast", SamplingIntervalMs: 50);
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 1000);
item.SamplingInterval.ShouldBe(50);
}
[Fact]
public void BuildMonitoredItem_applies_queue_size_and_discard_oldest_overrides()
{
var spec = new MonitoredTagSpec("ns=2;s=DeepQueue", QueueSize: 100, DiscardOldest: false);
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
item.QueueSize.ShouldBe(100u);
item.DiscardOldest.ShouldBeFalse(
"discard-oldest=false preserves earliest values — useful for audit-trail subscriptions where the first overflow sample is the most diagnostic");
}
[Theory]
[InlineData(SubscriptionMonitoringMode.Disabled, MonitoringMode.Disabled)]
[InlineData(SubscriptionMonitoringMode.Sampling, MonitoringMode.Sampling)]
[InlineData(SubscriptionMonitoringMode.Reporting, MonitoringMode.Reporting)]
public void BuildMonitoredItem_maps_each_monitoring_mode(SubscriptionMonitoringMode input, MonitoringMode expected)
{
var spec = new MonitoredTagSpec("ns=2;s=Mode", MonitoringMode: input);
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
item.MonitoringMode.ShouldBe(expected);
}
[Fact]
public void BuildMonitoredItem_with_absolute_deadband_emits_DataChangeFilter()
{
var spec = new MonitoredTagSpec(
"ns=2;s=Analog",
DataChangeFilter: new DataChangeFilterSpec(
Core.Abstractions.DataChangeTrigger.StatusValue,
Core.Abstractions.DeadbandType.Absolute,
DeadbandValue: 0.5));
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValue);
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Absolute);
filter.DeadbandValue.ShouldBe(0.5);
}
[Fact]
public void BuildMonitoredItem_with_percent_deadband_emits_percent_filter()
{
// PercentDeadband is calculated server-side as a fraction of EURange; the driver
// emits the filter unconditionally and lets the server return BadFilterNotAllowed
// if EURange isn't set on the variable. SubscribeAsync's catch-block swallows that
// status so other items in the batch still get created.
var spec = new MonitoredTagSpec(
"ns=2;s=Pct",
DataChangeFilter: new DataChangeFilterSpec(
Core.Abstractions.DataChangeTrigger.StatusValueTimestamp,
Core.Abstractions.DeadbandType.Percent,
DeadbandValue: 5.0));
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValueTimestamp);
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Percent);
filter.DeadbandValue.ShouldBe(5.0);
}
[Theory]
[InlineData(Core.Abstractions.DataChangeTrigger.Status, Opc.Ua.DataChangeTrigger.Status)]
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValue, Opc.Ua.DataChangeTrigger.StatusValue)]
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValueTimestamp, Opc.Ua.DataChangeTrigger.StatusValueTimestamp)]
public void MapTrigger_round_trips_each_enum_value(
Core.Abstractions.DataChangeTrigger input, Opc.Ua.DataChangeTrigger expected)
=> OpcUaClientDriver.MapTrigger(input).ShouldBe(expected);
[Theory]
[InlineData(Core.Abstractions.DeadbandType.None, Opc.Ua.DeadbandType.None)]
[InlineData(Core.Abstractions.DeadbandType.Absolute, Opc.Ua.DeadbandType.Absolute)]
[InlineData(Core.Abstractions.DeadbandType.Percent, Opc.Ua.DeadbandType.Percent)]
public void MapDeadbandType_round_trips_each_enum_value(
Core.Abstractions.DeadbandType input, Opc.Ua.DeadbandType expected)
=> OpcUaClientDriver.MapDeadbandType(input).ShouldBe(expected);
[Fact]
public async Task DefaultInterfaceImplementation_routes_through_legacy_overload()
{
// ISubscribable's default interface impl of the per-tag overload delegates to the
// simple-string overload, ignoring per-tag knobs. Drivers that DON'T override the
// new overload (Modbus / S7 / Galaxy / TwinCAT / FOCAS / AbCip / AbLegacy) still
// accept MonitoredTagSpec lists and just pass through the tag names — back-compat
// for ISubscribable consumers.
var stub = new StubSubscribableDriver();
var specs = new[]
{
new MonitoredTagSpec("Tag1", SamplingIntervalMs: 50, QueueSize: 5),
new MonitoredTagSpec("Tag2", DataChangeFilter: new DataChangeFilterSpec(
Core.Abstractions.DataChangeTrigger.StatusValue,
Core.Abstractions.DeadbandType.Absolute,
1.0)),
};
ISubscribable iface = stub;
_ = await iface.SubscribeAsync(specs, TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
stub.LastTagNames.ShouldBe(["Tag1", "Tag2"]);
stub.LastPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(250));
}
/// <summary>
/// Test-double <see cref="ISubscribable"/> that records whatever the legacy
/// <c>SubscribeAsync(IReadOnlyList&lt;string&gt;, ...)</c> overload was called with.
/// Used to verify the default-impl per-tag overload routes correctly without needing
/// a real OPC UA session.
/// </summary>
private sealed class StubSubscribableDriver : ISubscribable
{
public IReadOnlyList<string>? LastTagNames { get; private set; }
public TimeSpan LastPublishingInterval { get; private set; }
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
LastTagNames = fullReferences;
LastPublishingInterval = publishingInterval;
return Task.FromResult<ISubscriptionHandle>(new StubHandle());
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
#pragma warning disable CS0067 // event never used — the test only asserts the SubscribeAsync call routing
public event EventHandler<DataChangeEventArgs>? OnDataChange;
#pragma warning restore CS0067
}
private sealed record StubHandle() : ISubscriptionHandle
{
public string DiagnosticId => "stub";
}
}
@@ -0,0 +1,139 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for the OperationLimits chunking surface (PR #275 / opcuaclient-3). Focused
/// on the static <see cref="OpcUaClientDriver.ChunkBy{T}"/> helper + the
/// <see cref="OpcUaClientDriver.OperationLimitsCache"/> sentinel semantics. Live
/// end-to-end tests against an in-process server land in the integration suite.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientOperationLimitsTests
{
[Fact]
public void ChunkBy_with_cap_5_splits_12_items_into_3_slices_of_5_5_2()
{
// The PR-3 acceptance scenario: server advertises MaxNodesPerRead=5, client batches a
// 12-tag read; driver must issue exactly 3 wire calls of sizes 5/5/2 in order.
var input = Enumerable.Range(0, 12).ToArray();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
slices.Length.ShouldBe(3);
slices[0].Count.ShouldBe(5);
slices[1].Count.ShouldBe(5);
slices[2].Count.ShouldBe(2);
// Order + offsets must reflect the original sequence — chunking must not reorder
// tags, otherwise the indexMap ↔ result-index alignment breaks.
slices[0].ShouldBe(new[] { 0, 1, 2, 3, 4 });
slices[1].ShouldBe(new[] { 5, 6, 7, 8, 9 });
slices[2].ShouldBe(new[] { 10, 11 });
}
[Fact]
public void ChunkBy_with_null_cap_yields_single_slice_no_chunking()
{
// cap=null is the "fetch hasn't completed" / "server reports 0 = no limit" sentinel.
// Both must collapse to a single SDK call so the wire path doesn't change when the
// server doesn't impose a cap.
var input = Enumerable.Range(0, 12).ToArray();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: null).ToArray();
slices.Length.ShouldBe(1, "null cap means no chunking — single SDK call");
slices[0].Count.ShouldBe(12);
}
[Fact]
public void ChunkBy_with_zero_cap_yields_single_slice_no_chunking()
{
// OPC UA Part 5: 0 is the wire-level "no limit" sentinel. NormalizeLimit folds it
// into null upstream of ChunkBy, but the chunker itself must also treat 0 as
// no-chunking — defence in depth in case a caller bypasses NormalizeLimit.
var input = Enumerable.Range(0, 7).ToArray();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 0).ToArray();
slices.Length.ShouldBe(1);
slices[0].Count.ShouldBe(7);
}
[Fact]
public void ChunkBy_with_cap_larger_than_input_yields_single_slice()
{
var input = new[] { 1, 2, 3 };
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 100).ToArray();
slices.Length.ShouldBe(1);
slices[0].Count.ShouldBe(3);
}
[Fact]
public void ChunkBy_with_empty_input_yields_no_slices()
{
// Empty batch must short-circuit before the wire call — saves a round-trip and
// matches the !toSend.Count == 0 guard in the driver.
var input = Array.Empty<int>();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
slices.Length.ShouldBe(0);
}
[Fact]
public void ChunkBy_with_cap_equal_to_input_size_yields_single_slice()
{
// Edge case: exactly N items at cap N. Must NOT produce an extra empty slice.
var input = Enumerable.Range(0, 5).ToArray();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
slices.Length.ShouldBe(1);
slices[0].Count.ShouldBe(5);
}
[Fact]
public void ChunkBy_with_cap_1_splits_each_item_into_its_own_slice()
{
// Pathological cap — degrades to N wire calls. Verifies the chunker handles the
// boundary cleanly without off-by-one.
var input = new[] { 10, 20, 30 };
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 1).ToArray();
slices.Length.ShouldBe(3);
slices[0].ShouldBe(new[] { 10 });
slices[1].ShouldBe(new[] { 20 });
slices[2].ShouldBe(new[] { 30 });
}
[Fact]
public void OperationLimitsCache_records_all_four_caps_as_nullable_uint()
{
// The cache surfaces the four limits the driver chunks against. Storing as uint?
// lets the chunker distinguish "not yet fetched" / "no limit" (null) from "limit=N".
var cache = new OpcUaClientDriver.OperationLimitsCache(
MaxNodesPerRead: 100u,
MaxNodesPerWrite: 50u,
MaxNodesPerBrowse: null,
MaxNodesPerHistoryReadData: 10u);
cache.MaxNodesPerRead.ShouldBe(100u);
cache.MaxNodesPerWrite.ShouldBe(50u);
cache.MaxNodesPerBrowse.ShouldBeNull();
cache.MaxNodesPerHistoryReadData.ShouldBe(10u);
}
[Fact]
public void Driver_starts_with_no_cached_OperationLimits()
{
// Pre-init / pre-first-batch state: cache is null so callers fall through to
// single-call behaviour. Lazy fetch happens on the first ReadAsync/WriteAsync.
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cache-init");
drv.OperationLimitsForTest.ShouldBeNull();
}
}
@@ -1,5 +1,6 @@
using Shouldly;
using Xunit;
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
@@ -14,6 +15,8 @@ public sealed class S7AddressParserTests
[InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
[InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
[InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
[InlineData("DB1.DBLD0", 1, S7Size.LWord, 0, 0)] // 64-bit long DWord
[InlineData("DB1.DBL8", 1, S7Size.LWord, 8, 0)] // 64-bit alt suffix (LReal)
[InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
[InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
@@ -53,6 +56,9 @@ public sealed class S7AddressParserTests
[InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
[InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
[InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
[InlineData("MLD0", S7Area.Memory, S7Size.LWord, 0, 0)] // 64-bit Merker
[InlineData("ILD8", S7Area.Input, S7Size.LWord, 8, 0)]
[InlineData("QLD16", S7Area.Output, S7Size.LWord, 16, 0)]
public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
{
var r = S7AddressParser.Parse(input);
@@ -116,4 +122,45 @@ public sealed class S7AddressParserTests
r.DbNumber.ShouldBe(1);
r.Size.ShouldBe(S7Size.Word);
}
// --- V-memory (S7-200 / S7-200 Smart / LOGO!) ---
[Theory]
[InlineData("VB0", S7Size.Byte, 0, 0)]
[InlineData("VW0", S7Size.Word, 0, 0)]
[InlineData("VD4", S7Size.DWord, 4, 0)]
[InlineData("V0.0", S7Size.Bit, 0, 0)]
[InlineData("V10.7", S7Size.Bit, 10, 7)]
public void Parse_V_memory_maps_to_DB1_for_S7200(string input, S7Size size, int byteOff, int bitOff)
{
var r = S7AddressParser.Parse(input, S7NetCpuType.S7200);
r.Area.ShouldBe(S7Area.DataBlock);
r.DbNumber.ShouldBe(1);
r.Size.ShouldBe(size);
r.ByteOffset.ShouldBe(byteOff);
r.BitOffset.ShouldBe(bitOff);
}
[Theory]
[InlineData(S7NetCpuType.S7200Smart)]
[InlineData(S7NetCpuType.Logo0BA8)]
public void Parse_V_memory_maps_to_DB1_for_S7200Smart_and_LOGO(S7NetCpuType cpu)
{
var r = S7AddressParser.Parse("VW0", cpu);
r.Area.ShouldBe(S7Area.DataBlock);
r.DbNumber.ShouldBe(1);
r.Size.ShouldBe(S7Size.Word);
}
[Theory]
[InlineData(S7NetCpuType.S71500)]
[InlineData(S7NetCpuType.S71200)]
[InlineData(S7NetCpuType.S7300)]
[InlineData(S7NetCpuType.S7400)]
public void Parse_V_memory_rejected_on_modern_families(S7NetCpuType cpu)
=> Should.Throw<FormatException>(() => S7AddressParser.Parse("VW0", cpu));
[Fact]
public void Parse_V_memory_rejected_when_no_CpuType_supplied()
=> Should.Throw<FormatException>(() => S7AddressParser.Parse("VW0"));
}
@@ -0,0 +1,336 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Golden-byte unit tests for <see cref="S7DateTimeCodec"/>: DTL / DATE_AND_TIME /
/// S5TIME / TIME / TOD / DATE encode + decode round-trips, plus the
/// uninitialized-PLC-buffer rejection paths. These tests don't touch S7.Net — the
/// codec operates on raw byte spans, same testing pattern as
/// <see cref="S7StringCodecTests"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DateTimeCodecTests
{
// -------- DTL (12 bytes) --------
[Fact]
public void EncodeDtl_emits_be_year_then_components_then_be_nanoseconds()
{
// 2024-01-15 12:34:56.000 → year=0x07E8, mon=01, day=0F, dow=Mon=2,
// hour=0x0C, min=0x22, sec=0x38, nanos=0x00000000
var dt = new DateTime(2024, 1, 15, 12, 34, 56, DateTimeKind.Unspecified);
var bytes = S7DateTimeCodec.EncodeDtl(dt);
bytes.Length.ShouldBe(12);
bytes[0].ShouldBe<byte>(0x07);
bytes[1].ShouldBe<byte>(0xE8);
bytes[2].ShouldBe<byte>(0x01);
bytes[3].ShouldBe<byte>(0x0F);
// 2024-01-15 was a Monday (.NET DayOfWeek=Monday=1); S7 dow = 1+1 = 2.
bytes[4].ShouldBe<byte>(0x02);
bytes[5].ShouldBe<byte>(0x0C);
bytes[6].ShouldBe<byte>(0x22);
bytes[7].ShouldBe<byte>(0x38);
bytes[8].ShouldBe<byte>(0x00);
bytes[9].ShouldBe<byte>(0x00);
bytes[10].ShouldBe<byte>(0x00);
bytes[11].ShouldBe<byte>(0x00);
}
[Fact]
public void DecodeDtl_round_trips_encode_with_nanosecond_precision()
{
// 250 ms = 250_000_000 ns = 0x0EE6B280
var dt = new DateTime(2024, 1, 15, 12, 34, 56, 250, DateTimeKind.Unspecified);
var bytes = S7DateTimeCodec.EncodeDtl(dt);
var decoded = S7DateTimeCodec.DecodeDtl(bytes);
decoded.ShouldBe(dt);
}
[Fact]
public void DecodeDtl_rejects_all_zero_uninitialized_buffer()
{
// Brand-new DB — PLC hasn't written a real value yet. Must surface as a hard
// error, not as year-0001 garbage.
var buf = new byte[12];
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf));
}
[Fact]
public void DecodeDtl_rejects_out_of_range_components()
{
// Year 1969 → out of S7 DTL spec range (1970..2554).
var buf = new byte[] { 0x07, 0xB1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0 };
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf));
// Month 13 — invalid.
var buf2 = new byte[] { 0x07, 0xE8, 13, 1, 1, 0, 0, 0, 0, 0, 0, 0 };
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf2));
}
[Fact]
public void DecodeDtl_rejects_wrong_buffer_length()
{
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(new byte[11]));
}
[Fact]
public void EncodeDtl_rejects_year_outside_spec()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeDtl(new DateTime(1969, 1, 1)));
}
// -------- DATE_AND_TIME (DT, 8 bytes BCD) --------
[Fact]
public void EncodeDt_emits_bcd_components()
{
// 2024-01-15 12:34:56.789, dow Monday → S7 dow = 2.
// BCD: yy=24→0x24, mon=01→0x01, day=15→0x15, hh=12→0x12, mm=34→0x34, ss=56→0x56
// ms=789 → bytes[6]=0x78, bytes[7] high nibble=0x9, low nibble=dow=2 → 0x92.
var dt = new DateTime(2024, 1, 15, 12, 34, 56, 789, DateTimeKind.Unspecified);
var bytes = S7DateTimeCodec.EncodeDt(dt);
bytes.Length.ShouldBe(8);
bytes[0].ShouldBe<byte>(0x24);
bytes[1].ShouldBe<byte>(0x01);
bytes[2].ShouldBe<byte>(0x15);
bytes[3].ShouldBe<byte>(0x12);
bytes[4].ShouldBe<byte>(0x34);
bytes[5].ShouldBe<byte>(0x56);
bytes[6].ShouldBe<byte>(0x78);
bytes[7].ShouldBe<byte>(0x92);
}
[Fact]
public void DecodeDt_round_trips_post_2000()
{
var dt = new DateTime(2024, 1, 15, 12, 34, 56, 789, DateTimeKind.Unspecified);
var bytes = S7DateTimeCodec.EncodeDt(dt);
S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt);
}
[Fact]
public void DecodeDt_round_trips_pre_2000_using_90_to_99_year_window()
{
// 1995-06-30 — yy=95 → year window says 1995.
var dt = new DateTime(1995, 6, 30, 0, 0, 0, DateTimeKind.Unspecified);
var bytes = S7DateTimeCodec.EncodeDt(dt);
bytes[0].ShouldBe<byte>(0x95);
S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt);
}
[Fact]
public void DecodeDt_rejects_all_zero_uninitialized_buffer()
{
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDt(new byte[8]));
}
[Fact]
public void DecodeDt_rejects_invalid_bcd_nibble()
{
// Month 0x1A — high nibble OK but low nibble 0xA is not a decimal digit.
var buf = new byte[] { 0x24, 0x1A, 0x15, 0x12, 0x34, 0x56, 0x00, 0x02 };
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDt(buf));
}
[Fact]
public void EncodeDt_rejects_year_outside_1990_to_2089_window()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeDt(new DateTime(1989, 12, 31)));
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeDt(new DateTime(2090, 1, 1)));
}
// -------- S5TIME (16 bits BCD) --------
[Fact]
public void EncodeS5Time_one_second_uses_10ms_timebase_with_count_100()
{
// T#1S = 1000 ms = 100 × 10 ms (timebase 0). Layout: 0000 00BB BBBB BBBB
// tb=00, count=100=BCD 0x100 → byte0=0x01, byte1=0x00.
var bytes = S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(1));
bytes.Length.ShouldBe(2);
bytes[0].ShouldBe<byte>(0x01);
bytes[1].ShouldBe<byte>(0x00);
}
[Fact]
public void EncodeS5Time_picks_10s_timebase_for_long_durations()
{
// T#9990S = 9990 s = 999 × 10 s (timebase 11). count=999 BCD 0x999.
// byte0 = (3 << 4) | 9 = 0x39, byte1 = 0x99.
var bytes = S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(9990));
bytes[0].ShouldBe<byte>(0x39);
bytes[1].ShouldBe<byte>(0x99);
}
[Fact]
public void DecodeS5Time_round_trips_each_timebase()
{
foreach (var ts in new[]
{
TimeSpan.FromMilliseconds(10), // tb=00 (10 ms)
TimeSpan.FromMilliseconds(500), // tb=00 (10 ms × 50)
TimeSpan.FromSeconds(1), // tb=00 (10 ms × 100)
TimeSpan.FromSeconds(60), // tb=01 (100 ms × 600)? No — 60s/100ms=600 > 999, picks 1s.
TimeSpan.FromMinutes(10), // tb=10 (1 s × 600)
TimeSpan.FromMinutes(30), // tb=11 (10 s × 180)
})
{
var bytes = S7DateTimeCodec.EncodeS5Time(ts);
S7DateTimeCodec.DecodeS5Time(bytes).ShouldBe(ts);
}
}
[Fact]
public void EncodeS5Time_rejects_negative_or_too_large()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(-1)));
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(10_000)));
}
[Fact]
public void EncodeS5Time_rejects_durations_not_representable_in_any_timebase()
{
// 7 ms — no timebase divides cleanly, would lose precision.
Should.Throw<ArgumentException>(() =>
S7DateTimeCodec.EncodeS5Time(TimeSpan.FromMilliseconds(7)));
}
[Fact]
public void DecodeS5Time_rejects_invalid_bcd()
{
// Hi nibble of byte0 has timebase=00 but digit nibble 0xA — illegal.
var buf = new byte[] { 0x0A, 0x00 };
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeS5Time(buf));
}
// -------- TIME (Int32 ms BE, signed) --------
[Fact]
public void EncodeTime_emits_signed_int32_be_milliseconds()
{
// T#1S = 1000 ms = 0x000003E8.
var bytes = S7DateTimeCodec.EncodeTime(TimeSpan.FromSeconds(1));
bytes.ShouldBe(new byte[] { 0x00, 0x00, 0x03, 0xE8 });
}
[Fact]
public void EncodeTime_supports_negative_durations()
{
// -1000 ms = 0xFFFFFC18 (two's complement Int32).
var bytes = S7DateTimeCodec.EncodeTime(TimeSpan.FromSeconds(-1));
bytes.ShouldBe(new byte[] { 0xFF, 0xFF, 0xFC, 0x18 });
}
[Fact]
public void DecodeTime_round_trips_positive_and_negative()
{
foreach (var ts in new[]
{
TimeSpan.Zero,
TimeSpan.FromMilliseconds(1),
TimeSpan.FromHours(24),
TimeSpan.FromMilliseconds(-12345),
})
{
S7DateTimeCodec.DecodeTime(S7DateTimeCodec.EncodeTime(ts)).ShouldBe(ts);
}
}
[Fact]
public void EncodeTime_rejects_overflow()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeTime(TimeSpan.FromMilliseconds((long)int.MaxValue + 1)));
}
// -------- TOD (UInt32 ms BE, 0..86399999) --------
[Fact]
public void EncodeTod_emits_unsigned_int32_be_milliseconds()
{
// 12:00:00.000 = 12 × 3_600_000 = 43_200_000 ms = 0x0293_2E00.
var bytes = S7DateTimeCodec.EncodeTod(new TimeSpan(12, 0, 0));
bytes.ShouldBe(new byte[] { 0x02, 0x93, 0x2E, 0x00 });
}
[Fact]
public void DecodeTod_round_trips_midnight_and_max()
{
S7DateTimeCodec.DecodeTod(S7DateTimeCodec.EncodeTod(TimeSpan.Zero)).ShouldBe(TimeSpan.Zero);
// 23:59:59.999 — last valid TOD.
var max = new TimeSpan(0, 23, 59, 59, 999);
S7DateTimeCodec.DecodeTod(S7DateTimeCodec.EncodeTod(max)).ShouldBe(max);
}
[Fact]
public void DecodeTod_rejects_value_at_or_above_one_day()
{
// 86_400_000 ms = 0x0526_5C00 — exactly 24 h, must reject.
var buf = new byte[] { 0x05, 0x26, 0x5C, 0x00 };
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeTod(buf));
}
[Fact]
public void EncodeTod_rejects_negative_and_overflow()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeTod(TimeSpan.FromMilliseconds(-1)));
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeTod(TimeSpan.FromHours(24)));
}
// -------- DATE (UInt16 BE, days since 1990-01-01) --------
[Fact]
public void EncodeDate_emits_be_uint16_days_since_epoch()
{
// 1990-01-01 → 0 days → 0x0000.
S7DateTimeCodec.EncodeDate(new DateTime(1990, 1, 1)).ShouldBe(new byte[] { 0x00, 0x00 });
// 1990-01-02 → 1 day → 0x0001.
S7DateTimeCodec.EncodeDate(new DateTime(1990, 1, 2)).ShouldBe(new byte[] { 0x00, 0x01 });
// 2024-01-15 → 12_432 days = 0x3090.
var bytes = S7DateTimeCodec.EncodeDate(new DateTime(2024, 1, 15));
bytes.ShouldBe(new byte[] { 0x30, 0x90 });
}
[Fact]
public void DecodeDate_round_trips_encode()
{
foreach (var d in new[]
{
new DateTime(1990, 1, 1),
new DateTime(2000, 2, 29),
new DateTime(2024, 1, 15),
new DateTime(2099, 12, 31),
})
{
S7DateTimeCodec.DecodeDate(S7DateTimeCodec.EncodeDate(d)).ShouldBe(d);
}
}
[Fact]
public void EncodeDate_rejects_pre_epoch()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeDate(new DateTime(1989, 12, 31)));
}
[Fact]
public void DecodeDate_rejects_wrong_buffer_length()
{
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDate(new byte[1]));
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDate(new byte[3]));
}
}
@@ -65,6 +65,34 @@ public sealed class S7DiscoveryAndSubscribeTests
builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32);
}
[Fact]
public async Task DiscoverAsync_maps_64bit_types_to_matching_DriverDataType()
{
// PR-S7-A1: 64-bit scalar types must surface with their native DriverDataType
// (not collapse to Int32) so the OPC UA address-space layer publishes the right
// BuiltInType. Address suffixes: DBLD (DB long-DWord), MLD/ILD/QLD (M/I/Q long-DWord).
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Tags =
[
new("BigInt", "DB1.DBLD0", S7DataType.Int64),
new("BigUInt", "DB1.DBLD8", S7DataType.UInt64),
new("BigDouble", "DB1.DBLD16", S7DataType.Float64),
new("MerkerLong", "MLD0", S7DataType.Int64),
],
};
using var drv = new S7Driver(opts, "s7-64bit");
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single(v => v.Name == "BigInt").Attr.DriverDataType.ShouldBe(DriverDataType.Int64);
builder.Variables.Single(v => v.Name == "BigUInt").Attr.DriverDataType.ShouldBe(DriverDataType.UInt64);
builder.Variables.Single(v => v.Name == "BigDouble").Attr.DriverDataType.ShouldBe(DriverDataType.Float64);
builder.Variables.Single(v => v.Name == "MerkerLong").Attr.DriverDataType.ShouldBe(DriverDataType.Int64);
}
[Fact]
public async Task DiscoverAsync_propagates_WriteIdempotent_from_tag_to_attribute_info()
{
@@ -0,0 +1,146 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Unit tests for the S7 driver's 1-D array surface (PR-S7-A4). Wire-level round-trip
/// tests against a live S7 still need a real PLC, so these tests exercise the
/// driver's slice / pack helpers directly (visible via <c>InternalsVisibleTo</c>) and
/// the init-time validation that rejects unsupported element types and over-budget
/// ElementCount values up-front.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverArrayTests
{
[Fact]
public void Int16_array_roundtrip_via_pack_then_slice()
{
// Big-endian 16-bit elements: validate that PackArray + SliceArray round-trip
// a representative range including negatives and the boundary values.
var input = new short[] { 0, 1, -1, short.MinValue, short.MaxValue, 12345 };
var elem = S7Driver.ArrayElementBytes(S7DataType.Int16);
var bytes = S7Driver.PackArray(input, S7DataType.Int16, input.Length, elem, "t");
bytes.Length.ShouldBe(input.Length * elem);
// Sanity-check big-endian layout: element[2] = -1 → 0xFFFF at byte offset 4.
bytes[4].ShouldBe((byte)0xFF); bytes[5].ShouldBe((byte)0xFF);
var output = (short[])S7Driver.SliceArray(bytes, S7DataType.Int16, input.Length, elem);
output.ShouldBe(input);
}
[Fact]
public void Int32_array_roundtrip_via_pack_then_slice()
{
var input = new[] { 0, 1, -1, int.MinValue, int.MaxValue, 0x12345678 };
var elem = S7Driver.ArrayElementBytes(S7DataType.Int32);
var bytes = S7Driver.PackArray(input, S7DataType.Int32, input.Length, elem, "t");
var output = (int[])S7Driver.SliceArray(bytes, S7DataType.Int32, input.Length, elem);
output.ShouldBe(input);
}
[Fact]
public void Float32_array_roundtrip_via_pack_then_slice()
{
var input = new[] { 0f, 1.5f, -3.25f, float.MinValue, float.MaxValue, float.Epsilon };
var elem = S7Driver.ArrayElementBytes(S7DataType.Float32);
var bytes = S7Driver.PackArray(input, S7DataType.Float32, input.Length, elem, "t");
var output = (float[])S7Driver.SliceArray(bytes, S7DataType.Float32, input.Length, elem);
output.ShouldBe(input);
}
[Fact]
public void Float64_array_roundtrip_via_pack_then_slice()
{
var input = new[] { 0d, Math.PI, -Math.E, double.MinValue, double.MaxValue };
var elem = S7Driver.ArrayElementBytes(S7DataType.Float64);
var bytes = S7Driver.PackArray(input, S7DataType.Float64, input.Length, elem, "t");
var output = (double[])S7Driver.SliceArray(bytes, S7DataType.Float64, input.Length, elem);
output.ShouldBe(input);
}
[Fact]
public void IsArrayElementSupported_rejects_strings_and_bool()
{
// Variable-width string types and BOOL (packed-bit layout) are explicit follow-ups —
// surface them as init-time rejections rather than mysterious BadInternalError on read.
S7Driver.IsArrayElementSupported(S7DataType.Bool).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.String).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.WString).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.Char).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.WChar).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.Int16).ShouldBeTrue();
S7Driver.IsArrayElementSupported(S7DataType.Float64).ShouldBeTrue();
}
[Fact]
public async Task Initialize_rejects_array_of_String_with_FormatException()
{
// Init-time guard: even if the address parses cleanly, an array of variable-width
// STRING is not yet supported and must fail-fast at config-load. The driver never
// gets as far as opening the TcpClient because parsing is the first step.
var opts = new S7DriverOptions
{
Host = "192.0.2.1", // reserved — TCP would never connect anyway
Timeout = TimeSpan.FromMilliseconds(250),
Tags =
[
new S7TagDefinition(
Name: "BadStrArr",
Address: "DB1.DBB0",
DataType: S7DataType.String,
ElementCount: 4),
],
};
using var drv = new S7Driver(opts, "s7-arr-bad-string");
await Should.ThrowAsync<FormatException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
[Fact]
public async Task Initialize_rejects_array_of_Bool_with_FormatException()
{
// BOOL arrays are stored as packed bits (one bit per element rounded up to a byte) —
// the byte-range read trick used here for word-shaped elements doesn't generalize, so
// arrays of Bool are explicitly out-of-scope for PR-S7-A4 and reject at init.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Tags =
[
new S7TagDefinition(
Name: "BadBoolArr",
Address: "DB1.DBX0.0",
DataType: S7DataType.Bool,
ElementCount: 8),
],
};
using var drv = new S7Driver(opts, "s7-arr-bad-bool");
await Should.ThrowAsync<FormatException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
[Fact]
public async Task Initialize_rejects_oversized_ElementCount_with_FormatException()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Tags =
[
new S7TagDefinition(
Name: "TooBig",
Address: "DB1.DBW0",
DataType: S7DataType.Int16,
ElementCount: S7Driver.MaxArrayElements + 1),
],
};
using var drv = new S7Driver(opts, "s7-arr-too-big");
await Should.ThrowAsync<FormatException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
}
@@ -0,0 +1,228 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Golden-byte unit tests for <see cref="S7StringCodec"/>: STRING / WSTRING / CHAR /
/// WCHAR encode + decode round-trips and the firmware-bug header-clamp on read.
/// These tests intentionally don't touch S7.Net — the codec operates on raw byte
/// spans so reproducing the wire format here is sufficient to lock the contract.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7StringCodecTests
{
// -------- STRING --------
[Fact]
public void EncodeString_emits_two_byte_header_and_ascii_payload()
{
var bytes = S7StringCodec.EncodeString("HELLO", maxLen: 10);
bytes.Length.ShouldBe(2 + 10); // 2-byte header + max-len slot
bytes[0].ShouldBe<byte>(10); // declared max
bytes[1].ShouldBe<byte>(5); // actual length
// ASCII payload
bytes[2].ShouldBe<byte>((byte)'H');
bytes[3].ShouldBe<byte>((byte)'E');
bytes[4].ShouldBe<byte>((byte)'L');
bytes[5].ShouldBe<byte>((byte)'L');
bytes[6].ShouldBe<byte>((byte)'O');
// Padding bytes left as 0x00.
for (var i = 7; i < bytes.Length; i++) bytes[i].ShouldBe<byte>(0);
}
[Fact]
public void DecodeString_round_trips_encode()
{
var bytes = S7StringCodec.EncodeString("ABC", maxLen: 16);
var decoded = S7StringCodec.DecodeString(bytes, maxLen: 16);
decoded.ShouldBe("ABC");
}
[Fact]
public void DecodeString_clamps_when_actualLen_exceeds_maxLen_firmware_bug()
{
// Hand-craft a buffer where actualLen (255) > maxLen (10). Real firmware bug
// observed on legacy CP modules. Codec must clamp to maxLen rather than walk
// off the end of the wire buffer.
var max = 10;
var buf = new byte[2 + max];
buf[0] = (byte)max;
buf[1] = 255; // out-of-spec actual
for (var i = 0; i < max; i++) buf[2 + i] = (byte)('A' + i);
var s = S7StringCodec.DecodeString(buf, max);
s.Length.ShouldBe(max);
s.ShouldBe("ABCDEFGHIJ");
}
[Fact]
public void DecodeString_empty_actual_len_returns_empty_string()
{
var buf = new byte[2 + 8];
buf[0] = 8;
buf[1] = 0;
S7StringCodec.DecodeString(buf, 8).ShouldBe(string.Empty);
}
[Fact]
public void EncodeString_rejects_value_longer_than_maxLen()
{
Should.Throw<ArgumentException>(() => S7StringCodec.EncodeString("TOO-LONG", maxLen: 4));
}
[Fact]
public void DecodeString_rejects_wrong_length_buffer()
{
// 2 + 5 expected, give 3 — must throw rather than silently read.
var buf = new byte[3];
Should.Throw<System.IO.InvalidDataException>(() => S7StringCodec.DecodeString(buf, 5));
}
// -------- WSTRING --------
[Fact]
public void EncodeWString_emits_four_byte_header_and_utf16be_payload()
{
// "Hi" -> H = 0x0048, i = 0x0069. UTF-16 BE wire bytes 00 48 00 69.
var bytes = S7StringCodec.EncodeWString("Hi", maxLen: 4);
bytes.Length.ShouldBe(4 + 2 * 4); // 4-byte header + 2 × max-len bytes
bytes[0].ShouldBe<byte>(0x00); // maxLen high
bytes[1].ShouldBe<byte>(0x04); // maxLen low
bytes[2].ShouldBe<byte>(0x00); // actualLen high
bytes[3].ShouldBe<byte>(0x02); // actualLen low
bytes[4].ShouldBe<byte>(0x00); // 'H' high (BE)
bytes[5].ShouldBe<byte>(0x48); // 'H' low
bytes[6].ShouldBe<byte>(0x00); // 'i' high
bytes[7].ShouldBe<byte>(0x69); // 'i' low
// Padding bytes [8..11] left as 0x00.
bytes[8].ShouldBe<byte>(0);
bytes[9].ShouldBe<byte>(0);
bytes[10].ShouldBe<byte>(0);
bytes[11].ShouldBe<byte>(0);
}
[Fact]
public void DecodeWString_round_trips_unicode()
{
// U+00E9 (é) — non-ASCII, exercises the BE encoding.
var input = "café";
var bytes = S7StringCodec.EncodeWString(input, maxLen: 8);
var decoded = S7StringCodec.DecodeWString(bytes, maxLen: 8);
decoded.ShouldBe(input);
}
[Fact]
public void DecodeWString_clamps_when_actualLen_exceeds_maxLen_firmware_bug()
{
var max = 4;
var buf = new byte[4 + 2 * max];
// Header: max=4, actual=0xFFFF (firmware-bug).
buf[0] = 0x00; buf[1] = (byte)max;
buf[2] = 0xFF; buf[3] = 0xFF;
// Payload: 'A','B','C','D' (BE).
buf[4] = 0x00; buf[5] = (byte)'A';
buf[6] = 0x00; buf[7] = (byte)'B';
buf[8] = 0x00; buf[9] = (byte)'C';
buf[10] = 0x00; buf[11] = (byte)'D';
var s = S7StringCodec.DecodeWString(buf, max);
s.ShouldBe("ABCD"); // clamped to maxLen × 2 bytes
}
[Fact]
public void EncodeWString_rejects_value_longer_than_maxLen()
{
Should.Throw<ArgumentException>(() => S7StringCodec.EncodeWString("TOO-LONG", maxLen: 4));
}
[Fact]
public void DecodeWString_rejects_wrong_length_buffer()
{
Should.Throw<System.IO.InvalidDataException>(() => S7StringCodec.DecodeWString(new byte[5], maxLen: 4));
}
// -------- CHAR --------
[Fact]
public void EncodeChar_emits_single_ascii_byte()
{
var b = S7StringCodec.EncodeChar('A');
b.Length.ShouldBe(1);
b[0].ShouldBe<byte>(0x41);
}
[Fact]
public void DecodeChar_round_trips()
{
S7StringCodec.DecodeChar(new byte[] { 0x5A }).ShouldBe('Z');
}
[Fact]
public void EncodeChar_rejects_non_ascii()
{
Should.Throw<ArgumentException>(() => S7StringCodec.EncodeChar('é'));
}
[Fact]
public void DecodeChar_rejects_wrong_length()
{
Should.Throw<System.IO.InvalidDataException>(() => S7StringCodec.DecodeChar(new byte[2]));
}
// -------- WCHAR --------
[Fact]
public void EncodeWChar_emits_two_bytes_big_endian()
{
var b = S7StringCodec.EncodeWChar('Z');
b.Length.ShouldBe(2);
b[0].ShouldBe<byte>(0x00);
b[1].ShouldBe<byte>(0x5A);
}
[Fact]
public void EncodeWChar_handles_unicode_codepoint()
{
// U+00E9 (é) -> 00 E9 BE
var b = S7StringCodec.EncodeWChar('é');
b[0].ShouldBe<byte>(0x00);
b[1].ShouldBe<byte>(0xE9);
}
[Fact]
public void DecodeWChar_round_trips()
{
S7StringCodec.DecodeWChar(new byte[] { 0x00, 0x5A }).ShouldBe('Z');
S7StringCodec.DecodeWChar(new byte[] { 0x00, 0xE9 }).ShouldBe('é');
}
[Fact]
public void DecodeWChar_rejects_wrong_length()
{
Should.Throw<System.IO.InvalidDataException>(() => S7StringCodec.DecodeWChar(new byte[1]));
Should.Throw<System.IO.InvalidDataException>(() => S7StringCodec.DecodeWChar(new byte[3]));
}
// -------- StringLength default + range --------
[Fact]
public void EncodeString_default_max_length_254_round_trips()
{
// Default S7TagDefinition.StringLength is 254; codec must accept that.
var s = new string('x', 100);
var bytes = S7StringCodec.EncodeString(s, 254);
bytes.Length.ShouldBe(2 + 254);
bytes[0].ShouldBe<byte>(254);
bytes[1].ShouldBe<byte>(100);
S7StringCodec.DecodeString(bytes, 254).ShouldBe(s);
}
[Fact]
public void EncodeString_rejects_max_length_above_254()
{
Should.Throw<ArgumentOutOfRangeException>(() => S7StringCodec.EncodeString("x", maxLen: 255));
}
}
@@ -17,6 +17,7 @@ internal class FakeTwinCATClient : ITwinCATClient
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
public List<(string symbol, TwinCATDataType type, int? bit, int[]? arrayDimensions)> ReadLog { get; } = new();
public bool ProbeResult { get; set; } = true;
public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
@@ -28,20 +29,39 @@ internal class FakeTwinCATClient : ITwinCATClient
}
public virtual Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct)
string symbolPath, TwinCATDataType type, int? bitIndex, int[]? arrayDimensions, CancellationToken ct)
{
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
ReadLog.Add((symbolPath, type, bitIndex, arrayDimensions));
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
return Task.FromResult((value, status));
}
public virtual Task<uint> WriteValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
string symbolPath, TwinCATDataType type, int? bitIndex, int[]? arrayDimensions, object? value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
WriteLog.Add((symbolPath, type, bitIndex, value));
Values[symbolPath] = value;
// Model the parent-word RMW path the production AdsTwinCATClient performs for
// bit-indexed BOOL writes so driver-level tests can assert the resulting parent state.
if (bitIndex is int bit && type == TwinCATDataType.Bool)
{
var parentPath = AdsTwinCATClient.TryGetParentSymbolPath(symbolPath);
if (parentPath is not null)
{
var current = Values.TryGetValue(parentPath, out var p) && p is not null
? Convert.ToUInt32(p) : 0u;
Values[parentPath] = AdsTwinCATClient.ApplyBit(
current, bit, Convert.ToBoolean(value));
}
}
else
{
Values[symbolPath] = value;
}
var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
return Task.FromResult(status);
}
@@ -0,0 +1,176 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATArrayReadTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags)
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = tags,
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, factory);
}
// ---- Array shape mapping ----
[Fact]
public void ResolveArrayShape_returns_scalar_for_null_dimensions()
{
var (isArray, dim) = TwinCATDriver.ResolveArrayShape(null);
isArray.ShouldBeFalse();
dim.ShouldBeNull();
}
[Fact]
public void ResolveArrayShape_returns_scalar_for_empty_dimensions()
{
var (isArray, dim) = TwinCATDriver.ResolveArrayShape([]);
isArray.ShouldBeFalse();
dim.ShouldBeNull();
}
[Fact]
public void ResolveArrayShape_returns_length_for_single_dim()
{
var (isArray, dim) = TwinCATDriver.ResolveArrayShape([10]);
isArray.ShouldBeTrue();
dim.ShouldBe(10u);
}
[Fact]
public void ResolveArrayShape_flattens_multi_dim_to_product()
{
var (isArray, dim) = TwinCATDriver.ResolveArrayShape([3, 4]);
isArray.ShouldBeTrue();
dim.ShouldBe(12u);
}
[Fact]
public void ResolveArrayShape_rejects_non_positive_dim_as_scalar()
{
// Defensive — bad config flattens to scalar so the read path still runs without
// dragging a Make-Array-Type call into an empty allocation.
var (isArray, dim) = TwinCATDriver.ResolveArrayShape([3, 0, 2]);
isArray.ShouldBeFalse();
dim.ShouldBeNull();
}
// ---- Discovery surfaces IsArray + ArrayDim ----
[Fact]
public async Task DiscoverAsync_emits_IsArray_for_array_tags()
{
var builder = new RecordingBuilder();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags =
[
new TwinCATTagDefinition("Vec", Host, "MAIN.Vec", TwinCATDataType.DInt, ArrayDimensions: [10]),
new TwinCATTagDefinition("Mat", Host, "MAIN.Mat", TwinCATDataType.Real, ArrayDimensions: [3, 4]),
new TwinCATTagDefinition("Scalar", Host, "MAIN.S", TwinCATDataType.DInt),
],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
var vec = builder.Variables.Single(v => v.BrowseName == "Vec").Info;
vec.IsArray.ShouldBeTrue();
vec.ArrayDim.ShouldBe(10u);
var mat = builder.Variables.Single(v => v.BrowseName == "Mat").Info;
mat.IsArray.ShouldBeTrue();
mat.ArrayDim.ShouldBe(12u); // 3 * 4 flattened
var scalar = builder.Variables.Single(v => v.BrowseName == "Scalar").Info;
scalar.IsArray.ShouldBeFalse();
scalar.ArrayDim.ShouldBeNull();
}
// ---- Whole-array read fans through to the client with ArrayDimensions ----
[Fact]
public async Task Whole_array_read_passes_dimensions_to_client_and_returns_array()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Vec", Host, "MAIN.Vec", TwinCATDataType.DInt, ArrayDimensions: [4]));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Vec"] = new[] { 1, 2, 3, 4 } } };
var snapshots = await drv.ReadAsync(["Vec"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
snapshots.Single().Value.ShouldBe(new[] { 1, 2, 3, 4 });
var read = factory.Clients[0].ReadLog.Single();
read.symbol.ShouldBe("MAIN.Vec");
read.arrayDimensions.ShouldBe(new[] { 4 });
}
[Fact]
public async Task Multi_dim_array_read_flattens_to_product_on_wire()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Mat", Host, "MAIN.Mat", TwinCATDataType.Real,
ArrayDimensions: [2, 3]));
await drv.InitializeAsync("{}", CancellationToken.None);
var flat = new[] { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f };
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Mat"] = flat } };
var snapshots = await drv.ReadAsync(["Mat"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
snapshots.Single().Value.ShouldBe(flat);
var read = factory.Clients[0].ReadLog.Single();
read.arrayDimensions.ShouldBe(new[] { 2, 3 });
}
// ---- Whole-array writes are out of scope (read-only PR) ----
[Fact]
public async Task Whole_array_write_returns_BadNotSupported_via_AdsTwinCATClient()
{
// Use the production AdsTwinCATClient gate directly — driver-level writes against
// the fake client would succeed because the fake doesn't model the real ADS surface.
var client = new AdsTwinCATClient();
var status = await client.WriteValueAsync(
"MAIN.Vec", TwinCATDataType.DInt, bitIndex: null,
arrayDimensions: [4], value: new[] { 1, 2, 3, 4 }, CancellationToken.None);
status.ShouldBe(TwinCATStatusMapper.BadNotSupported);
client.Dispose();
}
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) { } }
}
}
@@ -0,0 +1,105 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATBitWriteTests
{
// ---- Helper unit tests ----
[Theory]
[InlineData("Flags.3", "Flags")]
[InlineData("MAIN.Status.7", "MAIN.Status")]
[InlineData("GVL.Motors[0].Status.5", "GVL.Motors[0].Status")]
public void TryGetParentSymbolPath_strips_trailing_bit_selector(string input, string expected)
{
AdsTwinCATClient.TryGetParentSymbolPath(input).ShouldBe(expected);
}
[Theory]
[InlineData("Counter")] // single segment — no parent
[InlineData(".Bad")] // leading dot
public void TryGetParentSymbolPath_returns_null_for_pathless(string input)
{
AdsTwinCATClient.TryGetParentSymbolPath(input).ShouldBeNull();
}
[Theory]
[InlineData(0u, 0, true, 0x0000_0001u)]
[InlineData(0u, 31, true, 0x8000_0000u)]
[InlineData(0xFFFF_FFFFu, 0, false, 0xFFFF_FFFEu)]
[InlineData(0xFFFF_FFFFu, 31, false, 0x7FFF_FFFFu)]
[InlineData(0x0000_0008u, 3, true, 0x0000_0008u)] // already set — idempotent
[InlineData(0x0000_0000u, 3, false, 0x0000_0000u)] // already clear — idempotent
public void ApplyBit_sets_or_clears_bit(uint word, int bit, bool setBit, uint expected)
{
AdsTwinCATClient.ApplyBit(word, bit, setBit).ShouldBe(expected);
}
// ---- Driver-level round-trip ----
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags)
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = tags,
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, factory);
}
[Fact]
public async Task Bit_write_sets_bit_in_parent_word()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("FlagBit3", "ads://5.23.91.23.1.1:851", "Flags.3", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
// Parent word starts at 0.
factory.Customise = () => new FakeTwinCATClient { Values = { ["Flags"] = 0u } };
var results = await drv.WriteAsync(
[new Core.Abstractions.WriteRequest("FlagBit3", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
// Parent should now have bit 3 set.
Convert.ToUInt32(factory.Clients[0].Values["Flags"]!).ShouldBe(0x0000_0008u);
}
[Fact]
public async Task Bit_write_clears_bit_without_disturbing_neighbours()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("FlagBit3", "ads://5.23.91.23.1.1:851", "Flags.3", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
// Parent word has bits 0, 3, 7 set initially.
factory.Customise = () => new FakeTwinCATClient { Values = { ["Flags"] = 0x0000_0089u } };
var results = await drv.WriteAsync(
[new Core.Abstractions.WriteRequest("FlagBit3", false)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
// Bit 3 cleared; bits 0 and 7 untouched.
Convert.ToUInt32(factory.Clients[0].Values["Flags"]!).ShouldBe(0x0000_0081u);
}
[Fact]
public async Task Bit_write_does_not_throw_NotSupported()
{
// Regression: AdsTwinCATClient previously threw NotSupportedException for bit writes.
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Bit", "ads://5.23.91.23.1.1:851", "GVL.Word.0", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Word"] = 0u } };
var results = await drv.WriteAsync(
[new Core.Abstractions.WriteRequest("Bit", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
// Status should be Good, not BadNotSupported (which is what the catch block produced).
results.Single().StatusCode.ShouldNotBe(TwinCATStatusMapper.BadNotSupported);
}
}
@@ -83,11 +83,97 @@ public sealed class TwinCATDriverTests
{
TwinCATDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
TwinCATDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
TwinCATDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
TwinCATDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
TwinCATDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String);
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Int32);
// IEC durations map to UA Duration; absolute timestamps map to UA DateTime.
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Duration);
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.Duration);
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
}
[Fact]
public void IecTime_post_process_converts_TIME_to_TimeSpan_ms()
{
var ts = (TimeSpan)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.Time, 12_345u)!;
ts.ShouldBe(TimeSpan.FromMilliseconds(12_345));
}
[Fact]
public void IecTime_post_process_converts_TOD_to_TimeSpan_ms()
{
var ts = (TimeSpan)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.TimeOfDay, 3_600_000u)!;
ts.ShouldBe(TimeSpan.FromHours(1));
}
[Fact]
public void IecTime_post_process_converts_DT_to_DateTime_utc()
{
// 1970-01-01 + 1 hour = 1970-01-01 01:00:00 UTC
var dt = (DateTime)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.DateTime, 3600u)!;
dt.ShouldBe(new DateTime(1970, 1, 1, 1, 0, 0, DateTimeKind.Utc));
dt.Kind.ShouldBe(DateTimeKind.Utc);
}
[Fact]
public void IecTime_post_process_converts_DATE_to_midnight_utc()
{
// 86400 seconds = exactly 1970-01-02 00:00 UTC
var dt = (DateTime)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.Date, 86_400u)!;
dt.ShouldBe(new DateTime(1970, 1, 2, 0, 0, 0, DateTimeKind.Utc));
}
[Fact]
public void IecTime_post_process_passthrough_for_non_time_types()
{
AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.DInt, 42).ShouldBe(42);
AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.LReal, 3.14).ShouldBe(3.14);
}
[Fact]
public void ConvertForWrite_TIME_accepts_TimeSpan_and_returns_UDINT_ms()
{
var raw = AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Time, TimeSpan.FromMilliseconds(2_500));
raw.ShouldBeOfType<uint>();
((uint)raw).ShouldBe(2_500u);
}
[Fact]
public void ConvertForWrite_TOD_accepts_double_ms()
{
var raw = AdsTwinCATClient.ConvertForWrite(TwinCATDataType.TimeOfDay, 60_000.0);
((uint)raw).ShouldBe(60_000u);
}
[Fact]
public void ConvertForWrite_DT_accepts_DateTime_and_returns_UDINT_seconds()
{
var raw = AdsTwinCATClient.ConvertForWrite(
TwinCATDataType.DateTime,
new DateTime(1970, 1, 1, 1, 0, 0, DateTimeKind.Utc));
((uint)raw).ShouldBe(3600u);
}
[Fact]
public void ConvertForWrite_DT_round_trips_via_post_process()
{
var original = new DateTime(2024, 6, 15, 12, 30, 45, DateTimeKind.Utc);
var raw = (uint)AdsTwinCATClient.ConvertForWrite(TwinCATDataType.DateTime, original);
var roundTrip = (DateTime)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.DateTime, raw)!;
roundTrip.ShouldBe(original);
}
[Fact]
public void ConvertForWrite_DT_rejects_pre_epoch_values()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
AdsTwinCATClient.ConvertForWrite(
TwinCATDataType.DateTime,
new DateTime(1969, 12, 31, 23, 59, 59, DateTimeKind.Utc)));
}
[Theory]
@@ -0,0 +1,136 @@
using Shouldly;
using TwinCAT.Ads.TypeSystem;
using TwinCAT.TypeSystem;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Coverage for <see cref="AdsTwinCATClient.ResolveSymbolDataType"/> — the discovery
/// helper that maps a TwinCAT <see cref="IDataType"/> to a driver <see cref="TwinCATDataType"/>.
/// PR-1.5 added ENUM and ALIAS chain handling on top of the original primitive name match.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATTypeResolutionTests
{
[Fact]
public void Primitive_resolves_directly()
{
var dt = new PrimitiveType("DINT", typeof(int));
AdsTwinCATClient.ResolveSymbolDataType(dt).ShouldBe(TwinCATDataType.DInt);
}
[Fact]
public void Null_data_type_returns_null()
{
AdsTwinCATClient.ResolveSymbolDataType(null).ShouldBeNull();
}
[Fact]
public void Single_alias_resolves_to_underlying_atomic()
{
var primitive = new PrimitiveType("INT", typeof(short));
var alias = new AliasType("DegreesC", primitive);
AdsTwinCATClient.ResolveSymbolDataType(alias).ShouldBe(TwinCATDataType.Int);
}
[Fact]
public void Chained_alias_resolves_through_multiple_links()
{
var primitive = new PrimitiveType("DINT", typeof(int));
var inner = new AliasType("Inner", primitive);
var outer = new AliasType("Outer", inner);
AdsTwinCATClient.ResolveSymbolDataType(outer).ShouldBe(TwinCATDataType.DInt);
}
[Fact]
public void Alias_to_unknown_primitive_returns_null()
{
// A primitive whose IEC name we don't recognise should drop through cleanly. Pick a CLR
// type with a known marshal size so PrimitiveType's ctor accepts it; the name is what
// exercises the unknown-type branch in MapSymbolTypeName.
var primitive = new PrimitiveType("MYSTERY", typeof(int));
var alias = new AliasType("Wrap", primitive);
AdsTwinCATClient.ResolveSymbolDataType(alias).ShouldBeNull();
}
[Fact]
public void Enum_resolves_to_underlying_integer_base_type()
{
var underlying = new PrimitiveType("INT", typeof(short));
var enumType = new TestEnumType("EState", underlying);
AdsTwinCATClient.ResolveSymbolDataType(enumType).ShouldBe(TwinCATDataType.Int);
}
[Fact]
public void Enum_with_dint_base_resolves_to_DInt()
{
var underlying = new PrimitiveType("DINT", typeof(int));
var enumType = new TestEnumType("EBigFlags", underlying);
AdsTwinCATClient.ResolveSymbolDataType(enumType).ShouldBe(TwinCATDataType.DInt);
}
[Fact]
public void Cyclic_alias_chain_terminates_at_depth_cap()
{
// Pathological: a self-referential alias. The resolver must give up rather than spin.
var loop = new SelfReferentialAlias();
AdsTwinCATClient.ResolveSymbolDataType(loop).ShouldBeNull();
}
/// <summary>
/// Minimal in-test stub for <see cref="IEnumType"/>. Only <c>Category</c> and
/// <c>BaseType</c> are exercised by <see cref="AdsTwinCATClient.ResolveSymbolDataType"/>;
/// the rest of the surface throws to flag any accidental dependence in future tests.
/// </summary>
private sealed class TestEnumType(string name, IDataType baseType) : IDataType, IAliasType
{
public string Name { get; } = name;
public IDataType BaseType { get; } = baseType;
public DataTypeCategory Category => DataTypeCategory.Enum;
public string BaseTypeName => BaseType.Name;
public string FullName => Name;
public string Namespace => string.Empty;
public int Id => 0;
public string Comment => string.Empty;
public ITypeAttributeCollection Attributes => throw new NotSupportedException();
public bool IsContainer => false;
public bool IsPointer => false;
public bool IsReference => false;
public bool IsPrimitive => false;
public int Size => BaseType.Size;
public int ByteSize => BaseType.ByteSize;
public int BitSize => BaseType.BitSize;
public bool IsBitType => false;
public bool IsByteAligned => true;
}
/// <summary>Self-referential alias — exercises the depth cap.</summary>
private sealed class SelfReferentialAlias : IDataType, IAliasType
{
public string Name => "Loop";
public IDataType BaseType => this;
public DataTypeCategory Category => DataTypeCategory.Alias;
public string BaseTypeName => Name;
public string FullName => Name;
public string Namespace => string.Empty;
public int Id => 0;
public string Comment => string.Empty;
public ITypeAttributeCollection Attributes => throw new NotSupportedException();
public bool IsContainer => false;
public bool IsPointer => false;
public bool IsReference => false;
public bool IsPrimitive => false;
public int Size => 0;
public int ByteSize => 0;
public int BitSize => 0;
public bool IsBitType => false;
public bool IsByteAligned => true;
}
}