Compare commits

...

112 Commits

Author SHA1 Message Date
eed5857aa9 Merge pull request '[focas] FOCAS — cnc_rdalmhistry alarm-history extension' (#372) from auto/focas/F3-a into auto/driver-gaps 2026-04-26 00:10:36 -04:00
Joseph Doherty
7f9d6a778e Auto: focas-f3a — cnc_rdalmhistry alarm-history extension
Adds FocasAlarmProjection with two modes (ActiveOnly default, ActivePlusHistory)
that polls cnc_rdalmhistry on connect + on a configurable cadence (5 min default,
HistoryDepth=100 capped at 250). Emits historic events via IAlarmSource with
SourceTimestampUtc set from the CNC's reported timestamp; dedup keyed on
(OccurrenceTime, AlarmNumber, AlarmType). Ships the ODBALMHIS packed-buffer
decoder + encoder in Wire/FocasAlarmHistoryDecoder.cs and threads
ReadAlarmHistoryAsync through IFocasClient (default no-op so existing transport
variants stay back-compat). FocasDriver now implements IAlarmSource.

13 new unit tests cover: mode switch, dedup, distinct-timestamp emission,
type-as-key behaviour, OccurrenceTime passthrough (not Now), HistoryDepth
clamp/fallback, and decoder round-trip. All 341 FOCAS unit tests still pass.

Docs: docs/drivers/FOCAS.md (new), docs/v2/focas-deployment.md (new),
docs/v2/implementation/focas-wire-protocol.md (new),
docs/v2/implementation/focas-simulator-plan.md (new),
docs/drivers/FOCAS-Test-Fixture.md (alarm-history bullet appended).

Closes #267
2026-04-26 00:07:59 -04:00
1922b93bd5 Merge pull request '[ablegacy] AbLegacy — Per-tag deadband / change filter' (#371) from auto/ablegacy/8 into auto/driver-gaps 2026-04-25 23:52:42 -04:00
Joseph Doherty
eb5286148e Auto: ablegacy-8 — per-tag deadband / change filter
Closes #251
2026-04-25 23:50:07 -04:00
69069aa3be Merge pull request '[ablegacy] AbLegacy — Array contiguous block addressing' (#370) from auto/ablegacy/7 into auto/driver-gaps 2026-04-25 23:38:40 -04:00
Joseph Doherty
c689ac58b1 Auto: ablegacy-7 — array contiguous block addressing
Closes #250
2026-04-25 23:36:01 -04:00
05528bf71c Merge pull request '[abcip] AbCip — Logical-blocking / non-blocking strategy selector' (#369) from auto/abcip/3.3 into auto/driver-gaps 2026-04-25 23:18:47 -04:00
Joseph Doherty
01f4ee6b53 Auto: abcip-3.3 — read-strategy selector (WholeUdt / MultiPacket / Auto)
Closes #237
2026-04-25 23:16:06 -04:00
8a8dc1ee5a Merge pull request '[abcip] AbCip — Symbolic vs logical (instance-ID) addressing toggle' (#368) from auto/abcip/3.2 into auto/driver-gaps 2026-04-25 23:01:13 -04:00
Joseph Doherty
0c6a0d6e50 Auto: abcip-3.2 — symbolic vs logical addressing toggle
Closes #236
2026-04-25 22:58:33 -04:00
73ff10b595 Merge pull request '[abcip] AbCip — Configurable CIP Connection Size per device' (#367) from auto/abcip/3.1 into auto/driver-gaps 2026-04-25 22:41:51 -04:00
Joseph Doherty
f6c26db609 Auto: abcip-3.1 — configurable CIP connection size per device
Closes #235
2026-04-25 22:39:05 -04:00
7cbddd4b4a Merge pull request '[twincat] TwinCAT — Symbol-version invalidation listener' (#366) from auto/twincat/2.3 into auto/driver-gaps 2026-04-25 22:18:47 -04:00
Joseph Doherty
4098d72bbb Auto: twincat-2.3 — symbol-version invalidation listener
Closes #312
2026-04-25 22:16:05 -04:00
569001364f Merge pull request '[twincat] TwinCAT — Handle-based access with caching' (#365) from auto/twincat/2.2 into auto/driver-gaps 2026-04-25 22:06:01 -04:00
Joseph Doherty
b67eb6c8d0 Auto: twincat-2.2 — handle-based access with caching
Closes #311
2026-04-25 22:03:20 -04:00
4a071b6d5a Merge pull request '[twincat] TwinCAT — ADS Sum-read / Sum-write' (#364) from auto/twincat/2.1 into auto/driver-gaps 2026-04-25 21:46:21 -04:00
Joseph Doherty
931049b5a7 Auto: twincat-2.1 — ADS Sum-read / Sum-write
Closes #310
2026-04-25 21:43:32 -04:00
fa2fbb404d Merge pull request '[s7] S7 — Block-read coalescing for contiguous DBs' (#363) from auto/s7/PR-S7-B2 into auto/driver-gaps 2026-04-25 21:25:56 -04:00
Joseph Doherty
17faf76ea7 Auto: s7-b2 — block-read coalescing for contiguous DBs
Closes #293
2026-04-25 21:23:06 -04:00
5432c49364 Merge pull request '[s7] S7 — Multi-variable PDU packing' (#362) from auto/s7/PR-S7-B1 into auto/driver-gaps 2026-04-25 21:06:55 -04:00
Joseph Doherty
d7633fe36f Auto: s7-b1 — multi-variable PDU packing
Replaces the per-tag Plc.ReadAsync loop in S7Driver.ReadAsync with a
batched ReadMultipleVarsAsync path. Scalar fixed-width tags (Bool, Byte,
Int16/UInt16, Int32/UInt32, Float32, Float64) are bin-packed into ≤18-item
batches at the default 240-byte PDU using S7.Net.Types.DataItem; arrays,
strings, dates, 64-bit ints, and UDT-shaped types stay on the legacy
ReadOneAsync path. On batch-level failure each tag in the batch falls
back to ReadOneAsync so good tags still produce values and the offender
gets its per-item StatusCode (BadDeviceFailure / BadCommunicationError).

100 scalar reads now coalesce into ≤6 PDU round-trips instead of 100.

Closes #292
2026-04-25 21:04:32 -04:00
69d9a6fbb5 Merge pull request '[opcuaclient] OpcUaClient — Method node mirroring + Call passthrough' (#361) from auto/opcuaclient/9 into auto/driver-gaps 2026-04-25 20:55:09 -04:00
Joseph Doherty
07abee5f6d Auto: opcuaclient-9 — method node mirroring + Call passthrough
Adds the 9th capability interface (IMethodInvoker) so the OPC UA Client
driver can mirror upstream OPC UA Method nodes into the local address
space and forward Call invocations as Session.CallAsync. Method-bearing
servers (e.g. ProgramStateMachine, Acknowledge / Confirm methods, custom
control surfaces) now show up downstream instead of being silently
filtered out.

- Core.Abstractions: IMethodInvoker + MethodCallResult; default no-op
  IAddressSpaceBuilder.RegisterMethodNode + MirroredMethodNodeInfo +
  MethodArgumentInfo. Default impls keep tag-based drivers and existing
  builders compiling without forced overrides.
- OpcUaClientDriver: BrowseRecursiveAsync now lifts the Method node-class
  filter; for each method it walks HasProperty to pick up InputArguments
  + OutputArguments and decodes the Argument extension objects into
  MethodArgumentInfo. Status-codes pass through verbatim (cascading
  quality), local NodeId-parse + lost-session faults surface as
  BadNodeIdInvalid / BadCommunicationError.
- 7 new unit tests cover the capability surface, the DTO shapes, and the
  back-compat default no-op for RegisterMethodNode. Suite green at
  160/160.

Server-side OnCallMethod dispatch (wiring local MethodNode handlers to
IMethodInvoker.CallMethodAsync inside DriverNodeManager) is deferred to
a follow-up — the driver-side surface + browse mirror ship cleanly here.

Closes #281

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:52:39 -04:00
0f3abed4c7 Merge pull request '[opcuaclient] OpcUaClient — Type definition mirroring' (#360) from auto/opcuaclient/8 into auto/driver-gaps 2026-04-25 20:40:49 -04:00
Joseph Doherty
cc21281cbb Auto: opcuaclient-8 — type definition mirroring
Adds an opt-in pass-3 walk of the upstream TypesFolder (i=86) so the OPC UA
Client driver can mirror upstream type definitions into the local address
space. Honours the curation rules from PR-7 (#359). Structural mirror only —
binary-encoding priming via LoadDataTypeSystem is tracked as a follow-up
because that helper was removed from the public ISession surface in
OPCFoundation.NetStandard 1.5.378+.

- IAddressSpaceBuilder.RegisterTypeNode (default no-op for back-compat)
- MirroredTypeNodeInfo + MirroredTypeKind in Core.Abstractions
- OpcUaClientDriverOptions.MirrorTypeDefinitions (default false)
- DiscoverAsync pass-3: FetchTypeTreeAsync + recursive HasSubtype walk per
  branch (ObjectTypes/VariableTypes/DataTypes/ReferenceTypes), best-effort
  IsAbstract read, IncludePaths/ExcludePaths still applied
- 6 new unit tests; all 153 OpcUaClient unit tests pass

Closes #280
2026-04-25 20:38:17 -04:00
5e164dc965 Merge pull request '[opcuaclient] OpcUaClient — Selective import + namespace remap' (#359) from auto/opcuaclient/7 into auto/driver-gaps 2026-04-25 20:23:14 -04:00
Joseph Doherty
02d1c85190 Auto: opcuaclient-7 — selective import + namespace remap
Adds OpcUaClientCurationOptions on OpcUaClientDriverOptions.Curation with:
- IncludePaths/ExcludePaths globs (* and ? only) matched against the
  slash-joined BrowsePath segments collected during BrowseRecursiveAsync.
  Empty Include = include all so existing deployments are unaffected;
  Exclude wins over Include. Pruned folders are skipped wholesale, so
  descendants don't reach the wire.
- NamespaceRemap (URI -> URI) applied to DriverAttributeInfo.FullName when
  registering variables. Index-0 / standard nodes round-trip unchanged;
  remapped nodes serialise via ExpandedNodeId so downstream clients see
  the local-side URI.
- RootAlias replaces the hard-coded "Remote" folder name at the top of
  the mirrored tree.

Closes #279
2026-04-25 20:20:47 -04:00
1d3e9a3237 Merge pull request '[opcuaclient] OpcUaClient — Discovery URL FindServers' (#358) from auto/opcuaclient/6 into auto/driver-gaps 2026-04-25 20:13:21 -04:00
Joseph Doherty
0f509fbd3a Auto: opcuaclient-6 — Discovery URL FindServers
Adds optional `DiscoveryUrl` knob to OpcUaClientDriverOptions. When set,
the driver runs `DiscoveryClient.CreateAsync` + `FindServersAsync` +
`GetEndpointsAsync` against that URL during InitializeAsync and prepends
the discovered endpoint URLs (filtered to matching SecurityPolicy +
SecurityMode) to the failover candidate list. De-duplicates URLs that
appear in both discovered and static lists (case-insensitive). Discovery
failures are non-fatal — falls back to statically configured candidates.

The doc comment notes that FindServers requires SecurityMode=None on the
discovery channel per OPC UA spec, even when the data channel uses Sign
or SignAndEncrypt.

Closes #278

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:10:59 -04:00
e879b3ae90 Merge pull request '[focas] FOCAS — Bulk PMC range read coalescing' (#357) from auto/focas/F2-d into auto/driver-gaps 2026-04-25 20:04:36 -04:00
Joseph Doherty
4d3ee47235 Auto: focas-f2d — PMC range coalescing
Closes #266
2026-04-25 20:02:10 -04:00
9ebe5bd523 Merge pull request '[focas] FOCAS — PMC F/G letters for 16i' (#356) from auto/focas/F2-c into auto/driver-gaps 2026-04-25 19:51:39 -04:00
Joseph Doherty
63099115bf Auto: focas-f2c — PMC F/G for 16i
Capability-matrix correctness fix: real 16i ladders use F (CNC->PMC) and
G (PMC->CNC) signal groups for handshakes, but PmcLetters(Sixteen_i) was
returning {X,Y,R,D} only. Widen to {X,Y,F,G,R,D}; M/C/E/A/K/T remain
0i-F / 30i-only. Updated the matching test row.

Closes #265
2026-04-25 19:49:25 -04:00
7042b11f34 Merge pull request '[focas] FOCAS — Multi-path/multi-channel CNC' (#355) from auto/focas/F2-b into auto/driver-gaps 2026-04-25 19:45:27 -04:00
Joseph Doherty
2f3eeecd17 Auto: focas-f2b — multi-path/multi-channel CNC
Adds optional `@N` path suffix to FocasAddress (PARAM:1815@2, R100@3.0,
MACRO:500@2, DIAG:280@2/1) with PathId defaulting to 1 for back-compat.
Per-device PathCount is discovered via cnc_rdpathnum at first connect and
cached on DeviceState; reads with PathId>PathCount return BadOutOfRange.
The driver issues cnc_setpath before each non-default-path read and
tracks LastSetPath so repeat reads on the same path skip the wire call.

Closes #264
2026-04-25 19:42:58 -04:00
3b82f4f5fb Merge pull request '[focas] FOCAS — DIAG: address scheme' (#354) from auto/focas/F2-a into auto/driver-gaps 2026-04-25 19:34:08 -04:00
Joseph Doherty
451b37a632 Auto: focas-f2a — DIAG: address scheme
New FocasAreaKind.Diagnostic parsed from DIAG:nnn (whole-CNC) and
DIAG:nnn/axis (per-axis), validated against a per-series
FocasCapabilityMatrix.DiagnosticRange table (16i: 0-499; 0i-F family:
0-999; 30i/31i/32i: 0-1023; Power Motion i: 0-255; Unknown: permissive
per existing matrix convention).

IFocasClient gains ReadDiagnosticAsync(diagNumber, axisOrZero, type,
ct) with a default returning BadNotSupported so older transport
variants degrade gracefully. FwlibFocasClient implements it via a new
cnc_rddiag P/Invoke that reuses the IODBPSD struct (same shape as
cnc_rdparam). FocasDriver.ReadAsync dispatches Diagnostic addresses
through the new path; non-Diagnostic kinds keep the existing
ReadAsync route unchanged.

Tests: parser positives (DIAG:1031, DIAG:280/2, case-insensitive,
zero, axis-8) + negatives (malformed, axis>31), capability matrix
boundaries per series, driver-level dispatch verifying axis index
threads through, init-time rejection on out-of-range, and
BadNotSupported fallback when the wire client doesn't override the
default. 266/266 pass in Driver.FOCAS.Tests.

Closes #263

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:31:49 -04:00
6743d51db8 Merge pull request '[ablegacy] AbLegacy — ST string verification + length guard' (#353) from auto/ablegacy/6 into auto/driver-gaps 2026-04-25 19:21:13 -04:00
Joseph Doherty
0044603902 Auto: ablegacy-6 — ST string verification + length guard
Verifies libplctag's GetString/SetString round-trips ST file strings (1-word
length prefix + 82 ASCII bytes) end-to-end through the driver, and adds a
client-side length guard so over-82-char writes return BadOutOfRange instead
of being silently truncated by libplctag.

- LibplctagLegacyTagRuntime.EncodeValue: throws ArgumentOutOfRangeException
  for >82-char String writes (StFileMaxStringLength constant).
- AbLegacyDriver.WriteAsync: catches ArgumentOutOfRangeException and maps to
  BadOutOfRange.
- AbLegacyStringEncodingTests: 16 unit tests covering empty / 41-char /
  82-char / embedded-NUL / non-ASCII reads + writes; over-length writes
  return BadOutOfRange and never call WriteAsync; both Slc500 and Plc5
  family paths exercised.

Closes #249

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:18:55 -04:00
2fc71d288e Merge pull request '[ablegacy] AbLegacy — PD/MG/PLS/BT structure files' (#352) from auto/ablegacy/5 into auto/driver-gaps 2026-04-25 19:11:11 -04:00
Joseph Doherty
286ab3ba41 Auto: ablegacy-5 — PD/MG/PLS/BT structure files
Adds PD (PID), MG (Message), PLS (Programmable Limit Switch) and BT
(Block Transfer) file types to the PCCC parser. New AbLegacyDataType
enum members (PidElement / MessageElement / PlsElement /
BlockTransferElement) plus per-type sub-element catalogue:

  - PD: SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT as Float32; EN/DN/MO/PE/
        AUTO/MAN/SP_VAL/SP_LL/SP_HL as Boolean (word-0 status bits).
  - MG: RBE/MS/SIZE/LEN as Int32; EN/EW/ER/DN/ST/CO/NR/TO as Boolean.
  - PLS: LEN as Int32 (bit table varies per PLC).
  - BT: RLEN/DLEN as Int32; EN/ST/DN/ER/CO/EW/TO/NR as Boolean.

Per-family flags on AbLegacyPlcFamilyProfile gate availability:

  - PD/MG: SLC500 + PLC-5 (operator + status bits both present).
  - PLS/BT: PLC-5 only (chassis-IO block transfer is PLC-5-specific).
  - MicroLogix + LogixPccc: rejected — no legacy file-letter form.

Status-bit indices match Rockwell DTAM / 1747-RM001 / 1785-6.5.12:
PD word 0 bits 0-8, MG/BT word 0 bits 8-15. PLC-set status bits
(PE/DN/SP_*; ST/DN/ER/CO/EW/NR/TO) are surfaced as ViewOnly via
IsPlcSetStatusBit, matching the Timer/Counter/Control pattern from
ablegacy-3.

LibplctagLegacyTagRuntime decodes PD non-bit members as Float32 and
MG/BT/PLS non-bit members as Int32; status bits route through GetBit
with the bit-position encoded by the driver via StatusBitIndex.

Tests: parser positive cases per family + negative cases per family,
catalogue + bit-index + read-only-bit assertions.

Closes #248
2026-04-25 19:08:51 -04:00
5ca2ad83cd Merge pull request '[abcip] AbCip — AOI input/output handling' (#351) from auto/abcip/2.6 into auto/driver-gaps 2026-04-25 19:01:03 -04:00
Joseph Doherty
e3c0750f7d Auto: abcip-2.6 — AOI input/output handling
AOI-aware browse paths: AOI instances now fan out under directional
sub-folders (Inputs/, Outputs/, InOut/) instead of a flat layout. The
sub-folders only appear when at least one member carries a non-Local
AoiQualifier, so plain UDT tags keep the pre-2.6 flat structure.

- Add AoiQualifier enum (Local / Input / Output / InOut) + new property
  on AbCipStructureMember (defaults to Local).
- L5K parser learns ADD_ON_INSTRUCTION_DEFINITION blocks; PARAMETER
  entries' Usage attribute flows through L5kMember.Usage.
- L5X parser captures the Usage attribute on <Parameter> elements.
- L5kIngest maps Usage strings (Input/Output/InOut) to AoiQualifier;
  null + unknown values map to Local.
- AbCipDriver.DiscoverAsync groups directional members under
  Inputs / Outputs / InOut sub-folders when any member is non-Local.
- Tests for L5K AOI block parsing, L5X Usage capture, ingest mapping
  (both formats), and AOI-vs-plain UDT discovery fan-out.

Closes #234
2026-04-25 18:58:49 -04:00
177d75784b Merge pull request '[abcip] AbCip — Online tag-DB refresh trigger' (#350) from auto/abcip/2.5 into auto/driver-gaps 2026-04-25 18:48:14 -04:00
Joseph Doherty
6e244e0c01 Auto: abcip-2.5 — online tag-DB refresh trigger
Add IDriverControl capability interface in Core.Abstractions with a
RebrowseAsync(IAddressSpaceBuilder, CancellationToken) hook so operators
can force a controller-side @tags re-walk without restarting the driver.

AbCipDriver now implements IDriverControl. RebrowseAsync clears the UDT
template cache (so stale shapes from a pre-download program don't
survive) then runs the same enumerator + builder fan-out as
DiscoverAsync, serialised against concurrent discovery / rebrowse via
a new SemaphoreSlim.

Driver.AbCip.Cli ships a `rebrowse` subcommand mirroring the existing
probe / read shape: connects to a single gateway, runs RebrowseAsync
against an in-memory builder, and prints discovered tag names so
operators can sanity-check the controller's symbol table from a shell.

Tests cover: two consecutive RebrowseAsync calls bump the enumerator's
Create / Enumerate counters once each, discovered tags reach the
supplied builder, the template cache is dropped on rebrowse, and the
driver exposes IDriverControl. 313 AbCip unit tests + 17 CLI tests +
37 Core.Abstractions tests pass.

Closes #233
2026-04-25 18:45:48 -04:00
27878d0faf Merge pull request '[abcip] AbCip — CSV tag import/export' (#349) from auto/abcip/2.4 into auto/driver-gaps 2026-04-25 18:36:17 -04:00
Joseph Doherty
08d8a104bb Auto: abcip-2.4 — CSV tag import/export
CsvTagImporter / CsvTagExporter parse and emit Kepware-format AB CIP tag
CSVs (Tag Name, Address, Data Type, Respect Data Type, Client Access,
Scan Rate, Description, Scaling). Import maps Tag Name → AbCipTagDefinition.Name,
Address → TagPath, Data Type → DataType, Description → Description,
Client Access → Writable. Skips blank rows + ;/# section markers; honours
column reordering via header lookup; RFC-4180-ish quoting.

CsvImports collection on AbCipDriverOptions mirrors L5kImports/L5xImports
and is consumed by InitializeAsync (declared > L5K > L5X > CSV precedence).

CLI tag-export command dumps the merged tag table from a driver-options JSON
to a Kepware CSV — runs the same import-merge precedence the driver uses but
without contacting any PLC.

Tests cover R/W mapping, blank-row skip, quoted comma, escaped quote, name
prefix, unknown-type fall-through, header reordering, and a load → export →
reparse round-trip.

Closes #232
2026-04-25 18:33:55 -04:00
7ee0cbc3f4 Merge pull request '[abcip] AbCip — Descriptions to OPC UA Description' (#348) from auto/abcip/2.3 into auto/driver-gaps 2026-04-25 18:25:59 -04:00
Joseph Doherty
e5299cda5a Auto: abcip-2.3 — descriptions to OPC UA Description
Threads tag/UDT-member descriptions captured by the L5K (#346) and L5X
(#347) parsers through AbCipTagDefinition + AbCipStructureMember into
DriverAttributeInfo, so the address-space builder sets the OPC UA
Description attribute on each Variable node. L5kMember and L5xParser
also now capture per-member descriptions (via the (Description := "...")
attribute block on L5K and the <Description> child on L5X), and
L5kIngest forwards them. DriverNodeManager surfaces
DriverAttributeInfo.Description as the Variable's Description property.

Description is added as a trailing optional parameter on
DriverAttributeInfo (default null) so every other driver continues
to construct the record unchanged.

Closes #231
2026-04-25 18:23:31 -04:00
e5b192fcb3 Merge pull request '[abcip] AbCip — L5X (XML) parser + ingest' (#347) from auto/abcip/2.2 into auto/driver-gaps 2026-04-25 18:13:14 -04:00
Joseph Doherty
cfcaf5c1d3 Auto: abcip-2.2 — L5X (XML) parser + ingest
Adds Import/L5xParser.cs that consumes Studio 5000 L5X (XML) controller
exports via System.Xml.XPath and produces the same L5kDocument bundle as
L5kParser, so L5kIngest handles both formats interchangeably.

- Controller-scope and program-scope <Tag> elements with Name, DataType,
  TagType, ExternalAccess, AliasFor, and <Description> child.
- <DataType>/<Members>/<Member> with Hidden BOOL-host (ZZZZZZZZZZ*) skip.
- AddOnInstructionDefinitions surfaced as L5kDataType entries so AOI-typed
  tags pick up a member layout the same way UDT-typed tags do; hidden
  EnableIn/EnableOut parameters skipped. Full directional Input/Output/InOut
  modelling stays deferred to PR 2.6.

AbCipDriverOptions gains parallel L5xImports collection (mirrors
L5kImports field-for-field). InitializeAsync funnels both through one
shared MergeImport helper that differs only in the parser delegate.

Tests: 8 L5X fixtures cover controller- and program-scope tags, alias skip,
UDT layout fan-out, AOI-typed tag, ZZZZZZZZZZ host skip, hidden AOI param
skip, missing-ExternalAccess default, and an empty-controller no-throw.

Closes #230
2026-04-25 18:10:53 -04:00
2731318c81 Merge pull request '[abcip] AbCip — L5K parser + ingest' (#346) from auto/abcip/2.1 into auto/driver-gaps 2026-04-25 18:03:31 -04:00
Joseph Doherty
86407e6ca2 Auto: abcip-2.1 — L5K parser + ingest
Pure-text parser for Studio 5000 L5K controller exports. Recognises
TAG/END_TAG, DATATYPE/END_DATATYPE, and PROGRAM/END_PROGRAM blocks,
strips (* ... *) comments, and tolerates multi-line entries + unknown
sections (CONFIG, MOTION_GROUP, etc.). Output records — L5kTag,
L5kDataType, L5kMember — feed L5kIngest which converts to
AbCipTagDefinition + AbCipStructureMember. Alias tags and
ExternalAccess=None tags are skipped per Kepware precedent.

AbCipDriverOptions gains an L5kImports collection
(AbCipL5kImportOptions records — file path or inline text + per-import
device + name prefix). InitializeAsync merges the imports into the
declared Tags map, with declared tags winning on Name conflicts so
operators can override import results without editing the L5K source.

Tests cover controller-scope TAG, program-scope TAG, alias-tag flag,
DATATYPE with member array dims, comment stripping, unknown-section
skipping, multi-line entries, and the full ingest path including
ExternalAccess=None / ReadOnly / UDT-typed tag fanout.

Closes #229

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:01:08 -04:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
156 changed files with 25067 additions and 418 deletions

View File

@@ -20,6 +20,7 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` | | `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix | | `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix |
| `--timeout-ms` | `5000` | Per-operation timeout | | `--timeout-ms` | `5000` | Per-operation timeout |
| `--addressing-mode` | `Auto` | `Auto` / `Symbolic` / `Logical` — see [AbCip-Performance §Addressing mode](drivers/AbCip-Performance.md#addressing-mode). `Logical` against Micro800 silently falls back to Symbolic with a warning. |
| `--verbose` | off | Serilog debug output | | `--verbose` | off | Serilog debug output |
Family ↔ CIP-path cheat sheet: Family ↔ CIP-path cheat sheet:
@@ -81,3 +82,25 @@ otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and - **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
read the status code — safety tags surface `BadNotWritable` / CIP errors, read the status code — safety tags surface `BadNotWritable` / CIP errors,
non-safety tags surface `Good`. non-safety tags surface `Good`.
## Connection Size
PR abcip-3.1 introduced a per-device `ConnectionSize` override on the driver
side (`AbCipDeviceOptions.ConnectionSize`, range `500..4002`). The CLI does
not expose a flag for it — every CLI invocation uses the family-default
Connection Size (4002 / 504 / 488 depending on `--family`). When a Forward
Open is rejected with a CIP error like `0x01/0x113` ("connection request
size invalid"), the symptom is almost always a **mismatch between the chosen
family default and the controller firmware**:
- **v19-and-earlier ControlLogix** caps at 504 — pick `--family CompactLogix`
on the CLI to fall back to that narrower default.
- **5069-L1/L2/L3 CompactLogix** narrow-buffer parts also cap at 504, which
is the family default already.
- **FW20+ ControlLogix** accepts the full 4002.
For the warning *"AbCip device 'X' family 'Y' uses a narrow-buffer profile
(default ConnectionSize Z); the configured ConnectionSize N exceeds the
511-byte legacy-firmware cap..."* see
[`docs/drivers/AbCip-Performance.md`](drivers/AbCip-Performance.md) — that
warning is fired by the driver host, not the CLI.

View File

@@ -95,6 +95,62 @@ PLC-managed — use with caution.
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500
``` ```
#### Deadband
PR 8 — per-tag absolute / percent change filter on top of the polled subscription. The driver
caches the last *published* value per tag and suppresses `OnDataChange` notifications until the
new sample crosses the configured threshold.
| Flag | Effect |
|---|---|
| `--deadband-absolute <value>` | Suppress until `|new - prev| >= value`. |
| `--deadband-percent <value>` | Suppress until `|new - prev| >= |prev * value / 100|`. `prev == 0` always publishes (avoids div-by-zero). |
Booleans bypass the filter entirely (every transition publishes); strings + status changes
always publish; first-seen always publishes; both flags set → either passing triggers a
publish (Kepware-style logical OR).
```powershell
# Float — drop sub-0.5 jitter from the noisy load-cell address.
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a F8:0 -t Float -i 500 `
--deadband-absolute 0.5
# Integer — only fire on >= 5% deviation from the last reported value.
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 `
--deadband-percent 5
```
## Array reads
PR 7 — one PCCC frame can carry up to ~120 words. Address an array tag with either the
Rockwell-native `,N` suffix or the libplctag-native `[N]` suffix on the word number; both
forms canonicalise to `[N]` when the driver hands the tag to libplctag, and the parser
caps `N` at 120.
```powershell
# Rockwell `,N` form — "10 consecutive words starting at N7:0"
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0,10" -t Int
# libplctag `[N]` form — same wire result
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0[10]" -t Int
# Float / Long arrays — same suffix syntax, narrower frame ceiling on Float (~60 elements)
# and Long (~60 elements) because each element is 4 bytes vs Int's 2.
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "F8:0,4" -t Float
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "L19:0,4" -t Long
# --array-length override — pin the element count from config rather than the address
# suffix. Wins over the parsed `,N` / `[N]` value when both are set; useful for keeping the
# address string compact while bumping the element count from a tags config file.
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0" --array-length 10 -t Int
```
Array tags reject sub-element references (`T4:0,5.ACC`) and bit suffixes (`N7:0,10/3`) at
parse time — both combinations are semantically meaningless against a contiguous block.
For `B`-files the Rockwell convention is "one BOOL per word, not per bit": `B3:0,10`
returns `bool[10]` (one per word's non-zero state), not `bool[160]`.
## Known caveat — ab_server upstream gap ## Known caveat — ab_server upstream gap
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC The integration-fixture `ab_server` Docker container accepts TCP but its PCCC

View File

@@ -77,6 +77,14 @@ otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "Recipe[3]" -t Real
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.sMessage -t WString otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.sMessage -t WString
``` ```
ADS variable handles for `read` / `write` symbols are cached transparently
inside the CLI's underlying `AdsTwinCATClient`. The first read of a symbol
resolves a handle; repeats reuse the cached handle for smaller AMS payloads
and skipped name resolution. The cache wipes on reconnect, on
`DeviceSymbolVersionInvalid` (with a one-shot retry), and on CLI exit. See
`docs/drivers/TwinCAT-Test-Fixture.md §Handle caching` for the full story
including the staleness caveat after an online change.
### `write` ### `write`
```powershell ```powershell
@@ -99,3 +107,7 @@ otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
The subscribe banner announces which mechanism is in play — "ADS notification" The subscribe banner announces which mechanism is in play — "ADS notification"
or "polling" — so it's obvious in screen-recorded bug reports. or "polling" — so it's obvious in screen-recorded bug reports.
`--poll-only` polls go through the same cached-handle path as `read`, so
repeated polls of the same symbol carry only a 4-byte handle on the wire
rather than the full symbolic path.

View File

@@ -0,0 +1,405 @@
# AB CIP — Performance knobs
Phase 3 of the AB CIP driver plan introduces a small set of operator-tunable
performance knobs that change how the driver talks to the controller without
altering the address space or per-tag semantics. They consolidate decisions
that Kepware exposes as a slider / advanced page so deployments running into
high-latency PLCs, narrow-CPU CompactLogix parts, or legacy ControlLogix
firmware have an explicit lever to pull.
This document is the home for those knobs as PRs land. PR abcip-3.1 ships the
first knob: per-device **CIP Connection Size**.
## Connection Size
### What it is
CIP Connection Size — the byte ceiling on a single Forward Open response
fragment, set during the EtherNet/IP Forward Open handshake. Larger
connection sizes pack more tags into a single CIP RTT (higher request-packing
density, fewer round-trips for the same scan list); smaller connection sizes
stay compatible with legacy or narrow-buffer firmware that rejects oversized
Forward Open requests.
### Family defaults
The driver picks a Connection Size from the per-family profile when the
device-level override is unset:
| Family | Default | Rationale |
|---|---:|---|
| `ControlLogix` | `4002` | Large Forward Open — FW20+ |
| `GuardLogix` | `4002` | Same wire protocol as ControlLogix |
| `CompactLogix` | `504` | 5069-L1/L2/L3 narrow-buffer parts (5370 family) |
| `Micro800` | `488` | Hard cap on Micro800 firmware |
These map straight to libplctag's `connection_size` attribute and match the
defaults Kepware uses out of the box for the same families.
### Override knob
`AbCipDeviceOptions.ConnectionSize` (`int?`, default `null`) overrides the
family default for one device. Bind it through driver config JSON:
```json
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"ConnectionSize": 504
}
]
}
```
The override threads through every libplctag handle the driver creates for
that device — read tags, write tags, probe tags, UDT-template reads, the
`@tags` walker, and BOOL-in-DINT parent runtimes. There is no per-tag
override; one Connection Size applies to the whole controller (matches CIP
session semantics).
### Valid range
`[500..4002]` bytes. This matches the slider Kepware exposes for the same
family. Values outside the range fail driver `InitializeAsync` with an
`InvalidOperationException` — there's no silent clamp; misconfigured devices
fail loudly so operators see the problem at deploy time.
| Value | Behaviour |
|---|---|
| `null` | Use family default (4002 / 504 / 488) |
| `499` or below | Driver init fault — out-of-range |
| `500..4002` | Threaded through to libplctag |
| `4003` or above | Driver init fault — out-of-range |
### Legacy-firmware caveat
ControlLogix firmware **v19 and earlier** caps the CIP buffer at **504
bytes** — Connection Sizes above that cause the controller to reject the
Forward Open with CIP error 0x01/0x113. The 5069-L1/L2/L3 CompactLogix narrow
parts are subject to the same cap.
The driver emits a warning via `AbCipDriverOptions.OnWarning` when the
configured Connection Size **exceeds 511** *and* the device's family profile
default is also at-or-below the legacy cap (i.e. CompactLogix with default
504, or Micro800 with default 488). Production hosting should wire
`OnWarning` to the application logger; the unit tests (`AbCipConnectionSizeTests`)
collect into a list to assert which warnings fired.
The warning fires once per device at `InitializeAsync`. It does not block
initialisation — operators may need the override anyway when running newer
CompactLogix firmware that does support the larger Forward Open. The
controller will reject the connection at runtime if it can't honour the size,
and that surfaces through the standard `IHostConnectivityProbe` channel.
### Performance trade-off
| Larger Connection Size | Smaller Connection Size |
|---|---|
| More tags per CIP RTT — higher throughput | Compatible with legacy / narrow firmware |
| Bigger buffers held by libplctag native (RSS impact) | Lower memory footprint |
| Forward Open rejected on FW19- ControlLogix | Always works (assuming ≥500) |
| Required for high-density scan lists | Forces more round-trips — higher latency |
For most FW20+ ControlLogix shops, the default `4002` is correct and the
override is unnecessary. The override is mainly useful when:
1. **Migrating off Kepware** with a controller-specific slider value already
tuned in production — set Connection Size to match.
2. **Mixed-firmware fleets** where some controllers are still on FW19 — set
the legacy controllers explicitly to `504`.
3. **CompactLogix L1/L2/L3** running newer firmware that supports a larger
Forward Open than the family-default 504 — bump the override up.
4. **Micro800** never goes above `488`; the override is for documentation /
discoverability rather than capability change.
### libplctag wrapper limitation
The libplctag .NET wrapper (1.5.x) does not expose `connection_size` as a
public `Tag` property. The driver propagates the value via reflection on the
wrapper's internal `NativeTagWrapper.SetIntAttribute("connection_size", N)`
after `InitializeAsync` — equivalent to libplctag's
`plc_tag_set_int_attribute`. Because libplctag native parses
`connection_size` only at create time, this is **best-effort** until either:
- the libplctag .NET wrapper exposes `ConnectionSize` directly (planned in
the upstream backlog), in which case the reflection no-ops cleanly, or
- libplctag native gains post-create hot-update for `connection_size`, in
which case the call lands as intended.
In the meantime the value is correctly stored on `DeviceState.ConnectionSize`
+ surfaces in every `AbCipTagCreateParams` the driver builds, so the override
is observable end-to-end through the public driver surface and unit tests
even if the underlying wrapper isn't yet honouring it on the wire.
Operators who need *guaranteed* Connection Size enforcement against FW19
controllers today can pin `libplctag` to a wrapper version that exposes
`ConnectionSize` once one is available, or run a libplctag native build
patched for runtime updates. Both paths are tracked in the AB CIP plan.
### See also
- [`docs/Driver.AbCip.Cli.md`](../Driver.AbCip.Cli.md) — AB CIP CLI uses the
family default ConnectionSize on each invocation; per-device overrides only
apply through the driver's device-config JSON, not the CLI's command-line.
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §5 —
ab_server simulator does not enforce the narrow CompactLogix cap, so
Connection Size correctness is verified by unit tests + Emulate-rig live
smokes only.
- [`PlcFamilies/AbCipPlcFamilyProfile.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
per-family default values.
- [`AbCipConnectionSize`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs) —
range bounds + legacy-firmware threshold constants.
## Addressing mode
### What it is
CIP exposes two equivalent ways to address a Logix tag on the wire:
1. **Symbolic** — the request carries the tag's ASCII name and the controller
parses + resolves the path on every read. This is the libplctag default
and what every previous driver build has used.
2. **Logical** — the request carries a CIP Symbol Object instance ID (a small
integer assigned by the controller when the project was downloaded). The
controller skips ASCII parsing entirely; the lookup is a single
instance-table dereference.
Logical addressing is faster on the controller side and produces smaller
request frames. The trade-off is that the driver has to learn the
name → instance-id mapping once, by reading the `@tags` pseudo-tag at
startup, and the resolution step has to repeat after a controller program
download (instance IDs are re-assigned).
### Enum values
`AbCipDeviceOptions.AddressingMode` (`AddressingMode` enum, default
`Auto`) takes one of three values:
| Value | Behaviour |
|---|---|
| `Auto` | Driver picks. **Currently resolves to `Symbolic`** — a future PR will plumb a real auto-detection heuristic (firmware version + symbol-table size). |
| `Symbolic` | Force ASCII symbolic addressing on the wire. The historical default. |
| `Logical` | Use CIP logical-segment / instance-ID addressing. Triggers a one-time `@tags` walk at the first read; subsequent reads consult the cached map. |
`Auto` is documented as "Symbolic-for-now" so deployments setting `Auto`
explicitly today will silently flip to a real heuristic when one ships,
matching the spirit of the toggle. Operators who want to pin the wire
behaviour should set `Symbolic` or `Logical` directly.
### Family compatibility
Logical addressing depends on the controller implementing CIP Symbol Object
class 0x6B with stable instance IDs. Older AB families don't:
| Family | Logical addressing supported? | Why |
|---|---|---|
| `ControlLogix` | yes | Native class 0x6B support, FW10+ |
| `CompactLogix` | yes | Same wire protocol as ControlLogix |
| `GuardLogix` | yes | Same wire protocol; safety partition is tag-level, not addressing-level |
| `Micro800` | **no** | Firmware does not implement class 0x6B; instance-ID reads trip CIP "Path Segment Error" 0x04 |
| `SLC500` / `PLC5` | **no** | Pre-CIP families; PCCC bridging only — no Symbol Object at all |
When `AddressingMode = Logical` is set on an unsupported family, the driver
**falls back to Symbolic with a warning** (via `OnWarning`) instead of
faulting. This keeps mixed-firmware deployments working — operators can ship
a uniform "Logical" config across the fleet and let the driver downgrade
the families that can't honour it.
The driver-level decision is exposed via
`PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing` and resolved at
`AbCipDriver.InitializeAsync` time; the resolved mode is stored on
`DeviceState.AddressingMode` and threaded through every
`AbCipTagCreateParams` from then on.
### One-time symbol-table walk
The first read on a Logical-mode device triggers a one-time `@tags` walk via
`LibplctagTagEnumerator` (the same component used for opt-in controller
browse). The driver caches the resulting name → instance-id map on
`DeviceState.LogicalInstanceMap`; subsequent reads consult the cache without
issuing another walk. The walk is gated by a per-device `SemaphoreSlim` so
parallel first-reads serialise on a single dispatch.
The walk happens in `AbCipDriver.EnsureLogicalMappingsAsync` and runs only
for devices that have actually resolved to `Logical`. Symbolic-mode devices
skip the walk entirely. Walk failures are non-fatal: the
`LogicalWalkComplete` flag still flips to `true` so the driver does not
re-attempt indefinitely, and per-tag handles fall back to Symbolic addressing
on the wire (libplctag's default).
A controller program download invalidates the instance IDs. There is no
auto-invalidation today — operators trigger a fresh walk by either
restarting the driver or calling `RebrowseAsync` (the same surface that
clears the UDT template cache) with logic-mode plumbing extended in a
future PR. For now, restart-on-download is the recommended workflow.
### libplctag wrapper limitation
The libplctag .NET wrapper (1.5.x) does **not** expose a public knob for
instance-ID addressing. The driver translates Logical-mode params into
libplctag attributes via reflection on
`NativeTagWrapper.SetAttributeString("use_connected_msg", "1")` +
`SetAttributeString("cip_addr", "0x6B,N")` — same best-effort fallback
pattern as the Connection Size knob.
This means **Logical mode is observable end-to-end through the public
driver surface and unit tests today**, but the actual wire behaviour
remains Symbolic until either:
- the upstream libplctag .NET wrapper exposes the
`UseConnectedMessaging` + `CipAddr` properties on `Tag` directly
(planned in the upstream backlog), in which case the reflection no-ops
cleanly, or
- libplctag native gains post-create hot-update for `cip_addr`, in which
case the call lands as intended.
The driver-level bookkeeping (resolved mode, instance-id map, family
compatibility, fall-back warning) is fully wired so the upgrade path is
purely a wrapper-version bump.
### Performance trade-off
| Symbolic addressing | Logical addressing |
|---|---|
| Works everywhere | Requires Symbol Object class 0x6B |
| ASCII parse on every read (controller-side cost) | One-time walk; instance-id lookup thereafter |
| No first-read latency | First read on a device pays the `@tags` walk |
| Smaller code surface | Stale on program download — restart driver to re-walk |
| Best for small / sparse tag sets | Best for >500-tag scans with stable controller |
For scan lists in the **single-digit-tag** range, the per-poll ASCII parse
cost is invisible. For **medium** scan lists (~100 tags) the gain is real
but small — typically 510% per CIP RTT depending on tag-name length. The
break-even point is where the ASCII-parse overhead starts dominating,
roughly **>500 tags** in a tight scan loop, which is also where libplctag's
own request-packing benefits compound. Large MES / batch projects with
many UDT instances are the canonical case.
### Driver config JSON
Bind the toggle through the driver-config JSON:
```json
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"AddressingMode": "Logical"
}
]
}
```
`"Auto"`, `"Symbolic"`, and `"Logical"` parse case-insensitively. Omitting
the field defaults to `"Auto"`.
### See also
- [`AbCipDriverOptions.AddressingMode`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
enum definition + per-value docstrings.
- [`AbCipPlcFamilyProfile.SupportsLogicalAddressing`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
family compatibility table source-of-truth.
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §
"What it actually covers" — Logical-mode fixture coverage status.
- [`AbCipAddressingModeBenchTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs) —
scaffold for the wall-clock comparison; gated on `[AbServerFact]`.
## Read strategy (PR abcip-3.3)
A per-device toggle that controls how multi-member UDT batches are read.
The default `Auto` value matches every previous build's behaviour for dense
reads but switches to per-member bundling when only a handful of members of
a large UDT are subscribed — the canonical "5 of 50" sparse-subscription
case where reading the whole UDT buffer just to extract a few fields wastes
wire bandwidth.
### Three modes
| Mode | When to use |
|---|---|
| `WholeUdt` | Most members of every subscribed UDT are read together. One libplctag read per parent UDT, members decoded in-memory at their byte offsets. The task #194 default. |
| `MultiPacket` | A few members of a large UDT are subscribed at a time. One read per subscribed member, bundled per parent into one CIP Multi-Service Packet. |
| `Auto` (default) | Planner picks per-batch from the subscribed-member fraction (see *Sparsity threshold*). |
### Sparsity threshold
Auto mode divides `subscribedMembers / totalMembers` for each parent UDT and
picks `MultiPacket` when the fraction is **strictly less than** the
threshold, else `WholeUdt`. Default threshold `0.25` — a 1/4 subscription is
the rough break-even where the wire-cost of one whole-UDT read still beats
N member reads on a ControlLogix 4002-byte connection-size buffer; above
1/4, the per-member overhead dominates.
Tune via `AbCipDeviceOptions.MultiPacketSparsityThreshold` (clamped to
`[0..1]`). Threshold `0.0` = "never MultiPacket"; `1.0` = "always MultiPacket
when any member is subscribed."
### Family compatibility
`MultiPacket` requires CIP service `0x0A` (Multi-Service Packet) on the
controller. Source of truth is
[`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs):
| Family | `SupportsRequestPacking` |
|---|---|
| ControlLogix | yes |
| CompactLogix | yes |
| GuardLogix | yes (wire identical to ControlLogix) |
| Micro800 | **no** |
| SLC500 / PLC5 (when those profiles ship) | **no** |
User-forced `MultiPacket` against a non-packing family logs a warning at
device init and falls back to `WholeUdt`. `Auto` against a non-packing
family stays `Auto` at the device level — the per-batch heuristic caps the
strategy to `WholeUdt` so the wire never sees a Multi-Service-Packet against
a controller that can't decode it.
### libplctag wrapper limitation
The libplctag .NET wrapper (1.5.x) does not expose the `0x0A` service as a
public knob — same wrapper-version constraint that gates PR abcip-3.1's
`connection_size` and PR abcip-3.2's instance-ID addressing. Today's
MultiPacket runtime therefore issues N libplctag reads sequentially when
the planner picks the strategy; the wire-level bundling lands cleanly when
an upstream wrapper release exposes the primitive.
The driver-level bookkeeping (resolved strategy, per-batch heuristic,
family-compat fall-back, per-device dispatch counters) is fully wired so
the upgrade path is a wrapper-version bump only — the planner already
produces the right plan, and `AbCipMultiPacketReadPlanner.Build` is
covered by unit tests that pin the plan shape rather than wire bytes.
### Driver config JSON
```json
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"ReadStrategy": "Auto",
"MultiPacketSparsityThreshold": 0.25
}
]
}
```
`"Auto"`, `"WholeUdt"`, and `"MultiPacket"` parse case-insensitively.
Omitting the field defaults to `"Auto"`. Omitting
`MultiPacketSparsityThreshold` defaults to `0.25`.
### See also
- [`AbCipDriverOptions.ReadStrategy`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
enum definition + per-value docstrings.
- [`AbCipMultiPacketReadPlanner`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs) —
plan shape + Auto-mode heuristic.
- [`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
family compatibility table source-of-truth.
- [`AbCipReadStrategyTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadStrategyTests.cs) —
device-init resolution, heuristic edges, dispatch counters, DTO round-trip.
- [`AbCipEmulateMultiPacketReadTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs) —
golden-box-tier wire-level coverage scaffold; gated on `AB_SERVER_PROFILE=emulate`.

View File

@@ -36,6 +36,12 @@ supplies a `FakeAbLegacyTag`.
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5 - `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
/ LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.) / LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
- `AbLegacyArrayTests` — PR 7 array contiguous-block addressing: parser
positives + rejects for `,N` / `[N]` suffixes, options-override
(`ArrayLength`), driver `IsArray` discovery, and array decoding for N / F /
L / B files (Rockwell convention: one BOOL per word for `B3:0,10`). Latency
benchmark against the Docker fixture is a perf-flagged integration case in
`AbLegacyArrayReadTests` — runs only when ab_server is reachable.
- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement - `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake - `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via - `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
@@ -43,6 +49,12 @@ supplies a `FakeAbLegacyTag`.
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by - `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
fake-returned statuses fake-returned statuses
- `AbLegacyDriverTests``IDriver` lifecycle - `AbLegacyDriverTests``IDriver` lifecycle
- `AbLegacyDeadbandTests` — PR 8 per-tag deadband / change filter:
absolute-only suppression sequence `[10.0, 10.5, 11.5, 11.6] -> [10.0, 11.5]`,
percent-only suppression with a zero-prev short-circuit, both-set logical-OR
semantics (Kepware), Boolean edge-only publish, string change-only publish,
status-change always-publish, first-seen always-publish, ReinitializeAsync
cache wipe, JSON DTO round-trip.
Capability surfaces whose contract is verified: `IDriver`, `IReadable`, Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,

View File

@@ -38,6 +38,16 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
- `--plc controllogix` and `--plc compactlogix` mode dispatch. - `--plc controllogix` and `--plc compactlogix` mode dispatch.
- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh - The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
clone without the simulator stays green. clone without the simulator stays green.
- **Symbolic vs Logical addressing wall-clock** (PR abcip-3.2,
`AbCipAddressingModeBenchTests`) — both modes complete + emit timing.
**Emulate-tier only**: `ab_server` does not currently honour the CIP Symbol
Object class 0x6B `cip_addr` attribute that Logical mode sets, so on the
fixture the two modes measure the same wire path. The bench scaffold
asserts both complete + records timing for human inspection; the actual
Symbolic-vs-Logical perf comparison requires a real ControlLogix /
CompactLogix on the network. See
[`docs/drivers/AbCip-Performance.md`](AbCip-Performance.md) §"Addressing
mode" for the full caveat.
## What it does NOT cover ## What it does NOT cover
@@ -60,6 +70,19 @@ Unit coverage: `AbCipFetchUdtShapeTests`, `CipTemplateObjectDecoderTests`,
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers `AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
+ offset-keyed `FakeAbCipTag` values. + offset-keyed `FakeAbCipTag` values.
PR abcip-3.3 layers a per-device **`ReadStrategy`** selector on top
(`WholeUdt` / `MultiPacket` / `Auto`, see
[`AbCip-Performance.md`](AbCip-Performance.md) §"Read strategy"). Strategy
switching is planner-side: the dispatcher picks between
`AbCipUdtReadPlanner` (whole-UDT) and `AbCipMultiPacketReadPlanner`
(per-member, bundled per parent) per batch. The selector + per-batch Auto
heuristic + family-compat fall-back + per-device dispatch counters are
**unit-tested only** in `AbCipReadStrategyTests``ab_server` cannot host
a 50-member UDT to exercise the sparse case the strategy is designed for,
and the libplctag .NET wrapper (1.5.x) does not expose explicit
Multi-Service-Packet bundling, so wire-level coverage stays Emulate-tier
in `AbCipEmulateMultiPacketReadTests` (gated on `AB_SERVER_PROFILE=emulate`).
### 2. ALMD / ALMA alarm projection (#177) ### 2. ALMD / ALMA alarm projection (#177)
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
@@ -96,6 +119,15 @@ value per PR 10, but `ab_server` accepts whatever the client asks for — the
cap's correctness is trusted from its unit test, never stressed against a cap's correctness is trusted from its unit test, never stressed against a
simulator that rejects oversized requests. simulator that rejects oversized requests.
PR abcip-3.1 layers the **per-device `ConnectionSize` override** on top
(`AbCipDeviceOptions.ConnectionSize`, range `[500..4002]`, see
[`AbCip-Performance.md`](AbCip-Performance.md)). Same gap — `ab_server`
happily honours an oversized override against the CompactLogix profile, so
the legacy-firmware warning + Forward Open rejection that real 5069-L1/L2/L3
parts emit are unit-tested only. Live coverage stays Emulate / rig-only
(connect against a real CompactLogix L2 with `ConnectionSize=1500` to
confirm the Forward Open fails with CIP error 0x01/0x113).
### 6. BOOL-within-DINT read-modify-write (#181) ### 6. BOOL-within-DINT read-modify-write (#181)
The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim` The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`

View File

@@ -106,6 +106,42 @@ Tier-C pipeline end-to-end without any CNC.
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) | | "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
| "Do macro variables round-trip across power cycles?" | no | yes (required) | | "Do macro variables round-trip across power cycles?" | no | yes (required) |
## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a
`FocasAlarmProjection` ships two modes:
- **`ActiveOnly`** (default) — surfaces only currently-active alarms.
No history poll. Same back-compat shape every prior FOCAS deployment used.
- **`ActivePlusHistory`** — additionally polls `cnc_rdalmhistry` on connect
+ on the configured cadence (`HistoryPollInterval`, default 5 min). Each
unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from
the CNC's reported timestamp, not Now.
Unit-test coverage in `FocasAlarmProjectionTests`:
- mode `ActiveOnly` — no `ReadAlarmHistoryAsync` call ever issued
- mode `ActivePlusHistory` — first poll fires on subscribe (== "on connect")
- dedup — same `(OccurrenceTime, AlarmNumber, AlarmType)` triple across two
polls only emits once
- distinct entries with different timestamps each emit separately
- same alarm number / different type still emits both (type is part of the
dedup key)
- `OccurrenceTime` is the wire timestamp (round-trips a year-old stamp
without bleeding into Now)
- `HistoryDepth` clamp — user-supplied 500 collapses to 250 on the wire;
zero / negative falls back to the 100 default
- `FocasAlarmHistoryDecoder` — round-trips through `Encode` / `Decode` and
pins the simulator command id at `0x0F1A`
Future integration coverage (not yet shipped — no FOCAS integration test
project exists):
- a focas-mock with a per-profile ring buffer and `mock_patch_alarmhistory`
admin endpoint will let `cnc_rdalmhistry` round-trip end-to-end through
the wire protocol
- `FocasSimFixture.SeedAlarmHistoryAsync` will let series tests prime canned
history without per-test JSON
## Follow-up candidates ## Follow-up candidates
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL 1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL

55
docs/drivers/FOCAS.md Normal file
View File

@@ -0,0 +1,55 @@
# FOCAS driver
Fanuc CNC driver for the FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / 35i /
Power Mate i families. Talks to the controller via the licensed
`Fwlib32.dll` (Tier C, process-isolated per
[`docs/v2/driver-stability.md`](../v2/driver-stability.md)).
For range-validation and per-series capability surface see
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md).
## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a
`FocasAlarmProjection` exposes two modes via `FocasDriverOptions.AlarmProjection`:
| Mode | Behaviour |
| --- | --- |
| `ActiveOnly` *(default)* | Subscribe / unsubscribe / acknowledge wire up so capability negotiation works, but no history poll runs. Back-compat with every pre-F3-a deployment. |
| `ActivePlusHistory` | On subscribe (== "on connect") and on every `HistoryPollInterval` tick, the projection issues `cnc_rdalmhistry` for the most recent `HistoryDepth` entries. Each previously-unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's reported timestamp — OPC UA dashboards see the real occurrence time, not the moment the projection polled. |
### Config knobs
```jsonc
{
"AlarmProjection": {
"Mode": "ActivePlusHistory", // "ActiveOnly" (default) | "ActivePlusHistory"
"HistoryPollInterval": "00:05:00", // default 5 min
"HistoryDepth": 100 // default 100, capped at 250
}
}
```
### Dedup key
`(OccurrenceTime, AlarmNumber, AlarmType)`. The same triple across two
polls only emits once. The dedup set is in-memory and **resets on
reconnect** — first poll after reconnect re-emits everything in the ring
buffer. OPC UA clients that need exactly-once semantics dedupe client-side
on the same triple (the timestamp + type + number tuple is stable across
the boundary).
### `HistoryDepth` cap
Capped at `FocasAlarmProjectionOptions.MaxHistoryDepth = 250` so an
operator who types `10000` by accident can't blast the wire session with a
giant request. Typical FANUC ring buffers cap at ~100 entries; the default
`HistoryDepth = 100` matches the most common ring-buffer size.
### Wire surface
- Wire-protocol command id: `0x0F1A` (see
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)).
- ODBALMHIS struct decoder: `Wire/FocasAlarmHistoryDecoder.cs`.
- Tier-C Fwlib32 backend short-circuits the packed-buffer decoder by
surfacing the FWLIB struct fields directly into
`FocasAlarmHistoryEntry`.

View File

@@ -125,6 +125,69 @@ back an `IAlarmSource`, but shipping that is a separate feature.
| "Do notifications coalesce under load?" | no | yes (required) | | "Do notifications coalesce under load?" | no | yes (required) |
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) | | "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
## Performance
PR 2.1 (Sum-read / Sum-write, IndexGroup `0xF080..0xF084`) replaced the per-tag
`ReadValueAsync` loop in `TwinCATDriver.ReadAsync` / `WriteAsync` with a
bucketed bulk dispatch — N tags addressed against the same device flow through a
single ADS sum-command round-trip via `SumInstancePathAnyTypeRead` (read) and
`SumWriteBySymbolPath` (write). Whole-array tags + bit-extracted BOOL tags
remain on the per-tag fallback path because the sum surface only marshals
scalars and bit-RMW writes need the per-parent serialisation lock.
**Baseline → Sum-command delta** (dev box, 1000 × DINT, XAR VM over LAN):
| Path | Round-trips | Wall-clock |
| --- | --- | --- |
| Per-tag loop (pre-PR 2.1) | 1000 | ~58 s |
| Sum-command bulk (PR 2.1) | 1 | ~250600 ms |
| Ratio | — | ≥ 10× typical, ≥ 5× CI floor |
The perf-tier test
`TwinCATSumCommandPerfTests.Driver_sum_read_1000_tags_beats_loop_baseline_by_5x`
asserts the ratio with a conservative 5× lower bound that survives noisy CI /
VM scheduling. It is gated behind both `TWINCAT_TARGET_NETID` (XAR reachable)
and `TWINCAT_PERF=1` (operator opt-in) — perf runs aren't part of the default
integration pass because they hit the wire heavily.
The required fixture state (1000-DINT GVL + churn POU) is documented in
`TwinCatProject/README.md §Performance scenarios`; XAE-form sources land at
`TwinCatProject/PLC/GVLs/GVL_Perf.TcGVL` + `TwinCatProject/PLC/POUs/FB_PerfChurn.TcPOU`.
### Handle caching (PR 2.2)
Per-tag reads / writes route through an in-process ADS variable-handle cache.
The first read of a symbol resolves a handle via `CreateVariableHandleAsync`;
subsequent reads / writes of the same symbol issue against the cached handle.
On the wire this trades a multi-byte symbolic path (`GVL_Perf.aTags[742]` =
20+ bytes) for a 4-byte handle, and the device server skips name resolution
on every subsequent op. Cache lifetime is process-scoped; entries are evicted
on `AdsErrorCode.DeviceSymbolVersionInvalid` (with one retry against a fresh
handle), wiped on reconnect (handles are per-AMS-session), and deleted
best-effort on driver disposal.
`TwinCATHandleCachePerfTests.Driver_handle_cache_avoids_repeat_symbol_resolution`
asserts the contract on real XAR by reading 50 symbols twice and verifying
the second pass issues zero new `CreateVariableHandleAsync` calls. It runs
under the standard `[TwinCATFact]` gate (XAR reachable; no `TWINCAT_PERF`
opt-in needed because 50 symbols is cheap).
**Self-invalidation (PR 2.3)**: handle cache is now self-invalidating on
TwinCAT online changes. `AdsTwinCATClient` registers an
`AdsSymbolVersionChanged` event listener (Beckhoff's high-level wrapper
around the SymbolVersion ADS notification, IndexGroup `0xF008`) on connect;
when the PLC's symbol-version counter increments — full re-init after a
download / activate-config — the listener fires and wipes the handle cache
proactively. Three-layered defence in depth: (1) proactive listener
preempts the next read entirely on full re-inits, (2) the
`DeviceSymbolVersionInvalid` evict-and-retry path from PR 2.2 catches the
narrower "symbol survives but its descriptor moved" race, and (3)
operators can still call `ITwinCATClient.FlushOptionalCachesAsync` manually
for the truly-paranoid case. The bulk Sum-read / Sum-write path remains
on symbolic paths in PR 2.2 (the bulk path's per-call symbol resolution
is already amortised across N tags; the perf delta vs. handle-batched
bulk is marginal — tracked as a follow-up for the Phase-2 perf sweep).
## Follow-up candidates ## Follow-up candidates
1. **XAR VM live-population** — scaffolding is in place (this PR); the 1. **XAR VM live-population** — scaffolding is in place (this PR); the

View File

@@ -0,0 +1,45 @@
# FOCAS deployment guide
Per-driver runbook for deploying the FANUC FOCAS driver. See
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) for the per-feature
reference and [`focas-version-matrix.md`](./focas-version-matrix.md) for
the per-CNC-series capability surface.
## Operator config-knob cheat sheet
| Knob | Where | Default | Notes |
| --- | --- | --- | --- |
| `Devices[].HostAddress` | `FocasDriverOptions.Devices` | — | `focas://{ip}[:{port}]` |
| `Devices[].Series` | `FocasDriverOptions.Devices` | `Unknown` | Drives per-series range validation in `FocasCapabilityMatrix`. |
| `Devices[].OverrideParameters` | `FocasDriverOptions.Devices` | `null` | MTB-specific parameter numbers for Feed/Rapid/Spindle/Jog overrides. `null` suppresses the `Override/` subtree. |
| `Probe.Enabled` | `FocasDriverOptions.Probe` | `true` | Background reachability probe. |
| `Probe.Interval` | `FocasDriverOptions.Probe` | `00:00:05` | Probe cadence. |
| `FixedTree.ApplyFigureScaling` | `FocasDriverOptions.FixedTree` | `true` | Divide position values by 10^decimal-places (issue #262). |
| **`AlarmProjection.Mode`** | **`FocasDriverOptions.AlarmProjection`** | **`ActiveOnly`** | **`ActiveOnly` keeps today's behaviour. `ActivePlusHistory` polls `cnc_rdalmhistry` on connect + on `HistoryPollInterval` ticks (issue #267, plan PR F3-a).** |
| **`AlarmProjection.HistoryPollInterval`** | **`FocasDriverOptions.AlarmProjection`** | **`00:05:00`** | **Cadence of the history poll. Operator dashboards run the default; high-frequency rigs can drop to 30 s.** |
| **`AlarmProjection.HistoryDepth`** | **`FocasDriverOptions.AlarmProjection`** | **`100`** | **Most-recent-N ring-buffer entries pulled per poll. Hard-capped at `250` so misconfigured values can't blast the wire session.** |
## Sample `appsettings.json` snippet for `ActivePlusHistory`
```jsonc
{
"Drivers": {
"FOCAS": {
"Devices": [
{ "HostAddress": "focas://10.0.0.5:8193", "Series": "Series30i" }
],
"AlarmProjection": {
"Mode": "ActivePlusHistory",
"HistoryPollInterval": "00:05:00",
"HistoryDepth": 100
}
}
}
}
```
The history projection emits each unseen entry through
`IAlarmSource.OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's
reported wall-clock — keep CNC clocks on UTC so the dedup key
`(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across DST
transitions.

View File

@@ -0,0 +1,102 @@
# FOCAS simulator (focas-mock) plan
Notes on the focas-mock simulator that the FOCAS driver's integration
tests will eventually talk to. Today there is no FOCAS integration-test
project; this doc is the contract the future fixture will be built
against. Keeping the contract tracked in repo means the wire-protocol
command ids (and their request/response payloads) don't drift between the
.NET wire client and a future Python implementation.
## Ground rules
- Append-only command ids. Mirror
[`focas-wire-protocol.md`](./focas-wire-protocol.md) verbatim.
- Per-profile state. The simulator hosts N CNC profiles concurrently
(`Series0i`, `Series30i`, `PowerMotion`, ...). Each profile has its own
alarm-history ring buffer + its own override map.
- Admin endpoints under `POST /admin/...` mutate state without going
through the wire protocol; integration tests use these to seed canned
inputs.
## Protocol surface (current scope)
| Cmd | API | State impact |
| --- | --- | --- |
| `0x0001` | `cnc_rdcncstat` | reads cached ODBST per profile |
| `0x0002` | `cnc_rdparam` | reads parameter map per profile |
| `0x0003` | `cnc_rdmacro` | reads macro variables per profile |
| `0x0004` | `cnc_rddiag` | reads diagnostic map per profile |
| `0x0010` | `pmc_rdpmcrng` | reads PMC byte ranges |
| `0x0020` | `cnc_modal` | reads cached modal MSTB per profile |
| ... | ... | ... |
| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
## `cnc_rdalmhistry` mock behaviour
The simulator keeps a per-profile ring buffer of alarm-history entries.
Default fixture seeds 5 profiles with 10 canned entries each (per the F3-a
plan).
### Request decode
```
[int16 LE depth]
```
### Response encode
Use `FocasAlarmHistoryDecoder.Encode` semantics in reverse: emit the
count followed by `ALMHIS_data` blocks padded to 4-byte boundaries. The
.NET-side decoder consumes the same format verbatim, so a Python encoder
written against the table in
[`focas-wire-protocol.md`](./focas-wire-protocol.md) interoperates without
extra glue.
### Admin endpoint — `POST /admin/mock_patch_alarmhistory`
Replaces the alarm-history ring buffer for a profile.
```
POST /admin/mock_patch_alarmhistory
{
"profile": "Series30i",
"entries": [
{
"occurrenceTime": "2025-04-01T09:30:00Z",
"axisNo": 1,
"alarmType": 2,
"alarmNumber": 100,
"message": "Spindle overload"
},
...
]
}
```
`entries` order is interpreted as ring-buffer order (most-recent first to
match FANUC's natural surface).
### `FocasSimFixture.SeedAlarmHistoryAsync`
The future test-support helper wraps the admin endpoint:
```csharp
await fixture.SeedAlarmHistoryAsync(
profile: "Series30i",
entries: new []
{
new FocasAlarmHistoryEntry(
new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero),
AxisNo: 1, AlarmType: 2, AlarmNumber: 100, Message: "Spindle overload"),
});
```
Integration test `Series/AlarmHistoryProjectionTests.cs` will assert:
- historic events fire once with the seeded timestamps
- second poll yields zero new events (dedup honoured end-to-end)
- active-alarm raise/clear still works alongside the history poll
These tests are blocked on the focas-mock + integration-test project
landing; the unit-test coverage in `FocasAlarmProjectionTests` already
exercises every same-process invariant.

View File

@@ -0,0 +1,76 @@
# FOCAS wire protocol — packed-buffer surface
Notes on the language-neutral packed-buffer encoding the FOCAS driver +
focas-mock simulator share. This format is **not** the FWLIB native struct
layout — Tier-C Fwlib32 backends marshal directly from the FANUC C struct.
The packed surface exists so the simulator (Python / FastAPI) and the .NET
wire client can speak a common format over IPC without piping a Win32 DLL
through both ends.
## Command id table
Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are
**append-only** — never renumber, never reuse.
| Id | FOCAS API | Surface |
| --- | --- | --- |
| `0x0001` | `cnc_rdcncstat` | ODBST 9-field status struct |
| `0x0002` | `cnc_rdparam` | parameter value (one number) |
| `0x0003` | `cnc_rdmacro` | macro variable value |
| `0x0004` | `cnc_rddiag` | diagnostic value |
| ... | ... | ... |
| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`)
Issued by `FocasAlarmProjection` when
`FocasDriverOptions.AlarmProjection.Mode == ActivePlusHistory`. Returns up
to `depth` most-recent ring-buffer entries.
### Request
| Offset | Width | Field | Notes |
| --- | --- | --- | --- |
| 0 | `int16 LE` | `depth` | clamped client-side to `[1..250]` (`FocasAlarmProjectionOptions.MaxHistoryDepth`) |
### Response (packed buffer, little-endian)
| Offset | Width | Field |
| --- | --- | --- |
| 0 | `int16 LE` | `num_alm` — number of entries that follow. `< 0` indicates CNC error. |
| 2 | repeated | `ALMHIS_data alm[num_alm]` (see below) |
Each entry block:
| Offset (rel.) | Width | Field |
| --- | --- | --- |
| 0 | `int16 LE` | `year` |
| 2 | `int16 LE` | `month` |
| 4 | `int16 LE` | `day` |
| 6 | `int16 LE` | `hour` |
| 8 | `int16 LE` | `minute` |
| 10 | `int16 LE` | `second` |
| 12 | `int16 LE` | `axis_no` (1-based; 0 = whole-CNC) |
| 14 | `int16 LE` | `alm_type` (P/S/OT/SV/SR/MC/SP/PW/IO encoded numerically) |
| 16 | `int16 LE` | `alm_no` |
| 18 | `int16 LE` | `msg_len` (0..32 typical) |
| 20 | `msg_len` | ASCII message (no null terminator) |
| `20 + msg_len` | 0..3 | pad to 4-byte boundary so per-entry blocks stay self-delimiting |
The CNC stamps `year..second` in **its own local time**. The deployment
guide instructs operators to keep CNC clocks on UTC so the projection's
dedup key `(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across
DST transitions. The .NET decoder
(`Wire/FocasAlarmHistoryDecoder.Decode`) constructs each
`DateTimeOffset` with `TimeSpan.Zero` (UTC) on that assumption.
### Error handling
- A negative `num_alm` short-circuits decode to an empty list — the
projection treats it as "no history this tick" and the next poll
retries.
- Malformed timestamps (e.g. month=0) are skipped per-entry instead of
faulting the whole decode; the dedup key for malformed entries would be
unstable anyway.
- `msg_len` overrunning the payload truncates the entry list at the
malformed entry rather than throwing.

View File

@@ -450,6 +450,104 @@ Test names:
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER` - **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
perspective. No known deltas [3]. perspective. No known deltas [3].
## Performance (native S7comm driver)
This section covers the native S7comm driver (`ZB.MOM.WW.OtOpcUa.Driver.S7`),
not the Modbus-on-S7 quirks above. Both share a CPU but use different ports,
different libraries, and different optimization levers.
### Block-read coalescing
The S7 driver runs a coalescing planner before every read pass: same-area /
same-DB tags are sorted by byte offset and merged into single
`Plc.ReadBytesAsync` requests when the gap between them is small. Reading
`DB1.DBW0`, `DB1.DBW2`, `DB1.DBW4` issues **one** 6-byte byte-range read
covering offsets 0..6, sliced client-side instead of three multi-var items
(let alone three individual `Plc.ReadAsync` round-trips). On a 50-tag
contiguous workload this reduces wire traffic from 50 single reads (or 3
multi-var batches at the 19-item PDU ceiling) to **1 byte-range PDU**.
#### Default 16-byte gap-merge threshold
The planner merges two adjacent ranges when the gap between them is at most
16 bytes. The default reflects the cost arithmetic on a 240-byte default
PDU: an S7 request frame is ~30 bytes and a per-item response header is
~12 bytes, so over-fetching 16 bytes (which decode-time discards) is
cheaper than paying for one extra PDU round-trip.
The math also holds for 480/960-byte PDUs but the relative cost flips —
on a 960-byte PDU you can fit a much larger request and the over-fetch
ceiling is less of a concern. Sites running the extended PDU on S7-1500
can safely raise the threshold (see operator guidance below).
#### Opaque-size opt-out for STRING / array / structured-timestamp tags
Variable-width and header-prefixed tag types **never** participate in
coalescing:
- **STRING / WSTRING** carry a 2-byte (or 4-byte) length header, and the
per-tag width depends on the configured `StringLength`.
- **CHAR / WCHAR** are routed through the dedicated `S7StringCodec` decode
path, which expects an exact byte slice, not an offset into a larger
buffer.
- **DTL / DT / S5TIME / TIME / TOD / DATE-as-DateTime** route through
`S7DateTimeCodec` for the same reason.
- **Arrays** (`ElementCount > 1`) carry a per-tag width of `N × elementBytes`
and would silently mis-decode if the slice landed mid-block.
Each opaque-size tag emits its own standalone `Plc.ReadBytesAsync` call.
A STRING in the middle of a contiguous run of DBWs will split the
neighbour reads into "before STRING" and "after STRING" merged ranges
without straddling the STRING's bytes — verified by the
`S7BlockCoalescingPlannerTests` unit suite.
#### Operator tuning: `BlockCoalescingGapBytes`
Surface knob in the driver options:
```jsonc
{
"Host": "10.0.0.50",
"Port": 102,
"CpuType": "S71500",
"BlockCoalescingGapBytes": 16, // default
// ...
}
```
Tuning guidance:
- **Raise the threshold (32-64 bytes)** when the PLC has chatty firmware
(S7-1200 with default 240-byte PDU and many DBs scattered every few
bytes). One fewer PDU round-trip beats over-fetching a kilobyte.
- **Lower the threshold (4-8 bytes)** when DBs are sparsely populated
with hot tags far apart — over-fetching dead bytes wastes the PDU
envelope and the saved round-trip never materialises.
- **Set to 0** to disable gap merging entirely (only literally adjacent
ranges with `gap == 0` coalesce). Useful as a debugging knob: if a
driver is misreading values you can flip the threshold to 0 to confirm
the slice math isn't the culprit.
- **Per-DB tuning isn't supported yet** — the knob is global per driver
instance. If a site needs different policies for two DBs they live in
different drivers (different `Host:Port` rows in the config DB).
#### Diagnostics counters
The driver surfaces three coalescing counters via `DriverHealth.Diagnostics`
under the standard `<DriverType>.<Counter>` naming convention:
- `S7.TotalBlockReads` — number of `Plc.ReadBytesAsync` calls issued by
the coalesced path. A fully-coalesced contiguous workload bumps this
by 1 per `ReadAsync`.
- `S7.TotalMultiVarBatches` — `Plc.ReadMultipleVarsAsync` batches issued
for residual singletons that didn't merge. With perfect coalescing this
stays at 0.
- `S7.TotalSingleReads` — per-tag fallbacks (strings, dates, arrays,
64-bit ints, anything that bypasses both the coalescer and the packer).
Observe via the `driver-diagnostics` RPC (`/api/v2/drivers/{id}/diagnostics`)
or the Admin UI's per-driver dashboard.
## References ## References
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf 1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf

View File

@@ -94,5 +94,30 @@ $results += Test-SubscribeSeesChange `
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $subValue)) ` -DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $subValue)) `
-ExpectedValue "$subValue" -ExpectedValue "$subValue"
# PR abcip-3.2 — Symbolic-vs-Logical sanity assertion. Reads the same tag with both
# addressing modes through the CLI's --addressing-mode flag. Logical-mode against ab_server
# falls back to Symbolic on the wire (libplctag wrapper limitation; see AbCip-Performance.md
# §Addressing mode), so the assertion is "both modes complete + return the same value" — not
# a perf comparison. Skipped on Micro800 (driver downgrades Logical → Symbolic with warning,
# making both reads identical-by-design + uninteresting to compare here).
if ($Family -ne "Micro800") {
$symValue = Get-Random -Minimum 40000 -Maximum 49999
Write-Host "AB CIP e2e: priming gateway with $symValue then reading via Symbolic + Logical"
$writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $symValue)
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
$symRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Symbolic"))
$logRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Logical"))
$symMatched = ($symRead -join "`n") -match "$symValue"
$logMatched = ($logRead -join "`n") -match "$symValue"
$passed = $symMatched -and $logMatched
$results += [PSCustomObject]@{
Name = "AddressingModeSanity"
Passed = $passed
Detail = if ($passed) { "Symbolic + Logical both returned $symValue" } else { "Sym=$symMatched Log=$logMatched" }
}
}
Write-Summary -Title "AB CIP e2e" -Results $results Write-Summary -Title "AB CIP e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 } if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -95,5 +95,60 @@ $results += Test-SubscribeSeesChange `
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) ` -DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) `
-ExpectedValue "$subValue" -ExpectedValue "$subValue"
# PR 7 — contiguous array read smoke. The default `--tag=N7[120]` in the Docker
# fixture's docker-compose.yml has plenty of room for `,10`; against real hardware
# the seeded N7 file just needs at least 10 words. Asserts the CLI exits 0 (the
# driver issued one PCCC frame for the whole block) — the per-element values are
# whatever the device currently holds.
Write-Header "Array contiguous read"
$arrayResult = Invoke-Cli -Cli $abLegacyCli `
-Args (@("read") + $commonAbLegacy + @("-a", "N7:0,10", "-t", "Int"))
if ($arrayResult.ExitCode -eq 0) {
Write-Pass "array read N7:0,10 succeeded"
$results += @{ Passed = $true }
} else {
Write-Fail "array read N7:0,10 exit=$($arrayResult.ExitCode)"
Write-Host $arrayResult.Output
$results += @{ Passed = $false; Reason = "array read exit $($arrayResult.ExitCode)" }
}
# PR 8 — deadband subscribe assertion. Subscribe with --deadband-absolute 5,
# write three small deltas (each within the 5-unit deadband), assert exactly
# one notification fires (the first-seen sample). The fourth write breaks
# above the threshold and the subscription should fire again.
Write-Header "Deadband subscribe (--deadband-absolute 5)"
$baseValue = Get-Random -Minimum 100 -Maximum 200
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $baseValue) | Out-Null
$subscribeProc = Start-Process -FilePath $abLegacyCli.File `
-ArgumentList ($abLegacyCli.PrefixArgs + @("subscribe") + $commonAbLegacy `
+ @("-a", $Address, "-t", "Int", "-i", "200", "--deadband-absolute", "5")) `
-PassThru -RedirectStandardOutput "$env:TEMP/ablegacy-deadband.out" `
-RedirectStandardError "$env:TEMP/ablegacy-deadband.err"
Start-Sleep -Seconds 2
# Three small deltas within deadband.
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 1)) | Out-Null
Start-Sleep -Milliseconds 500
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 2)) | Out-Null
Start-Sleep -Milliseconds 500
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 3)) | Out-Null
Start-Sleep -Milliseconds 500
Stop-Process -Id $subscribeProc.Id -Force -ErrorAction SilentlyContinue
$subscribeOutput = Get-Content "$env:TEMP/ablegacy-deadband.out" -ErrorAction SilentlyContinue
# Count `=` lines (the SubscribeCommand format prints one per OnDataChange). Expect exactly 1
# (the first-seen sample at $baseValue) — none of the +1/+2/+3 deltas crosses the 5 absolute.
$notifyLines = @($subscribeOutput | Where-Object { $_ -match " = " })
if ($notifyLines.Count -eq 1) {
Write-Pass "deadband subscribe emitted 1 notification (initial only); 3 sub-threshold writes suppressed"
$results += @{ Passed = $true }
} else {
Write-Fail "deadband subscribe expected 1 notification; got $($notifyLines.Count)"
Write-Host ($subscribeOutput -join "`n")
$results += @{ Passed = $false; Reason = "deadband notify count $($notifyLines.Count)" }
}
Write-Summary -Title "AB Legacy e2e" -Results $results Write-Summary -Title "AB Legacy e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 } if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -80,6 +80,11 @@ VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'ab-sim', 'abcip-001', 1);
-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture -- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture
-- gateway. DriverConfig shape mirrors AbCipDriverConfigDto. -- gateway. DriverConfig shape mirrors AbCipDriverConfigDto.
--
-- The second device entry (CompactLogix L2 example, commented out) demonstrates
-- the PR abcip-3.1 ConnectionSize override knob. Uncomment + point at a real
-- 5069-L2 to verify the narrow-buffer Forward Open path; ab_server itself
-- doesn't enforce the narrow cap (see docs/drivers/AbServer-Test-Fixture.md §5).
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId, INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled) Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
@@ -90,6 +95,14 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
"PlcFamily": "ControlLogix", "PlcFamily": "ControlLogix",
"DeviceName": "ab-server" "DeviceName": "ab-server"
} }
/*
, {
"HostAddress": "ab://10.0.0.7/1,0",
"PlcFamily": "CompactLogix",
"DeviceName": "compactlogix-l2-narrow",
"ConnectionSize": 504
}
*/
], ],
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 }, "Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
"Tags": [ "Tags": [

View File

@@ -31,10 +31,11 @@ DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line';
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq'; DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203'; DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5'; DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
DECLARE @ArrTagId nvarchar(64) = 'ablegacy-smoke-tag-n7_block';
BEGIN TRAN; BEGIN TRAN;
DELETE FROM dbo.Tag WHERE TagId IN (@TagId); DELETE FROM dbo.Tag WHERE TagId IN (@TagId, @ArrTagId);
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId; DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId; DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId; DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
@@ -98,7 +99,16 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
"Address": "N7:5", "Address": "N7:5",
"DataType": "Int", "DataType": "Int",
"Writable": true, "Writable": true,
"WriteIdempotent": true "WriteIdempotent": true,
"AbsoluteDeadband": 5
},
{
"Name": "N7_Block",
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
"Address": "N7:0,10",
"DataType": "Int",
"Writable": false,
"ArrayLength": 10
} }
] ]
}', 1); }', 1);
@@ -108,6 +118,17 @@ INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataTyp
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite', VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1); N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1);
-- PR 7 — array contiguous-block tag. The TagConfig JSON carries the address suffix
-- + ArrayLength override; the driver picks both up at discovery time and emits the
-- DriverAttributeInfo with IsArray=true + ArrayDim=10 so the generic node manager
-- materialises a 1-D Int16 array variable. The dbo.Tag schema doesn't carry
-- IsArray/ArrayDim columns — the array shape is fully driver-side metadata.
-- Read-only because the smoke harness only exercises array reads.
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @ArrTagId, @DrvId, @EqId, 'N7_Block', 'Int16', 'Read',
N'{"FullName":"N7_Block","Address":"N7:0,10","DataType":"Int","ArrayLength":10}', 0);
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen, EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'AB Legacy smoke — task #213'; @Notes = N'AB Legacy smoke — task #213';

View File

@@ -45,6 +45,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> — /// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise. /// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
/// </param> /// </param>
/// <param name="Description">
/// Human-readable description for this attribute. When non-null + non-empty the generic
/// node-manager surfaces the value as the OPC UA <c>Description</c> attribute on the
/// Variable node so SCADA / engineering clients see the field comment from the source
/// project (Studio 5000 tag descriptions, Galaxy attribute help text, etc.). Defaults to
/// null so drivers that don't carry descriptions are unaffected.
/// </param>
public sealed record DriverAttributeInfo( public sealed record DriverAttributeInfo(
string FullName, string FullName,
DriverDataType DriverDataType, DriverDataType DriverDataType,
@@ -56,7 +63,8 @@ public sealed record DriverAttributeInfo(
bool WriteIdempotent = false, bool WriteIdempotent = false,
NodeSourceKind Source = NodeSourceKind.Driver, NodeSourceKind Source = NodeSourceKind.Driver,
string? VirtualTagId = null, string? VirtualTagId = null,
string? ScriptedAlarmId = null); string? ScriptedAlarmId = null,
string? Description = null);
/// <summary> /// <summary>
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/ /// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/

View File

@@ -25,7 +25,7 @@ public enum DriverCapability
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary> /// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
Discover, Discover,
/// <summary><see cref="ISubscribable.SubscribeAsync"/> and unsubscribe. Retries by default.</summary> /// <summary><see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> and unsubscribe. Retries by default.</summary>
Subscribe, Subscribe,
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary> /// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>

View File

@@ -25,4 +25,11 @@ public enum DriverDataType
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary> /// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
Reference, Reference,
/// <summary>
/// OPC UA <c>Duration</c> — a Double-encoded period in milliseconds. Subtype of Double
/// in the address space; surfaced as <see cref="System.TimeSpan"/> in the driver layer.
/// Used by IEC 61131-3 <c>TIME</c> / <c>TOD</c> attributes (TwinCAT et al.).
/// </summary>
Duration,
} }

View File

@@ -7,10 +7,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <param name="State">Current driver-instance state.</param> /// <param name="State">Current driver-instance state.</param>
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param> /// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
/// <param name="LastError">Most recent error message; null when state is Healthy.</param> /// <param name="LastError">Most recent error message; null when state is Healthy.</param>
/// <param name="Diagnostics">
/// Optional driver-attributable counters/metrics surfaced for the <c>driver-diagnostics</c>
/// RPC (introduced for Modbus task #154). Drivers populate the dictionary with stable,
/// well-known keys (e.g. <c>PublishRequestCount</c>, <c>NotificationsPerSecond</c>);
/// Core treats it as opaque metadata. Defaulted to an empty read-only dictionary so
/// existing drivers and call-sites that don't construct this field stay back-compat.
/// </param>
public sealed record DriverHealth( public sealed record DriverHealth(
DriverState State, DriverState State,
DateTime? LastSuccessfulRead, DateTime? LastSuccessfulRead,
string? LastError); string? LastError,
IReadOnlyDictionary<string, double>? Diagnostics = null)
{
/// <summary>Driver-attributable counters, empty when the driver doesn't surface any.</summary>
public IReadOnlyDictionary<string, double> DiagnosticsOrEmpty
=> Diagnostics ?? EmptyDiagnostics;
private static readonly IReadOnlyDictionary<string, double> EmptyDiagnostics
= new Dictionary<string, double>(0);
}
/// <summary>Driver-instance lifecycle state.</summary> /// <summary>Driver-instance lifecycle state.</summary>
public enum DriverState public enum DriverState

View File

@@ -35,8 +35,159 @@ public interface IAddressSpaceBuilder
/// <c>_base</c> equipment-class template). /// <c>_base</c> equipment-class template).
/// </summary> /// </summary>
void AddProperty(string browseName, DriverDataType dataType, object? value); void AddProperty(string browseName, DriverDataType dataType, object? value);
/// <summary>
/// Register a type-definition node (ObjectType / VariableType / DataType / ReferenceType)
/// mirrored from an upstream OPC UA server. Optional surface — drivers that don't mirror
/// types simply never call it; address-space builders that don't materialise upstream
/// types can leave the default no-op in place. Default implementation drops the call so
/// adding this method doesn't break existing <see cref="IAddressSpaceBuilder"/>
/// implementations.
/// </summary>
/// <param name="info">Metadata describing the type-definition node to mirror.</param>
/// <remarks>
/// <para>
/// The OPC UA Client driver is the primary caller — it walks <c>i=86</c>
/// (TypesFolder) during <c>DiscoverAsync</c> when
/// <c>OpcUaClientDriverOptions.MirrorTypeDefinitions</c> is set so downstream clients
/// see the upstream type system instead of rendering structured-type values as opaque
/// strings.
/// </para>
/// <para>
/// The default no-op is intentional — most builders (Galaxy, Modbus, FOCAS, S7,
/// TwinCAT, AB-CIP) don't have a meaningful type folder to project into and would
/// otherwise need empty-stub overrides.
/// </para>
/// </remarks>
void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ }
/// <summary>
/// Register a method node mirrored from an upstream OPC UA server. The method is
/// registered as a child of the current builder scope (i.e. the folder representing
/// the upstream Object that owns the method). Optional surface — drivers that don't
/// mirror methods simply never call it; address-space builders that don't materialise
/// method nodes can leave the default no-op in place. Default implementation drops
/// the call so adding this method doesn't break existing
/// <see cref="IAddressSpaceBuilder"/> implementations.
/// </summary>
/// <param name="info">Metadata describing the method node, including input/output argument schemas.</param>
/// <remarks>
/// <para>
/// The OPC UA Client driver is the primary caller — it picks up
/// <c>NodeClass.Method</c> nodes during the <c>HierarchicalReferences</c> browse
/// pass, then walks each method's <c>HasProperty</c> references to harvest the
/// <c>InputArguments</c> / <c>OutputArguments</c> property values.
/// </para>
/// <para>
/// The OPC UA server-side <c>DriverNodeManager</c> overrides this to materialize
/// a real <c>MethodNode</c> in the local address space and wire its
/// <c>OnCallMethod</c> handler to the driver's
/// <see cref="IMethodInvoker.CallMethodAsync"/>. Other builders (Galaxy, Modbus,
/// FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) ignore the projection because their
/// backends don't expose method nodes.
/// </para>
/// </remarks>
void RegisterMethodNode(MirroredMethodNodeInfo info) { /* default: no-op */ }
} }
/// <summary>
/// Metadata describing a single method node mirrored from an upstream OPC UA server.
/// Built by the OPC UA Client driver during the discovery browse pass and consumed by
/// <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>.
/// </summary>
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
/// <param name="ObjectNodeId">
/// Stringified NodeId of the parent Object that owns this method — the <c>ObjectId</c>
/// argument the dispatcher passes back to <see cref="IMethodInvoker.CallMethodAsync"/>.
/// </param>
/// <param name="MethodNodeId">
/// Stringified NodeId of the method node itself — the <c>MethodId</c> argument.
/// </param>
/// <param name="InputArguments">
/// Declaration of the method's input arguments, in order. <c>null</c> or empty when the
/// method takes no inputs (or the upstream property couldn't be read).
/// </param>
/// <param name="OutputArguments">
/// Declaration of the method's output arguments, in order. <c>null</c> or empty when the
/// method returns no outputs (or the upstream property couldn't be read).
/// </param>
public sealed record MirroredMethodNodeInfo(
string BrowseName,
string DisplayName,
string ObjectNodeId,
string MethodNodeId,
IReadOnlyList<MethodArgumentInfo>? InputArguments,
IReadOnlyList<MethodArgumentInfo>? OutputArguments);
/// <summary>
/// One row of an OPC UA Argument array — name + data type + array hint. Mirrors the
/// <c>Opc.Ua.Argument</c> structure but without the SDK-only types so this DTO can live
/// in <c>Core.Abstractions</c>.
/// </summary>
/// <param name="Name">Argument name from the upstream Argument structure.</param>
/// <param name="DriverDataType">
/// Mapped local <see cref="DriverDataType"/>. Unknown / structured upstream types fall
/// through to <see cref="DriverDataType.String"/> — same convention as variable mirroring.
/// </param>
/// <param name="ValueRank">
/// OPC UA ValueRank: <c>-1</c> = scalar, <c>0</c> = OneOrMoreDimensions, <c>1+</c> = array
/// dimensions. Driven directly from the upstream Argument's ValueRank.
/// </param>
/// <param name="Description">
/// Human-readable description from the upstream Argument structure; <c>null</c> when the
/// upstream doesn't carry one.
/// </param>
public sealed record MethodArgumentInfo(
string Name,
DriverDataType DriverDataType,
int ValueRank,
string? Description);
/// <summary>
/// Categorises a mirrored type-definition node so the receiving builder can route it into
/// the right OPC UA standard subtree (<c>ObjectTypesFolder</c>, <c>VariableTypesFolder</c>,
/// <c>DataTypesFolder</c>, <c>ReferenceTypesFolder</c>) when projecting upstream types into
/// the local address space.
/// </summary>
public enum MirroredTypeKind
{
ObjectType,
VariableType,
DataType,
ReferenceType,
}
/// <summary>
/// Metadata describing a single type-definition node mirrored from an upstream OPC UA
/// server. Built by the OPC UA Client driver during type-mirror pass and consumed by
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>.
/// </summary>
/// <param name="Kind">Type category — drives which standard sub-folder the node lives under.</param>
/// <param name="UpstreamNodeId">
/// Stringified upstream NodeId (e.g. <c>"ns=2;i=1234"</c>) — preserves the original identity
/// so a builder that wants to project the type with a stable cross-namespace reference can do
/// so. The driver applies any configured namespace remap before stamping this field.
/// </param>
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
/// <param name="SuperTypeNodeId">
/// Stringified upstream NodeId of the super-type (parent type), or <c>null</c> when the node
/// sits directly under the root (e.g. <c>BaseObjectType</c>, <c>BaseVariableType</c>). Lets
/// the builder reconstruct the inheritance chain.
/// </param>
/// <param name="IsAbstract">
/// <c>true</c> when the upstream node has the <c>IsAbstract</c> flag set (Object / Variable /
/// ReferenceType). DataTypes also expose this — the driver passes it through verbatim.
/// </param>
public sealed record MirroredTypeNodeInfo(
MirroredTypeKind Kind,
string UpstreamNodeId,
string BrowseName,
string DisplayName,
string? SuperTypeNodeId,
bool IsAbstract);
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary> /// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
public interface IVariableHandle public interface IVariableHandle
{ {

View File

@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional control-plane capability — drivers whose backend exposes a way to refresh
/// the symbol table on-demand (without tearing the driver down) implement this so the
/// Admin UI / CLI can trigger a re-walk in response to an operator action.
/// </summary>
/// <remarks>
/// Distinct from <see cref="IRediscoverable"/>: that interface is the driver telling Core
/// a refresh is needed; this one is Core asking the driver to refresh now. For drivers that
/// implement both, the typical wiring is "operator clicks Rebrowse → Core calls
/// <see cref="RebrowseAsync"/> → driver re-walks → driver fires
/// <c>OnRediscoveryNeeded</c> so the address space is rebuilt".
///
/// For AB CIP this is the "force re-walk of @tags" hook — useful after a controller
/// program download added new tags but the static config still drives the address space.
/// </remarks>
public interface IDriverControl
{
/// <summary>
/// Re-run the driver's discovery pass against live backend state and stream the
/// resulting nodes through the supplied builder. Implementations must be safe to call
/// concurrently with reads / writes; they typically serialize internally so a second
/// concurrent rebrowse waits for the first to complete rather than racing it.
/// </summary>
Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for invoking OPC UA Methods on the upstream backend (the OPC UA
/// <c>Call</c> service). Optional — only drivers whose backends carry method nodes
/// implement it. Currently the OPC UA Client driver is the only implementer; tag-based
/// drivers (Modbus, S7, FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) don't expose method
/// nodes so they don't need this surface.
/// </summary>
/// <remarks>
/// <para>
/// Per <c>docs/v2/plan.md</c> decision #4 (composable capability interfaces) — the
/// server-side <c>DriverNodeManager</c> discovers method-bearing drivers via an
/// <c>is IMethodInvoker</c> check and routes <c>OnCallMethod</c> handlers to
/// <see cref="CallMethodAsync"/>. Drivers that don't implement the interface simply
/// never have method nodes registered for them.
/// </para>
/// <para>
/// The address-space mirror is driven by <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>
/// — drivers register the method node + its <c>InputArguments</c> /
/// <c>OutputArguments</c> properties during discovery, then invocations land back on
/// <see cref="CallMethodAsync"/> via the server-side dispatcher.
/// </para>
/// </remarks>
public interface IMethodInvoker
{
/// <summary>
/// Invoke an upstream OPC UA Method. The driver translates input arguments into the
/// wire-level <c>CallMethodRequest</c>, dispatches via the active session, and packs
/// the response back into a <see cref="MethodCallResult"/>. Per-argument validation
/// errors flow through <see cref="MethodCallResult.InputArgumentResults"/>; method-level
/// errors (<c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, etc.) flow through
/// <see cref="MethodCallResult.StatusCode"/>.
/// </summary>
/// <param name="objectNodeId">
/// Stringified NodeId of the OPC UA Object that owns the method (the <c>ObjectId</c>
/// field of <c>CallMethodRequest</c>). Same serialization as <c>IReadable</c>'s
/// <c>fullReference</c> — <c>ns=2;s=…</c> / <c>i=…</c> / <c>nsu=…;…</c>.
/// </param>
/// <param name="methodNodeId">
/// Stringified NodeId of the Method node itself (the <c>MethodId</c> field).
/// </param>
/// <param name="inputs">
/// Input arguments in declaration order. The driver wraps each value as a
/// <c>Variant</c>; callers pass CLR primitives (plus arrays) — the wire-level
/// encoding is the driver's concern.
/// </param>
/// <param name="cancellationToken">Per-call cancellation.</param>
/// <returns>
/// Result of the call — see <see cref="MethodCallResult"/>. Never throws for a
/// <c>Bad</c> upstream status; the bad code is surfaced via the result so the caller
/// can map it onto an OPC UA service-result for downstream clients.
/// </returns>
Task<MethodCallResult> CallMethodAsync(
string objectNodeId,
string methodNodeId,
object[] inputs,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of a single OPC UA <c>Call</c> service invocation.
/// </summary>
/// <param name="StatusCode">
/// Method-level status. <c>0</c> = Good. Bad codes pass through verbatim from the
/// upstream so downstream clients see the canonical OPC UA error (e.g.
/// <c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, <c>BadArgumentsMissing</c>).
/// </param>
/// <param name="Outputs">
/// Output argument values in declaration order. <c>null</c> when the upstream returned
/// no output arguments (or returned a Bad status before producing any).
/// </param>
/// <param name="InputArgumentResults">
/// Per-input-argument status codes. <c>null</c> when the upstream didn't surface
/// per-argument validation results (typical for Good calls). Each entry is the OPC UA
/// status code for the matching input argument — drivers can use this to surface
/// <c>BadTypeMismatch</c>, <c>BadOutOfRange</c>, etc. on a specific argument.
/// </param>
public sealed record MethodCallResult(
uint StatusCode,
object[]? Outputs,
uint[]? InputArgumentResults);

View File

@@ -20,7 +20,29 @@ public interface ISubscribable
TimeSpan publishingInterval, TimeSpan publishingInterval,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary> /// <summary>
/// Subscribe to data changes with per-tag advanced tuning (sampling interval, queue
/// size, monitoring mode, deadband filter). Drivers that don't have a native concept
/// of these knobs (e.g. polled drivers like Modbus) MAY ignore the per-tag knobs and
/// delegate to the simple
/// <see cref="SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>
/// overload — the default implementation does exactly that, so existing implementers
/// compile unchanged.
/// </summary>
/// <param name="tags">Per-tag subscription specs. <see cref="MonitoredTagSpec.TagName"/> is the driver-side full reference.</param>
/// <param name="publishingInterval">Subscription publishing interval, applied to the whole batch.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>Opaque subscription handle for <see cref="UnsubscribeAsync"/>.</returns>
Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<MonitoredTagSpec> tags,
TimeSpan publishingInterval,
CancellationToken cancellationToken)
=> SubscribeAsync(
tags.Select(t => t.TagName).ToList(),
publishingInterval,
cancellationToken);
/// <summary>Cancel a subscription returned by either <c>SubscribeAsync</c> overload.</summary>
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken); Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
/// <summary> /// <summary>
@@ -30,7 +52,7 @@ public interface ISubscribable
event EventHandler<DataChangeEventArgs>? OnDataChange; event EventHandler<DataChangeEventArgs>? OnDataChange;
} }
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync"/>.</summary> /// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.</summary>
public interface ISubscriptionHandle public interface ISubscriptionHandle
{ {
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary> /// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
@@ -38,10 +60,99 @@ public interface ISubscriptionHandle
} }
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary> /// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param> /// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> call.</param>
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param> /// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
/// <param name="Snapshot">New value + quality + timestamps.</param> /// <param name="Snapshot">New value + quality + timestamps.</param>
public sealed record DataChangeEventArgs( public sealed record DataChangeEventArgs(
ISubscriptionHandle SubscriptionHandle, ISubscriptionHandle SubscriptionHandle,
string FullReference, string FullReference,
DataValueSnapshot Snapshot); DataValueSnapshot Snapshot);
/// <summary>
/// Per-tag subscription tuning. Maps onto OPC UA <c>MonitoredItem</c> properties for the
/// OpcUaClient driver; non-OPC-UA drivers either map a subset (e.g. ADS picks up
/// <see cref="SamplingIntervalMs"/>) or ignore the knobs entirely and fall back to the
/// simple <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.
/// </summary>
/// <param name="TagName">Driver-side full reference (e.g. <c>ns=2;s=Foo</c> for OPC UA).</param>
/// <param name="SamplingIntervalMs">
/// Server-side sampling rate in milliseconds. <c>null</c> = use the publishing interval.
/// Sub-publish-interval values let a server sample faster than it publishes (queue +
/// coalesce), useful for events that change between publish ticks.
/// </param>
/// <param name="QueueSize">Server-side notification queue depth. <c>null</c> = driver default (1).</param>
/// <param name="DiscardOldest">
/// When the server-side queue overflows: <c>true</c> drops oldest, <c>false</c> drops newest.
/// <c>null</c> = driver default (true — preserve recency).
/// </param>
/// <param name="MonitoringMode">
/// Per-item monitoring mode. <c>Reporting</c> = sample + publish, <c>Sampling</c> = sample
/// but suppress publishing (useful with triggering), <c>Disabled</c> = neither.
/// </param>
/// <param name="DataChangeFilter">
/// Optional data-change filter (deadband + trigger semantics). <c>null</c> = no filter
/// (every change publishes regardless of magnitude).
/// </param>
public sealed record MonitoredTagSpec(
string TagName,
double? SamplingIntervalMs = null,
uint? QueueSize = null,
bool? DiscardOldest = null,
SubscriptionMonitoringMode? MonitoringMode = null,
DataChangeFilterSpec? DataChangeFilter = null);
/// <summary>
/// OPC UA <c>DataChangeFilter</c> spec. Mirrors the OPC UA Part 4 §7.17.2 structure but
/// lives in Core.Abstractions so non-OpcUaClient drivers (e.g. Modbus, S7) can accept it
/// as metadata even if they ignore the deadband mechanics.
/// </summary>
/// <param name="Trigger">When to fire: status only / status+value / status+value+timestamp.</param>
/// <param name="DeadbandType">Deadband mode: none / absolute (engineering units) / percent of EURange.</param>
/// <param name="DeadbandValue">
/// Magnitude of the deadband. For <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Absolute"/>
/// this is in the variable's engineering units; for <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Percent"/>
/// it's a 0..100 percentage of EURange (server returns BadFilterNotAllowed if EURange isn't set).
/// </param>
public sealed record DataChangeFilterSpec(
DataChangeTrigger Trigger,
DeadbandType DeadbandType,
double DeadbandValue);
/// <summary>
/// OPC UA <c>DataChangeTrigger</c> values. Wraps the SDK enum so Core.Abstractions doesn't
/// leak an OPC-UA-stack reference into every driver project.
/// </summary>
public enum DataChangeTrigger
{
/// <summary>Fire only when StatusCode changes.</summary>
Status = 0,
/// <summary>Fire when StatusCode or Value changes (the OPC UA default).</summary>
StatusValue = 1,
/// <summary>Fire when StatusCode, Value, or SourceTimestamp changes.</summary>
StatusValueTimestamp = 2,
}
/// <summary>OPC UA deadband-filter modes.</summary>
public enum DeadbandType
{
/// <summary>No deadband — every value change publishes.</summary>
None = 0,
/// <summary>Deadband expressed in the variable's engineering units.</summary>
Absolute = 1,
/// <summary>Deadband expressed as 0..100 percent of the variable's EURange.</summary>
Percent = 2,
}
/// <summary>
/// Per-item subscription monitoring mode. Wraps the OPC UA SDK's <c>MonitoringMode</c>
/// so Core.Abstractions stays SDK-free.
/// </summary>
public enum SubscriptionMonitoringMode
{
/// <summary>Item is created but neither sampling nor publishing.</summary>
Disabled = 0,
/// <summary>Item samples and queues but does not publish (useful with triggering).</summary>
Sampling = 1,
/// <summary>Item samples and publishes — the OPC UA default.</summary>
Reporting = 2,
}

View File

@@ -26,6 +26,20 @@ public abstract class AbCipCommandBase : DriverCommandBase
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")] [CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000; public int TimeoutMs { get; init; } = 5000;
/// <summary>
/// PR abcip-3.2 — pin the device's CIP addressing mode for this CLI invocation.
/// Auto / Symbolic / Logical. Defaults to <see cref="AddressingMode.Auto"/> (resolves
/// to Symbolic until a future PR plumbs auto-detection). Logical against an
/// unsupported family (Micro800) silently falls back to Symbolic with a logged
/// warning, so passing <c>--addressing-mode Logical</c> across a mixed-family
/// fleet is safe.
/// </summary>
[CommandOption("addressing-mode", Description =
"CIP addressing mode: Auto / Symbolic / Logical (default Auto, resolves to " +
"Symbolic). Logical uses CIP Symbol Object instance IDs after a one-time @tags " +
"walk; unsupported on Micro800 (silent fallback to Symbolic with warning).")]
public AddressingMode AddressingMode { get; init; } = AddressingMode.Auto;
/// <inheritdoc /> /// <inheritdoc />
public override TimeSpan Timeout public override TimeSpan Timeout
{ {
@@ -43,7 +57,8 @@ public abstract class AbCipCommandBase : DriverCommandBase
Devices = [new AbCipDeviceOptions( Devices = [new AbCipDeviceOptions(
HostAddress: Gateway, HostAddress: Gateway,
PlcFamily: Family, PlcFamily: Family,
DeviceName: $"cli-{Family}")], DeviceName: $"cli-{Family}",
AddressingMode: AddressingMode)],
Tags = tags, Tags = tags,
Timeout = Timeout, Timeout = Timeout,
Probe = new AbCipProbeOptions { Enabled = false }, Probe = new AbCipProbeOptions { Enabled = false },

View File

@@ -0,0 +1,107 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
/// <summary>
/// Force a controller-side @tags re-walk on a live AbCip driver instance. Issue #233 —
/// online tag-DB refresh trigger. The CLI variant builds a transient driver against the
/// supplied gateway, runs <see cref="AbCipDriver.RebrowseAsync"/>, and prints the freshly
/// discovered tag names. In-server (Tier-A) operators wire this same call to an Admin UI
/// button so a controller program-download is reflected in the address space without a
/// driver restart.
/// </summary>
[Command("rebrowse", Description =
"Re-walk the AB CIP controller symbol table (force @tags refresh) and print discovered tags.")]
public sealed class RebrowseCommand : AbCipCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
// EnableControllerBrowse must be true for the @tags walk to happen; the CLI baseline
// (BuildOptions in AbCipCommandBase) leaves it off for one-shot probes, so we flip it
// here without touching the base helper.
var baseOpts = BuildOptions(tags: []);
var options = new AbCipDriverOptions
{
Devices = baseOpts.Devices,
Tags = baseOpts.Tags,
Timeout = baseOpts.Timeout,
Probe = baseOpts.Probe,
EnableControllerBrowse = true,
EnableAlarmProjection = false,
};
await using var driver = new AbCipDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var builder = new ConsoleAddressSpaceBuilder();
await driver.RebrowseAsync(builder, ct);
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
await console.Output.WriteLineAsync($"Family: {Family}");
await console.Output.WriteLineAsync($"Variables: {builder.VariableCount}");
await console.Output.WriteLineAsync();
foreach (var line in builder.Lines)
await console.Output.WriteLineAsync(line);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>
/// Minimal in-memory <see cref="IAddressSpaceBuilder"/> that flattens the tree to one
/// line per variable for CLI display. Folder nesting is captured in the prefix so the
/// operator can see the same shape the in-server builder would receive.
/// </summary>
private sealed class ConsoleAddressSpaceBuilder : IAddressSpaceBuilder
{
private readonly string _prefix;
private readonly Counter _counter;
public List<string> Lines { get; }
public int VariableCount => _counter.Count;
public ConsoleAddressSpaceBuilder() : this("", new List<string>(), new Counter()) { }
private ConsoleAddressSpaceBuilder(string prefix, List<string> sharedLines, Counter counter)
{
_prefix = prefix;
Lines = sharedLines;
_counter = counter;
}
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
var newPrefix = string.IsNullOrEmpty(_prefix) ? browseName : $"{_prefix}/{browseName}";
return new ConsoleAddressSpaceBuilder(newPrefix, Lines, _counter);
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{
_counter.Count++;
Lines.Add($" {_prefix}/{browseName} ({info.DriverDataType}, {info.SecurityClass})");
return new Handle(info.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Counter { public int Count; }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}

View File

@@ -0,0 +1,124 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
/// <summary>
/// Dump the merged tag table from an <see cref="AbCipDriverOptions"/> JSON config to a
/// Kepware-format CSV. The command reads the pre-declared <c>Tags</c> list, pulls in any
/// <c>L5kImports</c> / <c>L5xImports</c> / <c>CsvImports</c> entries, applies the same
/// declared-wins precedence used by the live driver, and writes the union as one CSV.
/// Mirrors the round-trip path operators want for Excel-driven editing: export → edit →
/// re-import via the driver's <c>CsvImports</c>.
/// </summary>
/// <remarks>
/// The command does not contact any PLC — it is a pure transform over the options JSON.
/// <c>--driver-options-json</c> may point at a full options file or at a fragment that
/// deserialises to <see cref="AbCipDriverOptions"/>.
/// </remarks>
[Command("tag-export", Description = "Export the merged tag table from a driver-options JSON to Kepware CSV.")]
public sealed class TagExportCommand : ICommand
{
[CommandOption("driver-options-json", Description =
"Path to a JSON file deserialising to AbCipDriverOptions (Tags + L5kImports + " +
"L5xImports + CsvImports). Imports with FilePath are loaded relative to the JSON.",
IsRequired = true)]
public string DriverOptionsJsonPath { get; init; } = default!;
[CommandOption("out", 'o', Description = "Output CSV path (UTF-8, no BOM).", IsRequired = true)]
public string OutputPath { get; init; } = default!;
public ValueTask ExecuteAsync(IConsole console)
{
if (!File.Exists(DriverOptionsJsonPath))
throw new CommandException($"driver-options-json '{DriverOptionsJsonPath}' does not exist.");
var json = File.ReadAllText(DriverOptionsJsonPath);
var opts = JsonSerializer.Deserialize<AbCipDriverOptions>(json, JsonOpts)
?? throw new CommandException("driver-options-json deserialised to null.");
var basePath = Path.GetDirectoryName(Path.GetFullPath(DriverOptionsJsonPath)) ?? string.Empty;
var declaredNames = new HashSet<string>(
opts.Tags.Select(t => t.Name), StringComparer.OrdinalIgnoreCase);
var allTags = new List<AbCipTagDefinition>(opts.Tags);
foreach (var import in opts.L5kImports)
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
import.InlineText, import.NamePrefix, L5kParser.Parse, declaredNames, allTags);
foreach (var import in opts.L5xImports)
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
import.InlineText, import.NamePrefix, L5xParser.Parse, declaredNames, allTags);
foreach (var import in opts.CsvImports)
MergeCsv(import, basePath, declaredNames, allTags);
CsvTagExporter.WriteFile(allTags, OutputPath);
console.Output.WriteLine($"Wrote {allTags.Count} tag(s) to {OutputPath}");
return ValueTask.CompletedTask;
}
private static string? ResolvePath(string? path, string basePath)
{
if (string.IsNullOrEmpty(path)) return path;
return Path.IsPathRooted(path) ? path : Path.Combine(basePath, path);
}
private static void MergeL5(
string deviceHost, string? filePath, string? inlineText, string namePrefix,
Func<IL5kSource, L5kDocument> parse,
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
{
if (string.IsNullOrWhiteSpace(deviceHost)) return;
IL5kSource? src = null;
if (!string.IsNullOrEmpty(filePath)) src = new FileL5kSource(filePath);
else if (!string.IsNullOrEmpty(inlineText)) src = new StringL5kSource(inlineText);
if (src is null) return;
var doc = parse(src);
var ingest = new L5kIngest { DefaultDeviceHostAddress = deviceHost, NamePrefix = namePrefix };
foreach (var tag in ingest.Ingest(doc).Tags)
{
if (declaredNames.Contains(tag.Name)) continue;
allTags.Add(tag);
declaredNames.Add(tag.Name);
}
}
private static void MergeCsv(
AbCipCsvImportOptions import, string basePath,
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
{
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress)) return;
string? text = null;
var resolved = ResolvePath(import.FilePath, basePath);
if (!string.IsNullOrEmpty(resolved)) text = File.ReadAllText(resolved);
else if (!string.IsNullOrEmpty(import.InlineText)) text = import.InlineText;
if (text is null) return;
var importer = new CsvTagImporter
{
DefaultDeviceHostAddress = import.DeviceHostAddress,
NamePrefix = import.NamePrefix,
};
foreach (var tag in importer.Import(text).Tags)
{
if (declaredNames.Contains(tag.Name)) continue;
allTags.Add(tag);
declaredNames.Add(tag.Name);
}
}
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
}

View File

@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-1.3 — issues one libplctag tag-create with <c>ElementCount=N</c> per Rockwell
/// array-slice tag (<c>Tag[0..N]</c> in <see cref="AbCipTagPath"/>), then decodes the
/// contiguous buffer at element stride into <c>N</c> typed values. Mirrors the whole-UDT
/// planner pattern (<see cref="AbCipUdtReadPlanner"/>): pure shape — the planner never
/// touches the runtime + never reads the PLC, the driver wires the runtime in.
/// </summary>
/// <remarks>
/// <para>Stride is the natural Logix size of the element type (DInt = 4, Real = 4, LInt = 8).
/// Bool / String / Structure slices aren't supported here — Logix packs BOOLs into a host
/// byte (no fixed stride), STRING members carry a Length+DATA pair that's not a flat array,
/// and structure arrays need the CIP Template Object reader (PR-tracked separately).</para>
///
/// <para>Output is a single <c>object[]</c> snapshot value containing the N decoded
/// elements at indices 0..Count-1. Pairing with one slice tag = one snapshot keeps the
/// <c>ReadAsync</c> 1:1 contract (one fullReference -> one snapshot) intact.</para>
/// </remarks>
public static class AbCipArrayReadPlanner
{
/// <summary>
/// Build the libplctag create-params + decode descriptor for a slice tag. Returns
/// <c>null</c> when the slice element type isn't supported under this declaration-only
/// decoder (Bool / String / Structure / unrecognised) — the driver falls back to the
/// scalar read path so the operator gets a clean per-element result instead.
/// </summary>
public static AbCipArrayReadPlan? TryBuild(
AbCipTagDefinition definition,
AbCipTagPath parsedPath,
AbCipTagCreateParams baseParams)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(parsedPath);
ArgumentNullException.ThrowIfNull(baseParams);
if (parsedPath.Slice is null) return null;
if (!TryGetStride(definition.DataType, out var stride)) return null;
var slice = parsedPath.Slice;
var createParams = baseParams with
{
TagName = parsedPath.ToLibplctagSliceArrayName(),
ElementCount = slice.Count,
};
return new AbCipArrayReadPlan(definition.DataType, slice, stride, createParams);
}
/// <summary>
/// Decode <paramref name="plan"/>.Count elements from <paramref name="runtime"/> at
/// element stride. Caller has already invoked <see cref="IAbCipTagRuntime.ReadAsync"/>
/// and confirmed <see cref="IAbCipTagRuntime.GetStatus"/> == 0.
/// </summary>
public static object?[] Decode(AbCipArrayReadPlan plan, IAbCipTagRuntime runtime)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(runtime);
var values = new object?[plan.Slice.Count];
for (var i = 0; i < plan.Slice.Count; i++)
values[i] = runtime.DecodeValueAt(plan.ElementType, i * plan.Stride, bitIndex: null);
return values;
}
private static bool TryGetStride(AbCipDataType type, out int stride)
{
switch (type)
{
case AbCipDataType.SInt: case AbCipDataType.USInt:
stride = 1; return true;
case AbCipDataType.Int: case AbCipDataType.UInt:
stride = 2; return true;
case AbCipDataType.DInt: case AbCipDataType.UDInt:
case AbCipDataType.Real: case AbCipDataType.Dt:
stride = 4; return true;
case AbCipDataType.LInt: case AbCipDataType.ULInt:
case AbCipDataType.LReal:
stride = 8; return true;
default:
stride = 0; return false;
}
}
}
/// <summary>
/// Plan output: the libplctag create-params for the single array-read tag plus the
/// element-type / stride / slice metadata the decoder needs.
/// </summary>
public sealed record AbCipArrayReadPlan(
AbCipDataType ElementType,
AbCipTagPathSlice Slice,
int Stride,
AbCipTagCreateParams CreateParams);

View File

@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-3.1 — bounds + magic numbers for the per-device CIP <c>ConnectionSize</c>
/// override. Pulled into a single place so config validation, the legacy-firmware warning,
/// and the docs stay in sync.
/// </summary>
public static class AbCipConnectionSize
{
/// <summary>
/// Minimum supported CIP Forward Open buffer size, in bytes. Matches the lower bound of
/// Kepware's connection-size slider for ControlLogix drivers + the libplctag native
/// floor that still leaves headroom for the CIP MR header.
/// </summary>
public const int Min = 500;
/// <summary>
/// Maximum supported CIP Forward Open buffer size, in bytes. Matches the upper bound of
/// Kepware's slider + the Large Forward Open ceiling on FW20+ ControlLogix.
/// </summary>
public const int Max = 4002;
/// <summary>
/// Soft cap above which legacy ControlLogix firmware (v19 and earlier) rejects the
/// Forward Open. CompactLogix L1/L2/L3 narrow-cap parts (5069-L1/L2/L3) and Micro800
/// hard-cap below this too. Used as the threshold for the legacy-firmware warning.
/// </summary>
public const int LegacyFirmwareCap = 511;
}

View File

@@ -50,11 +50,12 @@ public static class AbCipDataTypeExtensions
AbCipDataType.Bool => DriverDataType.Boolean, AbCipDataType.Bool => DriverDataType.Boolean,
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32, AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32, AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
AbCipDataType.LInt or AbCipDataType.ULInt => DriverDataType.Int32, // TODO: Int64 — matches Modbus gap AbCipDataType.LInt => DriverDataType.Int64,
AbCipDataType.ULInt => DriverDataType.UInt64,
AbCipDataType.Real => DriverDataType.Float32, AbCipDataType.Real => DriverDataType.Float32,
AbCipDataType.LReal => DriverDataType.Float64, AbCipDataType.LReal => DriverDataType.Float64,
AbCipDataType.String => DriverDataType.String, AbCipDataType.String => DriverDataType.String,
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT AbCipDataType.Dt => DriverDataType.Int64, // Logix v32+ DT == LINT epoch-millis
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
_ => DriverDataType.Int32, _ => DriverDataType.Int32,
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,13 @@ public static class AbCipDriverFactoryExtensions
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"), $"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily", PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
fallback: AbCipPlcFamily.ControlLogix), fallback: AbCipPlcFamily.ControlLogix),
DeviceName: d.DeviceName))] DeviceName: d.DeviceName,
ConnectionSize: d.ConnectionSize,
AddressingMode: ParseEnum<AddressingMode>(d.AddressingMode, "device", driverInstanceId,
"AddressingMode", fallback: AddressingMode.Auto),
ReadStrategy: ParseEnum<ReadStrategy>(d.ReadStrategy, "device", driverInstanceId,
"ReadStrategy", fallback: ReadStrategy.Auto),
MultiPacketSparsityThreshold: d.MultiPacketSparsityThreshold ?? 0.25))]
: [], : [],
Tags = dto.Tags is { Count: > 0 } Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))] ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
@@ -119,6 +125,38 @@ public static class AbCipDriverFactoryExtensions
public string? HostAddress { get; init; } public string? HostAddress { get; init; }
public string? PlcFamily { get; init; } public string? PlcFamily { get; init; }
public string? DeviceName { get; init; } public string? DeviceName { get; init; }
/// <summary>
/// PR abcip-3.1 — optional per-device CIP <c>ConnectionSize</c> override. Validated
/// against <c>[500..4002]</c> at <see cref="AbCipDriver.InitializeAsync"/>.
/// </summary>
public int? ConnectionSize { get; init; }
/// <summary>
/// PR abcip-3.2 — optional per-device addressing-mode override. <c>"Auto"</c>,
/// <c>"Symbolic"</c>, or <c>"Logical"</c>. Defaults to <c>Auto</c> (resolves to
/// Symbolic until a future PR adds real auto-detection). Family compatibility is
/// enforced at <see cref="AbCipDriver.InitializeAsync"/>: Logical against
/// Micro800 / SLC500 / PLC5 falls back to Symbolic with a warning.
/// </summary>
public string? AddressingMode { get; init; }
/// <summary>
/// PR abcip-3.3 — optional per-device read-strategy override. <c>"Auto"</c>,
/// <c>"WholeUdt"</c>, or <c>"MultiPacket"</c>. Defaults to <c>Auto</c> (the planner
/// picks per-batch using <see cref="MultiPacketSparsityThreshold"/>). Family
/// compatibility is enforced at <see cref="AbCipDriver.InitializeAsync"/>: explicit
/// <c>MultiPacket</c> against Micro800 (no
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>) falls
/// back to <c>WholeUdt</c> with a warning.
/// </summary>
public string? ReadStrategy { get; init; }
/// <summary>
/// PR abcip-3.3 — sparsity-threshold knob applied when <see cref="ReadStrategy"/>
/// resolves to <c>Auto</c>. Default <c>0.25</c>; clamped to <c>[0..1]</c>.
/// </summary>
public double? MultiPacketSparsityThreshold { get; init; }
} }
internal sealed class AbCipTagDto internal sealed class AbCipTagDto

View File

@@ -21,6 +21,37 @@ public sealed class AbCipDriverOptions
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary> /// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = []; public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
/// <summary>
/// L5K (Studio 5000 controller export) imports merged into <see cref="Tags"/> at
/// <c>InitializeAsync</c>. Each entry points at one L5K file + the device whose tags it
/// describes; the parser extracts <c>TAG</c> + <c>DATATYPE</c> blocks and produces
/// <see cref="AbCipTagDefinition"/> records (alias tags + ExternalAccess=None tags
/// skipped — see <see cref="Import.L5kIngest"/>). Pre-declared <see cref="Tags"/> entries
/// win on <c>Name</c> conflicts so operators can override import results without
/// editing the L5K source.
/// </summary>
public IReadOnlyList<AbCipL5kImportOptions> L5kImports { get; init; } = [];
/// <summary>
/// L5X (Studio 5000 XML controller export) imports merged into <see cref="Tags"/> at
/// <c>InitializeAsync</c>. Same shape and merge semantics as <see cref="L5kImports"/> —
/// the entries differ only in source format. Pre-declared <see cref="Tags"/> entries win
/// on <c>Name</c> conflicts; entries already produced by <see cref="L5kImports"/> also win
/// so an L5X re-export of the same controller doesn't double-emit. See
/// <see cref="Import.L5xParser"/> for the format-specific mechanics.
/// </summary>
public IReadOnlyList<AbCipL5xImportOptions> L5xImports { get; init; } = [];
/// <summary>
/// Kepware-format CSV imports merged into <see cref="Tags"/> at <c>InitializeAsync</c>.
/// Same merge semantics as <see cref="L5kImports"/> / <see cref="L5xImports"/> —
/// pre-declared <see cref="Tags"/> entries win on <c>Name</c> conflicts, and tags
/// produced by earlier import collections (L5K → L5X → CSV in call order) also win
/// so an Excel-edited copy of the same controller does not double-emit. See
/// <see cref="Import.CsvTagImporter"/> for the column layout + parse rules.
/// </summary>
public IReadOnlyList<AbCipCsvImportOptions> CsvImports { get; init; } = [];
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary> /// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new(); public AbCipProbeOptions Probe { get; init; } = new();
@@ -56,6 +87,14 @@ public sealed class AbCipDriverOptions
/// 1 second — matches typical SCADA alarm-refresh conventions. /// 1 second — matches typical SCADA alarm-refresh conventions.
/// </summary> /// </summary>
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1); public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>
/// PR abcip-3.1 — optional sink for non-fatal driver warnings (legacy-firmware
/// <c>ConnectionSize</c> mis-match, etc.). Production hosting wires this to Serilog;
/// unit tests pin a list-collecting lambda to assert which warnings fired. <c>null</c>
/// swallows warnings — convenient for back-compat deployments that don't care.
/// </summary>
public Action<string>? OnWarning { get; init; }
} }
/// <summary> /// <summary>
@@ -67,10 +106,137 @@ public sealed class AbCipDriverOptions
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize, /// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
/// request-packing support, unconnected-only hint, and other quirks.</param> /// request-packing support, unconnected-only hint, and other quirks.</param>
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param> /// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
/// <param name="ConnectionSize">PR abcip-3.1 — optional override for the family-default
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.DefaultConnectionSize"/>. Threads through to
/// libplctag's <c>connection_size</c> attribute on the underlying tag handle so operators can
/// dial the CIP Forward Open buffer down for legacy firmware (v19-and-earlier ControlLogix
/// caps at 504) or up for high-throughput shops on FW20+. Validated against the Kepware
/// supported range [500..4002] at <c>InitializeAsync</c>; out-of-range values fault the
/// driver. <c>null</c> uses the family default — back-compat with deployments that haven't
/// touched the knob.</param>
/// <param name="AddressingMode">PR abcip-3.2 — controls whether the driver addresses tags by
/// ASCII symbolic path (the default), by CIP logical-segment instance ID, or asks the driver
/// to pick. Logical addressing skips per-poll ASCII parsing on every read and unlocks
/// symbol-table-cached scans for 500+-tag projects, but requires a one-time symbol-table
/// walk at first read + is unsupported on Micro800 / SLC500 / PLC5 (their CIP firmware does
/// not honour Symbol Object instance IDs). When the user picks <see cref="AbCip.AddressingMode.Logical"/>
/// against an unsupported family the driver logs a warning + falls back to symbolic so
/// misconfiguration does not fault the driver. <see cref="AbCip.AddressingMode.Auto"/> currently
/// resolves to symbolic — a future PR will plumb a real auto-detection heuristic; the docs
/// in <c>docs/drivers/AbCip-Performance.md</c> §"Addressing mode" call this out.</param>
/// <param name="ReadStrategy">PR abcip-3.3 — picks how a multi-member UDT batch is read on this
/// device. <see cref="AbCip.ReadStrategy.WholeUdt"/> issues one read per parent UDT and decodes
/// each subscribed member from the buffer in-memory (the historical behaviour that ships in
/// task #194 — best when a large fraction of a UDT's members are subscribed).
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> bundles per-member reads into one CIP
/// Multi-Service Packet — best for sparse UDT subscriptions where reading the whole UDT
/// buffer just to extract one or two fields wastes wire bandwidth. <see cref="AbCip.ReadStrategy.Auto"/>
/// (the default) lets the planner pick per-batch using
/// <paramref name="MultiPacketSparsityThreshold"/>: if the subscribed-member fraction is below
/// the threshold MultiPacket wins, otherwise WholeUdt wins. Family compatibility — Micro800 /
/// SLC500 / PLC5 lack Multi-Service-Packet support per
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>; user-forced
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> against those families logs a warning + falls
/// back to <see cref="AbCip.ReadStrategy.WholeUdt"/> at device-init time. The libplctag .NET
/// wrapper (1.5.x) does not expose a public knob for explicit Multi-Service-Packet bundling,
/// so today's MultiPacket runtime issues one libplctag read per member; the planner's grouping
/// is still load-bearing because it gives the runtime the right plan to execute when an
/// upstream wrapper release exposes wire-level bundling.</param>
/// <param name="MultiPacketSparsityThreshold">PR abcip-3.3 — sparsity-threshold knob the planner
/// uses when <paramref name="ReadStrategy"/> is <see cref="AbCip.ReadStrategy.Auto"/>. The
/// planner divides <c>subscribedMembers / totalMembers</c> for each parent UDT in a batch;
/// a fraction strictly less than the threshold picks
/// <see cref="AbCip.ReadStrategy.MultiPacket"/>, else <see cref="AbCip.ReadStrategy.WholeUdt"/>.
/// Default <c>0.25</c> — picked because reading 1/4 of a UDT's members is the rough break-even
/// where the wire-cost of one whole-UDT read still beats N member reads on ControlLogix's
/// 4002-byte connection size; see <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy".
/// Clamped to <c>[0..1]</c> at planner time; values outside the range silently saturate.</param>
public sealed record AbCipDeviceOptions( public sealed record AbCipDeviceOptions(
string HostAddress, string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix, AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
string? DeviceName = null); string? DeviceName = null,
int? ConnectionSize = null,
AddressingMode AddressingMode = AddressingMode.Auto,
ReadStrategy ReadStrategy = ReadStrategy.Auto,
double MultiPacketSparsityThreshold = 0.25);
/// <summary>
/// PR abcip-3.3 — per-device strategy for reading multi-member UDT batches. <see cref="WholeUdt"/>
/// mirrors the task #194 behaviour: one libplctag read on the parent tag, each subscribed member
/// decoded from the buffer at its computed offset. <see cref="MultiPacket"/> bundles per-member
/// reads into one CIP Multi-Service Packet so sparse UDT subscriptions don't pay for the whole
/// UDT buffer. <see cref="Auto"/> lets the planner pick per-batch using
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.
/// </summary>
/// <remarks>
/// <para>Strategy resolution lives at two layers:</para>
/// <list type="bullet">
/// <item><b>Device init</b> — user-forced <see cref="MultiPacket"/> against a family whose
/// profile sets <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>
/// = <c>false</c> (Micro800, SLC500, PLC5) falls back to <see cref="WholeUdt"/> with a
/// warning. <see cref="Auto"/> stays as-is (the planner re-evaluates per batch).</item>
/// <item><b>Per-batch (Auto only)</b> — for each parent UDT in the request set, the planner
/// computes <c>subscribedMembers / totalMembers</c> and routes the group through
/// <see cref="MultiPacket"/> when the fraction is below the threshold, else
/// <see cref="WholeUdt"/>.</item>
/// </list>
/// <para>libplctag .NET wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling,
/// so today's runtime issues one libplctag read per member when the planner picks MultiPacket —
/// the same wrapper limitation called out in PR abcip-3.1 (ConnectionSize) and PR abcip-3.2
/// (instance-ID addressing). The planner's grouping is still observable from tests + future-proofs
/// the driver for when an upstream wrapper release exposes wire-level bundling.</para>
/// </remarks>
public enum ReadStrategy
{
/// <summary>Driver picks per-batch based on
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>. Default.</summary>
Auto = 0,
/// <summary>One read per parent UDT; members decoded from the buffer in-memory. Best when a
/// large fraction of the UDT's members are subscribed (dense reads).</summary>
WholeUdt = 1,
/// <summary>Bundle per-member reads into one CIP Multi-Service Packet. Best when only a few
/// members of a large UDT are subscribed (sparse reads). Unsupported on Micro800 / SLC500 /
/// PLC5; the driver warns + falls back to <see cref="WholeUdt"/> at device init.</summary>
MultiPacket = 2,
}
/// <summary>
/// PR abcip-3.2 — how the AB CIP driver addresses tags on a given device. <see cref="Symbolic"/>
/// is the historical default + matches every previous driver build: each read carries the tag
/// name as ASCII bytes + the controller parses the path on every request. <see cref="Logical"/>
/// uses CIP logical-segment instance IDs (Symbol Object class 0x6B) — the controller looks the
/// tag up in its own symbol table once + the driver caches the resolved instance ID for
/// subsequent reads, eliminating the per-poll ASCII parse step. <see cref="Auto"/> lets the
/// driver pick (today: always Symbolic; a future PR fingerprints the controller and switches
/// to Logical when supported).
/// </summary>
/// <remarks>
/// Logical addressing requires a one-time symbol-table walk at the first read on the device
/// (the driver issues an <c>@tags</c> read via <see cref="LibplctagTagEnumerator"/> and stores
/// the name → instance-id map on the per-device <c>DeviceState</c>). It is unsupported on
/// Micro800 / SLC500 / PLC5 — see <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing"/>.
/// The libplctag .NET wrapper (1.5.x) does not expose a public knob for instance-ID
/// addressing, so the driver translates Logical → libplctag attribute via reflection on
/// <c>NativeTagWrapper.SetAttributeString</c> — same best-effort fallback pattern as
/// PR abcip-3.1's ConnectionSize plumbing.
/// </remarks>
public enum AddressingMode
{
/// <summary>Driver picks. Currently resolves to <see cref="Symbolic"/>; future PR may
/// auto-detect based on family + firmware + symbol-table size.</summary>
Auto = 0,
/// <summary>ASCII symbolic-path addressing — the libplctag default. Per-poll ASCII parse on
/// the controller; works on every CIP family.</summary>
Symbolic = 1,
/// <summary>CIP logical-segment / instance-ID addressing. Requires a one-time
/// symbol-table walk at first read; subsequent reads skip ASCII parsing on the
/// controller. Unsupported on Micro800 / SLC500 / PLC5.</summary>
Logical = 2,
}
/// <summary> /// <summary>
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape. /// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
@@ -92,6 +258,17 @@ public sealed record AbCipDeviceOptions(
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are /// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the /// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
/// write attempt failing at runtime.</param> /// write attempt failing at runtime.</param>
/// <param name="StringLength">Capacity of the DATA character array on a Logix STRING / STRINGnn
/// UDT — 82 for the stock <c>STRING</c>, 20/40/80/etc for user-defined <c>STRING_20</c>,
/// <c>STRING_40</c>, <c>STRING_80</c> variants. Threads through libplctag's
/// <c>str_max_capacity</c> attribute so the wrapper allocates the correct backing buffer
/// and <c>GetString</c> / <c>SetString</c> truncate at the right boundary. <c>null</c>
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
/// non-<see cref="AbCipDataType.String"/> types.</param>
/// <param name="Description">Tag description carried from the L5K/L5X export (or set explicitly
/// in pre-declared config). Surfaces as the OPC UA <c>Description</c> attribute on the
/// produced Variable node so SCADA / engineering clients see the comment from the source
/// project. <c>null</c> leaves Description unset, matching pre-2.3 behaviour.</param>
public sealed record AbCipTagDefinition( public sealed record AbCipTagDefinition(
string Name, string Name,
string DeviceHostAddress, string DeviceHostAddress,
@@ -100,7 +277,9 @@ public sealed record AbCipTagDefinition(
bool Writable = true, bool Writable = true,
bool WriteIdempotent = false, bool WriteIdempotent = false,
IReadOnlyList<AbCipStructureMember>? Members = null, IReadOnlyList<AbCipStructureMember>? Members = null,
bool SafetyTag = false); bool SafetyTag = false,
int? StringLength = null,
string? Description = null);
/// <summary> /// <summary>
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>, /// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
@@ -108,11 +287,92 @@ public sealed record AbCipTagDefinition(
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader /// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR. /// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
/// </summary> /// </summary>
/// <remarks>
/// <para><see cref="Description"/> carries the per-member comment from L5K/L5X UDT definitions so
/// the OPC UA Variable nodes produced for individual members surface their descriptions too,
/// not just the top-level tag.</para>
/// <para>PR abcip-2.6 — <see cref="AoiQualifier"/> tags AOI parameters as Input / Output /
/// InOut / Local. Plain UDT members default to <see cref="AoiQualifier.Local"/>. Discovery
/// groups Input / Output / InOut members under sub-folders so an AOI-typed tag fans out as
/// <c>Tag/Inputs/...</c>, <c>Tag/Outputs/...</c>, <c>Tag/InOut/...</c> while Local stays at the
/// UDT root — matching how AOIs visually present in Studio 5000.</para>
/// </remarks>
public sealed record AbCipStructureMember( public sealed record AbCipStructureMember(
string Name, string Name,
AbCipDataType DataType, AbCipDataType DataType,
bool Writable = true, bool Writable = true,
bool WriteIdempotent = false); bool WriteIdempotent = false,
int? StringLength = null,
string? Description = null,
AoiQualifier AoiQualifier = AoiQualifier.Local);
/// <summary>
/// PR abcip-2.6 — directional qualifier for AOI parameters. Surfaces the Studio 5000
/// <c>Usage</c> attribute (<c>Input</c> / <c>Output</c> / <c>InOut</c>) so discovery can group
/// AOI members into sub-folders and downstream consumers can reason about parameter direction.
/// Plain UDT members (non-AOI types) default to <see cref="Local"/>, which keeps them at the
/// UDT root + indicates they are internal storage rather than a directional parameter.
/// </summary>
public enum AoiQualifier
{
/// <summary>UDT member or AOI local tag — non-directional, browsed at the parent's root.</summary>
Local,
/// <summary>AOI input parameter — written by the caller, read by the AOI body.</summary>
Input,
/// <summary>AOI output parameter — written by the AOI body, read by the caller.</summary>
Output,
/// <summary>AOI bidirectional parameter — passed by reference, both sides may read/write.</summary>
InOut,
}
/// <summary>
/// One L5K-import entry. Either <see cref="FilePath"/> or <see cref="InlineText"/> must be
/// set (FilePath wins when both supplied — useful for tests that pre-load fixtures into
/// options without touching disk).
/// </summary>
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
/// <param name="FilePath">On-disk path to a <c>*.L5K</c> export. Loaded eagerly at InitializeAsync.</param>
/// <param name="InlineText">Pre-loaded L5K body — used by tests + Admin UI uploads.</param>
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
/// when ingesting multiple files into one driver instance.</param>
public sealed record AbCipL5kImportOptions(
string DeviceHostAddress,
string? FilePath = null,
string? InlineText = null,
string NamePrefix = "");
/// <summary>
/// One L5X-import entry. Mirrors <see cref="AbCipL5kImportOptions"/> field-for-field — the
/// two are kept as distinct types so configuration JSON makes the source format explicit
/// (an L5X file under an <c>L5kImports</c> entry would parse-fail confusingly otherwise).
/// </summary>
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
/// <param name="FilePath">On-disk path to a <c>*.L5X</c> XML export. Loaded eagerly at InitializeAsync.</param>
/// <param name="InlineText">Pre-loaded L5X body — used by tests + Admin UI uploads.</param>
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
/// when ingesting multiple files into one driver instance.</param>
public sealed record AbCipL5xImportOptions(
string DeviceHostAddress,
string? FilePath = null,
string? InlineText = null,
string NamePrefix = "");
/// <summary>
/// One Kepware-format CSV import entry. Field shape mirrors <see cref="AbCipL5kImportOptions"/>
/// so configuration JSON stays consistent across the three import sources.
/// </summary>
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
/// <param name="FilePath">On-disk path to a Kepware-format <c>*.csv</c>. Loaded eagerly at InitializeAsync.</param>
/// <param name="InlineText">Pre-loaded CSV body — used by tests + Admin UI uploads.</param>
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions.</param>
public sealed record AbCipCsvImportOptions(
string DeviceHostAddress,
string? FilePath = null,
string? InlineText = null,
string NamePrefix = "");
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary> /// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
public enum AbCipPlcFamily public enum AbCipPlcFamily

View File

@@ -0,0 +1,132 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-3.3 — sparse-UDT read planner. Where <see cref="AbCipUdtReadPlanner"/> reads each
/// parent UDT once and decodes every subscribed member from the buffer in-memory, this planner
/// keeps the per-member read shape and bundles the reads into one CIP Multi-Service Packet
/// per parent so a 5-of-50-member subscription doesn't pay for the whole UDT buffer.
/// </summary>
/// <remarks>
/// <para>Pure function — like its sibling planner, this one never touches the runtime + never
/// reads the PLC. It produces the plan; <see cref="AbCipDriver"/> executes it.</para>
///
/// <para>The planner is intentionally <c>libplctag</c>-agnostic: the output is just a list of
/// <see cref="AbCipMultiPacketReadBatch"/> records that name the parent UDT, the per-member
/// read targets, and their byte offsets. The runtime layer decides whether to issue one
/// libplctag read per member (today's wrapper-limited fallback) or to flush the batch onto
/// one Multi-Service Packet (a future wrapper release). Either way the planner-tier logic
/// stays correct, which is why the unit tests in
/// <c>AbCipMultiPacketReadPlannerTests</c> assert plan shape rather than wire bytes.</para>
///
/// <para>Auto-mode dispatch (the heuristic): callers run <see cref="ChooseStrategyForGroup"/>
/// for each parent UDT to pick between the WholeUdt and MultiPacket paths per-group. The
/// heuristic divides <c>subscribedMembers / totalMembers</c> and picks MultiPacket when the
/// fraction is strictly less than the device's
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.</para>
/// </remarks>
public static class AbCipMultiPacketReadPlanner
{
/// <summary>
/// Build a multi-packet read plan from <paramref name="requests"/>. Members of the same
/// parent UDT collapse into one <see cref="AbCipMultiPacketReadBatch"/>; references that
/// don't resolve to a UDT member fall back to <see cref="AbCipUdtReadFallback"/> for the
/// existing per-tag read path.
/// </summary>
public static AbCipMultiPacketReadPlan Build(
IReadOnlyList<string> requests,
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
{
ArgumentNullException.ThrowIfNull(requests);
ArgumentNullException.ThrowIfNull(tagsByName);
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < requests.Count; i++)
{
var name = requests[i];
if (!tagsByName.TryGetValue(name, out var def))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
continue;
}
var (parentName, memberName) = SplitParentMember(name);
if (parentName is null || memberName is null
|| !tagsByName.TryGetValue(parentName, out var parent)
|| parent.DataType != AbCipDataType.Structure
|| parent.Members is not { Count: > 0 })
{
fallback.Add(new AbCipUdtReadFallback(i, name));
continue;
}
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
continue;
}
if (!byParent.TryGetValue(parentName, out var members))
{
members = new List<AbCipUdtReadMember>();
byParent[parentName] = members;
}
members.Add(new AbCipUdtReadMember(i, def, offset));
}
var batches = new List<AbCipMultiPacketReadBatch>(byParent.Count);
foreach (var (parentName, members) in byParent)
{
batches.Add(new AbCipMultiPacketReadBatch(parentName, tagsByName[parentName], members));
}
return new AbCipMultiPacketReadPlan(batches, fallback);
}
/// <summary>
/// PR abcip-3.3 — Auto-mode heuristic. For a single parent UDT group with
/// <paramref name="subscribedMembers"/> of <paramref name="totalMembers"/> declared
/// members, pick <see cref="ReadStrategy.MultiPacket"/> when sparsity is strictly below
/// <paramref name="threshold"/>, else <see cref="ReadStrategy.WholeUdt"/>. Threshold is
/// clamped to <c>[0..1]</c>; out-of-range values saturate. Edge cases:
/// <c>totalMembers == 0</c> defaults to <see cref="ReadStrategy.WholeUdt"/> (the
/// historical behaviour) so a misconfigured tag map doesn't fault the read.
/// </summary>
public static ReadStrategy ChooseStrategyForGroup(int subscribedMembers, int totalMembers, double threshold)
{
if (totalMembers <= 0) return ReadStrategy.WholeUdt;
// Saturate the threshold to a sane range. 0.0 → never MultiPacket; 1.0 → always
// MultiPacket whenever any member is subscribed (deterministic boundary behaviour).
var t = threshold;
if (t < 0.0) t = 0.0;
if (t > 1.0) t = 1.0;
var fraction = (double)subscribedMembers / totalMembers;
return fraction < t ? ReadStrategy.MultiPacket : ReadStrategy.WholeUdt;
}
private static (string? Parent, string? Member) SplitParentMember(string reference)
{
var dot = reference.IndexOf('.');
if (dot <= 0 || dot == reference.Length - 1) return (null, null);
return (reference[..dot], reference[(dot + 1)..]);
}
}
/// <summary>A planner output: per-parent multi-packet batches + per-tag fallbacks.</summary>
public sealed record AbCipMultiPacketReadPlan(
IReadOnlyList<AbCipMultiPacketReadBatch> Batches,
IReadOnlyList<AbCipUdtReadFallback> Fallbacks);
/// <summary>
/// One UDT parent whose subscribed members are bundled into a Multi-Service Packet read.
/// Reuses <see cref="AbCipUdtReadMember"/> from the WholeUdt planner so callers can decode
/// the member offsets uniformly across both planners.
/// </summary>
public sealed record AbCipMultiPacketReadBatch(
string ParentName,
AbCipTagDefinition ParentDefinition,
IReadOnlyList<AbCipUdtReadMember> Members);

View File

@@ -0,0 +1,112 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-1.4 — multi-tag write planner. Groups a batch of <see cref="WriteRequest"/>s by
/// device so the driver can submit one round of writes per device instead of looping
/// strictly serially across the whole batch. Honours the per-family
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> flag: families that support
/// CIP request packing (ControlLogix / CompactLogix / GuardLogix) issue their writes in
/// parallel so libplctag's internal scheduler can coalesce them onto one Multi-Service
/// Packet (0x0A); Micro800 (no request packing) falls back to per-tag sequential writes.
/// </summary>
/// <remarks>
/// <para>The libplctag .NET wrapper exposes one CIP service per <c>Tag</c> instance and does
/// not surface Multi-Service Packet construction at the API surface — but the underlying
/// native library packs concurrent operations against the same connection automatically
/// when the family's protocol supports it. Issuing the writes concurrently per device
/// therefore gives us the round-trip reduction described in #228 without having to drop to
/// raw CIP, while still letting us short-circuit packing on Micro800 where it would be
/// unsafe.</para>
///
/// <para>Bit-RMW writes (BOOL-with-bitIndex against a DINT parent) are excluded from
/// packing here because they need a serialised read-modify-write under the per-parent
/// <c>SemaphoreSlim</c> in <see cref="AbCipDriver.WriteBitInDIntAsync"/>. Packing two RMWs
/// on the same DINT would risk losing one another's update.</para>
/// </remarks>
internal static class AbCipMultiWritePlanner
{
/// <summary>
/// One classified entry in the input batch. <see cref="OriginalIndex"/> preserves the
/// caller's ordering so per-tag <c>StatusCode</c> fan-out lands at the right slot in
/// the result array. <see cref="IsBitRmw"/> routes the entry through the RMW path even
/// when the device supports packing.
/// </summary>
internal readonly record struct ClassifiedWrite(
int OriginalIndex,
WriteRequest Request,
AbCipTagDefinition Definition,
AbCipTagPath? ParsedPath,
bool IsBitRmw);
/// <summary>
/// One device's plan slice. <see cref="Packable"/> entries can be issued concurrently;
/// <see cref="BitRmw"/> entries must go through the RMW path one-at-a-time per parent
/// DINT.
/// </summary>
internal sealed class DevicePlan
{
public required string DeviceHostAddress { get; init; }
public required AbCipPlcFamilyProfile Profile { get; init; }
public List<ClassifiedWrite> Packable { get; } = new();
public List<ClassifiedWrite> BitRmw { get; } = new();
}
/// <summary>
/// Build the per-device plan list. Entries are visited in input order so the resulting
/// plan's traversal preserves caller ordering within each device. Entries that fail
/// resolution (unknown reference, non-writable tag, unknown device) are reported via
/// <paramref name="reportPreflight"/> with the appropriate StatusCode and excluded from
/// the plan.
/// </summary>
public static IReadOnlyList<DevicePlan> Build(
IReadOnlyList<WriteRequest> writes,
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
IReadOnlyDictionary<string, AbCipDriver.DeviceState> devices,
Action<int, uint> reportPreflight)
{
var plans = new Dictionary<string, DevicePlan>(StringComparer.OrdinalIgnoreCase);
var order = new List<DevicePlan>();
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!tagsByName.TryGetValue(w.FullReference, out var def))
{
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
if (!def.Writable || def.SafetyTag)
{
reportPreflight(i, AbCipStatusMapper.BadNotWritable);
continue;
}
if (!devices.TryGetValue(def.DeviceHostAddress, out var device))
{
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
if (!plans.TryGetValue(def.DeviceHostAddress, out var plan))
{
plan = new DevicePlan
{
DeviceHostAddress = def.DeviceHostAddress,
Profile = device.Profile,
};
plans[def.DeviceHostAddress] = plan;
order.Add(plan);
}
var parsed = AbCipTagPath.TryParse(def.TagPath);
var isBitRmw = def.DataType == AbCipDataType.Bool && parsed?.BitIndex is int;
var entry = new ClassifiedWrite(i, w, def, parsed, isBitRmw);
if (isBitRmw) plan.BitRmw.Add(entry);
else plan.Packable.Add(entry);
}
return order;
}
}

View File

@@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
public sealed record AbCipTagPath( public sealed record AbCipTagPath(
string? ProgramScope, string? ProgramScope,
IReadOnlyList<AbCipTagPathSegment> Segments, IReadOnlyList<AbCipTagPathSegment> Segments,
int? BitIndex) int? BitIndex,
AbCipTagPathSlice? Slice = null)
{ {
/// <summary>Rebuild the canonical Logix tag string.</summary> /// <summary>Rebuild the canonical Logix tag string.</summary>
public string ToLibplctagName() public string ToLibplctagName()
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
if (seg.Subscripts.Count > 0) if (seg.Subscripts.Count > 0)
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']'); buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
} }
if (Slice is not null) buf.Append('[').Append(Slice.Start).Append("..").Append(Slice.End).Append(']');
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value); if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
return buf.ToString(); return buf.ToString();
} }
/// <summary>
/// Logix-symbol form for issuing a single libplctag tag-create that reads the slice as a
/// contiguous buffer — i.e. the bare array name (with the start subscript) without the
/// <c>..End</c> suffix. The driver pairs this with <see cref="AbCipTagCreateParams.ElementCount"/>
/// = <see cref="AbCipTagPathSlice.Count"/> to issue a single Rockwell array read.
/// </summary>
public string ToLibplctagSliceArrayName()
{
if (Slice is null) return ToLibplctagName();
var buf = new System.Text.StringBuilder();
if (ProgramScope is not null)
buf.Append("Program:").Append(ProgramScope).Append('.');
for (var i = 0; i < Segments.Count; i++)
{
if (i > 0) buf.Append('.');
var seg = Segments[i];
buf.Append(seg.Name);
if (seg.Subscripts.Count > 0)
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
}
// Anchor the read at the slice start; libplctag treats Name=Tag[0] + ElementCount=N as
// "read N consecutive elements starting at index 0", which is the exact Rockwell
// array-read semantic this PR is wiring up.
buf.Append('[').Append(Slice.Start).Append(']');
return buf.ToString();
}
/// <summary> /// <summary>
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser /// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
/// doesn't support — the driver surfaces that as a config-validation error rather than /// doesn't support — the driver surfaces that as a config-validation error rather than
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
} }
var segments = new List<AbCipTagPathSegment>(parts.Count); var segments = new List<AbCipTagPathSegment>(parts.Count);
foreach (var part in parts) AbCipTagPathSlice? slice = null;
for (var partIdx = 0; partIdx < parts.Count; partIdx++)
{ {
var part = parts[partIdx];
var bracketIdx = part.IndexOf('['); var bracketIdx = part.IndexOf('[');
if (bracketIdx < 0) if (bracketIdx < 0)
{ {
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
var name = part[..bracketIdx]; var name = part[..bracketIdx];
if (!IsValidIdent(name)) return null; if (!IsValidIdent(name)) return null;
var inner = part[(bracketIdx + 1)..^1]; var inner = part[(bracketIdx + 1)..^1];
// Slice syntax `[N..M]` — only allowed on the LAST segment, must not coexist with
// multi-dim subscripts, must not be combined with bit-index, and requires M >= N.
// Any other shape is rejected so callers see a config-validation error rather than
// the driver attempting a best-effort scalar read.
if (inner.Contains(".."))
{
if (partIdx != parts.Count - 1) return null; // slice + sub-element
if (bitIndex is not null) return null; // slice + bit index
if (inner.Contains(',')) return null; // slice cannot be multi-dim
var parts2 = inner.Split("..", 2, StringSplitOptions.None);
if (parts2.Length != 2) return null;
if (!int.TryParse(parts2[0], out var sliceStart) || sliceStart < 0) return null;
if (!int.TryParse(parts2[1], out var sliceEnd) || sliceEnd < sliceStart) return null;
slice = new AbCipTagPathSlice(sliceStart, sliceEnd);
segments.Add(new AbCipTagPathSegment(name, []));
continue;
}
var subs = new List<int>(); var subs = new List<int>();
foreach (var tok in inner.Split(',')) foreach (var tok in inner.Split(','))
{ {
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
} }
if (segments.Count == 0) return null; if (segments.Count == 0) return null;
return new AbCipTagPath(programScope, segments, bitIndex); return new AbCipTagPath(programScope, segments, bitIndex, slice);
} }
private static bool IsValidIdent(string s) private static bool IsValidIdent(string s)
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
/// <summary>One path segment: a member name plus any numeric subscripts.</summary> /// <summary>One path segment: a member name plus any numeric subscripts.</summary>
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts); public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
/// <summary>
/// Inclusive-on-both-ends array slice carried on the trailing segment of an
/// <see cref="AbCipTagPath"/>. <c>Tag[0..15]</c> parses to <c>Start=0, End=15</c>; the
/// planner pairs this with libplctag's <c>ElementCount</c> attribute to issue a single
/// Rockwell array read covering <c>End - Start + 1</c> elements.
/// </summary>
public sealed record AbCipTagPathSlice(int Start, int End)
{
/// <summary>Total element count covered by the slice (inclusive both ends).</summary>
public int Count => End - Start + 1;
}

View File

@@ -65,10 +65,43 @@ public interface IAbCipTagFactory
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param> /// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param> /// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param> /// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
/// <param name="StringMaxCapacity">Optional Logix STRINGnn DATA-array capacity (e.g. 20 / 40 / 80
/// for <c>STRING_20</c> / <c>STRING_40</c> / <c>STRING_80</c> UDTs). Threads through libplctag's
/// <c>str_max_capacity</c> attribute. <c>null</c> keeps libplctag's default 82-byte STRING
/// behaviour for back-compat.</param>
/// <param name="ElementCount">Optional libplctag <c>ElementCount</c> override — set to <c>N</c>
/// to issue a Rockwell array read covering <c>N</c> consecutive elements starting at the
/// subscripted index in <see cref="TagName"/>. Drives PR abcip-1.3 array-slice support;
/// <c>null</c> leaves libplctag's default scalar-element behaviour for back-compat.</param>
/// <param name="ConnectionSize">PR abcip-3.1 — CIP Forward Open buffer size in bytes. Threads
/// through to libplctag's <c>connection_size</c> attribute. The driver always supplies a
/// value here — either the per-device <see cref="AbCipDeviceOptions.ConnectionSize"/>
/// override or the family profile's <see cref="PlcFamilies.AbCipPlcFamilyProfile.DefaultConnectionSize"/>.
/// Bigger packets fit more tags per RTT (higher throughput); smaller packets stay compatible
/// with legacy firmware (v19-and-earlier ControlLogix caps at 504, Micro800 hard-caps at
/// 488).</param>
/// <param name="AddressingMode">PR abcip-3.2 — concrete addressing mode the runtime should
/// activate for this tag handle. Always either <see cref="AddressingMode.Symbolic"/> or
/// <see cref="AddressingMode.Logical"/> at this layer (the driver resolves <c>Auto</c> +
/// family-incompatibility before building the create-params). Symbolic is the libplctag
/// default and needs no extra attribute. Logical adds the libplctag <c>use_connected_msg=1</c>
/// attribute + (when an instance ID is known via <see cref="LogicalInstanceId"/>) reaches
/// into <c>NativeTagWrapper.SetAttributeString</c> by reflection because the .NET wrapper
/// does not expose a public knob for instance-ID addressing.</param>
/// <param name="LogicalInstanceId">PR abcip-3.2 — Symbol Object instance ID the controller
/// assigned to this tag, populated by the driver after a one-time <c>@tags</c> walk for
/// Logical-mode devices. <c>null</c> for Symbolic mode + for the very first read on a
/// Logical device when the symbol-table walk has not yet completed; the runtime falls back
/// to Symbolic addressing in either case so the read still completes.</param>
public sealed record AbCipTagCreateParams( public sealed record AbCipTagCreateParams(
string Gateway, string Gateway,
int Port, int Port,
string CipPath, string CipPath,
string LibplctagPlcAttribute, string LibplctagPlcAttribute,
string TagName, string TagName,
TimeSpan Timeout); TimeSpan Timeout,
int? StringMaxCapacity = null,
int? ElementCount = null,
int ConnectionSize = 4002,
AddressingMode AddressingMode = AddressingMode.Symbolic,
uint? LogicalInstanceId = null);

View File

@@ -0,0 +1,99 @@
using System.Globalization;
using System.IO;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// Render an enumerable of <see cref="AbCipTagDefinition"/> as a Kepware-format CSV
/// document. Emits the header expected by <see cref="CsvTagImporter"/> so the importer
/// and exporter form a complete round-trip path: load → export → reparse → identical
/// entries (modulo unknown-type tags, which export as <c>STRING</c> and reimport as
/// <see cref="AbCipDataType.Structure"/> per the importer's fall-through rule).
/// </summary>
public static class CsvTagExporter
{
public static readonly IReadOnlyList<string> KepwareColumns =
[
"Tag Name",
"Address",
"Data Type",
"Respect Data Type",
"Client Access",
"Scan Rate",
"Description",
"Scaling",
];
/// <summary>Write the tag list to <paramref name="writer"/> in Kepware CSV format.</summary>
public static void Write(IEnumerable<AbCipTagDefinition> tags, TextWriter writer)
{
ArgumentNullException.ThrowIfNull(tags);
ArgumentNullException.ThrowIfNull(writer);
writer.WriteLine(string.Join(",", KepwareColumns.Select(EscapeField)));
foreach (var tag in tags)
{
var fields = new[]
{
tag.Name ?? string.Empty,
tag.TagPath ?? string.Empty,
FormatDataType(tag.DataType),
"1", // Respect Data Type — Kepware EX default.
tag.Writable ? "Read/Write" : "Read Only",
"100", // Scan Rate (ms) — placeholder default.
tag.Description ?? string.Empty,
"None", // Scaling — driver doesn't apply scaling.
};
writer.WriteLine(string.Join(",", fields.Select(EscapeField)));
}
}
/// <summary>Render the tag list to a string.</summary>
public static string ToCsv(IEnumerable<AbCipTagDefinition> tags)
{
using var sw = new StringWriter(CultureInfo.InvariantCulture);
Write(tags, sw);
return sw.ToString();
}
/// <summary>Write the tag list to <paramref name="path"/> as UTF-8 (no BOM).</summary>
public static void WriteFile(IEnumerable<AbCipTagDefinition> tags, string path)
{
ArgumentNullException.ThrowIfNull(path);
using var sw = new StreamWriter(path, append: false, new UTF8Encoding(false));
Write(tags, sw);
}
private static string FormatDataType(AbCipDataType t) => t switch
{
AbCipDataType.Bool => "BOOL",
AbCipDataType.SInt => "SINT",
AbCipDataType.Int => "INT",
AbCipDataType.DInt => "DINT",
AbCipDataType.LInt => "LINT",
AbCipDataType.USInt => "USINT",
AbCipDataType.UInt => "UINT",
AbCipDataType.UDInt => "UDINT",
AbCipDataType.ULInt => "ULINT",
AbCipDataType.Real => "REAL",
AbCipDataType.LReal => "LREAL",
AbCipDataType.String => "STRING",
AbCipDataType.Dt => "DT",
AbCipDataType.Structure => "STRING", // Surface UDT-typed tags as STRING — Kepware has no UDT cell.
_ => "STRING",
};
/// <summary>Quote a field if it contains comma, quote, CR, or LF; escape embedded quotes by doubling.</summary>
private static string EscapeField(string value)
{
value ??= string.Empty;
var needsQuotes =
value.IndexOf(',') >= 0 ||
value.IndexOf('"') >= 0 ||
value.IndexOf('\r') >= 0 ||
value.IndexOf('\n') >= 0;
if (!needsQuotes) return value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
}

View File

@@ -0,0 +1,226 @@
using System.Globalization;
using System.IO;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// Parse a Kepware-format AB CIP tag CSV into <see cref="AbCipTagDefinition"/> entries.
/// The expected column layout matches the Kepware EX tag-export shape so operators can
/// round-trip tags through Excel without re-keying:
/// <c>Tag Name, Address, Data Type, Respect Data Type, Client Access, Scan Rate,
/// Description, Scaling</c>. The first non-blank, non-comment row is treated as the
/// header — column order is honoured by name lookup, so reorderings out of Excel still
/// work. Blank rows + rows whose first cell starts with a Kepware section marker
/// (<c>;</c> / <c>#</c>) are skipped.
/// </summary>
/// <remarks>
/// <para>
/// Mapping: <c>Tag Name</c> → <see cref="AbCipTagDefinition.Name"/>;
/// <c>Address</c> → <see cref="AbCipTagDefinition.TagPath"/>;
/// <c>Data Type</c> → <see cref="AbCipTagDefinition.DataType"/> (Logix atomic name —
/// BOOL/SINT/INT/DINT/REAL/STRING/...; unknown values fall through as
/// <see cref="AbCipDataType.Structure"/> the same way <see cref="L5kIngest"/> handles
/// unknown types);
/// <c>Description</c> → <see cref="AbCipTagDefinition.Description"/>;
/// <c>Client Access</c> → <see cref="AbCipTagDefinition.Writable"/>: any value
/// containing <c>W</c> (case-insensitive) is treated as Read/Write; everything else
/// is Read-Only.
/// </para>
/// <para>
/// CSV semantics are RFC-4180-ish: double-quoted fields support embedded commas, line
/// breaks, and escaped quotes (<c>""</c>). The parser is single-pass + deliberately
/// narrow — Kepware's exporter does not produce anything more exotic.
/// </para>
/// </remarks>
public sealed class CsvTagImporter
{
/// <summary>Default device host address applied to every imported tag.</summary>
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
/// <summary>Optional prefix prepended to each imported tag's name. Default empty.</summary>
public string NamePrefix { get; init; } = string.Empty;
public CsvTagImportResult Import(string csvText)
{
ArgumentNullException.ThrowIfNull(csvText);
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
throw new InvalidOperationException(
$"{nameof(CsvTagImporter)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Import)} is called — every imported tag needs a target device.");
var rows = CsvReader.ReadAll(csvText);
var tags = new List<AbCipTagDefinition>();
var skippedBlank = 0;
Dictionary<string, int>? header = null;
foreach (var row in rows)
{
if (row.Count == 0 || row.All(string.IsNullOrWhiteSpace))
{
skippedBlank++;
continue;
}
var first = row[0].TrimStart();
if (first.StartsWith(';') || first.StartsWith('#'))
{
skippedBlank++;
continue;
}
if (header is null)
{
header = BuildHeader(row);
continue;
}
var name = GetCell(row, header, "Tag Name");
if (string.IsNullOrWhiteSpace(name))
{
skippedBlank++;
continue;
}
var address = GetCell(row, header, "Address");
var dataTypeText = GetCell(row, header, "Data Type");
var description = GetCell(row, header, "Description");
var clientAccess = GetCell(row, header, "Client Access");
var dataType = ParseDataType(dataTypeText);
var writable = !string.IsNullOrEmpty(clientAccess)
&& clientAccess.IndexOf('W', StringComparison.OrdinalIgnoreCase) >= 0;
tags.Add(new AbCipTagDefinition(
Name: string.IsNullOrEmpty(NamePrefix) ? name : $"{NamePrefix}{name}",
DeviceHostAddress: DefaultDeviceHostAddress,
TagPath: string.IsNullOrEmpty(address) ? name : address,
DataType: dataType,
Writable: writable,
Description: string.IsNullOrEmpty(description) ? null : description));
}
return new CsvTagImportResult(tags, skippedBlank);
}
public CsvTagImportResult ImportFile(string path) =>
Import(File.ReadAllText(path, Encoding.UTF8));
private static Dictionary<string, int> BuildHeader(IReadOnlyList<string> row)
{
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < row.Count; i++)
{
var key = row[i]?.Trim() ?? string.Empty;
if (key.Length > 0 && !dict.ContainsKey(key))
dict[key] = i;
}
return dict;
}
private static string GetCell(IReadOnlyList<string> row, Dictionary<string, int> header, string column)
{
if (!header.TryGetValue(column, out var idx)) return string.Empty;
if (idx < 0 || idx >= row.Count) return string.Empty;
return row[idx]?.Trim() ?? string.Empty;
}
private static AbCipDataType ParseDataType(string s) =>
s?.Trim().ToUpperInvariant() switch
{
"BOOL" or "BIT" => AbCipDataType.Bool,
"SINT" or "BYTE" => AbCipDataType.SInt,
"INT" or "WORD" or "SHORT" => AbCipDataType.Int,
"DINT" or "DWORD" or "LONG" => AbCipDataType.DInt,
"LINT" => AbCipDataType.LInt,
"USINT" => AbCipDataType.USInt,
"UINT" => AbCipDataType.UInt,
"UDINT" => AbCipDataType.UDInt,
"ULINT" => AbCipDataType.ULInt,
"REAL" or "FLOAT" => AbCipDataType.Real,
"LREAL" or "DOUBLE" => AbCipDataType.LReal,
"STRING" => AbCipDataType.String,
"DT" or "DATETIME" or "DATE" => AbCipDataType.Dt,
_ => AbCipDataType.Structure,
};
}
/// <summary>Result of <see cref="CsvTagImporter.Import"/>.</summary>
public sealed record CsvTagImportResult(
IReadOnlyList<AbCipTagDefinition> Tags,
int SkippedBlankCount);
/// <summary>
/// Tiny RFC-4180-ish CSV reader. Supports double-quoted fields, escaped <c>""</c>
/// quotes, and embedded line breaks inside quotes. Internal because the importer +
/// exporter are the only two callers and we don't want to add a CSV dep.
/// </summary>
internal static class CsvReader
{
public static List<List<string>> ReadAll(string text)
{
var rows = new List<List<string>>();
var row = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < text.Length && text[i + 1] == '"')
{
field.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
field.Append(c);
}
continue;
}
switch (c)
{
case '"':
inQuotes = true;
break;
case ',':
row.Add(field.ToString());
field.Clear();
break;
case '\r':
// Swallow CR — handle CRLF and lone CR alike.
row.Add(field.ToString());
field.Clear();
rows.Add(row);
row = new List<string>();
if (i + 1 < text.Length && text[i + 1] == '\n') i++;
break;
case '\n':
row.Add(field.ToString());
field.Clear();
rows.Add(row);
row = new List<string>();
break;
default:
field.Append(c);
break;
}
}
if (field.Length > 0 || row.Count > 0)
{
row.Add(field.ToString());
rows.Add(row);
}
return rows;
}
}

View File

@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// Abstraction over an L5K text source so the parser can consume strings, files, or streams
/// without coupling to <see cref="System.IO"/>. Implementations return the full text in a
/// single call — L5K files are typically &lt;10 MB even for large controllers, and the parser
/// needs random access to handle nested DATATYPE/TAG blocks regardless.
/// </summary>
public interface IL5kSource
{
/// <summary>Reads the full L5K body as a string.</summary>
string ReadAll();
}
/// <summary>String-backed source — used by tests + when the L5K body is loaded elsewhere.</summary>
public sealed class StringL5kSource : IL5kSource
{
private readonly string _text;
public StringL5kSource(string text) => _text = text ?? throw new ArgumentNullException(nameof(text));
public string ReadAll() => _text;
}
/// <summary>File-backed source — used by Admin / driver init to load <c>*.L5K</c> exports.</summary>
public sealed class FileL5kSource : IL5kSource
{
private readonly string _path;
public FileL5kSource(string path) => _path = path ?? throw new ArgumentNullException(nameof(path));
public string ReadAll() => System.IO.File.ReadAllText(_path);
}

View File

@@ -0,0 +1,161 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// Converts a parsed <see cref="L5kDocument"/> into <see cref="AbCipTagDefinition"/> entries
/// ready to be merged into <see cref="AbCipDriverOptions.Tags"/>. UDT definitions become
/// <see cref="AbCipStructureMember"/> lists keyed by data-type name; tags whose
/// <see cref="L5kTag.DataType"/> matches a known UDT get those members attached so the
/// discovery code can fan out the structure.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Alias tags are skipped</strong> — when <see cref="L5kTag.AliasFor"/> is
/// non-null the entry is dropped at ingest. Surfacing both the alias + its target
/// creates duplicate Variables in the OPC UA address space (Kepware's L5K importer
/// takes the same approach for this reason; the alias target is the single source of
/// truth for storage).
/// </para>
/// <para>
/// <strong>Tags with <c>ExternalAccess := None</c> are skipped</strong> — the controller
/// actively rejects external reads/writes, so emitting them as Variables would just
/// produce permanent BadCommunicationError. <c>Read Only</c> maps to <c>Writable=false</c>;
/// <c>Read/Write</c> (or absent) maps to <c>Writable=true</c>.
/// </para>
/// <para>
/// Unknown data-type names (not atomic + not a parsed UDT) fall through as
/// <see cref="AbCipDataType.Structure"/> with no member layout — discovery can still
/// expose them as black-box variables and the operator can pin them via dotted paths.
/// </para>
/// </remarks>
public sealed class L5kIngest
{
/// <summary>Default device host address applied to every imported tag.</summary>
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
/// <summary>
/// Optional prefix prepended to imported tag names — useful when ingesting multiple
/// L5K exports into one driver instance to avoid name collisions. Default empty.
/// </summary>
public string NamePrefix { get; init; } = string.Empty;
public L5kIngestResult Ingest(L5kDocument document)
{
ArgumentNullException.ThrowIfNull(document);
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
throw new InvalidOperationException(
$"{nameof(L5kIngest)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Ingest)} is called — every imported tag needs a target device.");
// Index UDT definitions by name so we can fan out structure tags inline.
var udtIndex = new Dictionary<string, IReadOnlyList<AbCipStructureMember>>(StringComparer.OrdinalIgnoreCase);
foreach (var dt in document.DataTypes)
{
var members = new List<AbCipStructureMember>(dt.Members.Count);
foreach (var m in dt.Members)
{
var atomic = TryMapAtomic(m.DataType);
var memberType = atomic ?? AbCipDataType.Structure;
var writable = !IsReadOnly(m.ExternalAccess) && !IsAccessNone(m.ExternalAccess);
members.Add(new AbCipStructureMember(
Name: m.Name,
DataType: memberType,
Writable: writable,
Description: m.Description,
AoiQualifier: MapAoiUsage(m.Usage)));
}
udtIndex[dt.Name] = members;
}
var tags = new List<AbCipTagDefinition>();
var skippedAliases = 0;
var skippedNoAccess = 0;
foreach (var t in document.Tags)
{
if (!string.IsNullOrEmpty(t.AliasFor)) { skippedAliases++; continue; }
if (IsAccessNone(t.ExternalAccess)) { skippedNoAccess++; continue; }
var atomic = TryMapAtomic(t.DataType);
AbCipDataType dataType;
IReadOnlyList<AbCipStructureMember>? members = null;
if (atomic is { } a)
{
dataType = a;
}
else
{
dataType = AbCipDataType.Structure;
if (udtIndex.TryGetValue(t.DataType, out var udtMembers))
members = udtMembers;
}
var tagPath = t.ProgramScope is { Length: > 0 }
? $"Program:{t.ProgramScope}.{t.Name}"
: t.Name;
var name = string.IsNullOrEmpty(NamePrefix) ? t.Name : $"{NamePrefix}{t.Name}";
// Make the OPC UA tag name unique when both controller-scope + program-scope tags
// share the same simple Name.
if (t.ProgramScope is { Length: > 0 })
name = string.IsNullOrEmpty(NamePrefix)
? $"{t.ProgramScope}.{t.Name}"
: $"{NamePrefix}{t.ProgramScope}.{t.Name}";
var writable = !IsReadOnly(t.ExternalAccess);
tags.Add(new AbCipTagDefinition(
Name: name,
DeviceHostAddress: DefaultDeviceHostAddress,
TagPath: tagPath,
DataType: dataType,
Writable: writable,
Members: members,
Description: t.Description));
}
return new L5kIngestResult(tags, skippedAliases, skippedNoAccess);
}
private static bool IsReadOnly(string? externalAccess) =>
externalAccess is not null
&& externalAccess.Trim().Replace(" ", string.Empty).Equals("ReadOnly", StringComparison.OrdinalIgnoreCase);
private static bool IsAccessNone(string? externalAccess) =>
externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// PR abcip-2.6 — map the AOI <c>Usage</c> attribute string to <see cref="AoiQualifier"/>.
/// Plain UDT members (Usage = null) + unrecognised values map to <see cref="AoiQualifier.Local"/>.
/// </summary>
private static AoiQualifier MapAoiUsage(string? usage) =>
usage?.Trim().ToUpperInvariant() switch
{
"INPUT" => AoiQualifier.Input,
"OUTPUT" => AoiQualifier.Output,
"INOUT" => AoiQualifier.InOut,
_ => AoiQualifier.Local,
};
/// <summary>Map a Logix atomic type name. Returns <c>null</c> for UDT/structure references.</summary>
private static AbCipDataType? TryMapAtomic(string logixType) =>
logixType?.Trim().ToUpperInvariant() switch
{
"BOOL" or "BIT" => AbCipDataType.Bool,
"SINT" => AbCipDataType.SInt,
"INT" => AbCipDataType.Int,
"DINT" => AbCipDataType.DInt,
"LINT" => AbCipDataType.LInt,
"USINT" => AbCipDataType.USInt,
"UINT" => AbCipDataType.UInt,
"UDINT" => AbCipDataType.UDInt,
"ULINT" => AbCipDataType.ULInt,
"REAL" => AbCipDataType.Real,
"LREAL" => AbCipDataType.LReal,
"STRING" => AbCipDataType.String,
"DT" or "DATETIME" => AbCipDataType.Dt,
_ => null,
};
}
/// <summary>Result of <see cref="L5kIngest.Ingest"/> — produced tags + per-skip-reason counts.</summary>
public sealed record L5kIngestResult(
IReadOnlyList<AbCipTagDefinition> Tags,
int SkippedAliasCount,
int SkippedNoAccessCount);

View File

@@ -0,0 +1,469 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// Pure-text parser for Studio 5000 L5K controller exports. L5K is a labelled-section export
/// with TAG/END_TAG, DATATYPE/END_DATATYPE, PROGRAM/END_PROGRAM blocks. This parser handles
/// the common shapes:
/// <list type="bullet">
/// <item>Controller-scope <c>TAG ... END_TAG</c> with <c>Name</c>, <c>DataType</c>,
/// optional <c>ExternalAccess</c>, optional <c>Description</c>.</item>
/// <item>Program-scope tags inside <c>PROGRAM ... END_PROGRAM</c>.</item>
/// <item>UDT definitions via <c>DATATYPE ... END_DATATYPE</c> with <c>MEMBER</c> lines.</item>
/// <item>Alias tags (<c>AliasFor</c>) — recognised + flagged so callers can skip them.</item>
/// </list>
/// Unknown sections (CONFIG, MODULE, AOI, MOTION_GROUP, etc.) are skipped silently.
/// Per Kepware precedent, alias tags are typically skipped on ingest because the alias target
/// is what owns the storage — surfacing both creates duplicate writes/reads.
/// </summary>
/// <remarks>
/// This is a permissive line-oriented parser, not a full L5K grammar. Comments
/// (<c>(* ... *)</c>) are stripped before tokenization. The parser is deliberately tolerant of
/// extra whitespace, unknown attributes, and trailing semicolons — real-world L5K files are
/// produced by RSLogix exports that vary across versions.
/// </remarks>
public static class L5kParser
{
public static L5kDocument Parse(IL5kSource source)
{
ArgumentNullException.ThrowIfNull(source);
var raw = source.ReadAll();
var stripped = StripBlockComments(raw);
var lines = stripped.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None);
var tags = new List<L5kTag>();
var datatypes = new List<L5kDataType>();
string? currentProgram = null;
var i = 0;
while (i < lines.Length)
{
var line = lines[i].Trim();
if (line.Length == 0) { i++; continue; }
// PROGRAM block — opens a program scope; the body contains nested TAG blocks.
if (StartsWithKeyword(line, "PROGRAM"))
{
currentProgram = ExtractFirstQuotedOrToken(line.Substring("PROGRAM".Length).Trim());
i++;
continue;
}
if (StartsWithKeyword(line, "END_PROGRAM"))
{
currentProgram = null;
i++;
continue;
}
// TAG block — collects 1..N tag entries until END_TAG.
if (StartsWithKeyword(line, "TAG"))
{
var consumed = ParseTagBlock(lines, i, currentProgram, tags);
i += consumed;
continue;
}
// DATATYPE block.
if (StartsWithKeyword(line, "DATATYPE"))
{
var consumed = ParseDataTypeBlock(lines, i, datatypes);
i += consumed;
continue;
}
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION block. AOI parameters carry a Usage
// attribute (Input / Output / InOut); each PARAMETER becomes a member of the AOI's
// L5kDataType entry so AOI-typed tags pick up a layout the same way UDT-typed tags do.
if (StartsWithKeyword(line, "ADD_ON_INSTRUCTION_DEFINITION"))
{
var consumed = ParseAoiDefinitionBlock(lines, i, datatypes);
i += consumed;
continue;
}
i++;
}
return new L5kDocument(tags, datatypes);
}
// ---- TAG block ---------------------------------------------------------
// Each TAG block contains 1..N entries of the form:
// TagName : DataType (Description := "...", ExternalAccess := Read/Write) := initialValue;
// until END_TAG. Entries can span multiple lines, terminated by ';'.
private static int ParseTagBlock(string[] lines, int start, string? program, List<L5kTag> into)
{
var i = start + 1;
while (i < lines.Length)
{
var line = lines[i].Trim();
if (StartsWithKeyword(line, "END_TAG")) return i - start + 1;
if (line.Length == 0) { i++; continue; }
var sb = new System.Text.StringBuilder(line);
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
{
var peek = lines[i + 1].Trim();
if (StartsWithKeyword(peek, "END_TAG")) break;
i++;
sb.Append(' ').Append(peek);
}
i++;
var entry = sb.ToString().TrimEnd(';').Trim();
var tag = ParseTagEntry(entry, program);
if (tag is not null) into.Add(tag);
}
return i - start;
}
private static L5kTag? ParseTagEntry(string entry, string? program)
{
// entry shape: Name : DataType [ (attribute := value, ...) ] [ := initialValue ]
// Find the first ':' that separates Name from DataType. Avoid ':=' (the assign op).
var colonIdx = FindBareColon(entry);
if (colonIdx < 0) return null;
var name = entry.Substring(0, colonIdx).Trim();
if (name.Length == 0) return null;
var rest = entry.Substring(colonIdx + 1).Trim();
// The attribute parens themselves contain ':=' assignments, so locate the top-level
// assignment (depth-0 ':=') that introduces the initial value before stripping.
var assignIdx = FindTopLevelAssign(rest);
var head = assignIdx >= 0 ? rest.Substring(0, assignIdx).Trim() : rest;
// Pull attribute tuple out of head: "DataType (attr := val, attr := val)".
string dataType;
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var openParen = head.IndexOf('(');
if (openParen >= 0)
{
dataType = head.Substring(0, openParen).Trim();
var closeParen = head.LastIndexOf(')');
if (closeParen > openParen)
{
var attrBody = head.Substring(openParen + 1, closeParen - openParen - 1);
ParseAttributeList(attrBody, attributes);
}
}
else
{
dataType = head.Trim();
}
if (dataType.Length == 0) return null;
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
var aliasFor = attributes.TryGetValue("AliasFor", out var af) ? Unquote(af) : null;
return new L5kTag(
Name: name,
DataType: dataType,
ProgramScope: program,
ExternalAccess: externalAccess,
Description: description,
AliasFor: aliasFor);
}
// Find the first ':=' at depth 0 (not inside parens / brackets / quotes). Returns -1 if none.
private static int FindTopLevelAssign(string entry)
{
var depth = 0;
var inQuote = false;
for (var k = 0; k < entry.Length - 1; k++)
{
var c = entry[k];
if (c == '"' || c == '\'') inQuote = !inQuote;
if (inQuote) continue;
if (c == '(' || c == '[' || c == '{') depth++;
else if (c == ')' || c == ']' || c == '}') depth--;
else if (c == ':' && entry[k + 1] == '=' && depth == 0) return k;
}
return -1;
}
// Find the first colon that is NOT part of ':=' and not inside a quoted string.
private static int FindBareColon(string entry)
{
var inQuote = false;
for (var k = 0; k < entry.Length; k++)
{
var c = entry[k];
if (c == '"' || c == '\'') inQuote = !inQuote;
if (inQuote) continue;
if (c != ':') continue;
if (k + 1 < entry.Length && entry[k + 1] == '=') continue;
return k;
}
return -1;
}
private static void ParseAttributeList(string body, Dictionary<string, string> into)
{
foreach (var part in SplitTopLevelCommas(body))
{
var assign = part.IndexOf(":=", StringComparison.Ordinal);
if (assign < 0) continue;
var key = part.Substring(0, assign).Trim();
var val = part.Substring(assign + 2).Trim();
if (key.Length > 0) into[key] = val;
}
}
private static IEnumerable<string> SplitTopLevelCommas(string body)
{
var depth = 0;
var inQuote = false;
var start = 0;
for (var k = 0; k < body.Length; k++)
{
var c = body[k];
if (c == '"' || c == '\'') inQuote = !inQuote;
if (inQuote) continue;
if (c == '(' || c == '[' || c == '{') depth++;
else if (c == ')' || c == ']' || c == '}') depth--;
else if (c == ',' && depth == 0)
{
yield return body.Substring(start, k - start);
start = k + 1;
}
}
if (start < body.Length) yield return body.Substring(start);
}
// ---- DATATYPE block ----------------------------------------------------
private static int ParseDataTypeBlock(string[] lines, int start, List<L5kDataType> into)
{
var first = lines[start].Trim();
var head = first.Substring("DATATYPE".Length).Trim();
var name = ExtractFirstQuotedOrToken(head);
var members = new List<L5kMember>();
var i = start + 1;
while (i < lines.Length)
{
var line = lines[i].Trim();
if (StartsWithKeyword(line, "END_DATATYPE"))
{
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
return i - start + 1;
}
if (line.Length == 0) { i++; continue; }
if (StartsWithKeyword(line, "MEMBER"))
{
var sb = new System.Text.StringBuilder(line);
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
{
var peek = lines[i + 1].Trim();
if (StartsWithKeyword(peek, "END_DATATYPE")) break;
i++;
sb.Append(' ').Append(peek);
}
var entry = sb.ToString().TrimEnd(';').Trim();
entry = entry.Substring("MEMBER".Length).Trim();
var member = ParseMemberEntry(entry);
if (member is not null) members.Add(member);
}
i++;
}
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
return i - start;
}
private static L5kMember? ParseMemberEntry(string entry)
{
// entry shape: MemberName : DataType [ [arrayDim] ] [ (attr := val, ...) ] [ := default ]
var colonIdx = FindBareColon(entry);
if (colonIdx < 0) return null;
var name = entry.Substring(0, colonIdx).Trim();
if (name.Length == 0) return null;
var rest = entry.Substring(colonIdx + 1).Trim();
var assignIdx = FindTopLevelAssign(rest);
if (assignIdx >= 0) rest = rest.Substring(0, assignIdx).Trim();
int? arrayDim = null;
var bracketOpen = rest.IndexOf('[');
if (bracketOpen >= 0)
{
var bracketClose = rest.IndexOf(']', bracketOpen + 1);
if (bracketClose > bracketOpen)
{
var dimText = rest.Substring(bracketOpen + 1, bracketClose - bracketOpen - 1).Trim();
if (int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim))
arrayDim = dim;
rest = (rest.Substring(0, bracketOpen) + rest.Substring(bracketClose + 1)).Trim();
}
}
string typePart;
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var openParen = rest.IndexOf('(');
if (openParen >= 0)
{
typePart = rest.Substring(0, openParen).Trim();
var closeParen = rest.LastIndexOf(')');
if (closeParen > openParen)
{
var attrBody = rest.Substring(openParen + 1, closeParen - openParen - 1);
ParseAttributeList(attrBody, attributes);
}
}
else
{
typePart = rest.Trim();
}
if (typePart.Length == 0) return null;
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
// PR abcip-2.6 — Usage attribute on AOI parameters (Input / Output / InOut). Plain UDT
// members don't carry it; null on a regular DATATYPE MEMBER is the default + maps to Local
// in the ingest layer.
var usage = attributes.TryGetValue("Usage", out var u) ? u.Trim() : null;
return new L5kMember(name, typePart, arrayDim, externalAccess, description, usage);
}
// ---- AOI block ---------------------------------------------------------
/// <summary>
/// PR abcip-2.6 — parse <c>ADD_ON_INSTRUCTION_DEFINITION ... END_ADD_ON_INSTRUCTION_DEFINITION</c>
/// blocks. Body is structured around PARAMETER entries (each carrying a <c>Usage</c>
/// attribute) and optional LOCAL_TAGS / ROUTINE blocks. We extract the parameters as
/// <see cref="L5kMember"/> rows + leave routines alone — only the surface API matters for
/// tag-discovery fan-out. The L5K format encloses parameters either inside a
/// <c>PARAMETERS ... END_PARAMETERS</c> block or as bare <c>PARAMETER ... ;</c> lines at
/// the AOI top level depending on Studio 5000 export options; this parser accepts both.
/// </summary>
private static int ParseAoiDefinitionBlock(string[] lines, int start, List<L5kDataType> into)
{
var first = lines[start].Trim();
var head = first.Substring("ADD_ON_INSTRUCTION_DEFINITION".Length).Trim();
var name = ExtractFirstQuotedOrToken(head);
var members = new List<L5kMember>();
var i = start + 1;
var inLocalsBlock = false;
var inRoutineBlock = false;
while (i < lines.Length)
{
var line = lines[i].Trim();
if (StartsWithKeyword(line, "END_ADD_ON_INSTRUCTION_DEFINITION"))
{
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
return i - start + 1;
}
if (line.Length == 0) { i++; continue; }
// Skip routine bodies — they hold ladder / ST / FBD code we don't care about for
// tag-discovery, and their own END_ROUTINE / END_LOCAL_TAGS tokens close them out.
if (StartsWithKeyword(line, "ROUTINE")) { inRoutineBlock = true; i++; continue; }
if (StartsWithKeyword(line, "END_ROUTINE")) { inRoutineBlock = false; i++; continue; }
if (StartsWithKeyword(line, "LOCAL_TAGS")) { inLocalsBlock = true; i++; continue; }
if (StartsWithKeyword(line, "END_LOCAL_TAGS")) { inLocalsBlock = false; i++; continue; }
if (inRoutineBlock || inLocalsBlock) { i++; continue; }
// PARAMETERS / END_PARAMETERS wrappers are skipped — bare PARAMETER lines drive parsing.
if (StartsWithKeyword(line, "PARAMETERS")) { i++; continue; }
if (StartsWithKeyword(line, "END_PARAMETERS")) { i++; continue; }
if (StartsWithKeyword(line, "PARAMETER"))
{
var sb = new System.Text.StringBuilder(line);
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
{
var peek = lines[i + 1].Trim();
if (StartsWithKeyword(peek, "END_ADD_ON_INSTRUCTION_DEFINITION")) break;
i++;
sb.Append(' ').Append(peek);
}
var entry = sb.ToString().TrimEnd(';').Trim();
entry = entry.Substring("PARAMETER".Length).Trim();
var member = ParseMemberEntry(entry);
if (member is not null) members.Add(member);
}
i++;
}
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
return i - start;
}
// ---- helpers -----------------------------------------------------------
private static bool StartsWithKeyword(string line, string keyword)
{
if (line.Length < keyword.Length) return false;
if (!line.StartsWith(keyword, StringComparison.OrdinalIgnoreCase)) return false;
if (line.Length == keyword.Length) return true;
var next = line[keyword.Length];
return !char.IsLetterOrDigit(next) && next != '_';
}
private static string ExtractFirstQuotedOrToken(string fragment)
{
var trimmed = fragment.TrimStart();
if (trimmed.Length == 0) return string.Empty;
if (trimmed[0] == '"' || trimmed[0] == '\'')
{
var quote = trimmed[0];
var end = trimmed.IndexOf(quote, 1);
if (end > 0) return trimmed.Substring(1, end - 1);
}
var k = 0;
while (k < trimmed.Length)
{
var c = trimmed[k];
if (char.IsWhiteSpace(c) || c == '(' || c == ',' || c == ';') break;
k++;
}
return trimmed.Substring(0, k);
}
private static string Unquote(string s)
{
s = s.Trim();
if (s.Length >= 2 && (s[0] == '"' || s[0] == '\'') && s[s.Length - 1] == s[0])
return s.Substring(1, s.Length - 2);
return s;
}
private static string StripBlockComments(string text)
{
// L5K comments: `(* ... *)`. Strip so the line scanner doesn't trip on tokens inside.
var pattern = new Regex(@"\(\*.*?\*\)", RegexOptions.Singleline);
return pattern.Replace(text, string.Empty);
}
}
/// <summary>Output of <see cref="L5kParser.Parse(IL5kSource)"/>.</summary>
public sealed record L5kDocument(IReadOnlyList<L5kTag> Tags, IReadOnlyList<L5kDataType> DataTypes);
/// <summary>One L5K tag entry (controller- or program-scope).</summary>
public sealed record L5kTag(
string Name,
string DataType,
string? ProgramScope,
string? ExternalAccess,
string? Description,
string? AliasFor);
/// <summary>One UDT definition extracted from a <c>DATATYPE ... END_DATATYPE</c> block.</summary>
public sealed record L5kDataType(string Name, IReadOnlyList<L5kMember> Members);
/// <summary>One member line inside a UDT definition or AOI parameter list.</summary>
/// <remarks>
/// PR abcip-2.6 — <see cref="Usage"/> carries the AOI <c>Usage</c> attribute (<c>Input</c> /
/// <c>Output</c> / <c>InOut</c>) raw text. Plain UDT members + L5K AOI <c>LOCAL_TAGS</c> leave
/// it null; the ingest layer maps null → <see cref="AoiQualifier.Local"/>.
/// </remarks>
public sealed record L5kMember(
string Name,
string DataType,
int? ArrayDim,
string? ExternalAccess,
string? Description = null,
string? Usage = null);

View File

@@ -0,0 +1,237 @@
using System.Globalization;
using System.Xml;
using System.Xml.XPath;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// XML-format parser for Studio 5000 L5X controller exports. L5X is the XML sibling of L5K
/// and carries the same tag / datatype / program shape, plus richer metadata (notably the
/// AddOnInstructionDefinition catalogue and explicit <c>TagType</c> attributes).
/// <para>
/// This parser produces the same <see cref="L5kDocument"/> bundle as
/// <see cref="L5kParser"/> so <see cref="L5kIngest"/> consumes both formats interchangeably.
/// The two parsers share the post-parse downstream layer; the only difference is how the
/// bundle is materialized from the source bytes.
/// </para>
/// <para>
/// AOIs (<c>AddOnInstructionDefinition</c>) are surfaced as L5K-style UDT entries — their
/// parameters become <see cref="L5kMember"/> rows so AOI-typed tags pick up a member layout
/// the same way UDT-typed tags do. Full Inputs/Outputs/InOut directional metadata + per-call
/// parameter scoping is deferred to PR 2.6 per plan; this PR keeps AOIs visible without
/// attempting to model their call semantics.
/// </para>
/// </summary>
/// <remarks>
/// Uses <see cref="System.Xml.XPath"/> with an <see cref="XPathDocument"/> for read-only
/// traversal. L5X exports are typically &lt;50 MB, so a single in-memory navigator beats
/// forward-only <c>XmlReader</c> on simplicity for the same throughput at this size class.
/// The parser is permissive about missing optional attributes — a real export always has
/// <c>Name</c> + <c>DataType</c>, but <c>ExternalAccess</c> defaults to <c>Read/Write</c>
/// when absent (matching Studio 5000's own default for new tags).
/// </remarks>
public static class L5xParser
{
public static L5kDocument Parse(IL5kSource source)
{
ArgumentNullException.ThrowIfNull(source);
var xml = source.ReadAll();
using var reader = XmlReader.Create(
new System.IO.StringReader(xml),
new XmlReaderSettings
{
// L5X exports never include a DOCTYPE, but disable DTD processing defensively.
DtdProcessing = DtdProcessing.Prohibit,
IgnoreWhitespace = true,
IgnoreComments = true,
});
var doc = new XPathDocument(reader);
var nav = doc.CreateNavigator();
var tags = new List<L5kTag>();
var datatypes = new List<L5kDataType>();
// Controller-scope tags: /RSLogix5000Content/Controller/Tags/Tag
foreach (XPathNavigator tagNode in nav.Select("/RSLogix5000Content/Controller/Tags/Tag"))
{
var t = ReadTag(tagNode, programScope: null);
if (t is not null) tags.Add(t);
}
// Program-scope tags: /RSLogix5000Content/Controller/Programs/Program/Tags/Tag
foreach (XPathNavigator programNode in nav.Select("/RSLogix5000Content/Controller/Programs/Program"))
{
var programName = programNode.GetAttribute("Name", string.Empty);
if (string.IsNullOrEmpty(programName)) continue;
foreach (XPathNavigator tagNode in programNode.Select("Tags/Tag"))
{
var t = ReadTag(tagNode, programName);
if (t is not null) tags.Add(t);
}
}
// UDTs: /RSLogix5000Content/Controller/DataTypes/DataType
foreach (XPathNavigator dtNode in nav.Select("/RSLogix5000Content/Controller/DataTypes/DataType"))
{
var udt = ReadDataType(dtNode);
if (udt is not null) datatypes.Add(udt);
}
// AOIs: surfaced as L5kDataType entries so AOI-typed tags pick up a member layout.
// Per the plan, full directional Input/Output/InOut modelling is deferred to PR 2.6.
foreach (XPathNavigator aoiNode in nav.Select("/RSLogix5000Content/Controller/AddOnInstructionDefinitions/AddOnInstructionDefinition"))
{
var aoi = ReadAddOnInstruction(aoiNode);
if (aoi is not null) datatypes.Add(aoi);
}
return new L5kDocument(tags, datatypes);
}
private static L5kTag? ReadTag(XPathNavigator tagNode, string? programScope)
{
var name = tagNode.GetAttribute("Name", string.Empty);
if (string.IsNullOrEmpty(name)) return null;
var tagType = tagNode.GetAttribute("TagType", string.Empty); // Base | Alias | Produced | Consumed
var dataType = tagNode.GetAttribute("DataType", string.Empty);
var aliasFor = tagNode.GetAttribute("AliasFor", string.Empty);
var externalAccess = tagNode.GetAttribute("ExternalAccess", string.Empty);
// Alias tags often omit DataType (it's inherited from the target). Surface them with
// an empty type — L5kIngest skips alias entries before TryMapAtomic ever sees the type.
if (string.IsNullOrEmpty(dataType)
&& !string.Equals(tagType, "Alias", StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Description child — L5X wraps description text in <Description> (sometimes inside CDATA).
string? description = null;
var descNode = tagNode.SelectSingleNode("Description");
if (descNode is not null)
{
var raw = descNode.Value;
if (!string.IsNullOrEmpty(raw)) description = raw.Trim();
}
return new L5kTag(
Name: name,
DataType: string.IsNullOrEmpty(dataType) ? string.Empty : dataType,
ProgramScope: programScope,
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
Description: description,
AliasFor: string.IsNullOrEmpty(aliasFor) ? null : aliasFor);
}
private static L5kDataType? ReadDataType(XPathNavigator dtNode)
{
var name = dtNode.GetAttribute("Name", string.Empty);
if (string.IsNullOrEmpty(name)) return null;
var members = new List<L5kMember>();
foreach (XPathNavigator memberNode in dtNode.Select("Members/Member"))
{
var m = ReadMember(memberNode);
if (m is not null) members.Add(m);
}
return new L5kDataType(name, members);
}
private static L5kMember? ReadMember(XPathNavigator memberNode)
{
var name = memberNode.GetAttribute("Name", string.Empty);
if (string.IsNullOrEmpty(name)) return null;
// Skip auto-inserted hidden host members for backing storage of BOOL packing — they're
// emitted by RSLogix as members named with the ZZZZZZZZZZ prefix and aren't useful to
// surface as OPC UA variables.
if (name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) return null;
var dataType = memberNode.GetAttribute("DataType", string.Empty);
if (string.IsNullOrEmpty(dataType)) return null;
var externalAccess = memberNode.GetAttribute("ExternalAccess", string.Empty);
int? arrayDim = null;
var dimText = memberNode.GetAttribute("Dimension", string.Empty);
if (!string.IsNullOrEmpty(dimText)
&& int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim)
&& dim > 0)
{
arrayDim = dim;
}
// Description child — same shape as on Tag nodes; sometimes wrapped in CDATA.
string? description = null;
var descNode = memberNode.SelectSingleNode("Description");
if (descNode is not null)
{
var raw = descNode.Value;
if (!string.IsNullOrEmpty(raw)) description = raw.Trim();
}
return new L5kMember(
Name: name,
DataType: dataType,
ArrayDim: arrayDim,
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
Description: description);
}
private static L5kDataType? ReadAddOnInstruction(XPathNavigator aoiNode)
{
var name = aoiNode.GetAttribute("Name", string.Empty);
if (string.IsNullOrEmpty(name)) return null;
var members = new List<L5kMember>();
foreach (XPathNavigator paramNode in aoiNode.Select("Parameters/Parameter"))
{
var paramName = paramNode.GetAttribute("Name", string.Empty);
if (string.IsNullOrEmpty(paramName)) continue;
// RSLogix marks the implicit EnableIn / EnableOut parameters as Hidden=true.
// Skip them — they aren't part of the AOI's user-facing surface.
var hidden = paramNode.GetAttribute("Hidden", string.Empty);
if (string.Equals(hidden, "true", StringComparison.OrdinalIgnoreCase)) continue;
var dataType = paramNode.GetAttribute("DataType", string.Empty);
if (string.IsNullOrEmpty(dataType)) continue;
var externalAccess = paramNode.GetAttribute("ExternalAccess", string.Empty);
int? arrayDim = null;
var dimText = paramNode.GetAttribute("Dimension", string.Empty);
if (!string.IsNullOrEmpty(dimText)
&& int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim)
&& dim > 0)
{
arrayDim = dim;
}
string? paramDescription = null;
var paramDescNode = paramNode.SelectSingleNode("Description");
if (paramDescNode is not null)
{
var raw = paramDescNode.Value;
if (!string.IsNullOrEmpty(raw)) paramDescription = raw.Trim();
}
// PR abcip-2.6 — capture the AOI Usage attribute (Input / Output / InOut). RSLogix
// also serialises Local AOI tags inside <LocalTags>, but those don't go through this
// path — only <Parameters>/<Parameter> entries do — so any Usage value on a parameter
// is one of the directional buckets.
var usage = paramNode.GetAttribute("Usage", string.Empty);
members.Add(new L5kMember(
Name: paramName,
DataType: dataType,
ArrayDim: arrayDim,
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
Description: paramDescription,
Usage: string.IsNullOrEmpty(usage) ? null : usage));
}
return new L5kDataType(name, members);
}
}

View File

@@ -1,3 +1,4 @@
using System.Reflection;
using libplctag; using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -12,6 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
internal sealed class LibplctagTagRuntime : IAbCipTagRuntime internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{ {
private readonly Tag _tag; private readonly Tag _tag;
private readonly int _connectionSize;
private readonly AddressingMode _addressingMode;
private readonly uint? _logicalInstanceId;
public LibplctagTagRuntime(AbCipTagCreateParams p) public LibplctagTagRuntime(AbCipTagCreateParams p)
{ {
@@ -24,12 +28,119 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
Name = p.TagName, Name = p.TagName,
Timeout = p.Timeout, Timeout = p.Timeout,
}; };
// PR abcip-1.2 — Logix STRINGnn variant decoding. When the caller pins a non-default
// DATA-array capacity (STRING_20 / STRING_40 / STRING_80 etc.), forward it to libplctag
// via the StringMaxCapacity attribute so GetString / SetString truncate at the right
// boundary. Null leaves libplctag at its default 82-byte STRING for back-compat.
if (p.StringMaxCapacity is int cap && cap > 0)
_tag.StringMaxCapacity = (uint)cap;
// PR abcip-1.3 — slice reads. Setting ElementCount tells libplctag to allocate a buffer
// covering N consecutive elements; the array-read planner pairs this with TagName=Tag[N]
// to issue one Rockwell array read for a [N..M] slice.
if (p.ElementCount is int n && n > 0)
_tag.ElementCount = n;
_connectionSize = p.ConnectionSize;
_addressingMode = p.AddressingMode;
_logicalInstanceId = p.LogicalInstanceId;
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
// PR abcip-3.1 — propagate the configured CIP connection size to the native libplctag
// handle. The 1.5.x C# wrapper does not expose <c>connection_size</c> as a public Tag
// property, so we reach into the internal <c>NativeTagWrapper</c>'s
// <c>SetIntAttribute</c> (mirroring libplctag's <c>plc_tag_set_int_attribute</c>).
// libplctag native parses <c>connection_size</c> at create time, so this best-effort
// call lights up automatically when a future wrapper release exposes the attribute or
// when libplctag native gains post-create hot-update support — until then it falls back
// to the wrapper default. Failures (older / patched wrappers without the internal API)
// are intentionally swallowed so the driver keeps initialising.
TrySetConnectionSize(_tag, _connectionSize);
// PR abcip-3.2 — propagate the addressing mode + (when known) the resolved Symbol
// Object instance ID. Same reflection-fallback shape as ConnectionSize: the libplctag
// .NET wrapper (1.5.x) doesn't expose a public knob for instance-ID addressing, so
// we forward the relevant attribute string through NativeTagWrapper.SetAttributeString.
// Logical mode lights up only when the driver has populated LogicalInstanceId via the
// one-time @tags walk; first reads on a Logical device + every Symbolic-mode read take
// the libplctag default ASCII-symbolic path.
if (_addressingMode == AddressingMode.Logical)
TrySetLogicalAddressing(_tag, _logicalInstanceId);
} }
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken); public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken); public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
/// <summary>
/// Best-effort propagation of <c>connection_size</c> to libplctag native. Reflects into
/// the wrapper's internal <c>NativeTagWrapper.SetIntAttribute(string, int)</c>; isolated
/// in a static helper so the lookup costs run once + the failure path is one line.
/// </summary>
private static void TrySetConnectionSize(Tag tag, int connectionSize)
{
try
{
var wrapperField = typeof(Tag).GetField("_tag", BindingFlags.NonPublic | BindingFlags.Instance);
var wrapper = wrapperField?.GetValue(tag);
if (wrapper is null) return;
var setInt = wrapper.GetType().GetMethod(
"SetIntAttribute",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
binder: null,
types: [typeof(string), typeof(int)],
modifiers: null);
setInt?.Invoke(wrapper, ["connection_size", connectionSize]);
}
catch
{
// Wrapper internals shifted (newer libplctag.NET) — drop quietly. Either the new
// wrapper exposes ConnectionSize directly (our reflection no-ops) or operators must
// upgrade to a known-good version per docs/drivers/AbCip-Performance.md.
}
}
/// <summary>
/// PR abcip-3.2 — best-effort propagation of CIP logical-segment / instance-ID
/// addressing to libplctag native. Two attributes are forwarded:
/// <list type="bullet">
/// <item><c>use_connected_msg=1</c> — instance-ID addressing only works over a
/// connected CIP session; switch the tag to use Forward Open + Class3 messaging.</item>
/// <item><c>cip_addr=0x6B,N</c> — replace the ASCII Symbol Object lookup with a
/// direct logical segment reference, where <c>N</c> is the resolved instance ID
/// from the driver's one-time <c>@tags</c> walk.</item>
/// </list>
/// Same reflection-via-<c>NativeTagWrapper.SetAttributeString</c> shape as
/// <see cref="TrySetConnectionSize"/> — the 1.5.x .NET wrapper does not expose a
/// public knob, so we degrade gracefully when the internal API is not present.
/// </summary>
private static void TrySetLogicalAddressing(Tag tag, uint? logicalInstanceId)
{
try
{
var wrapperField = typeof(Tag).GetField("_tag", BindingFlags.NonPublic | BindingFlags.Instance);
var wrapper = wrapperField?.GetValue(tag);
if (wrapper is null) return;
var setStr = wrapper.GetType().GetMethod(
"SetAttributeString",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
binder: null,
types: [typeof(string), typeof(string)],
modifiers: null);
if (setStr is null) return;
setStr.Invoke(wrapper, ["use_connected_msg", "1"]);
if (logicalInstanceId is uint id)
setStr.Invoke(wrapper, ["cip_addr", $"0x6B,{id}"]);
}
catch
{
// Wrapper internals not present / shifted — fall back to symbolic addressing on
// the wire. Driver-level logical-mode bookkeeping (the @tags map) is still useful
// because future wrapper releases may expose this attribute publicly + the
// reflection lights up cleanly then.
}
}
public int GetStatus() => (int)_tag.GetStatus(); public int GetStatus() => (int)_tag.GetStatus();
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex); public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
@@ -50,7 +161,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
AbCipDataType.Real => _tag.GetFloat32(offset), AbCipDataType.Real => _tag.GetFloat32(offset),
AbCipDataType.LReal => _tag.GetFloat64(offset), AbCipDataType.LReal => _tag.GetFloat64(offset),
AbCipDataType.String => _tag.GetString(offset), AbCipDataType.String => _tag.GetString(offset),
AbCipDataType.Dt => _tag.GetInt32(offset), AbCipDataType.Dt => _tag.GetInt64(offset),
AbCipDataType.Structure => null, AbCipDataType.Structure => null,
_ => null, _ => null,
}; };
@@ -105,7 +216,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_tag.SetString(0, Convert.ToString(value) ?? string.Empty); _tag.SetString(0, Convert.ToString(value) ?? string.Empty);
break; break;
case AbCipDataType.Dt: case AbCipDataType.Dt:
_tag.SetInt32(0, Convert.ToInt32(value)); _tag.SetInt64(0, Convert.ToInt64(value));
break; break;
case AbCipDataType.Structure: case AbCipDataType.Structure:
throw new NotSupportedException("Whole-UDT writes land in PR 6."); throw new NotSupportedException("Whole-UDT writes land in PR 6.");

View File

@@ -16,7 +16,8 @@ public sealed record AbCipPlcFamilyProfile(
string DefaultCipPath, string DefaultCipPath,
bool SupportsRequestPacking, bool SupportsRequestPacking,
bool SupportsConnectedMessaging, bool SupportsConnectedMessaging,
int MaxFragmentBytes) int MaxFragmentBytes,
bool SupportsLogicalAddressing = true)
{ {
/// <summary>Look up the profile for a configured family.</summary> /// <summary>Look up the profile for a configured family.</summary>
public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch
@@ -34,7 +35,8 @@ public sealed record AbCipPlcFamilyProfile(
DefaultCipPath: "1,0", DefaultCipPath: "1,0",
SupportsRequestPacking: true, SupportsRequestPacking: true,
SupportsConnectedMessaging: true, SupportsConnectedMessaging: true,
MaxFragmentBytes: 4000); MaxFragmentBytes: 4000,
SupportsLogicalAddressing: true);
public static readonly AbCipPlcFamilyProfile CompactLogix = new( public static readonly AbCipPlcFamilyProfile CompactLogix = new(
LibplctagPlcAttribute: "compactlogix", LibplctagPlcAttribute: "compactlogix",
@@ -42,15 +44,21 @@ public sealed record AbCipPlcFamilyProfile(
DefaultCipPath: "1,0", DefaultCipPath: "1,0",
SupportsRequestPacking: true, SupportsRequestPacking: true,
SupportsConnectedMessaging: true, SupportsConnectedMessaging: true,
MaxFragmentBytes: 500); MaxFragmentBytes: 500,
SupportsLogicalAddressing: true);
// PR abcip-3.2 — Micro800 firmware does not implement the Symbol Object class 0x6B
// instance-ID addressing path; @tags returns the symbol set but reads keyed on instance
// IDs trip a CIP "Path Segment Error" (0x04). Logical mode is therefore disabled here
// + the driver silently falls back to Symbolic with a warning per AbCipDriverOptions.OnWarning.
public static readonly AbCipPlcFamilyProfile Micro800 = new( public static readonly AbCipPlcFamilyProfile Micro800 = new(
LibplctagPlcAttribute: "micro800", LibplctagPlcAttribute: "micro800",
DefaultConnectionSize: 488, // Micro800 hard cap DefaultConnectionSize: 488, // Micro800 hard cap
DefaultCipPath: "", // no backplane routing DefaultCipPath: "", // no backplane routing
SupportsRequestPacking: false, SupportsRequestPacking: false,
SupportsConnectedMessaging: false, // unconnected-only on most models SupportsConnectedMessaging: false, // unconnected-only on most models
MaxFragmentBytes: 484); MaxFragmentBytes: 484,
SupportsLogicalAddressing: false);
public static readonly AbCipPlcFamilyProfile GuardLogix = new( public static readonly AbCipPlcFamilyProfile GuardLogix = new(
LibplctagPlcAttribute: "controllogix", // wire protocol identical; safety partition is tag-level LibplctagPlcAttribute: "controllogix", // wire protocol identical; safety partition is tag-level
@@ -58,5 +66,6 @@ public sealed record AbCipPlcFamilyProfile(
DefaultCipPath: "1,0", DefaultCipPath: "1,0",
SupportsRequestPacking: true, SupportsRequestPacking: true,
SupportsConnectedMessaging: true, SupportsConnectedMessaging: true,
MaxFragmentBytes: 4000); MaxFragmentBytes: 4000,
SupportsLogicalAddressing: true);
} }

View File

@@ -26,6 +26,7 @@
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/> <InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -24,6 +24,16 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
"Publishing interval in milliseconds (default 1000).")] "Publishing interval in milliseconds (default 1000).")]
public int IntervalMs { get; init; } = 1000; public int IntervalMs { get; init; } = 1000;
[CommandOption("deadband-absolute", Description =
"PR 8 — absolute change filter. Suppress notifications until |new - prev| >= this value. " +
"Booleans bypass; strings + status changes always publish.")]
public double? DeadbandAbsolute { get; init; }
[CommandOption("deadband-percent", Description =
"PR 8 — percent-of-previous change filter. Suppress notifications until " +
"|new - prev| >= |prev * pct / 100|. prev=0 always publishes.")]
public double? DeadbandPercent { get; init; }
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
ConfigureLogging(); ConfigureLogging();
@@ -35,7 +45,9 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
DeviceHostAddress: Gateway, DeviceHostAddress: Gateway,
Address: Address, Address: Address,
DataType: DataType, DataType: DataType,
Writable: false); Writable: false,
AbsoluteDeadband: DeadbandAbsolute,
PercentDeadband: DeadbandPercent);
var options = BuildOptions([tag]); var options = BuildOptions([tag]);
await using var driver = new AbLegacyDriver(options, DriverInstanceId); await using var driver = new AbLegacyDriver(options, DriverInstanceId);

View File

@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary> /// <summary>
@@ -30,35 +32,102 @@ public sealed record AbLegacyAddress(
int? FileNumber, int? FileNumber,
int WordNumber, int WordNumber,
int? BitIndex, int? BitIndex,
string? SubElement) string? SubElement,
AbLegacyAddress? IndirectFileSource = null,
AbLegacyAddress? IndirectWordSource = null,
int? ArrayCount = null)
{ {
/// <summary>
/// PR 7 — PCCC frame ceiling. A single SLC/PLC-5 PCCC read can return up to about 240
/// bytes (~120 INT words / 60 DINTs / 60 floats). The parser caps <see cref="ArrayCount"/>
/// at 120 so a misconfigured tag fails fast instead of bouncing off the wire as a fragmented
/// multi-frame read.
/// </summary>
public const int MaxArrayCount = 120;
/// <summary>
/// True when either the file number or the word number is sourced from another PCCC
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
/// <c>N[N7:0]:5</c>). libplctag PCCC does not natively decode bracket-form indirection,
/// so the runtime layer must resolve the inner address first and rewrite the tag name
/// before issuing the actual read/write. See <see cref="ToLibplctagName"/>.
/// </summary>
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
public string ToLibplctagName() public string ToLibplctagName()
{ {
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}"; // Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
var wordPart = $"{file}:{WordNumber}"; // accept the bracket form directly — callers that need a libplctag-ready name must
// resolve the inner addresses first and substitute concrete numbers. Driver runtime
// path (TODO: resolve-then-read) is gated on IsIndirect.
string filePart;
if (IndirectFileSource is not null)
{
filePart = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]";
}
else
{
filePart = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
}
string wordSegment = IndirectWordSource is not null
? $"[{IndirectWordSource.ToLibplctagName()}]"
: WordNumber.ToString();
var wordPart = $"{filePart}:{wordSegment}";
// PR 7 — emit libplctag's `[N]` array suffix when the parsed address carries an
// ArrayCount. libplctag's PCCC text decoder treats `N7:0[10]` as "10 consecutive
// words starting at N7:0"; the comma form (`N7:0,10`) is Rockwell-native and gets
// canonicalised to bracket form here so the driver always hands libplctag a single
// recognisable shape.
if (ArrayCount is int n) wordPart += $"[{n}]";
if (SubElement is not null) wordPart += $".{SubElement}"; if (SubElement is not null) wordPart += $".{SubElement}";
if (BitIndex is not null) wordPart += $"/{BitIndex}"; if (BitIndex is not null) wordPart += $"/{BitIndex}";
return wordPart; return wordPart;
} }
public static AbLegacyAddress? TryParse(string? value) public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
/// <summary>
/// Family-aware parser. PLC-5 (RSLogix 5) displays the word + bit indices on
/// <c>I:</c>/<c>O:</c> file references as octal — <c>I:001/17</c> is rack 1, bit 15.
/// Pass the device's family so the parser can interpret those digits as octal when the
/// family's <see cref="AbLegacyPlcFamilyProfile.OctalIoAddressing"/> is true. The parsed
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
/// is what libplctag's PCCC layer expects.
/// </summary>
/// <remarks>
/// Also accepts indirect / indexed forms (Issue #247): <c>N7:[N7:0]</c> reads file 7,
/// word=value-of(N7:0); <c>N[N7:0]:5</c> reads file=value-of(N7:0), word 5. Recursion
/// depth is capped at 1 — the inner address must be a plain direct PCCC address.
/// </remarks>
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
{ {
if (string.IsNullOrWhiteSpace(value)) return null; if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim(); var src = value.Trim();
// BitIndex: trailing /N var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
int? bitIndex = null;
var slashIdx = src.IndexOf('/'); // BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
if (slashIdx >= 0) // I:/O: bit indices are octal in RSLogix 5, everything else is decimal.
string? bitText = null;
var slashIdx = src.LastIndexOf('/');
if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']'))
{ {
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null; bitText = src[(slashIdx + 1)..];
bitIndex = bit;
src = src[..slashIdx]; src = src[..slashIdx];
} }
return ParseTail(src, bitText, profile, allowIndirect: true);
}
private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile, bool allowIndirect)
{
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.) // SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
// Only consider dots OUTSIDE of any bracketed inner address — the inner address may
// itself contain a sub-element dot (e.g. N[T4:0.ACC]:5).
string? subElement = null; string? subElement = null;
var dotIdx = src.LastIndexOf('.'); var dotIdx = LastIndexOfTopLevel(src, '.');
if (dotIdx >= 0) if (dotIdx >= 0)
{ {
var candidate = src[(dotIdx + 1)..]; var candidate = src[(dotIdx + 1)..];
@@ -69,29 +138,220 @@ public sealed record AbLegacyAddress(
} }
} }
var colonIdx = src.IndexOf(':'); var colonIdx = IndexOfTopLevel(src, ':');
if (colonIdx <= 0) return null; if (colonIdx <= 0) return null;
var filePart = src[..colonIdx]; var filePart = src[..colonIdx];
var wordPart = src[(colonIdx + 1)..]; var wordPart = src[(colonIdx + 1)..];
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
// File letter + optional file number (single letter for I/O/S, letter+number otherwise). // File letter (always literal) + optional file number — either decimal digits or a
// bracketed indirect address like N[N7:0].
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null; if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
var letterEnd = 1; var letterEnd = 1;
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++; while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
var letter = filePart[..letterEnd].ToUpperInvariant(); var letter = filePart[..letterEnd].ToUpperInvariant();
int? fileNumber = null; int? fileNumber = null;
AbLegacyAddress? indirectFile = null;
if (letterEnd < filePart.Length) if (letterEnd < filePart.Length)
{ {
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null; var fileTail = filePart[letterEnd..];
fileNumber = fn; if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']')
{
if (!allowIndirect) return null;
var inner = fileTail[1..^1];
indirectFile = ParseInner(inner, profile);
if (indirectFile is null) return null;
}
else
{
if (!int.TryParse(fileTail, out var fn) || fn < 0) return null;
fileNumber = fn;
}
} }
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families. // Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
if (!IsKnownFileLetter(letter)) return null; // Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only.
// Structure-file letters (PD/MG/PLS/BT) are gated per family — PD/MG are common on
// SLC500 + PLC-5; PLS/BT are PLC-5 only. MicroLogix and LogixPccc reject them.
if (!IsKnownFileLetter(letter))
{
if (IsFunctionFileLetter(letter))
{
if (profile?.SupportsFunctionFiles != true) return null;
}
else if (IsStructureFileLetter(letter))
{
if (!StructureFileSupported(letter, profile)) return null;
}
else return null;
}
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement); var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
// PR 7 — strip an optional array suffix from the trailing edge of the word part.
// Two accepted forms: Rockwell-native `,N` (e.g. `N7:0,10`) and libplctag-native
// `[N]` (e.g. `N7:0[10]`). Both resolve to the same ArrayCount. The bracket form
// collides syntactically with the indirect-word form (`N7:[N7:0]`) — the
// disambiguation is "leading bracket = indirect; trailing bracket after the
// numeric word literal = array". A trailing `[N]` may also follow an indirect
// word (`N7:[N7:0][10]`) — supported.
int? arrayCount = null;
// Try comma form first — only meaningful when no leading-bracket indirect form is
// present. Comma never appears in indirect-word source addresses (those use ':').
var commaIdx = wordPart.LastIndexOf(',');
if (commaIdx > 0 && wordPart[0] != '[')
{
var arrayText = wordPart[(commaIdx + 1)..];
if (!int.TryParse(arrayText, out var ac) || ac < 1 || ac > MaxArrayCount) return null;
arrayCount = ac;
wordPart = wordPart[..commaIdx];
}
else if (wordPart.Length > 0 && wordPart[^1] == ']')
{
// Trailing `[N]` — only valid when there's already a primary word/indirect
// segment in front of it. Walk back to the matching `[`.
// Use top-level-aware index so a nested indirect like `[N7:0]` doesn't trip us.
// We want the LAST top-level `[` whose body is a pure integer.
var openIdx = MatchingOpenBracket(wordPart, wordPart.Length - 1);
if (openIdx > 0)
{
var arrayText = wordPart[(openIdx + 1)..^1];
if (int.TryParse(arrayText, out var ac))
{
if (ac < 1 || ac > MaxArrayCount) return null;
arrayCount = ac;
wordPart = wordPart[..openIdx];
}
// If the bracket body isn't a pure integer, leave wordPart alone — likely
// an indirect-word source address (handled below) or malformed input.
}
}
// Word part: either a numeric literal (octal-aware for PLC-5 I:/O:) or a bracketed
// indirect address.
int word = 0;
AbLegacyAddress? indirectWord = null;
if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']')
{
if (!allowIndirect) return null;
var inner = wordPart[1..^1];
indirectWord = ParseInner(inner, profile);
if (indirectWord is null) return null;
}
else
{
if (!TryParseIndex(wordPart, octalForIo, out word) || word < 0) return null;
}
int? bitIndex = null;
if (bitText is not null)
{
if (!TryParseIndex(bitText, octalForIo, out var bit) || bit < 0 || bit > 31) return null;
bitIndex = bit;
}
// PR 7 — array tags can't combine with a bit suffix (`N7:0,10/3` is meaningless —
// "the third bit of ten different words"?) or with a sub-element pull (`T4:0,5.ACC`
// is also meaningless — the sub-element targets one timer's accumulator). The
// libplctag PCCC layer would silently accept the combination; reject up-front so
// the OPC UA client sees a clean parse failure rather than a wire-level surprise.
if (arrayCount is not null)
{
if (bitIndex is not null) return null;
if (subElement is not null) return null;
}
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord, arrayCount);
}
/// <summary>
/// Find the index of the `[` that matches the `]` at <paramref name="closeIdx"/> in
/// <paramref name="s"/>, accounting for nested brackets. Returns -1 if no match.
/// </summary>
private static int MatchingOpenBracket(string s, int closeIdx)
{
if (closeIdx < 0 || closeIdx >= s.Length || s[closeIdx] != ']') return -1;
var depth = 1;
for (var i = closeIdx - 1; i >= 0; i--)
{
if (s[i] == ']') depth++;
else if (s[i] == '[')
{
depth--;
if (depth == 0) return i;
}
}
return -1;
}
/// <summary>
/// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself
/// must NOT be indirect — nesting beyond one level is rejected.
/// </summary>
private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile)
{
if (string.IsNullOrWhiteSpace(inner)) return null;
var src = inner.Trim();
// Reject any further bracket — depth cap at 1.
if (src.IndexOf('[') >= 0 || src.IndexOf(']') >= 0) return null;
string? bitText = null;
var slashIdx = src.LastIndexOf('/');
if (slashIdx >= 0)
{
bitText = src[(slashIdx + 1)..];
src = src[..slashIdx];
}
return ParseTail(src, bitText, profile, allowIndirect: false);
}
private static int IndexOfTopLevel(string s, char c)
{
var depth = 0;
for (var i = 0; i < s.Length; i++)
{
if (s[i] == '[') depth++;
else if (s[i] == ']') depth--;
else if (depth == 0 && s[i] == c) return i;
}
return -1;
}
private static int LastIndexOfTopLevel(string s, char c)
{
var depth = 0;
var last = -1;
for (var i = 0; i < s.Length; i++)
{
if (s[i] == '[') depth++;
else if (s[i] == ']') depth--;
else if (depth == 0 && s[i] == c) last = i;
}
return last;
}
private static bool TryParseIndex(string text, bool octal, out int value)
{
if (octal)
{
// Octal accepts only digits 0-7. Reject 8/9 explicitly.
if (text.Length == 0) { value = 0; return false; }
var start = 0;
var sign = 1;
if (text[0] == '-') { sign = -1; start = 1; }
if (start >= text.Length) { value = 0; return false; }
var acc = 0;
for (var i = start; i < text.Length; i++)
{
var c = text[i];
if (c < '0' || c > '7') { value = 0; return false; }
acc = (acc * 8) + (c - '0');
}
value = sign * acc;
return true;
}
return int.TryParse(text, out value);
} }
private static bool IsKnownFileLetter(string letter) => letter switch private static bool IsKnownFileLetter(string letter) => letter switch
@@ -99,4 +359,38 @@ public sealed record AbLegacyAddress(
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true, "N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
_ => false, _ => false,
}; };
/// <summary>
/// MicroLogix 1100/1400 function-file prefixes. Each maps to a single fixed instance with a
/// known sub-element catalogue (see <see cref="AbLegacyDataType"/>).
/// </summary>
internal static bool IsFunctionFileLetter(string letter) => letter switch
{
"RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => true,
_ => false,
};
/// <summary>
/// Structure-file prefixes added in #248: PD (PID), MG (Message), PLS (Programmable Limit
/// Switch), BT (Block Transfer). Per-family availability is gated by the matching
/// <c>Supports*File</c> flag on <see cref="AbLegacyPlcFamilyProfile"/>.
/// </summary>
internal static bool IsStructureFileLetter(string letter) => letter switch
{
"PD" or "MG" or "PLS" or "BT" => true,
_ => false,
};
private static bool StructureFileSupported(string letter, AbLegacyPlcFamilyProfile? profile)
{
if (profile is null) return false;
return letter switch
{
"PD" => profile.SupportsPidFile,
"MG" => profile.SupportsMessageFile,
"PLS" => profile.SupportsPlsFile,
"BT" => profile.SupportsBlockTransferFile,
_ => false,
};
}
} }

View File

@@ -26,6 +26,96 @@ public enum AbLegacyDataType
CounterElement, CounterElement,
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary> /// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
ControlElement, ControlElement,
/// <summary>
/// MicroLogix 1100/1400 function-file sub-element (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI).
/// Sub-element catalogue lives in <see cref="AbLegacyFunctionFile.SubElementType"/>.
/// </summary>
MicroLogixFunctionFile,
/// <summary>
/// PD-file (PID) sub-element — caller addresses <c>.SP</c>, <c>.PV</c>, <c>.CV</c>,
/// <c>.KP</c>, <c>.KI</c>, <c>.KD</c>, <c>.MAXS</c>, <c>.MINS</c>, <c>.DB</c>, <c>.OUT</c>
/// (Float) and <c>.EN</c>, <c>.DN</c>, <c>.MO</c>, <c>.PE</c>, <c>.AUTO</c>, <c>.MAN</c>
/// (Boolean status bits in word 0).
/// </summary>
PidElement,
/// <summary>
/// MG-file (Message) sub-element — caller addresses <c>.RBE</c>, <c>.MS</c>, <c>.SIZE</c>,
/// <c>.LEN</c> (Int32) and <c>.EN</c>, <c>.EW</c>, <c>.ER</c>, <c>.DN</c>, <c>.ST</c>,
/// <c>.CO</c>, <c>.NR</c>, <c>.TO</c> (Boolean status bits).
/// </summary>
MessageElement,
/// <summary>
/// PLS-file (Programmable Limit Switch) sub-element — caller addresses <c>.LEN</c>
/// (Int32). Bit semantics vary by PLC; unknown sub-elements fall back to Int32.
/// </summary>
PlsElement,
/// <summary>
/// BT-file (Block Transfer) sub-element — caller addresses <c>.RLEN</c>, <c>.DLEN</c>
/// (Int32) and <c>.EN</c>, <c>.ST</c>, <c>.DN</c>, <c>.ER</c>, <c>.CO</c>, <c>.EW</c>,
/// <c>.TO</c>, <c>.NR</c> (Boolean status bits in word 0).
/// </summary>
BlockTransferElement,
}
/// <summary>
/// MicroLogix function-file sub-element catalogue. Covers the most-commonly-addressed members
/// per file — not exhaustive (Rockwell defines 30+ on RTC alone). Unknown sub-elements fall
/// back to <see cref="DriverDataType.Int32"/> at the <see cref="AbLegacyDataTypeExtensions"/>
/// boundary so the driver never refuses a tag the customer happens to know about.
/// </summary>
public static class AbLegacyFunctionFile
{
/// <summary>
/// Driver-surface type for <paramref name="fileLetter"/>.<paramref name="subElement"/>.
/// Returns <see cref="DriverDataType.Int32"/> if the sub-element is unrecognised — keeps
/// the driver permissive without forcing every quirk into the catalogue.
/// </summary>
public static DriverDataType SubElementType(string fileLetter, string? subElement)
{
if (subElement is null) return DriverDataType.Int32;
var key = (fileLetter.ToUpperInvariant(), subElement.ToUpperInvariant());
return key switch
{
// Real-time clock — all stored as Int16 (year is 4-digit Int16).
("RTC", "HR") or ("RTC", "MIN") or ("RTC", "SEC") or
("RTC", "MON") or ("RTC", "DAY") or ("RTC", "YR") or ("RTC", "DOW") => DriverDataType.Int32,
("RTC", "DS") or ("RTC", "BL") or ("RTC", "EN") => DriverDataType.Boolean,
// High-speed counter — accumulator/preset are Int32, status flags are bits.
("HSC", "ACC") or ("HSC", "PRE") or ("HSC", "OVF") or ("HSC", "UNF") => DriverDataType.Int32,
("HSC", "EN") or ("HSC", "UF") or ("HSC", "IF") or
("HSC", "IN") or ("HSC", "IH") or ("HSC", "IL") or
("HSC", "DN") or ("HSC", "CD") or ("HSC", "CU") => DriverDataType.Boolean,
// Daylight saving + memory module info.
("DLS", "STR") or ("DLS", "STD") => DriverDataType.Int32,
("DLS", "EN") => DriverDataType.Boolean,
("MMI", "FT") or ("MMI", "LBN") => DriverDataType.Int32,
("MMI", "MP") or ("MMI", "MCP") => DriverDataType.Boolean,
// Pulse-train / PWM output blocks.
("PTO", "ACC") or ("PTO", "OF") or ("PTO", "IDA") or ("PTO", "ODA") => DriverDataType.Int32,
("PTO", "EN") or ("PTO", "DN") or ("PTO", "EH") or ("PTO", "ED") or
("PTO", "RP") or ("PTO", "OUT") => DriverDataType.Boolean,
("PWM", "ACC") or ("PWM", "OF") or ("PWM", "PE") or ("PWM", "PD") => DriverDataType.Int32,
("PWM", "EN") or ("PWM", "DN") or ("PWM", "EH") or ("PWM", "ED") or
("PWM", "RP") or ("PWM", "OUT") => DriverDataType.Boolean,
// Selectable timed interrupt + event input interrupt.
("STI", "SPM") or ("STI", "ER") or ("STI", "PFN") => DriverDataType.Int32,
("STI", "EN") or ("STI", "TIE") or ("STI", "DN") or
("STI", "PS") or ("STI", "ED") => DriverDataType.Boolean,
("EII", "PFN") or ("EII", "ER") => DriverDataType.Int32,
("EII", "EN") or ("EII", "TIE") or ("EII", "PE") or
("EII", "ES") or ("EII", "ED") => DriverDataType.Boolean,
// I/O status + base hardware info — mostly status flags + a few counters.
("IOS", "ID") or ("IOS", "TYP") => DriverDataType.Int32,
("BHI", "OS") or ("BHI", "FRN") or ("BHI", "BSN") or ("BHI", "CC") => DriverDataType.Int32,
_ => DriverDataType.Int32,
};
}
} }
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary> /// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
@@ -40,6 +130,196 @@ public static class AbLegacyDataTypeExtensions
AbLegacyDataType.String => DriverDataType.String, AbLegacyDataType.String => DriverDataType.String,
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => DriverDataType.Int32, or AbLegacyDataType.ControlElement => DriverDataType.Int32,
AbLegacyDataType.MicroLogixFunctionFile => DriverDataType.Int32,
// PD/MG/PLS/BT default to Int32 at the parent-element level. The sub-element-aware
// EffectiveDriverDataType refines specific members (Float for PID gains, Boolean for
// status bits).
AbLegacyDataType.PidElement or AbLegacyDataType.MessageElement
or AbLegacyDataType.PlsElement or AbLegacyDataType.BlockTransferElement
=> DriverDataType.Int32,
_ => DriverDataType.Int32, _ => DriverDataType.Int32,
}; };
/// <summary>
/// Sub-element-aware driver type. Timer/Counter/Control elements expose Boolean status
/// bits (<c>.DN</c>, <c>.EN</c>, <c>.TT</c>, <c>.CU</c>, <c>.CD</c>, <c>.OV</c>,
/// <c>.UN</c>, <c>.ER</c>, etc.) and Int32 word members (<c>.PRE</c>, <c>.ACC</c>,
/// <c>.LEN</c>, <c>.POS</c>). Unknown sub-elements fall back to
/// <see cref="ToDriverDataType"/> so the driver remains permissive.
/// </summary>
public static DriverDataType EffectiveDriverDataType(AbLegacyDataType t, string? subElement)
{
if (subElement is null) return t.ToDriverDataType();
var key = subElement.ToUpperInvariant();
return t switch
{
AbLegacyDataType.TimerElement => key switch
{
"EN" or "TT" or "DN" => DriverDataType.Boolean,
"PRE" or "ACC" => DriverDataType.Int32,
_ => t.ToDriverDataType(),
},
AbLegacyDataType.CounterElement => key switch
{
"CU" or "CD" or "DN" or "OV" or "UN" => DriverDataType.Boolean,
"PRE" or "ACC" => DriverDataType.Int32,
_ => t.ToDriverDataType(),
},
AbLegacyDataType.ControlElement => key switch
{
"EN" or "EU" or "DN" or "EM" or "ER" or "UL" or "IN" or "FD" => DriverDataType.Boolean,
"LEN" or "POS" => DriverDataType.Int32,
_ => t.ToDriverDataType(),
},
// PD-file (PID): SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT are 32-bit floats; EN/DN/MO/PE/
// AUTO/MAN/SP_VAL/SP_LL/SP_HL are status bits in word 0.
AbLegacyDataType.PidElement => key switch
{
"SP" or "PV" or "CV" or "KP" or "KI" or "KD"
or "MAXS" or "MINS" or "DB" or "OUT" => DriverDataType.Float32,
"EN" or "DN" or "MO" or "PE"
or "AUTO" or "MAN" or "SP_VAL" or "SP_LL" or "SP_HL" => DriverDataType.Boolean,
_ => t.ToDriverDataType(),
},
// MG-file (Message): RBE/MS/SIZE/LEN are control words; EN/EW/ER/DN/ST/CO/NR/TO are
// status bits.
AbLegacyDataType.MessageElement => key switch
{
"RBE" or "MS" or "SIZE" or "LEN" => DriverDataType.Int32,
"EN" or "EW" or "ER" or "DN" or "ST" or "CO" or "NR" or "TO" => DriverDataType.Boolean,
_ => t.ToDriverDataType(),
},
// PLS-file (Programmable Limit Switch): LEN is a length word; bit semantics vary by
// PLC so unknown sub-elements stay Int32.
AbLegacyDataType.PlsElement => key switch
{
"LEN" => DriverDataType.Int32,
_ => t.ToDriverDataType(),
},
// BT-file (Block Transfer, PLC-5): RLEN/DLEN are length words; EN/ST/DN/ER/CO/EW/
// TO/NR are status bits in word 0.
AbLegacyDataType.BlockTransferElement => key switch
{
"RLEN" or "DLEN" => DriverDataType.Int32,
"EN" or "ST" or "DN" or "ER" or "CO" or "EW" or "TO" or "NR" => DriverDataType.Boolean,
_ => t.ToDriverDataType(),
},
_ => t.ToDriverDataType(),
};
}
/// <summary>
/// Bit position within the parent control word for Timer/Counter/Control status bits.
/// Returns <c>null</c> if the sub-element is not a known bit member of the given element
/// type. Bit numbering follows Rockwell DTAM / PCCC documentation.
/// </summary>
public static int? StatusBitIndex(AbLegacyDataType t, string? subElement)
{
if (subElement is null) return null;
var key = subElement.ToUpperInvariant();
return t switch
{
// T4 element word 0: bit 13=DN, 14=TT, 15=EN.
AbLegacyDataType.TimerElement => key switch
{
"DN" => 13,
"TT" => 14,
"EN" => 15,
_ => null,
},
// C5 element word 0: bit 10=UN, 11=OV, 12=DN, 13=CD, 14=CU.
AbLegacyDataType.CounterElement => key switch
{
"UN" => 10,
"OV" => 11,
"DN" => 12,
"CD" => 13,
"CU" => 14,
_ => null,
},
// R6 element word 0: bit 8=FD, 9=IN, 10=UL, 11=ER, 12=EM, 13=DN, 14=EU, 15=EN.
AbLegacyDataType.ControlElement => key switch
{
"FD" => 8,
"IN" => 9,
"UL" => 10,
"ER" => 11,
"EM" => 12,
"DN" => 13,
"EU" => 14,
"EN" => 15,
_ => null,
},
// PD element word 0 (SLC 5/02+ PID, 1747-RM001 / PLC-5 PID-RM): bit 0=EN, 1=PE,
// 2=DN, 3=MO (manual mode), 4=AUTO, 5=MAN, 6=SP_VAL, 7=SP_LL, 8=SP_HL. Bits 48 are
// the SP-validity / SP-limit flags exposed in RSLogix 5 / 500.
AbLegacyDataType.PidElement => key switch
{
"EN" => 0,
"PE" => 1,
"DN" => 2,
"MO" => 3,
"AUTO" => 4,
"MAN" => 5,
"SP_VAL" => 6,
"SP_LL" => 7,
"SP_HL" => 8,
_ => null,
},
// MG element word 0 (PLC-5 MSG / SLC 5/05 MSG, 1785-6.5.12 / 1747-RM001):
// bit 15=EN, 14=ST, 13=DN, 12=ER, 11=CO, 10=EW, 9=NR, 8=TO.
AbLegacyDataType.MessageElement => key switch
{
"TO" => 8,
"NR" => 9,
"EW" => 10,
"CO" => 11,
"ER" => 12,
"DN" => 13,
"ST" => 14,
"EN" => 15,
_ => null,
},
// BT element word 0 (PLC-5 chassis BTR/BTW, 1785-6.5.12):
// bit 15=EN, 14=ST, 13=DN, 12=ER, 11=CO, 10=EW, 9=NR, 8=TO. Same layout as MG.
AbLegacyDataType.BlockTransferElement => key switch
{
"TO" => 8,
"NR" => 9,
"EW" => 10,
"CO" => 11,
"ER" => 12,
"DN" => 13,
"ST" => 14,
"EN" => 15,
_ => null,
},
_ => null,
};
}
/// <summary>
/// PLC-set status bits — read-only from the OPC UA side. Operator-controllable bits
/// (e.g. <c>.EN</c> on a timer/counter, <c>.CU</c>/<c>.CD</c> rung-driven inputs) are
/// omitted so they keep default writable behaviour.
/// </summary>
public static bool IsPlcSetStatusBit(AbLegacyDataType t, string? subElement)
{
if (subElement is null) return false;
var key = subElement.ToUpperInvariant();
return t switch
{
AbLegacyDataType.TimerElement => key is "DN" or "TT",
AbLegacyDataType.CounterElement => key is "DN" or "OV" or "UN",
AbLegacyDataType.ControlElement => key is "DN" or "EM" or "ER" or "FD" or "UL" or "IN",
// PID: PE (PID-error), DN (process-done), SP_VAL/SP_LL/SP_HL are PLC-set status.
// EN/MO/AUTO/MAN are operator-controllable via the .EN bit / mode select.
AbLegacyDataType.PidElement => key is "PE" or "DN" or "SP_VAL" or "SP_LL" or "SP_HL",
// MG/BT: ST (started), DN (done), ER (error), CO (continuous), EW (enabled-waiting),
// NR (no-response), TO (timeout) are PLC-set. EN is operator-driven via the rung.
AbLegacyDataType.MessageElement => key is "ST" or "DN" or "ER" or "CO" or "EW" or "NR" or "TO",
AbLegacyDataType.BlockTransferElement => key is "ST" or "DN" or "ER" or "CO" or "EW" or "NR" or "TO",
_ => false,
};
}
} }

View File

@@ -17,6 +17,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private readonly PollGroupEngine _poll; private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// PR 8 — per-tag last published <c>(value, status)</c> cache for the deadband filter.
/// Layered on top of <see cref="PollGroupEngine"/> because the engine's change-detection
/// is binary (publish on any value/status diff). Cleared on <see cref="ShutdownAsync"/>
/// so a reconnect doesn't suppress legitimate post-reconnect updates against stale state.
/// Keyed by full reference (== tag name) — matches the engine's own <c>LastValues</c> key
/// space.
/// </summary>
private readonly Dictionary<string, (object? Value, uint StatusCode)> _lastPublished =
new(StringComparer.OrdinalIgnoreCase);
private readonly object _lastPublishedLock = new();
private DriverHealth _health = new(DriverState.Unknown, null, null); private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange; public event EventHandler<DataChangeEventArgs>? OnDataChange;
@@ -31,8 +43,99 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory(); _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
_poll = new PollGroupEngine( _poll = new PollGroupEngine(
reader: ReadAsync, reader: ReadAsync,
onChange: (handle, tagRef, snapshot) => onChange: DispatchPollChange);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); }
/// <summary>
/// PR 8 — wraps the <see cref="PollGroupEngine"/> change callback with a per-tag
/// deadband filter. Booleans bypass (publish on every edge); strings + status changes
/// always publish; numerics pass only when <c>|new - prev|</c> meets the configured
/// absolute and / or percent deadband. First-seen always publishes.
/// </summary>
private void DispatchPollChange(ISubscriptionHandle handle, string tagRef, DataValueSnapshot snapshot)
{
if (!ShouldPublish(tagRef, snapshot)) return;
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
}
/// <summary>
/// PR 8 — deadband decision for one new sample. Updates the per-tag last-published
/// cache when the publish goes through so the next sample compares against the actual
/// emitted value (not every polled value).
/// </summary>
internal bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
{
// Tags absent from config (impossible via the engine path, defensive against callers
// that exercise the dispatch logic in isolation) bypass the filter.
var hasTag = _tagsByName.TryGetValue(tagRef, out var def);
lock (_lastPublishedLock)
{
var firstSeen = !_lastPublished.TryGetValue(tagRef, out var prev);
// First-seen, status change, or no tag config: always publish.
if (firstSeen || prev.StatusCode != snapshot.StatusCode || !hasTag)
{
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
// No deadband configured -> defer to PollGroupEngine's value-equality decision
// (the engine already filtered to "different from last engine snapshot" before we
// got here, so any sample reaching this point is a legitimate change).
if (def!.AbsoluteDeadband is null && def.PercentDeadband is null)
{
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
// Booleans + strings + non-numerics: deadband is meaningless; publish whenever the
// value differs from the last published one.
if (!TryAsDouble(snapshot.Value, out var newD) || !TryAsDouble(prev.Value, out var prevD))
{
if (Equals(prev.Value, snapshot.Value)) return false;
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
var delta = Math.Abs(newD - prevD);
var absPass = def.AbsoluteDeadband is double abs && delta >= abs;
// Percent: |prev| == 0 short-circuits to "always publish on any change" — avoids
// div-by-zero and matches Kepware's documented behaviour.
bool percentPass;
if (def.PercentDeadband is double pct)
{
if (prevD == 0) percentPass = delta > 0;
else percentPass = delta >= Math.Abs(prevD * pct / 100.0);
}
else percentPass = false;
// Logical OR — either filter triggering is enough. Matches the spec note in the
// PR plan ("Both deadbands set -> either triggers, Kepware semantics").
var pass = (def.AbsoluteDeadband is not null && absPass)
|| (def.PercentDeadband is not null && percentPass);
if (!pass) return false;
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
}
private static bool TryAsDouble(object? value, out double result)
{
switch (value)
{
case null: result = 0; return false;
case bool: result = 0; return false; // booleans use the equality fast path
case string: result = 0; return false;
case Array: result = 0; return false;
case IConvertible conv:
try { result = conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture); return true; }
catch { result = 0; return false; }
default: result = 0; return false;
}
} }
public string DriverInstanceId => _driverInstanceId; public string DriverInstanceId => _driverInstanceId;
@@ -91,6 +194,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
} }
_devices.Clear(); _devices.Clear();
_tagsByName.Clear(); _tagsByName.Clear();
// PR 8 — clear the deadband last-published cache so a ReinitializeAsync (or a
// reconnect-driven shutdown) doesn't suppress the very first post-reconnect sample
// by comparing it against pre-disconnect state.
lock (_lastPublishedLock) { _lastPublished.Clear(); }
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
} }
@@ -140,8 +247,32 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
continue; continue;
} }
var parsed = AbLegacyAddress.TryParse(def.Address); var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); // PR 7 — array contiguous block. Decode N consecutive elements via the runtime's
// per-index accessor and box the result as a typed .NET array. The parser has
// already rejected array+bit and array+sub-element combinations, so the array
// path can ignore the bit/sub-element decoders entirely.
int arrayCount;
if (parsed is not null && (def.ArrayLength is not null || (parsed.ArrayCount ?? 1) > 1))
{
arrayCount = ResolveElementCount(def, parsed);
}
else arrayCount = 1;
if (arrayCount > 1)
{
var arr = DecodeArrayAs(runtime, def.DataType, arrayCount);
results[i] = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
continue;
}
// Timer/Counter/Control status bits route through GetBit at the parent-word
// address — translate the .DN/.EN/etc. sub-element to its standard bit position
// and pass it down to the runtime as a synthetic bitIndex.
var decodeBit = parsed?.BitIndex
?? AbLegacyDataTypeExtensions.StatusBitIndex(def.DataType, parsed?.SubElement);
var value = runtime.DecodeValue(def.DataType, decodeBit);
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null); _health = new DriverHealth(DriverState.Healthy, now, null);
} }
@@ -186,7 +317,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
try try
{ {
var parsed = AbLegacyAddress.TryParse(def.Address); var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
// Timer/Counter/Control PLC-set status bits (DN, TT, OV, UN, FD, ER, EM, UL,
// IN) are read-only — the PLC sets them; any client write would be silently
// overwritten on the next scan. Reject up front with BadNotWritable.
if (AbLegacyDataTypeExtensions.IsPlcSetStatusBit(def.DataType, parsed?.SubElement))
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
continue;
}
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel // PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises // parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
@@ -223,6 +363,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{ {
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange); results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
} }
catch (ArgumentOutOfRangeException)
{
// ST-file string writes exceeding the 82-byte fixed element. Surfaces from
// LibplctagLegacyTagRuntime.EncodeValue's length guard; mapped to BadOutOfRange so
// the OPC UA client sees a clean rejection rather than a silent truncation.
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
}
catch (Exception ex) catch (Exception ex)
{ {
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
@@ -247,12 +394,24 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice) foreach (var tag in tagsForDevice)
{ {
var parsed = AbLegacyAddress.TryParse(tag.Address, device.PlcFamily);
// Timer/Counter/Control sub-elements (.DN/.EN/.TT/.PRE/.ACC/etc.) refine the
// base element's Int32 to Boolean for status bits and Int32 for word members.
var effectiveType = AbLegacyDataTypeExtensions.EffectiveDriverDataType(
tag.DataType, parsed?.SubElement);
var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit(
tag.DataType, parsed?.SubElement);
// PR 7 — array contiguous-block tags advertise IsArray + ArrayDim so the OPC UA
// generic node-manager builds a 1-D array variable. ArrayLength on the tag
// definition wins over the parsed `,N` / `[N]` suffix; both null = scalar.
var arrayLen = tag.ArrayLength
?? (parsed?.ArrayCount is int n && n > 1 ? n : (int?)null);
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name, FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(), DriverDataType: effectiveType,
IsArray: false, IsArray: arrayLen is int al && al > 1,
ArrayDim: null, ArrayDim: arrayLen is int al2 && al2 > 1 ? (uint)al2 : null,
SecurityClass: tag.Writable SecurityClass: tag.Writable && !plcSetBit
? SecurityClassification.Operate ? SecurityClassification.Operate
: SecurityClassification.ViewOnly, : SecurityClassification.ViewOnly,
IsHistorized: false, IsHistorized: false,
@@ -413,17 +572,37 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{ {
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing; if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
var parsed = AbLegacyAddress.TryParse(def.Address) var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
// TODO(#247): libplctag's PCCC text decoder does not natively accept the bracket-form
// indirect address. Resolving N7:[N7:0] requires reading the inner address first, then
// rewriting the tag name with the resolved word number, then issuing the actual read.
// For now we surface a clear runtime error rather than letting libplctag fail with an
// opaque parser error.
if (parsed.IsIndirect)
throw new NotSupportedException(
$"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented.");
// PR 7 — resolve the effective array length: explicit ArrayLength override on the tag
// definition wins over the parsed `,N` / `[N]` suffix. ElementCount of 1 means
// single-element scalar (libplctag's default); >1 triggers the contiguous-block path.
var elementCount = ResolveElementCount(def, parsed);
// Drop the parsed array suffix from the libplctag tag name when ArrayLength overrides
// it — libplctag would otherwise read the parsed length, not the override.
var tagName = (def.ArrayLength is int && parsed.ArrayCount is not null)
? (parsed with { ArrayCount = null }).ToLibplctagName()
: parsed.ToLibplctagName();
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway, Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port, Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath, CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(), TagName: tagName,
Timeout: _options.Timeout)); Timeout: _options.Timeout,
ElementCount: elementCount));
try try
{ {
await runtime.InitializeAsync(ct).ConfigureAwait(false); await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -437,6 +616,54 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
return runtime; return runtime;
} }
/// <summary>
/// PR 7 — pull <paramref name="elementCount"/> consecutive elements from a runtime that
/// just completed a single contiguous-block read. Element type drives both the .NET
/// array shape (Int32[] / Single[] / Boolean[]) and the per-index decoder routing.
/// </summary>
private static object DecodeArrayAs(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int elementCount)
{
return type switch
{
AbLegacyDataType.Bit => BuildArray<bool>(runtime, type, elementCount),
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => BuildArray<int>(runtime, type, elementCount),
AbLegacyDataType.Long => BuildArray<int>(runtime, type, elementCount),
AbLegacyDataType.Float => BuildArray<float>(runtime, type, elementCount),
_ => throw new NotSupportedException(
$"AbLegacyDataType {type} is not supported in array contiguous-block reads."),
};
}
private static T[] BuildArray<T>(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int n)
{
var arr = new T[n];
for (var i = 0; i < n; i++)
{
var element = runtime.DecodeArrayElement(type, i);
arr[i] = (T)Convert.ChangeType(element!, typeof(T))!;
}
return arr;
}
/// <summary>
/// PR 7 — resolve the effective array element count for a tag. Explicit
/// <see cref="AbLegacyTagDefinition.ArrayLength"/> on the tag definition wins; otherwise
/// the parsed <see cref="AbLegacyAddress.ArrayCount"/> from the address suffix is used;
/// otherwise 1 (scalar). Validates the override against the same PCCC frame ceiling
/// enforced by the parser so config-overrides can't bypass the limit.
/// </summary>
internal static int ResolveElementCount(AbLegacyTagDefinition def, AbLegacyAddress parsed)
{
if (def.ArrayLength is int n)
{
if (n < 1 || n > AbLegacyAddress.MaxArrayCount)
throw new InvalidOperationException(
$"AbLegacy tag '{def.Name}' has ArrayLength {n}; expected 1..{AbLegacyAddress.MaxArrayCount}.");
return n;
}
return parsed.ArrayCount ?? 1;
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);

View File

@@ -51,7 +51,10 @@ public static class AbLegacyDriverFactoryExtensions
DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType", DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
tagName: t.Name), tagName: t.Name),
Writable: t.Writable ?? true, Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false))] WriteIdempotent: t.WriteIdempotent ?? false,
ArrayLength: t.ArrayLength,
AbsoluteDeadband: t.AbsoluteDeadband,
PercentDeadband: t.PercentDeadband))]
: [], : [],
Probe = new AbLegacyProbeOptions Probe = new AbLegacyProbeOptions
{ {
@@ -112,6 +115,25 @@ public static class AbLegacyDriverFactoryExtensions
public string? DataType { get; init; } public string? DataType { get; init; }
public bool? Writable { get; init; } public bool? Writable { get; init; }
public bool? WriteIdempotent { get; init; } public bool? WriteIdempotent { get; init; }
/// <summary>
/// PR 7 — optional override for the parsed array suffix. When set and &gt; 1 the
/// driver issues a single contiguous PCCC block read for N elements.
/// </summary>
public int? ArrayLength { get; init; }
/// <summary>
/// PR 8 — optional absolute change filter for numeric tags. <c>OnDataChange</c> is
/// suppressed unless <c>|new - prev| &gt;= AbsoluteDeadband</c>. Booleans bypass;
/// strings + status changes always publish.
/// </summary>
public double? AbsoluteDeadband { get; init; }
/// <summary>
/// PR 8 — optional percent-of-previous change filter for numeric tags.
/// <c>OnDataChange</c> is suppressed unless <c>|new - prev| &gt;= |prev * Percent / 100|</c>.
/// <c>prev == 0</c> always publishes (avoids division-by-zero).
/// </summary>
public double? PercentDeadband { get; init; }
} }
internal sealed class AbLegacyProbeDto internal sealed class AbLegacyProbeDto

View File

@@ -22,16 +22,31 @@ public sealed record AbLegacyDeviceOptions(
string? DeviceName = null); string? DeviceName = null);
/// <summary> /// <summary>
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC /// One PCCC-backed OPC UA variable. <c>Address</c> is the canonical PCCC file-address
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>. /// string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
/// </summary> /// </summary>
/// <remarks>
/// PR 8 deadband fields:
/// <list type="bullet">
/// <item><c>AbsoluteDeadband</c> — when set, suppresses <c>OnDataChange</c> for numeric
/// tags unless <c>|new - prev| &gt;= AbsoluteDeadband</c>.</item>
/// <item><c>PercentDeadband</c> — when set, suppresses unless
/// <c>|new - prev| &gt;= |prev * Percent / 100|</c>; <c>prev == 0</c> always publishes.</item>
/// </list>
/// Booleans bypass deadband entirely (every transition publishes); strings + status
/// changes always publish; first-seen always publishes; both set → logical-OR (Kepware
/// semantics).
/// </remarks>
public sealed record AbLegacyTagDefinition( public sealed record AbLegacyTagDefinition(
string Name, string Name,
string DeviceHostAddress, string DeviceHostAddress,
string Address, string Address,
AbLegacyDataType DataType, AbLegacyDataType DataType,
bool Writable = true, bool Writable = true,
bool WriteIdempotent = false); bool WriteIdempotent = false,
int? ArrayLength = null,
double? AbsoluteDeadband = null,
double? PercentDeadband = null);
public sealed class AbLegacyProbeOptions public sealed class AbLegacyProbeOptions
{ {

View File

@@ -13,6 +13,16 @@ public interface IAbLegacyTagRuntime : IDisposable
int GetStatus(); int GetStatus();
object? DecodeValue(AbLegacyDataType type, int? bitIndex); object? DecodeValue(AbLegacyDataType type, int? bitIndex);
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value); void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
/// <summary>
/// PR 7 — decode element <paramref name="elementIndex"/> of an N-element contiguous
/// block read. Implementations call the same per-element accessors used by
/// <see cref="DecodeValue"/> at offset <c>elementIndex × elementBytes</c>. Default
/// implementation throws so existing fakes that don't override remain explicit.
/// </summary>
object? DecodeArrayElement(AbLegacyDataType type, int elementIndex)
=> throw new NotSupportedException(
"Array decoding requires an IAbLegacyTagRuntime that overrides DecodeArrayElement.");
} }
public interface IAbLegacyTagFactory public interface IAbLegacyTagFactory
@@ -26,4 +36,5 @@ public sealed record AbLegacyTagCreateParams(
string CipPath, string CipPath,
string LibplctagPlcAttribute, string LibplctagPlcAttribute,
string TagName, string TagName,
TimeSpan Timeout); TimeSpan Timeout,
int ElementCount = 1);

View File

@@ -12,6 +12,15 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{ {
private readonly Tag _tag; private readonly Tag _tag;
/// <summary>
/// Maximum payload length for an ST (string) file element on SLC / MicroLogix / PLC-5.
/// The on-wire layout is a 1-word length prefix followed by 82 ASCII bytes — libplctag's
/// <c>SetString</c> handles the framing internally, but it does NOT validate length, so a
/// 93-byte source string would silently truncate. We reject up-front so the OPC UA client
/// gets a clean <c>BadOutOfRange</c> rather than a corrupted PLC value.
/// </summary>
internal const int StFileMaxStringLength = 82;
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p) public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
{ {
_tag = new Tag _tag = new Tag
@@ -23,6 +32,11 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
Name = p.TagName, Name = p.TagName,
Timeout = p.Timeout, Timeout = p.Timeout,
}; };
// PR 7 — array contiguous-block reads. Setting ElementCount tells libplctag to allocate
// a buffer covering N consecutive PCCC words (one frame, up to ~120 elements). The
// driver decodes element-by-element through DecodeArrayElement after a single ReadAsync.
if (p.ElementCount > 1)
_tag.ElementCount = p.ElementCount;
} }
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken); public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
@@ -40,8 +54,25 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
AbLegacyDataType.Long => _tag.GetInt32(0), AbLegacyDataType.Long => _tag.GetInt32(0),
AbLegacyDataType.Float => _tag.GetFloat32(0), AbLegacyDataType.Float => _tag.GetFloat32(0),
AbLegacyDataType.String => _tag.GetString(0), AbLegacyDataType.String => _tag.GetString(0),
// Timer/Counter/Control sub-elements: bitIndex is the status bit position within the
// parent control word (encoded by AbLegacyDriver from the .DN / .EN / etc. sub-element
// name). Word members (.PRE / .ACC / .LEN / .POS) come through with bitIndex=null and
// decode as Int32 like before.
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => _tag.GetInt32(0), or AbLegacyDataType.ControlElement => bitIndex is int statusBit
? _tag.GetBit(statusBit)
: _tag.GetInt32(0),
// PD-file (PID): non-bit members (SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT) are 32-bit floats.
// Status bits (EN/DN/MO/PE/AUTO/MAN/SP_VAL/SP_LL/SP_HL) live in the parent control word
// and read through GetBit — the driver encodes the position via StatusBitIndex.
AbLegacyDataType.PidElement => bitIndex is int pidBit
? _tag.GetBit(pidBit)
: _tag.GetFloat32(0),
// MG/BT/PLS: non-bit members (RBE/MS/SIZE/LEN, RLEN/DLEN) are word-sized integers.
AbLegacyDataType.MessageElement or AbLegacyDataType.BlockTransferElement
or AbLegacyDataType.PlsElement => bitIndex is int statusBit2
? _tag.GetBit(statusBit2)
: _tag.GetInt32(0),
_ => null, _ => null,
}; };
@@ -70,18 +101,63 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
_tag.SetFloat32(0, Convert.ToSingle(value)); _tag.SetFloat32(0, Convert.ToSingle(value));
break; break;
case AbLegacyDataType.String: case AbLegacyDataType.String:
_tag.SetString(0, Convert.ToString(value) ?? string.Empty); {
var s = Convert.ToString(value) ?? string.Empty;
if (s.Length > StFileMaxStringLength)
throw new ArgumentOutOfRangeException(
nameof(value),
$"ST string write exceeds {StFileMaxStringLength}-byte file element capacity (was {s.Length}).");
_tag.SetString(0, s);
}
break; break;
case AbLegacyDataType.TimerElement: case AbLegacyDataType.TimerElement:
case AbLegacyDataType.CounterElement: case AbLegacyDataType.CounterElement:
case AbLegacyDataType.ControlElement: case AbLegacyDataType.ControlElement:
_tag.SetInt32(0, Convert.ToInt32(value)); _tag.SetInt32(0, Convert.ToInt32(value));
break; break;
// PD-file non-bit writes route to the Float backing store. Status-bit writes within
// the parent word are blocked at the driver layer (PLC-set bits are read-only and
// operator-controllable bits go through the bit-RMW path with the parent word typed
// as Int).
case AbLegacyDataType.PidElement:
_tag.SetFloat32(0, Convert.ToSingle(value));
break;
case AbLegacyDataType.MessageElement:
case AbLegacyDataType.BlockTransferElement:
case AbLegacyDataType.PlsElement:
_tag.SetInt32(0, Convert.ToInt32(value));
break;
default: default:
throw new NotSupportedException($"AbLegacyDataType {type} not writable."); throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
} }
} }
/// <summary>
/// PR 7 — decode element <paramref name="elementIndex"/> of an N-element contiguous
/// PCCC block read. Element width is fixed per data type: Int / AnalogInt / Bit-as-word
/// are 16-bit (2 bytes/element), Long / Float are 32-bit (4 bytes/element). Mirrors the
/// non-array decoder shape but at byte offset <c>elementIndex × elementBytes</c>.
/// </summary>
public object? DecodeArrayElement(AbLegacyDataType type, int elementIndex)
{
if (elementIndex < 0) throw new ArgumentOutOfRangeException(nameof(elementIndex));
return type switch
{
// Bit / N-array reads — Rockwell convention is one BOOL per word (e.g. `B3:0,10`
// returns 10 BOOLs, not 160 individual bits). Each word is non-zero → true.
AbLegacyDataType.Bit => _tag.GetInt16(elementIndex * 2) != 0,
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)_tag.GetInt16(elementIndex * 2),
AbLegacyDataType.Long => _tag.GetInt32(elementIndex * 4),
AbLegacyDataType.Float => _tag.GetFloat32(elementIndex * 4),
// String + element types are out-of-scope for PR 7 array reads — the PCCC layer's
// 240-byte frame ceiling means an ST array would only fit a couple of strings, and
// sub-element arrays (`T4:0,5.ACC`) are rejected at parse time. Surface a clear
// error if the driver mis-routes us here.
_ => throw new NotSupportedException(
$"AbLegacyDataType {type} cannot be decoded as a contiguous array element."),
};
}
public void Dispose() => _tag.Dispose(); public void Dispose() => _tag.Dispose();
private static PlcType MapPlcType(string attribute) => attribute switch private static PlcType MapPlcType(string attribute) => attribute switch

View File

@@ -9,7 +9,13 @@ public sealed record AbLegacyPlcFamilyProfile(
string DefaultCipPath, string DefaultCipPath,
int MaxTagBytes, int MaxTagBytes,
bool SupportsStringFile, bool SupportsStringFile,
bool SupportsLongFile) bool SupportsLongFile,
bool OctalIoAddressing,
bool SupportsFunctionFiles,
bool SupportsPidFile,
bool SupportsMessageFile,
bool SupportsPlsFile,
bool SupportsBlockTransferFile)
{ {
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
{ {
@@ -25,21 +31,39 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0", DefaultCipPath: "1,0",
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
SupportsStringFile: true, // ST file available SLC 5/04+ SupportsStringFile: true, // ST file available SLC 5/04+
SupportsLongFile: true); // L file available SLC 5/05+ SupportsLongFile: true, // L file available SLC 5/05+
OctalIoAddressing: false, // SLC500 I:/O: indices are decimal in RSLogix 500
SupportsFunctionFiles: false, // SLC500 has no function files
SupportsPidFile: true, // SLC 5/02+ supports PD via PID instruction
SupportsMessageFile: true, // SLC 5/02+ supports MG via MSG instruction
SupportsPlsFile: false, // SLC500 has no native PLS file (uses SQO/SQC instead)
SupportsBlockTransferFile: false); // SLC500 has no BT file (BT is PLC-5 ChassisIO only)
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new( public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
LibplctagPlcAttribute: "micrologix", LibplctagPlcAttribute: "micrologix",
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
MaxTagBytes: 232, MaxTagBytes: 232,
SupportsStringFile: true, SupportsStringFile: true,
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
OctalIoAddressing: false, // MicroLogix follows SLC-style decimal I/O addressing
SupportsFunctionFiles: true, // ML 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI
SupportsPidFile: false, // MicroLogix 1100/1400 use PID-instruction-only addressing — no PD file type
SupportsMessageFile: false, // No MG file — MSG instruction control words live in standard files
SupportsPlsFile: false,
SupportsBlockTransferFile: false);
public static readonly AbLegacyPlcFamilyProfile Plc5 = new( public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
LibplctagPlcAttribute: "plc5", LibplctagPlcAttribute: "plc5",
DefaultCipPath: "1,0", DefaultCipPath: "1,0",
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
SupportsStringFile: true, SupportsStringFile: true,
SupportsLongFile: false); // PLC-5 predates L files SupportsLongFile: false, // PLC-5 predates L files
OctalIoAddressing: true, // RSLogix 5 displays I:/O: word + bit indices as octal
SupportsFunctionFiles: false,
SupportsPidFile: true, // PLC-5 PID instruction needs PD file
SupportsMessageFile: true, // PLC-5 MSG instruction needs MG file
SupportsPlsFile: true, // PLC-5 has PLS (programmable limit switch) file
SupportsBlockTransferFile: true); // PLC-5 chassis I/O block transfer (BTR/BTW) needs BT file
/// <summary> /// <summary>
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer. /// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
@@ -51,7 +75,15 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0", DefaultCipPath: "1,0",
MaxTagBytes: 240, MaxTagBytes: 240,
SupportsStringFile: true, SupportsStringFile: true,
SupportsLongFile: true); SupportsLongFile: true,
OctalIoAddressing: false, // Logix natively uses decimal arrays even via the PCCC bridge
SupportsFunctionFiles: false,
// Logix native UDTs (PID_ENHANCED / MESSAGE) replace the legacy PD/MG file types — the
// PCCC bridge does not expose them as letter-prefixed files.
SupportsPidFile: false,
SupportsMessageFile: false,
SupportsPlsFile: false,
SupportsBlockTransferFile: false);
} }
/// <summary>Which PCCC PLC family the device is.</summary> /// <summary>Which PCCC PLC family the device is.</summary>

View File

@@ -1,35 +1,57 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary> /// <summary>
/// Parsed FOCAS address covering the three addressing spaces a driver touches: /// Parsed FOCAS address covering the four addressing spaces a driver touches:
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>, /// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number — /// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/> /// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>). /// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
/// <see cref="FocasAreaKind.Diagnostic"/> (CNC diagnostic number, optionally per-axis —
/// <c>DIAG:1031</c>, <c>DIAG:280/2</c>) routed through <c>cnc_rddiag</c>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal /// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c> /// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based; /// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
/// bit index when present is 07 and uses <c>.N</c> for PMC or <c>/N</c> for parameters. /// bit index when present is 07 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
/// Diagnostic addresses reuse the <c>/N</c> form to encode an axis index — <c>BitIndex</c>
/// carries the 1-based axis number (0 = whole-CNC diagnostic).
/// <para>
/// Multi-path / multi-channel CNCs (e.g. lathe + sub-spindle, dual-turret) expose multiple
/// "paths"; <see cref="PathId"/> selects which one a given address is read from. Encoded
/// as a trailing <c>@N</c> after the address body but before any bit / axis suffix —
/// <c>R100@2</c>, <c>PARAM:1815@2</c>, <c>PARAM:1815@2/0</c>, <c>MACRO:500@3</c>,
/// <c>DIAG:280@2/1</c>. Defaults to <c>1</c> for back-compat (single-path CNCs).
/// </para>
/// </remarks> /// </remarks>
public sealed record FocasAddress( public sealed record FocasAddress(
FocasAreaKind Kind, FocasAreaKind Kind,
string? PmcLetter, string? PmcLetter,
int Number, int Number,
int? BitIndex) int? BitIndex,
int PathId = 1)
{ {
public string Canonical => Kind switch public string Canonical
{ {
FocasAreaKind.Pmc => BitIndex is null get
? $"{PmcLetter}{Number}" {
: $"{PmcLetter}{Number}.{BitIndex}", var pathSuffix = PathId == 1 ? string.Empty : $"@{PathId}";
FocasAreaKind.Parameter => BitIndex is null return Kind switch
? $"PARAM:{Number}" {
: $"PARAM:{Number}/{BitIndex}", FocasAreaKind.Pmc => BitIndex is null
FocasAreaKind.Macro => $"MACRO:{Number}", ? $"{PmcLetter}{Number}{pathSuffix}"
_ => $"?{Number}", : $"{PmcLetter}{Number}{pathSuffix}.{BitIndex}",
}; FocasAreaKind.Parameter => BitIndex is null
? $"PARAM:{Number}{pathSuffix}"
: $"PARAM:{Number}{pathSuffix}/{BitIndex}",
FocasAreaKind.Macro => $"MACRO:{Number}{pathSuffix}",
FocasAreaKind.Diagnostic => BitIndex is null or 0
? $"DIAG:{Number}{pathSuffix}"
: $"DIAG:{Number}{pathSuffix}/{BitIndex}",
_ => $"?{Number}",
};
}
}
public static FocasAddress? TryParse(string? value) public static FocasAddress? TryParse(string? value)
{ {
@@ -42,7 +64,10 @@ public sealed record FocasAddress(
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase)) if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null); return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
// PMC path: letter + digits + optional .bit if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["DIAG:".Length..], FocasAreaKind.Diagnostic, bitSeparator: '/');
// PMC path: letter + digits + optional @path + optional .bit
if (src.Length < 2 || !char.IsLetter(src[0])) return null; if (src.Length < 2 || !char.IsLetter(src[0])) return null;
var letter = src[0..1].ToUpperInvariant(); var letter = src[0..1].ToUpperInvariant();
if (!IsValidPmcLetter(letter)) return null; if (!IsValidPmcLetter(letter)) return null;
@@ -57,8 +82,15 @@ public sealed record FocasAddress(
bit = bitValue; bit = bitValue;
remainder = remainder[..dotIdx]; remainder = remainder[..dotIdx];
} }
var pmcPath = 1;
var atIdx = remainder.IndexOf('@');
if (atIdx >= 0)
{
if (!TryParsePathId(remainder[(atIdx + 1)..], out pmcPath)) return null;
remainder = remainder[..atIdx];
}
if (!int.TryParse(remainder, out var number) || number < 0) return null; if (!int.TryParse(remainder, out var number) || number < 0) return null;
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit); return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit, pmcPath);
} }
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator) private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
@@ -75,8 +107,30 @@ public sealed record FocasAddress(
body = body[..slashIdx]; body = body[..slashIdx];
} }
} }
// Path suffix (@N) sits between the body number and any bit/axis (which has already
// been peeled off above): PARAM:1815@2/0 → body="1815@2", bit=0.
var path = 1;
var atIdx = body.IndexOf('@');
if (atIdx >= 0)
{
if (!TryParsePathId(body[(atIdx + 1)..], out path)) return null;
body = body[..atIdx];
}
if (!int.TryParse(body, out var number) || number < 0) return null; if (!int.TryParse(body, out var number) || number < 0) return null;
return new FocasAddress(kind, PmcLetter: null, number, bit); return new FocasAddress(kind, PmcLetter: null, number, bit, path);
}
private static bool TryParsePathId(string text, out int pathId)
{
// Path 0 is reserved (FOCAS path numbering is 1-based); upper-bound is the FWLIB
// ceiling — Fanuc spec lists 10 paths max even on the largest 30i-B configurations.
if (int.TryParse(text, out var v) && v is >= 1 and <= 10)
{
pathId = v;
return true;
}
pathId = 0;
return false;
} }
private static bool IsValidPmcLetter(string letter) => letter switch private static bool IsValidPmcLetter(string letter) => letter switch
@@ -92,4 +146,12 @@ public enum FocasAreaKind
Pmc, Pmc,
Parameter, Parameter,
Macro, Macro,
/// <summary>
/// CNC diagnostic number routed through <c>cnc_rddiag</c>. <c>DIAG:nnn</c> is a
/// whole-CNC diagnostic (axis = 0); <c>DIAG:nnn/axis</c> is per-axis (axis is the
/// 1-based FANUC axis index). Like parameters, diagnostics span Int / Float /
/// Bit shapes — the driver picks the wire shape based on the configured tag's
/// <see cref="FocasDataType"/>.
/// </summary>
Diagnostic,
} }

View File

@@ -0,0 +1,255 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Issue #267 (plan PR F3-a) — projects FANUC CNC alarms onto the OPC UA alarm surface
/// via <see cref="IAlarmSource"/>. Two modes:
/// <list type="bullet">
/// <item><see cref="FocasAlarmProjectionMode.ActiveOnly"/> (default) — only
/// currently-active alarms surface. Subscribe / unsubscribe / acknowledge wire up,
/// but no history poll runs. This is the conservative mode operators get when
/// they don't explicitly opt into history.</item>
/// <item><see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> — additionally
/// polls <c>cnc_rdalmhistry</c> on connect and on every
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> tick. Each
/// previously-unseen entry fires an <c>OnAlarmEvent</c> with
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp (not Now)
/// so OPC UA dashboards see the real occurrence time.</item>
/// </list>
/// </summary>
/// <remarks>
/// <para><b>Dedup</b> — an in-memory <see cref="HashSet{T}"/> keyed on
/// <c>(OccurrenceTime, AlarmNumber, AlarmType)</c> tracks every entry the projection has
/// emitted. The same triple across two polls only emits once. The set resets on reconnect
/// — first poll after reconnect re-emits everything in the ring buffer; OPC UA clients
/// that care about exactly-once semantics dedupe on their side via the
/// timestamp + number + type tuple.</para>
///
/// <para><b>HistoryDepth clamp</b> — user-supplied depth is bounded to
/// <c>[1..<see cref="FocasAlarmProjectionOptions.MaxHistoryDepth"/>]</c> so an operator
/// who types <c>10000</c> by accident doesn't blow up the wire session. The clamp lives
/// in <see cref="ResolveDepth"/>.</para>
///
/// <para><b>Active alarms</b> — first cut surfaces history only. Active alarms (raise +
/// clear via <c>cnc_rdalmmsg</c>/<c>cnc_rdalmmsg2</c>) are a follow-up; this projection's
/// subscribe path returns a handle but does not poll for active alarms today. The
/// ActiveOnly mode therefore is functionally a no-op subscribe — the IAlarmSource
/// contract still wires up so capability negotiation works + a future PR can add the
/// active-alarm poll without reshaping the projection. The plan deliberately scopes F3-a
/// to the history extension; the active poll lands as F3-b.</para>
/// </remarks>
internal sealed class FocasAlarmProjection : IAsyncDisposable
{
private readonly Func<CancellationToken, Task<IFocasClient?>> _connectAsync;
private readonly Action<AlarmEventArgs> _emit;
private readonly FocasAlarmProjectionOptions _options;
private readonly string _diagnosticPrefix;
private readonly Dictionary<long, Subscription> _subs = new();
private readonly Lock _subsLock = new();
private long _nextId;
/// <summary>
/// Dedup set across the entire projection — alarm history is per-CNC, not
/// per-subscription, so a single set across all subscriptions matches operator
/// intent (one CNC, one ring buffer, one set of history events even if multiple
/// OPC UA clients have subscribed).
/// </summary>
private readonly HashSet<DedupKey> _seen = new();
private readonly Lock _seenLock = new();
public FocasAlarmProjection(
FocasAlarmProjectionOptions options,
Func<CancellationToken, Task<IFocasClient?>> connectAsync,
Action<AlarmEventArgs> emit,
string diagnosticPrefix = "focas-alarm-sub")
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(connectAsync);
ArgumentNullException.ThrowIfNull(emit);
_options = options;
_connectAsync = connectAsync;
_emit = emit;
_diagnosticPrefix = diagnosticPrefix;
}
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextId);
var handle = new FocasAlarmSubscriptionHandle(id, _diagnosticPrefix);
if (_options.Mode != FocasAlarmProjectionMode.ActivePlusHistory)
{
// ActiveOnly — return the handle so capability negotiation works, but skip the
// history poll entirely. The active-alarm poll lands as a follow-up PR.
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
}
var cts = new CancellationTokenSource();
var sub = new Subscription(handle, [..sourceNodeIds], cts);
lock (_subsLock) _subs[id] = sub;
sub.Loop = Task.Run(() => RunHistoryPollAsync(sub, cts.Token), cts.Token);
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
}
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not FocasAlarmSubscriptionHandle h) return;
Subscription? sub;
lock (_subsLock)
{
if (!_subs.Remove(h.Id, out sub)) return;
}
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
/// <summary>
/// Acknowledge stub — FANUC's history surface is read-only (the ring buffer only
/// records what the CNC has cleared internally), so per-history-entry ack is a no-op.
/// A future PR may extend the active-alarm flow with a per-CNC reset call.
/// </summary>
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
=> Task.CompletedTask;
public async ValueTask DisposeAsync()
{
List<Subscription> snap;
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
foreach (var sub in snap)
{
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
}
/// <summary>
/// Reset the dedup set — used after reconnect so the next history poll re-emits
/// everything in the ring buffer. Public for tests + the driver's reconnect hook.
/// </summary>
public void ResetDedup()
{
lock (_seenLock) _seen.Clear();
}
/// <summary>
/// Pull one history snapshot + emit unseen entries. Extracted from the timer loop so
/// unit tests can drive a single tick without standing up Task.Run.
/// </summary>
internal async Task<int> PollOnceAsync(Subscription sub, CancellationToken ct)
{
var client = await _connectAsync(ct).ConfigureAwait(false);
if (client is null) return 0;
var depth = ResolveDepth(_options.HistoryDepth);
IReadOnlyList<FocasAlarmHistoryEntry> entries;
try
{
entries = await client.ReadAlarmHistoryAsync(depth, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch
{
// Per-tick failure — leave dedup intact, next tick retries. Matches the
// AbCip alarm projection's "non-fatal per-tick" pattern (#177).
return 0;
}
var emitted = 0;
foreach (var entry in entries)
{
var key = new DedupKey(entry.OccurrenceTime, entry.AlarmNumber, entry.AlarmType);
bool added;
lock (_seenLock) added = _seen.Add(key);
if (!added) continue;
// Each subscription gets its own copy of the event — multiple OPC UA clients
// can subscribe + each sees the historic events through their own subscription
// handle. Source node id is the first declared id (sub.SourceNodeIds[0]) when
// present; empty subscriptions get a synthetic "alarm-history" id so the
// event still threads through the IAlarmSource contract cleanly.
var sourceNodeId = sub.SourceNodeIds.Count > 0 ? sub.SourceNodeIds[0] : "alarm-history";
_emit(new AlarmEventArgs(
SubscriptionHandle: sub.Handle,
SourceNodeId: sourceNodeId,
ConditionId: $"focas-history#{entry.AlarmType}-{entry.AlarmNumber}-{entry.OccurrenceTime:O}",
AlarmType: $"FOCAS_T{entry.AlarmType}",
Message: BuildMessage(entry),
Severity: AlarmSeverity.High,
SourceTimestampUtc: entry.OccurrenceTime.UtcDateTime));
emitted++;
}
return emitted;
}
private async Task RunHistoryPollAsync(Subscription sub, CancellationToken ct)
{
// First poll fires immediately on subscribe (== "on connect" per F3-a) so operators
// get history dashboard data without waiting for the cadence to elapse.
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch { /* swallowed in PollOnceAsync; defensive double-catch */ }
var interval = _options.HistoryPollInterval > TimeSpan.Zero
? _options.HistoryPollInterval
: FocasAlarmProjectionOptions.DefaultHistoryPollInterval;
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* per-tick failures are non-fatal */ }
}
}
/// <summary>
/// Bound user-requested depth to <c>[1..MaxHistoryDepth]</c>. <c>0</c>/negative
/// values fall back to <see cref="FocasAlarmProjectionOptions.DefaultHistoryDepth"/>
/// so misconfigured options still pull a reasonable batch.
/// </summary>
internal static int ResolveDepth(int requested)
{
if (requested <= 0) return FocasAlarmProjectionOptions.DefaultHistoryDepth;
return Math.Min(requested, FocasAlarmProjectionOptions.MaxHistoryDepth);
}
private static string BuildMessage(FocasAlarmHistoryEntry entry)
{
if (string.IsNullOrEmpty(entry.Message))
return $"FOCAS alarm T{entry.AlarmType} #{entry.AlarmNumber}";
return $"FOCAS T{entry.AlarmType} #{entry.AlarmNumber}: {entry.Message}";
}
/// <summary>Composite dedup key — see class-level remarks.</summary>
private readonly record struct DedupKey(DateTimeOffset OccurrenceTime, int AlarmNumber, int AlarmType);
internal sealed class Subscription
{
public Subscription(FocasAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
{
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
}
public FocasAlarmSubscriptionHandle Handle { get; }
public IReadOnlyList<string> SourceNodeIds { get; }
public CancellationTokenSource Cts { get; }
public Task Loop { get; set; } = Task.CompletedTask;
}
}
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
public sealed record FocasAlarmSubscriptionHandle(long Id, string DiagnosticPrefix) : IAlarmSubscriptionHandle
{
public string DiagnosticId => $"{DiagnosticPrefix}-{Id}";
}

View File

@@ -32,9 +32,10 @@ public static class FocasCapabilityMatrix
return address.Kind switch return address.Kind switch
{ {
FocasAreaKind.Macro => ValidateMacro(series, address.Number), FocasAreaKind.Macro => ValidateMacro(series, address.Number),
FocasAreaKind.Parameter => ValidateParameter(series, address.Number), FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number), FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
FocasAreaKind.Diagnostic => ValidateDiagnostic(series, address.Number),
_ => null, _ => null,
}; };
} }
@@ -73,11 +74,35 @@ public static class FocasCapabilityMatrix
_ => (0, int.MaxValue), _ => (0, int.MaxValue),
}; };
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C /// <summary>
/// signal groups that 30i-family ladder programs use.</summary> /// CNC diagnostic number range accepted by a series; from <c>cnc_rddiag</c>
/// (and <c>cnc_rddiagdgn</c> for axis-scoped reads). Returning <c>null</c>
/// means the series doesn't support <c>cnc_rddiag</c> at all — the driver
/// rejects every <c>DIAG:</c> address on that series. Conservative ceilings
/// per the FOCAS Developer Kit: legacy 16i-family caps at 499; modern 0i-F
/// family at 999; 30i / 31i / 32i extend to 1023. Power Motion i has a
/// narrow diagnostic surface (0..255).
/// </summary>
internal static (int min, int max)? DiagnosticRange(FocasCncSeries series) => series switch
{
FocasCncSeries.Sixteen_i => (0, 499),
FocasCncSeries.Zero_i_D => (0, 499),
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
FocasCncSeries.Zero_i_TF => (0, 999),
FocasCncSeries.Thirty_i or
FocasCncSeries.ThirtyOne_i or
FocasCncSeries.ThirtyTwo_i => (0, 1023),
FocasCncSeries.PowerMotion_i => (0, 255),
_ => (0, int.MaxValue),
};
/// <summary>PMC letters accepted per series. Legacy 16i ladders use X/Y/F/G
/// for handshakes plus R/D for retained/data; M/C/E/A/K/T are the 0i-F /
/// 30i-family extensions.</summary>
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
{ {
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" }, FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D" },
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" }, FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
FocasCncSeries.Zero_i_F or FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or FocasCncSeries.Zero_i_MF or
@@ -106,6 +131,27 @@ public static class FocasCapabilityMatrix
_ => int.MaxValue, _ => int.MaxValue,
}; };
/// <summary>
/// Whether the FOCAS driver should expose the per-device <c>Tooling/</c>
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
/// <c>cnc_rdtnum</c>, which is documented for every modern Fanuc series
/// (0i / 16i / 30i families) — defaulting to <c>true</c>. The capability
/// hook exists so a future controller without <c>cnc_rdtnum</c> can opt
/// out without touching the driver. <see cref="FocasCncSeries.Unknown"/>
/// stays permissive (matches the modal / override fixed-tree precedent in
/// issue #259). Issue #260.
/// </summary>
public static bool SupportsTooling(FocasCncSeries series) => true;
/// <summary>
/// Whether the FOCAS driver should expose the per-device <c>Offsets/</c>
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
/// <c>cnc_rdzofs(n=1..6)</c> for the standard G54..G59 surfaces; extended
/// G54.1 P1..P48 surfaces are deferred to a follow-up. Same permissive
/// policy as <see cref="SupportsTooling"/>. Issue #260.
/// </summary>
public static bool SupportsWorkOffsets(FocasCncSeries series) => true;
private static string? ValidateMacro(FocasCncSeries series, int number) private static string? ValidateMacro(FocasCncSeries series, int number)
{ {
var (min, max) = MacroRange(series); var (min, max) = MacroRange(series);
@@ -122,6 +168,16 @@ public static class FocasCapabilityMatrix
: null; : null;
} }
private static string? ValidateDiagnostic(FocasCncSeries series, int number)
{
if (DiagnosticRange(series) is not { } range)
return $"Diagnostic addresses are not supported on {series} (no documented cnc_rddiag range).";
var (min, max) = range;
return (number < min || number > max)
? $"Diagnostic #{number} is outside the documented range [{min}, {max}] for {series}."
: null;
}
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number) private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
{ {
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix."; if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";

File diff suppressed because it is too large Load Diff

View File

@@ -11,22 +11,135 @@ public sealed class FocasDriverOptions
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = []; public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
public FocasProbeOptions Probe { get; init; } = new(); public FocasProbeOptions Probe { get; init; } = new();
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Fixed-tree behaviour knobs (issue #262, plan PR F1-f). Carries the
/// <c>ApplyFigureScaling</c> toggle that gates the <c>cnc_getfigure</c>
/// decimal-place division applied to position values before publishing.
/// </summary>
public FocasFixedTreeOptions FixedTree { get; init; } = new();
/// <summary>
/// Alarm projection knobs (issue #267, plan PR F3-a). Default mode is
/// <see cref="FocasAlarmProjectionMode.ActiveOnly"/> — the projection only surfaces
/// currently-active alarms. Operators who want the on-CNC ring-buffer history
/// replayed as historic OPC UA events (so dashboards see the real CNC timestamp,
/// not the moment the projection polled) flip this to
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
/// </summary>
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
}
/// <summary>
/// Mode for the FOCAS alarm projection (issue #267, plan PR F3-a). Default
/// <see cref="ActiveOnly"/> matches today's behaviour — only currently-active
/// alarms surface as OPC UA events. <see cref="ActivePlusHistory"/> additionally
/// polls <c>cnc_rdalmhistry</c> on connect + on a configurable cadence and emits the
/// ring-buffer entries as historic events, deduped by <c>(OccurrenceTime, AlarmNumber,
/// AlarmType)</c> so a polled entry never re-fires.
/// </summary>
public enum FocasAlarmProjectionMode
{
/// <summary>Surface only currently-active CNC alarms. No history poll. Default.</summary>
ActiveOnly = 0,
/// <summary>
/// Surface active alarms plus the on-CNC ring-buffer history. The projection
/// polls <c>cnc_rdalmhistry</c> on connect and on
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> ticks afterward.
/// Each new entry (keyed by <c>(OccurrenceTime, AlarmNumber, AlarmType)</c>)
/// fires an <see cref="Core.Abstractions.IAlarmSource.OnAlarmEvent"/> with
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp, not Now.
/// </summary>
ActivePlusHistory = 1,
}
/// <summary>
/// FOCAS alarm-projection knobs (issue #267, plan PR F3-a). Carries the mode switch +
/// the cadence / depth tuning for the <c>cnc_rdalmhistry</c> poll loop. Defaults match
/// "operator dashboard with five-minute refresh" — the single most common deployment
/// shape per the F3-a deployment doc.
/// </summary>
public sealed record FocasAlarmProjectionOptions
{
/// <summary>Default poll interval — 5 minutes. Matches dashboard-class cadences.</summary>
public static readonly TimeSpan DefaultHistoryPollInterval = TimeSpan.FromMinutes(5);
/// <summary>
/// Default ring-buffer depth requested per poll — <c>100</c>. Most FANUC controllers
/// keep ~100 entries by default; pulling the full depth on every poll keeps the
/// dedup set authoritative across reconnects without burning extra wire bandwidth on
/// entries the dedup key would discard anyway.
/// </summary>
public const int DefaultHistoryDepth = 100;
/// <summary>
/// Hard ceiling on <see cref="HistoryDepth"/>. The projection clamps user-requested
/// depths above this value down — typical CNC ring buffers cap well below this and
/// letting an operator type <c>10000</c> by accident shouldn't take down the wire
/// session with a giant <c>cnc_rdalmhistry</c> request.
/// </summary>
public const int MaxHistoryDepth = 250;
/// <summary>Active-only (default) vs Active-plus-history. See <see cref="FocasAlarmProjectionMode"/>.</summary>
public FocasAlarmProjectionMode Mode { get; init; } = FocasAlarmProjectionMode.ActiveOnly;
/// <summary>
/// Cadence at which the projection re-polls <c>cnc_rdalmhistry</c> when
/// <see cref="Mode"/> is <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
/// Default <see cref="DefaultHistoryPollInterval"/> = 5 minutes. Only applies after
/// the on-connect poll fires.
/// </summary>
public TimeSpan HistoryPollInterval { get; init; } = DefaultHistoryPollInterval;
/// <summary>
/// Number of most-recent ring-buffer entries to request per poll. Clamped to
/// <c>[1..<see cref="MaxHistoryDepth"/>]</c> at projection startup so misconfigured
/// values can't hammer the CNC. Default <see cref="DefaultHistoryDepth"/> = 100.
/// </summary>
public int HistoryDepth { get; init; } = DefaultHistoryDepth;
}
/// <summary>
/// Per-driver fixed-tree options. New installs default <see cref="ApplyFigureScaling"/>
/// to <c>true</c> so position values surface in user units (mm / inch). Existing
/// deployments that already published raw scaled integers can flip this to <c>false</c>
/// for migration parity — the operator-facing concern is that switching the flag
/// mid-deployment changes the values clients see, so the migration path is
/// documentation-only (issue #262).
/// </summary>
public sealed record FocasFixedTreeOptions
{
/// <summary>
/// When <c>true</c> (default), position values from <c>cnc_absolute</c> /
/// <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> /
/// <c>cnc_actf</c> are divided by <c>10^decimalPlaces</c> per axis using the
/// <c>cnc_getfigure</c> snapshot cached at probe time. When <c>false</c>, the
/// raw integer values are published unchanged — used for migrations from
/// older drivers that didn't apply the scaling.
/// </summary>
public bool ApplyFigureScaling { get; init; } = true;
} }
/// <summary> /// <summary>
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series /// One CNC the driver talks to. <paramref name="Series"/> enables per-series
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as /// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour). /// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
/// </summary> /// </summary>
public sealed record FocasDeviceOptions( public sealed record FocasDeviceOptions(
string HostAddress, string HostAddress,
string? DeviceName = null, string? DeviceName = null,
FocasCncSeries Series = FocasCncSeries.Unknown); FocasCncSeries Series = FocasCncSeries.Unknown,
FocasOverrideParameters? OverrideParameters = null);
/// <summary> /// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS /// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
/// address string that parses via <see cref="FocasAddress.TryParse"/> — /// address string that parses via <see cref="FocasAddress.TryParse"/> —
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>. /// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c> /
/// <c>DIAG:1031</c> / <c>DIAG:280/2</c>.
/// </summary> /// </summary>
public sealed record FocasTagDefinition( public sealed record FocasTagDefinition(
string Name, string Name,

View File

@@ -59,10 +59,20 @@ internal sealed class FwlibFocasClient : IFocasClient
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)), FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)), FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)), FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
FocasAreaKind.Diagnostic => Task.FromResult(
ReadDiagnostic(address.Number, address.BitIndex ?? 0, type)),
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)), _ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
}; };
} }
public Task<(object? value, uint status)> ReadDiagnosticAsync(
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(ReadDiagnostic(diagNumber, axisOrZero, type));
}
public async Task<uint> WriteAsync( public async Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
{ {
@@ -129,6 +139,26 @@ internal sealed class FwlibFocasClient : IFocasClient
} }
} }
public Task<int> GetPathCountAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult(1);
var buf = new FwlibNative.ODBPATH();
var ret = FwlibNative.RdPathNum(_handle, ref buf);
// EW_FUNC / EW_NOOPT on single-path controllers — fall back to 1 rather than failing.
if (ret != 0 || buf.MaxPath < 1) return Task.FromResult(1);
return Task.FromResult((int)buf.MaxPath);
}
public Task SetPathAsync(int pathId, CancellationToken cancellationToken)
{
if (!_connected) return Task.CompletedTask;
var ret = FwlibNative.SetPath(_handle, (short)pathId);
if (ret != 0)
throw new InvalidOperationException(
$"FWLIB cnc_setpath failed with EW_{ret} switching to path {pathId}.");
return Task.CompletedTask;
}
public Task<bool> ProbeAsync(CancellationToken cancellationToken) public Task<bool> ProbeAsync(CancellationToken cancellationToken)
{ {
if (!_connected) return Task.FromResult(false); if (!_connected) return Task.FromResult(false);
@@ -137,6 +167,256 @@ internal sealed class FwlibFocasClient : IFocasClient
return Task.FromResult(ret == 0); return Task.FromResult(ret == 0);
} }
public Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasStatusInfo?>(null);
var buf = new FwlibNative.ODBST();
var ret = FwlibNative.StatInfo(_handle, ref buf);
if (ret != 0) return Task.FromResult<FocasStatusInfo?>(null);
return Task.FromResult<FocasStatusInfo?>(new FocasStatusInfo(
Dummy: buf.Dummy,
Tmmode: buf.TmMode,
Aut: buf.Aut,
Run: buf.Run,
Motion: buf.Motion,
Mstb: buf.Mstb,
EmergencyStop: buf.Emergency,
Alarm: buf.Alarm,
Edit: buf.Edit));
}
public Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasProductionInfo?>(null);
if (!TryReadInt32Param(6711, out var produced) ||
!TryReadInt32Param(6712, out var required) ||
!TryReadInt32Param(6713, out var total))
{
return Task.FromResult<FocasProductionInfo?>(null);
}
// Cycle-time timer (type=2). Total seconds = minute*60 + msec/1000. Best-effort:
// a non-zero return leaves cycle-time at 0 rather than failing the whole snapshot
// — the parts counters are still useful even when cycle-time isn't supported.
var cycleSeconds = 0;
var tmrBuf = new FwlibNative.IODBTMR();
if (FwlibNative.RdTimer(_handle, type: 2, ref tmrBuf) == 0)
cycleSeconds = checked(tmrBuf.Minute * 60 + tmrBuf.Msec / 1000);
return Task.FromResult<FocasProductionInfo?>(new FocasProductionInfo(
PartsProduced: produced,
PartsRequired: required,
PartsTotal: total,
CycleTimeSeconds: cycleSeconds));
}
private bool TryReadInt32Param(ushort number, out int value)
{
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 4, ref buf);
if (ret != 0) { value = 0; return false; }
value = BinaryPrimitives.ReadInt32LittleEndian(buf.Data);
return true;
}
private bool TryReadInt16Param(ushort number, out short value)
{
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf);
if (ret != 0) { value = 0; return false; }
value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
return true;
}
public Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasModalInfo?>(null);
// type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0).
// Best-effort — if any single read fails we still surface the others as 0; the
// probe loop only updates the cache on a non-null return so a partial snapshot
// is preferable to throwing away every successful field.
return Task.FromResult<FocasModalInfo?>(new FocasModalInfo(
MCode: ReadModalAux(type: 100),
SCode: ReadModalAux(type: 101),
TCode: ReadModalAux(type: 102),
BCode: ReadModalAux(type: 103)));
}
private short ReadModalAux(short type)
{
var buf = new FwlibNative.ODBMDL { Data = new byte[8] };
var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf);
if (ret != 0) return 0;
// For aux types (100..103) the union holds the code at offset 0 as a 2-byte
// value (<c>aux_data</c>). Reading as Int16 keeps the surface identical to the
// record contract; oversized values would have been truncated by FWLIB anyway.
return BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
}
public Task<FocasOverrideInfo?> GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasOverrideInfo?>(null);
// Each parameter is independently nullable — a null parameter number keeps the
// corresponding field at null + skips the wire call. A successful read on at
// least one parameter is enough to publish a snapshot; this matches the
// best-effort policy used by GetProductionAsync (issue #259).
var feed = TryReadOverride(parameters.FeedParam);
var rapid = TryReadOverride(parameters.RapidParam);
var spindle = TryReadOverride(parameters.SpindleParam);
var jog = TryReadOverride(parameters.JogParam);
return Task.FromResult<FocasOverrideInfo?>(new FocasOverrideInfo(feed, rapid, spindle, jog));
}
private short? TryReadOverride(ushort? param)
{
if (param is null) return null;
return TryReadInt16Param(param.Value, out var v) ? v : null;
}
public Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasToolingInfo?>(null);
var buf = new FwlibNative.IODBTNUM();
var ret = FwlibNative.RdToolNumber(_handle, ref buf);
if (ret != 0) return Task.FromResult<FocasToolingInfo?>(null);
// FWLIB returns long; clamp to short for the surfaced Int16 (T-codes
// overflowing 32767 are vanishingly rare on Fanuc tool tables).
var t = buf.Data;
if (t > short.MaxValue) t = short.MaxValue;
else if (t < short.MinValue) t = short.MinValue;
return Task.FromResult<FocasToolingInfo?>(new FocasToolingInfo((short)t));
}
public Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasWorkOffsetsInfo?>(null);
// 1..6 = G54..G59. Extended G54.1 P1..P48 use cnc_rdzofsr and are deferred.
// Pass axis=-1 so FWLIB fills every axis it has; we read the first 3 (X/Y/Z).
// Length = 4-byte header + 3 axes * 10-byte OFSB = 34. We request 4 + 8*10 = 84
// (the buffer ceiling) so a CNC with more axes still completes the call.
var slots = new List<FocasWorkOffset>(6);
string[] names = ["G54", "G55", "G56", "G57", "G58", "G59"];
for (short n = 1; n <= 6; n++)
{
var buf = new FwlibNative.IODBZOFS { Data = new byte[80] };
var ret = FwlibNative.RdWorkOffset(_handle, n, axis: -1, length: 4 + 8 * 10, ref buf);
if (ret != 0)
{
// Best-effort — a single-slot failure leaves the slot at 0.0; the cache
// still publishes so reads on the other offsets serve Good. The probe
// loop will retry on the next tick.
slots.Add(new FocasWorkOffset(names[n - 1], 0, 0, 0));
continue;
}
slots.Add(new FocasWorkOffset(
Name: names[n - 1],
X: DecodeOfsbAxis(buf.Data, axisIndex: 0),
Y: DecodeOfsbAxis(buf.Data, axisIndex: 1),
Z: DecodeOfsbAxis(buf.Data, axisIndex: 2)));
}
return Task.FromResult<FocasWorkOffsetsInfo?>(new FocasWorkOffsetsInfo(slots));
}
public Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasOperatorMessagesInfo?>(null);
// type 0..3 = OPMSG / MACRO / EXTERN / REJ-EXT (issue #261). Single-slot read
// (length 4 + 256 = 260) returns the most-recent message in each class — best-
// effort: a single-class failure leaves that class out of the snapshot rather
// than failing the whole call, mirroring GetProductionAsync's policy.
var list = new List<FocasOperatorMessage>(4);
string[] classNames = ["OPMSG", "MACRO", "EXTERN", "REJ-EXT"];
for (short t = 0; t < 4; t++)
{
var buf = new FwlibNative.OPMSG3 { Data = new byte[256] };
var ret = FwlibNative.RdOpMsg3(_handle, t, length: 4 + 256, ref buf);
if (ret != 0) continue;
var text = TrimAnsiPadding(buf.Data);
if (string.IsNullOrEmpty(text)) continue;
list.Add(new FocasOperatorMessage(buf.Datano, classNames[t], text));
}
return Task.FromResult<FocasOperatorMessagesInfo?>(new FocasOperatorMessagesInfo(list));
}
public Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasCurrentBlockInfo?>(null);
var buf = new FwlibNative.ODBACTPT { Data = new byte[256] };
var ret = FwlibNative.RdActPt(_handle, ref buf);
if (ret != 0) return Task.FromResult<FocasCurrentBlockInfo?>(null);
return Task.FromResult<FocasCurrentBlockInfo?>(
new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data)));
}
public Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
// kind=0 → position figures (absolute/relative/machine/distance share the same
// increment system per axis). cnc_rdaxisname is deferred — the wire impl keys
// by fallback "axis{n}" (1-based), the driver re-keys when it gains axis-name
// discovery in a follow-up. Issue #262, plan PR F1-f.
short count = 0;
var buf = new FwlibNative.IODBAXIS { Data = new byte[FwlibNative.MAX_AXIS * 8] };
var ret = FwlibNative.GetFigure(_handle, kind: 0, ref count, ref buf);
if (ret != 0) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
return Task.FromResult<IReadOnlyDictionary<string, int>?>(DecodeFigureScaling(buf.Data, count));
}
/// <summary>
/// Decode the per-axis decimal-place counts from a <c>cnc_getfigure</c> reply
/// buffer. Each axis entry per <c>fwlib32.h</c> is 8 bytes laid out as
/// <c>short dec</c> + <c>short unit</c> + 4 reserved bytes; we read only
/// <c>dec</c>. Keys are 1-based <c>"axis{n}"</c> placeholders — a follow-up
/// PR can rewire to <c>cnc_rdaxisname</c> once that surface lands without
/// changing the cache contract (issue #262).
/// </summary>
internal static IReadOnlyDictionary<string, int> DecodeFigureScaling(byte[] data, short count)
{
var clamped = Math.Max((short)0, Math.Min(count, (short)FwlibNative.MAX_AXIS));
var result = new Dictionary<string, int>(clamped, StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < clamped; i++)
{
var offset = i * 8;
if (offset + 2 > data.Length) break;
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset, 2));
if (dec < 0 || dec > 9) dec = 0;
result[$"axis{i + 1}"] = dec;
}
return result;
}
/// <summary>
/// Decode + trim a Fanuc ANSI byte buffer. The CNC right-pads block text + opmsg
/// bodies with nulls or spaces; trim them so the round-trip through the OPC UA
/// address space stays stable (issue #261). Stops at the first NUL so any wire
/// buffer that gets reused doesn't leak old bytes.
/// </summary>
internal static string TrimAnsiPadding(byte[] data)
{
if (data is null) return string.Empty;
var len = 0;
for (; len < data.Length; len++)
if (data[len] == 0) break;
return System.Text.Encoding.ASCII.GetString(data, 0, len).TrimEnd(' ', '\0');
}
/// <summary>
/// Decode one OFSB axis block from a <c>cnc_rdzofs</c> data buffer. Each axis
/// occupies 10 bytes per <c>fwlib32.h</c>: <c>int data</c> + <c>short dec</c> +
/// <c>short unit</c> + <c>short disp</c>. The user-facing offset is
/// <c>data / 10^dec</c> — same convention as <c>cnc_rdmacro</c>.
/// </summary>
internal static double DecodeOfsbAxis(byte[] data, int axisIndex)
{
const int blockSize = 10;
var offset = axisIndex * blockSize;
if (offset + blockSize > data.Length) return 0;
var raw = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4));
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset + 4, 2));
if (dec < 0 || dec > 9) dec = 0;
return raw / Math.Pow(10.0, dec);
}
// ---- PMC ---- // ---- PMC ----
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type) private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
@@ -165,6 +445,42 @@ internal sealed class FwlibFocasClient : IFocasClient
return (value, FocasStatusMapper.Good); return (value, FocasStatusMapper.Good);
} }
/// <summary>
/// Range read for the PMC coalescer (issue #266). FWLIB's <c>pmc_rdpmcrng</c>
/// payload is capped at 40 bytes (the IODBPMC.Data union width), so requested
/// ranges larger than that are chunked into 32-byte sub-calls internally —
/// callers still see one logical range, which matches the
/// <see cref="Wire.FocasPmcCoalescer"/>'s "one wire call per group" semantics.
/// </summary>
public Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.BadCommunicationError));
cancellationToken.ThrowIfCancellationRequested();
if (byteCount <= 0) return Task.FromResult<(byte[]?, uint)>((Array.Empty<byte>(), FocasStatusMapper.Good));
var addrType = FocasPmcAddrType.FromLetter(letter)
?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'.");
var result = new byte[byteCount];
const int chunkBytes = 32;
var offset = 0;
while (offset < byteCount)
{
cancellationToken.ThrowIfCancellationRequested();
var thisChunk = Math.Min(chunkBytes, byteCount - offset);
var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
var ret = FwlibNative.PmcRdPmcRng(
_handle, addrType, FocasPmcDataType.Byte,
(ushort)(startByte + offset),
(ushort)(startByte + offset + thisChunk - 1),
(ushort)(8 + thisChunk), ref buf);
if (ret != 0) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.MapFocasReturn(ret)));
Array.Copy(buf.Data, 0, result, offset, thisChunk);
offset += thisChunk;
}
return Task.FromResult<(byte[]?, uint)>((result, FocasStatusMapper.Good));
}
private uint WritePmc(FocasAddress address, FocasDataType type, object? value) private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
{ {
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0; var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
@@ -217,6 +533,36 @@ internal sealed class FwlibFocasClient : IFocasClient
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret); return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
} }
private (object? value, uint status) ReadDiagnostic(int diagNumber, int axisOrZero, FocasDataType type)
{
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
var length = DiagnosticReadLength(type);
var ret = FwlibNative.RdDiag(_handle, (ushort)diagNumber, (short)axisOrZero, (short)length, ref buf);
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
var value = type switch
{
FocasDataType.Bit => (object)ExtractBit(buf.Data[0], 0),
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
};
return (value, FocasStatusMapper.Good);
}
private static int DiagnosticReadLength(FocasDataType type) => type switch
{
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
FocasDataType.Int16 => 4 + 2,
FocasDataType.Int32 => 4 + 4,
FocasDataType.Float32 => 4 + 4,
FocasDataType.Float64 => 4 + 8,
_ => 4 + 4,
};
private (object? value, uint status) ReadMacro(FocasAddress address) private (object? value, uint status) ReadMacro(FocasAddress address)
{ {
var buf = new FwlibNative.ODBM(); var buf = new FwlibNative.ODBM();

View File

@@ -88,6 +88,144 @@ internal static class FwlibNative
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)] [DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
public static extern short StatInfo(ushort handle, ref ODBST buffer); public static extern short StatInfo(ushort handle, ref ODBST buffer);
// ---- Timers ----
/// <summary>
/// <c>cnc_rdtimer</c> — read CNC running timers. <paramref name="type"/>: 0 = power-on
/// time (ms), 1 = operating time (ms), 2 = cycle time (ms), 3 = cutting time (ms).
/// Only the cycle-time variant is consumed today (issue #258); the call is generic
/// so the surface can grow without another P/Invoke.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)]
public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer);
// ---- Modal codes ----
/// <summary>
/// <c>cnc_modal</c> — read modal information for one G-group or auxiliary code.
/// <paramref name="type"/>: 1..21 = G-group N (single group), 100 = M, 101 = S,
/// 102 = T, 103 = B (per Fanuc FOCAS reference). <paramref name="block"/>: 0 =
/// active modal commands. We only consume types 100..103 today (M/S/T/B); the
/// G-group decode is deferred to a follow-up because the <c>ODBMDL</c> union
/// varies by group + series (issue #259).
/// </summary>
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
// ---- Tooling ----
/// <summary>
/// <c>cnc_rdtnum</c> — read the currently selected tool number. Returns
/// <c>EW_OK</c> + populates <see cref="IODBTNUM.Data"/> with the active T-code.
/// Tool life + current offset index reads (<c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c>/
/// <c>cnc_rdtofs</c>) are deferred per the F1-d plan — those calls use ODBTLIFE*
/// unions whose shape varies per series.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdtnum", ExactSpelling = true)]
public static extern short RdToolNumber(ushort handle, ref IODBTNUM buffer);
// ---- Work coordinate offsets ----
/// <summary>
/// <c>cnc_rdzofs</c> — read one work-coordinate offset slot. <paramref name="number"/>:
/// 1..6 = G54..G59 (standard). Extended <c>G54.1 P1..P48</c> use <c>cnc_rdzofsr</c>
/// and are deferred. <paramref name="axis"/>: -1 = all axes returned, 1..N = single
/// axis. <paramref name="length"/>: 12 + (N axes * 8) — we request -1 and let FWLIB
/// fill up to <see cref="IODBZOFS.Data"/>'s 8-axis ceiling.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdzofs", ExactSpelling = true)]
public static extern short RdWorkOffset(
ushort handle,
short number,
short axis,
short length,
ref IODBZOFS buffer);
// ---- Operator messages ----
/// <summary>
/// <c>cnc_rdopmsg3</c> — read FANUC operator messages by class. <paramref name="type"/>:
/// 0 = OPMSG (op-msg ladder/macro), 1 = MACRO, 2 = EXTERN (external operator message),
/// 3 = REJ-EXT (rejected EXTERN). <paramref name="length"/>: per <c>fwlib32.h</c> the
/// buffer is <c>4 + 256 = 260</c> bytes per message slot — single-slot reads (length 260)
/// return the most-recent message in that class. Issue #261, plan PR F1-e.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdopmsg3", CharSet = CharSet.Ansi, ExactSpelling = true)]
public static extern short RdOpMsg3(
ushort handle,
short type,
short length,
ref OPMSG3 buffer);
// ---- Figure (per-axis decimal scaling) ----
/// <summary>
/// <c>cnc_getfigure</c> — read per-axis figure info (decimal-place counts + units).
/// <paramref name="kind"/>: 0 = absolute / relative / machine position figures,
/// 1 = work-coord shift figures (per Fanuc reference). The reply struct holds
/// up to <see cref="MAX_AXIS"/> axis entries; the managed side reads the count
/// out via <paramref name="outCount"/>. Position values from <c>cnc_absolute</c>
/// / <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> / <c>cnc_actf</c>
/// are scaled integers — divide by <c>10^figureinfo[axis].dec</c> for user units
/// (issue #262, plan PR F1-f).
/// </summary>
[DllImport(Library, EntryPoint = "cnc_getfigure", ExactSpelling = true)]
public static extern short GetFigure(
ushort handle,
short kind,
ref short outCount,
ref IODBAXIS figureinfo);
// ---- Diagnostics ----
/// <summary>
/// <c>cnc_rddiag</c> — read a CNC diagnostic value. <paramref name="number"/> is the
/// diagnostic number (e.g. 1031 = current alarm cause); <paramref name="axis"/> is 0
/// for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
/// <paramref name="length"/> is sized like <see cref="RdParam"/> — 4-byte header +
/// widest payload (8 bytes for Float64). The shape of the payload depends on the
/// diagnostic; the managed side decodes via <see cref="FocasDataType"/> on the
/// configured tag (issue #263).
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rddiag", ExactSpelling = true)]
public static extern short RdDiag(
ushort handle,
ushort number,
short axis,
short length,
ref IODBPSD buffer);
// ---- Multi-path / multi-channel ----
/// <summary>
/// <c>cnc_rdpathnum</c> — read the number of CNC paths (channels) the controller
/// exposes + the currently-active path. Multi-path CNCs (lathe + sub-spindle,
/// dual-turret) return 2..N; single-path CNCs return 1. The driver caches
/// <see cref="ODBPATH.MaxPath"/> at connect and uses it to validate per-tag
/// <c>PathId</c> values (issue #264).
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdpathnum", ExactSpelling = true)]
public static extern short RdPathNum(ushort handle, ref ODBPATH buffer);
/// <summary>
/// <c>cnc_setpath</c> — switch the active CNC path (channel) for subsequent
/// calls. <paramref name="path"/> is 1-based. The driver issues this before
/// every read whose path differs from the last one set on the session;
/// single-path tags (PathId=1 only) skip the call entirely (issue #264).
/// </summary>
[DllImport(Library, EntryPoint = "cnc_setpath", ExactSpelling = true)]
public static extern short SetPath(ushort handle, short path);
// ---- Currently-executing block ----
/// <summary>
/// <c>cnc_rdactpt</c> — read the currently-executing program block text. The
/// reply struct holds the program / sequence numbers + the active block as a
/// null-padded ASCII string. Issue #261, plan PR F1-e.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdactpt", CharSet = CharSet.Ansi, ExactSpelling = true)]
public static extern short RdActPt(ushort handle, ref ODBACTPT buffer);
// ---- Structs ---- // ---- Structs ----
/// <summary> /// <summary>
@@ -129,6 +267,134 @@ internal static class FwlibNative
public short DecVal; // decimal-point count public short DecVal; // decimal-point count
} }
/// <summary>
/// IODBTMR — running-timer read buffer per <c>fwlib32.h</c>. Minute portion in
/// <see cref="Minute"/>; sub-minute remainder in milliseconds in <see cref="Msec"/>.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBTMR
{
public int Minute;
public int Msec;
}
/// <summary>
/// ODBMDL — single-group modal read buffer. 4-byte header + a 4-byte union which we
/// marshal as a fixed byte array. For type=100..103 (M/S/T/B) the union holds an
/// <c>int aux_data</c> at offset 0; we read the first <c>short</c> for symmetry with
/// the FWLIB <c>g_modal.aux_data</c> width on G-group reads. The G-group decode
/// (type=1..21) is deferred — see <see cref="Modal"/> for context (issue #259).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBMDL
{
public short Datano;
public short Type;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
public byte[] Data;
}
/// <summary>
/// IODBTNUM — current tool number read buffer. <see cref="Data"/> holds the active
/// T-code (Fanuc reference uses <c>long</c>; we narrow to <c>short</c> on the
/// managed side because <see cref="FocasToolingInfo.CurrentTool"/> surfaces as
/// <c>Int16</c>). Issue #260, F1-d.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBTNUM
{
public short Datano;
public short Type;
public int Data;
}
/// <summary>
/// IODBZOFS — work-coordinate offset read buffer. 4-byte header + per-axis
/// <c>OFSB</c> blocks (8 bytes each: 4-byte signed integer <c>data</c> + 2-byte
/// <c>dec</c> decimal-point count + 2-byte <c>unit</c> + 2-byte <c>disp</c>).
/// We marshal a fixed ceiling of 8 axes (= 64 bytes); the managed side reads
/// only the first 3 (X / Y / Z) per the F1-d effort sizing.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBZOFS
{
public short Datano;
public short Type;
// Up to 8 axes * 8 bytes per OFSB = 64 bytes. Each block: int data, short dec,
// short unit, short disp (10 bytes per fwlib32.h). We size for the worst case.
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 80)]
public byte[] Data;
}
/// <summary>
/// OPMSG3 — single-slot operator-message read buffer per <c>fwlib32.h</c>. Per Fanuc
/// reference: <c>short datano</c> + <c>short type</c> + <c>char data[256]</c>. The
/// text is null-terminated + space-padded; the managed side trims trailing nulls /
/// spaces before publishing. Length = 4 + 256 = 260 bytes; total 256 wide enough
/// for the longest documented operator message body (issue #261).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct OPMSG3
{
public short Datano;
public short Type;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
public byte[] Data;
}
/// <summary>
/// ODBACTPT — current-block read buffer per <c>fwlib32.h</c>. Per Fanuc reference:
/// <c>long o_no</c> (currently active O-number) + <c>long n_no</c> (sequence) +
/// <c>char data[256]</c> (active block text). The text is null-terminated +
/// space-padded; trimmed before publishing for stable round-trip (issue #261).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBACTPT
{
public int ONo;
public int NNo;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
public byte[] Data;
}
/// <summary>
/// Maximum axis count per the FWLIB <c>fwlib32.h</c> ceiling for figure-info reads.
/// Real Fanuc CNCs cap at 8 simultaneous axes for most series; we marshal an
/// 8-entry array (matches <see cref="IODBAXIS"/>) so the call completes regardless
/// of the deployment's axis count (issue #262).
/// </summary>
public const int MAX_AXIS = 8;
/// <summary>
/// IODBAXIS — per-axis figure info read buffer for <c>cnc_getfigure</c>. Each
/// axis entry carries the decimal-place count (<c>dec</c>) the CNC reports for
/// that axis's increment system + a unit code. The managed side reads the first
/// <c>outCount</c> entries returned by FWLIB; we marshal a fixed 8-entry ceiling
/// (issue #262, plan PR F1-f).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBAXIS
{
// Each entry per fwlib32.h is { short dec, short unit, short reserved, short reserved2 }
// = 8 bytes. 8 axes * 8 bytes = 64 bytes; we marshal a fixed byte buffer + decode on
// the managed side so axis-count growth doesn't churn the P/Invoke surface.
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8 * 8)]
public byte[] Data;
}
/// <summary>
/// ODBPATH — <c>cnc_rdpathnum</c> reply. <see cref="PathNo"/> is the currently-active
/// path (1-based); <see cref="MaxPath"/> is the controller's path count. We consume
/// <see cref="MaxPath"/> at bootstrap to validate per-tag PathId; runtime path
/// selection happens via <see cref="SetPath"/> (issue #264).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBPATH
{
public short PathNo;
public short MaxPath;
}
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary> /// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)] [StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBST public struct ODBST

View File

@@ -48,8 +48,349 @@ public interface IFocasClient : IDisposable
/// responds with any valid status. /// responds with any valid status.
/// </summary> /// </summary>
Task<bool> ProbeAsync(CancellationToken cancellationToken); Task<bool> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the full <c>cnc_rdcncstat</c> ODBST struct (9 small-int status flags). The
/// boolean <see cref="ProbeAsync"/> is preserved for cheap reachability checks; this
/// method exposes the per-field detail used by the FOCAS driver's <c>Status/</c>
/// fixed-tree nodes (see issue #257). Returns <c>null</c> if the wire client cannot
/// supply the struct (e.g. transport/IPC variant where the contract has not been
/// extended yet) — callers fall back to surfacing Bad on the per-field nodes.
/// </summary>
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasStatusInfo?>(null);
/// <summary>
/// Read the per-CNC production counters (parts produced / required / total via
/// <c>cnc_rdparam(6711/6712/6713)</c>) plus the current cycle-time seconds counter
/// (<c>cnc_rdtimer(2)</c>). Surfaced on the FOCAS driver's <c>Production/</c>
/// fixed-tree per device (issue #258). Returns <c>null</c> when the wire client
/// cannot supply the snapshot (e.g. older transport variant) — the driver leaves
/// the cache untouched and the per-field nodes report Bad until the first refresh.
/// </summary>
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasProductionInfo?>(null);
/// <summary>
/// Read the active modal M/S/T/B codes via <c>cnc_modal</c>. G-group decoding is
/// deferred — the FWLIB <c>ODBMDL</c> union differs per series + group and the
/// issue body permits surfacing only the universally-present M/S/T/B fields in
/// the first cut (issue #259). Returns <c>null</c> when the wire client cannot
/// supply the snapshot.
/// </summary>
Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasModalInfo?>(null);
/// <summary>
/// Read the four operator override values (feed / rapid / spindle / jog) via
/// <c>cnc_rdparam</c>. The parameter numbers are MTB-specific so the caller passes
/// them in via <paramref name="parameters"/>; a <c>null</c> entry suppresses that
/// field's read (the corresponding node is also omitted from the address space).
/// Returns <c>null</c> when the wire client cannot supply the snapshot (issue #259).
/// </summary>
Task<FocasOverrideInfo?> GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken cancellationToken)
=> Task.FromResult<FocasOverrideInfo?>(null);
/// <summary>
/// Read the current tool number via <c>cnc_rdtnum</c>. Surfaced on the FOCAS driver's
/// <c>Tooling/</c> fixed-tree per device (issue #260). Tool life + current offset
/// index are deferred — <c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c> vary heavily across
/// CNC series + the FWLIB <c>ODBTLIFE*</c> unions need per-series shape handling
/// that exceeds the L-sized scope of this PR. Returns <c>null</c> when the wire
/// client cannot supply the snapshot (e.g. older transport variant).
/// </summary>
Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasToolingInfo?>(null);
/// <summary>
/// Read the standard G54..G59 work-coordinate offsets via
/// <c>cnc_rdzofs(handle, n=1..6)</c>. Returns one <see cref="FocasWorkOffset"/>
/// per slot (issue #260). Extended G54.1 P1..P48 offsets are deferred — they use
/// a different FOCAS call (<c>cnc_rdzofsr</c>) + different range handling. Each
/// offset surfaces a fixed X/Y/Z view; lathes/mills with extra rotational axes
/// have those columns reported as 0.0. Returns <c>null</c> when the wire client
/// cannot supply the snapshot.
/// </summary>
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasWorkOffsetsInfo?>(null);
/// <summary>
/// Read the four FANUC operator-message classes via <c>cnc_rdopmsg3</c> (issue #261).
/// The call returns up to 4 active messages per class; the driver collapses the
/// latest non-empty message per class onto the <c>Messages/External/Latest</c>
/// fixed-tree node — the issue body permits this minimal surface in the first cut.
/// Trailing nulls / spaces are trimmed before publishing so the same message
/// round-trips with stable text. Returns <c>null</c> when the wire client cannot
/// supply the snapshot (older transport variant).
/// </summary>
Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasOperatorMessagesInfo?>(null);
/// <summary>
/// Read the currently-executing block text via <c>cnc_rdactpt</c> (issue #261).
/// The call returns the active block of the running program; surfaced as
/// <c>Program/CurrentBlock</c> Float-trimmed string. Returns <c>null</c> when the
/// wire client cannot supply the snapshot.
/// </summary>
Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasCurrentBlockInfo?>(null);
/// <summary>
/// Read the per-axis decimal-place counts via <c>cnc_getfigure</c> (issue #262).
/// Returned dictionary maps axis name (or fallback <c>"axis{n}"</c> when
/// <c>cnc_rdaxisname</c> isn't available) to the decimal-place count the CNC
/// reports for that axis's increment system. Cached at bootstrap by the driver +
/// applied to position values before publishing — raw integer / 10^decimalPlaces.
/// Returns <c>null</c> when the wire client cannot supply the snapshot (older
/// transport variant) — the driver leaves the cache untouched and falls back to
/// publishing raw values.
/// </summary>
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
/// <summary>
/// Read a CNC diagnostic value via <c>cnc_rddiag</c>. <paramref name="diagNumber"/> is
/// the diagnostic number (validated against <see cref="FocasCapabilityMatrix.DiagnosticRange"/>
/// by <see cref="FocasDriver.InitializeAsync"/>). <paramref name="axisOrZero"/>
/// is 0 for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
/// The shape of the returned value depends on the diagnostic — Int / Float / Bit are
/// all possible. Returns <c>null</c> on default (transport variants that haven't yet
/// implemented diagnostics) so the driver falls back to BadNotSupported on those nodes
/// until the wire client is extended (issue #263).
/// </summary>
Task<(object? value, uint status)> ReadDiagnosticAsync(
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
=> Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported));
/// <summary>
/// Discover the number of CNC paths (channels) the controller exposes via
/// <c>cnc_rdpathnum</c>. Multi-path CNCs (lathe + sub-spindle, dual-turret,
/// etc.) report 2..N; single-path CNCs return 1. The driver caches the result
/// once per device after connect + uses it to validate per-tag <c>PathId</c>
/// values (issue #264). Default returns 1 so transports that haven't extended
/// their wire surface keep behaving as single-path.
/// </summary>
Task<int> GetPathCountAsync(CancellationToken cancellationToken)
=> Task.FromResult(1);
/// <summary>
/// Switch the active CNC path (channel) for subsequent reads via
/// <c>cnc_setpath</c>. Called by the driver before every read whose
/// <c>FocasAddress.PathId</c> differs from the path most recently set on the
/// session — single-path devices (PathId=1 only) skip the wire call entirely.
/// Default is a no-op so transports that haven't extended their wire surface
/// simply read whatever path the CNC has selected (issue #264).
/// </summary>
Task SetPathAsync(int pathId, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>
/// Read up to <paramref name="depth"/> most-recent entries from the CNC's alarm-history
/// ring buffer via <c>cnc_rdalmhistry</c>. Used by <see cref="FocasAlarmProjection"/>
/// when <see cref="FocasAlarmProjectionOptions.Mode"/> is
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> (issue #267, plan PR F3-a).
/// Default returns an empty list so transport variants that have not yet implemented
/// the call keep working — the projection's history poll becomes a no-op rather than
/// faulting. Wire decode of the FWLIB <c>ODBALMHIS</c> struct lives in
/// <see cref="Wire.FocasAlarmHistoryDecoder"/>.
/// </summary>
Task<IReadOnlyList<FocasAlarmHistoryEntry>> ReadAlarmHistoryAsync(
int depth, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<FocasAlarmHistoryEntry>>(Array.Empty<FocasAlarmHistoryEntry>());
/// <summary>
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>
/// (<c>R</c>, <c>D</c>, <c>X</c>, etc.) starting at <paramref name="startByte"/> and
/// spanning <paramref name="byteCount"/> bytes. Returned tuple has the byte buffer
/// (length <paramref name="byteCount"/> on success) + the OPC UA status mapped through
/// <see cref="FocasStatusMapper"/>. Used by <see cref="FocasDriver"/> to coalesce
/// same-letter/same-path PMC reads in a batch into one round trip per range
/// (issue #266 — see <see cref="Wire.FocasPmcCoalescer"/>).
/// <para>
/// Default falls back to per-byte <see cref="ReadAsync(FocasAddress, FocasDataType, CancellationToken)"/>
/// calls so transport variants that haven't extended their wire surface still work
/// correctly — they just won't see the round-trip reduction. The fallback short-circuits
/// on the first non-Good status so a partial buffer isn't returned with a Good code.
/// </para>
/// </summary>
async Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
{
if (byteCount <= 0) return (Array.Empty<byte>(), FocasStatusMapper.Good);
var buf = new byte[byteCount];
for (var i = 0; i < byteCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var addr = new FocasAddress(FocasAreaKind.Pmc, letter, startByte + i, BitIndex: null, PathId: pathId);
var (value, status) = await ReadAsync(addr, FocasDataType.Byte, cancellationToken).ConfigureAwait(false);
if (status != FocasStatusMapper.Good) return (null, status);
buf[i] = value switch
{
sbyte s => unchecked((byte)s),
byte b => b,
int n => unchecked((byte)n),
short s => unchecked((byte)s),
_ => 0,
};
}
return (buf, FocasStatusMapper.Good);
}
} }
/// <summary>
/// Snapshot of the 9 fields returned by Fanuc's <c>cnc_rdcncstat</c> (ODBST). All fields
/// are <c>short</c> per the FWLIB header — small enums whose meaning is documented in the
/// Fanuc FOCAS reference (e.g. <c>emergency</c>: 0=released, 1=stop, 2=reset). Surfaced as
/// <c>Int16</c> in the OPC UA address space rather than mapped enums so operators see
/// exactly what the CNC reported.
/// </summary>
public sealed record FocasStatusInfo(
short Dummy,
short Tmmode,
short Aut,
short Run,
short Motion,
short Mstb,
short EmergencyStop,
short Alarm,
short Edit);
/// <summary>
/// Snapshot of per-CNC production counters refreshed on the probe tick (issue #258).
/// Sourced from <c>cnc_rdparam(6711/6712/6713)</c> for the parts counts + the cycle-time
/// timer counter (FWLIB <c>cnc_rdtimer</c> when available). All values surfaced as
/// <c>Int32</c> in the OPC UA address space.
/// </summary>
public sealed record FocasProductionInfo(
int PartsProduced,
int PartsRequired,
int PartsTotal,
int CycleTimeSeconds);
/// <summary>
/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a
/// deferred follow-up — the FWLIB <c>ODBMDL</c> union differs per series + group, and
/// the issue body permits the first cut to surface only the universally-present
/// M/S/T/B fields. <c>short</c> matches the FWLIB <c>aux_data</c> width.
/// </summary>
public sealed record FocasModalInfo(
short MCode,
short SCode,
short TCode,
short BCode);
/// <summary>
/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259).
/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A
/// <c>null</c> entry suppresses that field's read on the wire and removes the matching
/// node from the address space; this lets a deployment hide overrides their MTB doesn't
/// wire up rather than always serving Bad.
/// </summary>
public sealed record FocasOverrideParameters(
ushort? FeedParam,
ushort? RapidParam,
ushort? SpindleParam,
ushort? JogParam)
{
/// <summary>Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015.</summary>
public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015);
}
/// <summary>
/// Snapshot of the four operator overrides (issue #259). Each value is a percentage
/// surfaced as <c>Int16</c>; a value of <c>null</c> means the corresponding parameter
/// was not configured (suppressed at <see cref="FocasOverrideParameters"/>). All four
/// fields nullable so the driver can omit nodes whose MTB parameter is unset.
/// </summary>
public sealed record FocasOverrideInfo(
short? Feed,
short? Rapid,
short? Spindle,
short? Jog);
/// <summary>
/// Snapshot of the currently selected tool number (issue #260). Sourced from
/// <c>cnc_rdtnum</c>. The active offset index is deferred — most modern CNCs
/// interleave tool number and offset H/D codes through different FOCAS calls
/// (<c>cnc_rdtofs</c> against a specific slot) and the issue body permits
/// surfacing tool number alone in the first cut. Surfaced as <c>Int16</c> in
/// the OPC UA address space.
/// </summary>
public sealed record FocasToolingInfo(short CurrentTool);
/// <summary>
/// One work-coordinate offset slot (G54..G59). Three axis columns are surfaced
/// (X / Y / Z) — the issue body permits a fixed 3-axis view because lathes and
/// mills typically don't expose extended rotational offsets via the standard
/// <c>cnc_rdzofs</c> call. Extended <c>G54.1 Pn</c> offsets via <c>cnc_rdzofsr</c>
/// are deferred to a follow-up PR. Values surfaced as <c>Float64</c> in microns
/// converted to user units (the FWLIB <c>data</c> field is an integer + decimal-
/// point count, decoded the same way <c>cnc_rdmacro</c> values are).
/// </summary>
public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
/// <summary>
/// Snapshot of the six standard work-coordinate offsets (G54..G59). Refreshed on
/// the probe tick + served from the per-device cache by reads of the
/// <c>Offsets/{name}/{X|Y|Z}</c> fixed-tree nodes (issue #260).
/// </summary>
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets);
/// <summary>
/// One FANUC operator message — the <see cref="Number"/> + <see cref="Class"/>
/// + <see cref="Text"/> tuple returned by <c>cnc_rdopmsg3</c> for a single
/// active message slot. <see cref="Class"/> is one of <c>"OPMSG"</c> /
/// <c>"MACRO"</c> / <c>"EXTERN"</c> / <c>"REJ-EXT"</c> per the FOCAS reference
/// for the four message types. <see cref="Text"/> is trimmed of trailing
/// nulls + spaces so round-trips through the OPC UA address space stay stable
/// (issue #261).
/// </summary>
public sealed record FocasOperatorMessage(short Number, string Class, string Text);
/// <summary>
/// Snapshot of all active FANUC operator messages across the four message
/// classes (issue #261). Surfaced under the FOCAS driver's
/// <c>Messages/External/Latest</c> fixed-tree node — the latest non-empty
/// message in the list is what gets published. Empty list means the CNC
/// reported no active messages; the node publishes an empty string in that
/// case.
/// </summary>
public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessage> Messages);
/// <summary>
/// Snapshot of the currently-executing program block text via
/// <c>cnc_rdactpt</c> (issue #261). <see cref="Text"/> is trimmed of trailing
/// nulls + spaces so the same block round-trips with stable text. Surfaced
/// as a String node at <c>Program/CurrentBlock</c>.
/// </summary>
public sealed record FocasCurrentBlockInfo(string Text);
/// <summary>
/// One entry returned by <c>cnc_rdalmhistry</c> — a single historical alarm
/// occurrence the CNC retained in its ring buffer (issue #267, plan PR F3-a).
/// The projection emits these as historic <see cref="Core.Abstractions.AlarmEventArgs"/>
/// with <c>SourceTimestampUtc</c> set from <see cref="OccurrenceTime"/> so OPC UA clients
/// see the real CNC timestamp rather than the moment the projection polled.
/// </summary>
/// <remarks>
/// <para>The dedup key for the projection is
/// <c>(<see cref="OccurrenceTime"/>, <see cref="AlarmNumber"/>, <see cref="AlarmType"/>)</c>.
/// Same triple across two polls only emits once — see
/// <see cref="FocasAlarmProjection"/>.</para>
///
/// <para>FANUC ring buffers are typically capped at ~100 entries; the host parameter that
/// governs the cap varies by series + MTB so the driver clamps user-requested depth to a
/// conservative <c>250</c> ceiling (see <see cref="FocasAlarmProjectionOptions.HistoryDepth"/>).</para>
/// </remarks>
public sealed record FocasAlarmHistoryEntry(
DateTimeOffset OccurrenceTime,
int AxisNo,
int AlarmType,
int AlarmNumber,
string Message);
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary> /// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory public interface IFocasClientFactory
{ {

View File

@@ -0,0 +1,182 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// FWLIB <c>ODBALMHIS</c> struct decoder for the <c>cnc_rdalmhistry</c> alarm-history
/// extension (issue #267, plan PR F3-a). Documents + decodes the historical-alarm
/// payload returned by FANUC controllers when asked for the most-recent N ring-buffer
/// entries.
/// </summary>
/// <remarks>
/// <para><b>ODBALMHIS layout (per FOCAS reference, abridged)</b>:</para>
/// <list type="bullet">
/// <item><c>short num_alm</c> — number of valid alarm-history records that follow.
/// Negative on CNC-reported error.</item>
/// <item><c>ALMHIS_data alm[N]</c> — repeated entry record. Each record carries:
/// <list type="bullet">
/// <item><c>short year, month, day, hour, minute, second</c> — wall-clock
/// time the CNC stamped on the entry. Surfaced here as
/// <see cref="DateTimeOffset"/> in UTC; the wire field is the CNC's
/// local time, but the deployment doc instructs operators to keep their
/// CNC clocks on UTC for the history projection so the dedup key stays
/// stable across DST transitions.</item>
/// <item><c>short axis_no</c> — axis the alarm relates to (1-based;
/// 0 means "no specific axis").</item>
/// <item><c>short alm_type</c> — alarm type (P/S/OT/SV/SR/MC/SP/PW/IO).
/// The numeric encoding varies slightly per series; surfaced as-is so
/// downstream consumers don't lose detail.</item>
/// <item><c>short alm_no</c> — alarm number within the type.</item>
/// <item><c>short msg_len</c> — length of the message string that follows.
/// Capped server-side at 32 chars on most series.</item>
/// <item><c>char msg[msg_len]</c> — message text. Trimmed of trailing
/// nulls + spaces before publishing.</item>
/// </list>
/// </item>
/// </list>
/// <para>The simulator-mock surface assigns command id <c>0x0F1A</c> to
/// <c>cnc_rdalmhistry</c> — see <c>docs/v2/implementation/focas-simulator-plan.md</c>.</para>
/// </remarks>
public static class FocasAlarmHistoryDecoder
{
/// <summary>Wire-protocol command identifier the simulator routes <c>cnc_rdalmhistry</c> on.</summary>
public const ushort CommandId = 0x0F1A;
/// <summary>
/// Decode a packed ODBALMHIS payload into a list of
/// <see cref="FocasAlarmHistoryEntry"/> records ordered most-recent-first (the
/// FANUC ring buffer's natural order). Returns an empty list when the buffer is
/// too small to hold the count prefix or when the CNC reported zero entries.
/// </summary>
/// <remarks>
/// <para>Layout of <paramref name="payload"/> in little-endian wire form:</para>
/// <list type="number">
/// <item>Bytes 0..1 — <c>short num_alm</c></item>
/// <item>Bytes 2..N — repeated entry blocks. Each block: 14 bytes of fixed
/// header (<c>year, month, day, hour, minute, second, axis_no, alm_type,
/// alm_no, msg_len</c> — 7×short with the seventh shared between
/// <c>axis_no</c>+packing — laid out as 10 little-endian shorts here for
/// simplicity), followed by <c>msg_len</c> ASCII bytes. The simulator pads
/// each block to a 4-byte boundary; this decoder follows.</item>
/// </list>
/// <para>Real FWLIB hands back a Marshal-shaped struct, not a packed buffer; the
/// packed-buffer convention here is purely for the simulator + IPC transport so
/// the wire protocol stays language-neutral. Tier-C Fwlib32-backed clients
/// short-circuit this decoder by surfacing the struct fields directly.</para>
/// </remarks>
public static IReadOnlyList<FocasAlarmHistoryEntry> Decode(ReadOnlySpan<byte> payload)
{
if (payload.Length < 2) return Array.Empty<FocasAlarmHistoryEntry>();
var count = BinaryPrimitives.ReadInt16LittleEndian(payload[..2]);
if (count <= 0) return Array.Empty<FocasAlarmHistoryEntry>();
var entries = new List<FocasAlarmHistoryEntry>(count);
var offset = 2;
for (var i = 0; i < count; i++)
{
// Each entry: 10 little-endian shorts of header (20 bytes) + msg_len bytes.
// Header layout: year, month, day, hour, minute, second, axis_no, alm_type,
// alm_no, msg_len.
const int headerBytes = 20;
if (offset + headerBytes > payload.Length) break;
var header = payload.Slice(offset, headerBytes);
var year = BinaryPrimitives.ReadInt16LittleEndian(header[0..2]);
var month = BinaryPrimitives.ReadInt16LittleEndian(header[2..4]);
var day = BinaryPrimitives.ReadInt16LittleEndian(header[4..6]);
var hour = BinaryPrimitives.ReadInt16LittleEndian(header[6..8]);
var minute = BinaryPrimitives.ReadInt16LittleEndian(header[8..10]);
var second = BinaryPrimitives.ReadInt16LittleEndian(header[10..12]);
var axisNo = BinaryPrimitives.ReadInt16LittleEndian(header[12..14]);
var almType = BinaryPrimitives.ReadInt16LittleEndian(header[14..16]);
var almNo = BinaryPrimitives.ReadInt16LittleEndian(header[16..18]);
var msgLen = BinaryPrimitives.ReadInt16LittleEndian(header[18..20]);
offset += headerBytes;
if (msgLen < 0 || offset + msgLen > payload.Length) break;
var msgBytes = payload.Slice(offset, msgLen);
var msg = Encoding.ASCII.GetString(msgBytes).TrimEnd('\0', ' ');
offset += msgLen;
// Pad to 4-byte boundary so per-entry blocks stay self-delimiting on the wire.
var pad = (4 - (msgLen % 4)) % 4;
offset += pad;
DateTimeOffset occurrence;
try
{
occurrence = new DateTimeOffset(
year, month, day, hour, minute, second, TimeSpan.Zero);
}
catch (ArgumentOutOfRangeException)
{
// CNC reported a malformed timestamp — skip the entry rather than
// exception-spew the entire history poll. The dedup key would be
// unstable for malformed timestamps anyway.
continue;
}
entries.Add(new FocasAlarmHistoryEntry(
OccurrenceTime: occurrence,
AxisNo: axisNo,
AlarmType: almType,
AlarmNumber: almNo,
Message: msg));
}
return entries;
}
/// <summary>
/// Encode <paramref name="entries"/> into the wire format <see cref="Decode"/>
/// consumes. Used by the simulator-mock + tests to build canned payloads without
/// having to know the byte-level layout. Output is a fresh array; callers don't
/// need to manage a pooled buffer.
/// </summary>
public static byte[] Encode(IReadOnlyList<FocasAlarmHistoryEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
// Pre-size: 2-byte count + 20-byte header + msg + pad per entry.
var size = 2;
foreach (var e in entries)
{
var msg = e.Message ?? string.Empty;
var msgBytes = Encoding.ASCII.GetByteCount(msg);
size += 20 + msgBytes + ((4 - (msgBytes % 4)) % 4);
}
var buf = new byte[size];
var span = buf.AsSpan();
BinaryPrimitives.WriteInt16LittleEndian(span[..2], (short)Math.Min(entries.Count, short.MaxValue));
var offset = 2;
foreach (var e in entries)
{
var msg = e.Message ?? string.Empty;
var t = e.OccurrenceTime.ToUniversalTime();
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 0, 2), (short)t.Year);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 2, 2), (short)t.Month);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 4, 2), (short)t.Day);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 6, 2), (short)t.Hour);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 8, 2), (short)t.Minute);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 10, 2), (short)t.Second);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 12, 2), (short)e.AxisNo);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 14, 2), (short)e.AlarmType);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 16, 2), (short)e.AlarmNumber);
var msgLen = Encoding.ASCII.GetByteCount(msg);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 18, 2), (short)msgLen);
offset += 20;
Encoding.ASCII.GetBytes(msg, span.Slice(offset, msgLen));
offset += msgLen;
offset += (4 - (msgLen % 4)) % 4;
}
return buf;
}
}

View File

@@ -0,0 +1,152 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// One PMC byte-level read request a caller wants to satisfy. <see cref="ByteWidth"/> is
/// how many consecutive PMC bytes the caller's tag occupies (e.g. Bit/Byte = 1, Int16 = 2,
/// Int32/Float32 = 4, Float64 = 8). <see cref="OriginalIndex"/> is the caller's row index
/// in the batch — the coalescer carries it through to the planned group so the driver can
/// fan-out the slice back to the original snapshot slot.
/// </summary>
/// <remarks>
/// Bit-addressed PMC tags (e.g. <c>R100.3</c>) supply their parent byte (<c>100</c>) +
/// <c>ByteWidth = 1</c>; the slice-then-mask happens in the existing decode path, so
/// the coalescer doesn't need to know about the bit index.
/// </remarks>
public sealed record PmcAddressRequest(
string Letter,
int PathId,
int ByteNumber,
int ByteWidth,
int OriginalIndex);
/// <summary>
/// One member of a coalesced PMC range — the original index + the byte offset within
/// the planned range buffer where the member's bytes start. The caller slices
/// <c>buffer[Offset .. Offset + ByteWidth]</c> to recover the per-tag wire-shape bytes.
/// </summary>
public sealed record PmcRangeMember(int OriginalIndex, int Offset, int ByteWidth);
/// <summary>
/// One coalesced PMC range — a single FOCAS <c>pmc_rdpmcrng</c> wire call satisfies
/// every member. <see cref="ByteCount"/> is bounded by <see cref="FocasPmcCoalescer.MaxRangeBytes"/>.
/// </summary>
public sealed record PmcRangeGroup(
string Letter,
int PathId,
int StartByte,
int ByteCount,
IReadOnlyList<PmcRangeMember> Members);
/// <summary>
/// Plans one or more coalesced PMC range reads from a flat batch of per-tag requests.
/// Same-letter / same-path requests whose byte ranges overlap or whose gap is no larger
/// than <see cref="MaxBridgeGap"/> are merged into a single wire call up to a
/// <see cref="MaxRangeBytes"/> cap (issue #266).
/// </summary>
/// <remarks>
/// <para>The cap matches the conservative ceiling Fanuc spec lists for
/// <c>pmc_rdpmcrng</c> — most controllers accept larger ranges but 256 is the lowest
/// common denominator across 0i / 16i / 30i firmware. Splitting on the cap is fine —
/// each partition still saves N-1 round trips relative to per-byte reads.</para>
///
/// <para>The <see cref="MaxBridgeGap"/> = 16 mirrors the Modbus coalescer's bridge
/// policy: small gaps are cheaper to over-read (one extra wire call vs. several short
/// ones) but unbounded bridging would pull large unused regions over the wire on sparse
/// PMC layouts.</para>
/// </remarks>
public static class FocasPmcCoalescer
{
/// <summary>Maximum bytes per coalesced range — conservative ceiling for older Fanuc firmware.</summary>
public const int MaxRangeBytes = 256;
/// <summary>Maximum gap (in bytes) bridged between consecutive sub-requests within a group.</summary>
public const int MaxBridgeGap = 16;
/// <summary>
/// Plan range reads from <paramref name="addresses"/>. Group key is
/// <c>(Letter, PathId)</c>. Within a group, requests are sorted by start byte then
/// greedily packed into ranges that respect <see cref="MaxRangeBytes"/> +
/// <see cref="MaxBridgeGap"/>.
/// </summary>
public static IReadOnlyList<PmcRangeGroup> Plan(IEnumerable<PmcAddressRequest> addresses)
{
ArgumentNullException.ThrowIfNull(addresses);
var groups = new List<PmcRangeGroup>();
var byKey = addresses
.Where(a => !string.IsNullOrEmpty(a.Letter) && a.ByteWidth > 0 && a.ByteNumber >= 0)
.GroupBy(a => (Letter: a.Letter.ToUpperInvariant(), a.PathId));
foreach (var key in byKey)
{
var sorted = key.OrderBy(a => a.ByteNumber).ThenBy(a => a.OriginalIndex).ToList();
var pending = new List<PmcAddressRequest>();
var rangeStart = -1;
var rangeEnd = -1;
void Flush()
{
if (pending.Count == 0) return;
var members = pending.Select(p =>
new PmcRangeMember(p.OriginalIndex, p.ByteNumber - rangeStart, p.ByteWidth)).ToList();
groups.Add(new PmcRangeGroup(key.Key.Letter, key.Key.PathId, rangeStart,
rangeEnd - rangeStart + 1, members));
pending.Clear();
rangeStart = -1;
rangeEnd = -1;
}
foreach (var req in sorted)
{
var reqStart = req.ByteNumber;
var reqEnd = req.ByteNumber + req.ByteWidth - 1;
if (pending.Count == 0)
{
rangeStart = reqStart;
rangeEnd = reqEnd;
pending.Add(req);
continue;
}
// Bridge if the gap between the existing range end + this request's start is
// within the bridge cap. Overlapping or contiguous ranges always bridge
// (gap <= 0). The cap is enforced on the projected union: extending the range
// must not exceed MaxRangeBytes from rangeStart.
var gap = reqStart - rangeEnd - 1;
var projectedEnd = Math.Max(rangeEnd, reqEnd);
var projectedSize = projectedEnd - rangeStart + 1;
if (gap <= MaxBridgeGap && projectedSize <= MaxRangeBytes)
{
rangeEnd = projectedEnd;
pending.Add(req);
}
else
{
Flush();
rangeStart = reqStart;
rangeEnd = reqEnd;
pending.Add(req);
}
}
Flush();
}
return groups;
}
/// <summary>
/// The number of consecutive PMC bytes a tag of <paramref name="type"/> occupies on
/// the wire. Used by the driver to populate <see cref="PmcAddressRequest.ByteWidth"/>
/// before planning. Bit-addressed tags supply <c>1</c> here — the bit-extract happens
/// in the decode path after the slice.
/// </summary>
public static int ByteWidth(FocasDataType type) => type switch
{
FocasDataType.Bit or FocasDataType.Byte => 1,
FocasDataType.Int16 => 2,
FocasDataType.Int32 or FocasDataType.Float32 => 4,
FocasDataType.Float64 => 8,
_ => 1,
};
}

View File

@@ -0,0 +1,134 @@
using System.Collections.Generic;
using System.Threading;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// Per-driver counters surfaced via <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
/// for the <c>driver-diagnostics</c> RPC (task #276). Hot-path increments use
/// <see cref="Interlocked"/> so they're lock-free; the read path snapshots into a
/// <see cref="IReadOnlyDictionary{TKey, TValue}"/> keyed by stable counter names.
/// </summary>
/// <remarks>
/// The counters are operational metrics, not config — they reset to zero when the
/// driver instance is recreated (Reinitialize tear-down + rebuild) and there is no
/// persistence across process restarts. NotificationsPerSecond is a simple decay-EWMA
/// so a quiet subscription doesn't latch the value at the last burst rate.
/// </remarks>
internal sealed class OpcUaClientDiagnostics
{
// ---- Hot-path counters (Interlocked) ----
private long _publishRequestCount;
private long _notificationCount;
private long _missingPublishRequestCount;
private long _droppedNotificationCount;
private long _sessionResetCount;
// ---- EWMA state for NotificationsPerSecond ----
//
// Use ticks (long) for the timestamp so we can swap atomically. The rate is a double
// updated under a tight lock — the EWMA arithmetic (load, blend, store) isn't naturally
// atomic on doubles, and the spinlock is held only for arithmetic so contention is
// bounded. A subscription firing at 10 kHz with one driver instance is dominated by
// the SDK's notification path, not this lock.
private readonly object _ewmaLock = new();
private double _notificationsPerSecond;
private long _lastNotificationTicks;
/// <summary>Half-life ~5 seconds — recent activity dominates but a paused subscription decays toward zero.</summary>
private static readonly TimeSpan EwmaHalfLife = TimeSpan.FromSeconds(5);
// ---- Reconnect state (lock-free, single-writer in OnReconnectComplete) ----
private long _lastReconnectUtcTicks;
public long PublishRequestCount => Interlocked.Read(ref _publishRequestCount);
public long NotificationCount => Interlocked.Read(ref _notificationCount);
public long MissingPublishRequestCount => Interlocked.Read(ref _missingPublishRequestCount);
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotificationCount);
public long SessionResetCount => Interlocked.Read(ref _sessionResetCount);
public DateTime? LastReconnectUtc
{
get
{
var ticks = Interlocked.Read(ref _lastReconnectUtcTicks);
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
}
}
public double NotificationsPerSecond
{
get { lock (_ewmaLock) return _notificationsPerSecond; }
}
public void IncrementPublishRequest() => Interlocked.Increment(ref _publishRequestCount);
public void IncrementMissingPublishRequest() => Interlocked.Increment(ref _missingPublishRequestCount);
public void IncrementDroppedNotification() => Interlocked.Increment(ref _droppedNotificationCount);
/// <summary>Records one delivered notification (any monitored item) + folds the inter-arrival into the EWMA rate.</summary>
public void RecordNotification() => RecordNotification(DateTime.UtcNow);
internal void RecordNotification(DateTime nowUtc)
{
Interlocked.Increment(ref _notificationCount);
// EWMA over instantaneous rate. instRate = 1 / dt (events per second since last sample).
// Decay factor a = 2^(-dt/halfLife) puts a five-second window on the smoothing — recent
// bursts win, idle periods bleed back to zero.
var nowTicks = nowUtc.Ticks;
lock (_ewmaLock)
{
if (_lastNotificationTicks == 0)
{
_lastNotificationTicks = nowTicks;
// First sample: seed at 0 — we don't know the prior rate. The next sample
// produces a real instRate.
return;
}
var dtTicks = nowTicks - _lastNotificationTicks;
if (dtTicks <= 0)
{
// Same-tick collisions on bursts: treat as no time elapsed for rate purposes
// (count was already incremented above) so we don't divide by zero or feed
// an absurd instRate spike.
return;
}
var dtSeconds = (double)dtTicks / TimeSpan.TicksPerSecond;
var instRate = 1.0 / dtSeconds;
var alpha = System.Math.Pow(0.5, dtSeconds / EwmaHalfLife.TotalSeconds);
_notificationsPerSecond = (alpha * _notificationsPerSecond) + ((1.0 - alpha) * instRate);
_lastNotificationTicks = nowTicks;
}
}
public void RecordSessionReset(DateTime nowUtc)
{
Interlocked.Increment(ref _sessionResetCount);
Interlocked.Exchange(ref _lastReconnectUtcTicks, nowUtc.Ticks);
}
/// <summary>
/// Snapshot the counters into the dictionary shape <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
/// surfaces. Numeric-only (so the RPC can render generically); LastReconnectUtc is
/// emitted as ticks to keep the value type uniform.
/// </summary>
public IReadOnlyDictionary<string, double> Snapshot()
{
var dict = new Dictionary<string, double>(7, System.StringComparer.Ordinal)
{
["PublishRequestCount"] = PublishRequestCount,
["NotificationCount"] = NotificationCount,
["NotificationsPerSecond"] = NotificationsPerSecond,
["MissingPublishRequestCount"] = MissingPublishRequestCount,
["DroppedNotificationCount"] = DroppedNotificationCount,
["SessionResetCount"] = SessionResetCount,
};
var last = LastReconnectUtc;
if (last is not null)
dict["LastReconnectUtcTicks"] = last.Value.Ticks;
return dict;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,34 @@ public sealed class OpcUaClientDriverOptions
/// </summary> /// </summary>
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3); public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
/// <summary>
/// Optional discovery URL pointing at a Local Discovery Server (LDS) or a server's
/// own discovery endpoint. When set, the driver runs <c>FindServers</c> +
/// <c>GetEndpoints</c> against this URL during <see cref="OpcUaClientDriver.InitializeAsync"/>
/// and prepends the discovered endpoint URLs to the failover candidate list. When
/// <see cref="EndpointUrls"/> is empty (and only <see cref="EndpointUrl"/> is set as
/// a fallback), the discovered URLs replace the candidate list entirely so a
/// discovery-driven deployment can be configured without specifying any endpoints
/// up front. Discovery failures are non-fatal — the driver logs and falls back to the
/// statically configured candidates.
/// </summary>
/// <remarks>
/// <para>
/// <b>FindServers requires SecurityMode=None on the discovery channel</b> per the
/// OPC UA spec — discovery is unauthenticated even when the data channel uses
/// <c>Sign</c> or <c>SignAndEncrypt</c>. The driver opens the discovery channel
/// unsecured regardless of <see cref="SecurityMode"/>; only the resulting data
/// session is bound to the configured policy.
/// </para>
/// <para>
/// Endpoints returned by discovery are filtered to those matching
/// <see cref="SecurityPolicy"/> + <see cref="SecurityMode"/> before being added to
/// the candidate list, so a discovery sweep against a multi-policy server only
/// surfaces endpoints the driver could actually connect to.
/// </para>
/// </remarks>
public string? DiscoveryUrl { get; init; }
/// <summary> /// <summary>
/// Security policy to require when selecting an endpoint. Either a /// Security policy to require when selecting an endpoint. Either a
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for /// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
@@ -134,8 +162,198 @@ public sealed class OpcUaClientDriverOptions
/// browse forever. /// browse forever.
/// </summary> /// </summary>
public int MaxBrowseDepth { get; init; } = 10; public int MaxBrowseDepth { get; init; } = 10;
/// <summary>
/// Per-subscription tuning knobs applied when the driver creates data + alarm
/// subscriptions on the upstream session. Defaults preserve the previous hard-coded
/// values so existing deployments see no behaviour change.
/// </summary>
public OpcUaSubscriptionDefaults Subscriptions { get; init; } = new();
/// <summary>
/// Server-certificate validation knobs applied during the
/// <c>CertificateValidator.CertificateValidation</c> callback. Surfaces explicit
/// handling for revoked certs (always rejected, never auto-accepted), unknown
/// revocation status (rejected only when <see cref="OpcUaCertificateValidationOptions.RejectUnknownRevocationStatus"/>
/// is set), SHA-1 signature rejection, and minimum RSA key size. Defaults preserve
/// existing behaviour wherever possible — the one tightening is
/// <see cref="OpcUaCertificateValidationOptions.RejectSHA1SignedCertificates"/>=true
/// since SHA-1 is spec-deprecated for OPC UA.
/// </summary>
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
/// <summary>
/// Curation rules applied to the upstream address space during
/// <c>DiscoverAsync</c>. Lets operators trim the mirrored tree to the subset their
/// downstream clients actually need, rename namespace URIs so the local-side metadata
/// stays consistent across upstream-server swaps, and override the default
/// <c>"Remote"</c> root folder name. Defaults are empty / null which preserves the
/// pre-curation behaviour exactly — empty include = include all.
/// </summary>
public OpcUaClientCurationOptions Curation { get; init; } = new();
/// <summary>
/// When <c>true</c>, <c>DiscoverAsync</c> runs an additional pass that walks the upstream
/// <c>TypesFolder</c> (<c>i=86</c>) — ObjectTypes (<c>i=88</c>), VariableTypes
/// (<c>i=89</c>), DataTypes (<c>i=90</c>), ReferenceTypes (<c>i=91</c>) — and projects the
/// discovered type-definition nodes into the local address space via
/// <c>IAddressSpaceBuilder.RegisterTypeNode</c>. Default <c>false</c> — opt-in so
/// existing deployments don't suddenly see a flood of type nodes after upgrade. Enable
/// when downstream clients need the upstream type system to render structured values or
/// decode custom event fields.
/// </summary>
/// <remarks>
/// <para>
/// The type-mirror pass uses <c>Session.FetchTypeTreeAsync</c> on each of the four
/// root type nodes so the SDK's local TypeTree cache is populated efficiently (one
/// batched browse per root rather than per-node round trips). This PR ships the
/// <i>structural</i> mirror only — every type node is registered with its identity,
/// super-type chain, and IsAbstract flag, but structured-type binary encodings are
/// NOT primed. (The OPCFoundation SDK removed
/// <c>ISession.LoadDataTypeSystem(NodeId, CancellationToken)</c> from the public
/// surface in 1.5.378+; loading binary encodings now requires per-node walks of
/// <c>HasEncoding</c> + dictionary nodes which is tracked as a follow-up.) Clients
/// that need structured-type decoding can still consume
/// <c>Variant&lt;ExtensionObject&gt;</c> on the wire.
/// </para>
/// <para>
/// <see cref="OpcUaClientCurationOptions.IncludePaths"/> +
/// <see cref="OpcUaClientCurationOptions.ExcludePaths"/> still apply to the type
/// walk; paths are slash-joined under their root (e.g.
/// <c>"ObjectTypes/BaseObjectType/SomeType"</c>). Most operators want all types so
/// empty include = include all is the right default.
/// </para>
/// </remarks>
public bool MirrorTypeDefinitions { get; init; } = false;
} }
/// <summary>
/// Selective import + namespace remap rules for the OPC UA Client driver. Pure local
/// filtering inside <c>BrowseRecursiveAsync</c> + <c>EnrichAndRegisterVariablesAsync</c>;
/// no new SDK calls.
/// </summary>
/// <remarks>
/// <para>
/// <b>Glob semantics</b>: patterns are matched against the slash-joined BrowseName
/// segments accumulated during the browse pass (e.g. <c>"Server/Diagnostics/SessionsDiagnosticsArray"</c>).
/// Two wildcards are supported — <c>*</c> matches any sequence of characters
/// (including empty / slashes) and <c>?</c> matches exactly one character. No
/// character classes, no <c>**</c>, no escapes — keep the surface tight so the doc
/// + behaviour stay simple.
/// </para>
/// <para>
/// Empty <see cref="IncludePaths"/> = include all (existing behaviour).
/// <see cref="ExcludePaths"/> wins over <see cref="IncludePaths"/> when both match.
/// Folders pruned by the rules are skipped wholesale — their descendants don't get
/// browsed, which keeps the wire cost down on large servers.
/// </para>
/// </remarks>
/// <param name="IncludePaths">
/// Glob patterns matched against the BrowsePath segment list. Empty = include all
/// (default — preserves pre-curation behaviour).
/// </param>
/// <param name="ExcludePaths">
/// Glob patterns matched against the BrowsePath segment list. Wins over
/// <see cref="IncludePaths"/> — useful for "include everything under <c>Plant/*</c>
/// except <c>Plant/Diagnostics</c>" rules.
/// </param>
/// <param name="NamespaceRemap">
/// Upstream-namespace-URI → local-namespace-URI translation table applied to the
/// <c>FullName</c> field of <c>DriverAttributeInfo</c> when registering variables.
/// The driver's stored <c>FullName</c> swaps the prefix before persisting so downstream
/// clients see the remapped URI. Lookup is case-sensitive — match the upstream URI
/// exactly. Defaults to empty (no remap).
/// </param>
/// <param name="RootAlias">
/// Replaces the default <c>"Remote"</c> folder name at the top of the mirrored tree.
/// Useful when multiple OPC UA Client drivers are aggregated and operators need to
/// distinguish them in the local browse tree. Default <c>null</c> = use <c>"Remote"</c>.
/// </param>
public sealed record OpcUaClientCurationOptions(
IReadOnlyList<string>? IncludePaths = null,
IReadOnlyList<string>? ExcludePaths = null,
IReadOnlyDictionary<string, string>? NamespaceRemap = null,
string? RootAlias = null);
/// <summary>
/// Knobs governing the server-certificate validation callback. Plumbed onto
/// <see cref="OpcUaClientDriverOptions.CertificateValidation"/> rather than the top-level
/// options to keep cert-related config grouped together.
/// </summary>
/// <remarks>
/// <para>
/// <b>CRL discovery:</b> the OPC UA SDK reads CRL files automatically from the
/// <c>crl/</c> sub-directory of each cert store (own, trusted, issuers). Drop the
/// issuer's <c>.crl</c> in that folder and the SDK picks it up — no driver-side wiring
/// required. When the directory is absent or empty, the SDK reports
/// <c>BadCertificateRevocationUnknown</c>, which this driver gates with
/// <see cref="RejectUnknownRevocationStatus"/>.
/// </para>
/// </remarks>
/// <param name="RejectSHA1SignedCertificates">
/// Reject server certificates whose signature uses SHA-1. Default <c>true</c> — SHA-1 was
/// deprecated by the OPC UA spec and is treated as a hard fail in production. Flip to
/// <c>false</c> only for short-term interop with legacy controllers.
/// </param>
/// <param name="RejectUnknownRevocationStatus">
/// When the SDK can't determine revocation status (no CRL present, or stale CRL),
/// reject the cert if <c>true</c>; allow if <c>false</c>. Default <c>false</c> — many
/// plant deployments don't run CRL infrastructure, and a hard-fail default would break
/// them on first connection. Set <c>true</c> in environments with a managed PKI.
/// </param>
/// <param name="MinimumCertificateKeySize">
/// Minimum RSA key size (bits) accepted. Certs with shorter keys are rejected. Default
/// <c>2048</c> matches the current OPC UA spec floor; raise to 3072 or 4096 for stricter
/// deployments. Non-RSA keys (ECC) bypass this check.
/// </param>
public sealed record OpcUaCertificateValidationOptions(
bool RejectSHA1SignedCertificates = true,
bool RejectUnknownRevocationStatus = false,
int MinimumCertificateKeySize = 2048);
/// <summary>
/// Tuning surface for OPC UA subscriptions created by <see cref="OpcUaClientDriver"/>.
/// Lifted from the per-call hard-coded literals so operators can tune publish cadence,
/// keep-alive ratio, and alarm-vs-data prioritisation without recompiling the driver.
/// Defaults match the original hard-coded values (KeepAlive=10, Lifetime=1000,
/// MaxNotifications=0 unlimited, Priority=0, MinPublishingInterval=50ms).
/// </summary>
/// <param name="KeepAliveCount">
/// Number of consecutive empty publish cycles before the server sends a keep-alive
/// response. Default 10 — high enough to suppress idle traffic, low enough that the
/// client notices a stalled subscription within ~5x the publish interval.
/// </param>
/// <param name="LifetimeCount">
/// Number of consecutive missed publish responses before the server tears down the
/// subscription. Must be ≥3×<see cref="KeepAliveCount"/> per OPC UA spec; default 1000
/// gives ~100 keep-alives of slack which is conservative on flaky networks.
/// </param>
/// <param name="MaxNotificationsPerPublish">
/// Cap on notifications returned per publish response. <c>0</c> = unlimited (the OPC UA
/// spec sentinel). Lower this to bound publish-message size on bursty servers.
/// </param>
/// <param name="Priority">
/// Subscription priority for data subscriptions (0..255). Higher = scheduled ahead of
/// lower. Default 0 matches the SDK's default for ordinary tag subscriptions.
/// </param>
/// <param name="MinPublishingIntervalMs">
/// Floor (ms) applied to <c>publishingInterval</c> requests. Sub-floor values are
/// clamped up so wire-side negotiations don't waste round-trips on intervals the server
/// will only round up anyway. Default 50ms.
/// </param>
/// <param name="AlarmsPriority">
/// Subscription priority for the alarm subscription (0..255). Higher than
/// <see cref="Priority"/> by default (1 vs 0) so alarm publishes aren't starved during
/// data-tag bursts.
/// </param>
public sealed record OpcUaSubscriptionDefaults(
int KeepAliveCount = 10,
uint LifetimeCount = 1000,
uint MaxNotificationsPerPublish = 0,
byte Priority = 0,
int MinPublishingIntervalMs = 50,
byte AlarmsPriority = 1);
/// <summary>OPC UA message security mode.</summary> /// <summary>OPC UA message security mode.</summary>
public enum OpcUaSecurityMode public enum OpcUaSecurityMode
{ {

View File

@@ -1,3 +1,5 @@
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7; namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary> /// <summary>
@@ -26,10 +28,12 @@ public enum S7Size
Byte, // B Byte, // B
Word, // W — 16-bit Word, // W — 16-bit
DWord, // D — 32-bit DWord, // D — 32-bit
LWord, // LD / DBL — 64-bit (LInt/ULInt/LReal). S7.Net has no native size suffix; the
// driver issues an 8-byte ReadBytes and converts big-endian in-process.
} }
/// <summary> /// <summary>
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>. /// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse(string)"/>.
/// </summary> /// </summary>
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param> /// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param> /// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
@@ -48,9 +52,12 @@ public readonly record struct S7ParsedAddress(
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5: /// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item> /// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
/// <item><c>DB{n}.{DBLD|DBL}{offset}</c> — 64-bit (LInt / ULInt / LReal) e.g. <c>DB1.DBLD0</c>, <c>DB1.DBL8</c></item>
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item> /// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
/// <item><c>M{LD}{offset}</c> — 64-bit Merker, e.g. <c>MLD0</c></item>
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item> /// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item> /// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
/// <item><c>I{LD}{offset}</c> / <c>Q{LD}{offset}</c> — 64-bit Input/Output, e.g. <c>ILD0</c>, <c>QLD0</c></item>
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item> /// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item> /// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
/// </list> /// </list>
@@ -69,7 +76,29 @@ public static class S7AddressParser
/// the offending input echoed in the message so operators can correlate to the tag /// the offending input echoed in the message so operators can correlate to the tag
/// config that produced the fault. /// config that produced the fault.
/// </summary> /// </summary>
public static S7ParsedAddress Parse(string address) /// <remarks>
/// The CPU-agnostic overload rejects the <c>V</c> area letter; <c>V</c> is only
/// meaningful on S7-200 / S7-200 Smart / LOGO! where it maps to a fixed DB number
/// (DB1 by convention) — call <see cref="Parse(string, S7NetCpuType?)"/> with the
/// device's CPU family for V-memory tags.
/// </remarks>
public static S7ParsedAddress Parse(string address) => Parse(address, cpuType: null);
/// <summary>
/// Parse an S7 address with knowledge of the device's CPU family. Required for the
/// <c>V</c> area letter (S7-200 / S7-200 Smart / LOGO! V-memory), which maps to
/// DataBlock DB1 on those families. On S7-300 / S7-400 / S7-1200 / S7-1500 the
/// <c>V</c> letter is rejected because it has no equivalent — those families use
/// explicit <c>DB{n}.DB...</c> addressing.
/// </summary>
/// <remarks>
/// LOGO! firmware bands map V-memory to different underlying DB numbers in some
/// 0BA editions; the driver currently uses DB1 (the most common LOGO! 8 / 0BA8
/// mapping). If a future site ships a firmware band where VM lives in a different
/// DB, the mapping table in <see cref="VMemoryDbNumberFor"/> is the single point
/// to extend. Live LOGO! testing is out of scope for the initial PR.
/// </remarks>
public static S7ParsedAddress Parse(string address, S7NetCpuType? cpuType)
{ {
if (string.IsNullOrWhiteSpace(address)) if (string.IsNullOrWhiteSpace(address))
throw new FormatException("S7 address must not be empty"); throw new FormatException("S7 address must not be empty");
@@ -92,21 +121,28 @@ public static class S7AddressParser
case 'Q': return ParseMIQ(S7Area.Output, rest, address); case 'Q': return ParseMIQ(S7Area.Output, rest, address);
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address); case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address); case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
case 'V': return ParseV(rest, address, cpuType);
default: default:
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)"); throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C/V)");
} }
} }
/// <summary> /// <summary>
/// Try-parse variant for callers that can't afford an exception on bad input (e.g. /// Try-parse variant for callers that can't afford an exception on bad input (e.g.
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that /// config validation pages in the Admin UI). Returns <c>false</c> for any input that
/// would throw from <see cref="Parse"/>. /// would throw from <see cref="Parse(string)"/>.
/// </summary> /// </summary>
public static bool TryParse(string address, out S7ParsedAddress result) public static bool TryParse(string address, out S7ParsedAddress result)
=> TryParse(address, cpuType: null, out result);
/// <summary>
/// Try-parse variant that accepts a CPU family for V-memory addressing.
/// </summary>
public static bool TryParse(string address, S7NetCpuType? cpuType, out S7ParsedAddress result)
{ {
try try
{ {
result = Parse(address); result = Parse(address, cpuType);
return true; return true;
} }
catch (FormatException) catch (FormatException)
@@ -130,18 +166,36 @@ public static class S7AddressParser
throw new FormatException($"S7 DB number in '{s}' must be a positive integer"); throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
if (!tail.StartsWith("DB") || tail.Length < 4) if (!tail.StartsWith("DB") || tail.Length < 4)
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}"); throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D|LD|L}}");
var sizeChar = tail[2]; // 64-bit suffixes are two-letter (LD or DBL-as-prefix). Detect them up front so the
var offsetStart = 3; // single-char switch below stays readable. "DBLD" is the symmetric extension of
var size = sizeChar switch // DBX/DBB/DBW/DBD; "DBL" is the shorter Siemens "long" alias accepted as an alternate.
S7Size size;
int offsetStart;
if (tail.Length >= 5 && tail[2] == 'L' && tail[3] == 'D')
{ {
'X' => S7Size.Bit, size = S7Size.LWord;
'B' => S7Size.Byte, offsetStart = 4;
'W' => S7Size.Word, }
'D' => S7Size.DWord, else if (tail.Length >= 4 && tail[2] == 'L')
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"), {
}; size = S7Size.LWord;
offsetStart = 3;
}
else
{
var sizeChar = tail[2];
offsetStart = 3;
size = sizeChar switch
{
'X' => S7Size.Bit,
'B' => S7Size.Byte,
'W' => S7Size.Word,
'D' => S7Size.DWord,
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D/LD/L"),
};
}
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s); var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset); result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
@@ -156,23 +210,73 @@ public static class S7AddressParser
var first = rest[0]; var first = rest[0];
S7Size size; S7Size size;
int offsetStart; int offsetStart;
switch (first) // Two-char "LD" prefix (8-byte LWord) checked first so it doesn't get swallowed by
// the single-letter cases below.
if (rest.Length >= 2 && first == 'L' && rest[1] == 'D')
{ {
case 'B': size = S7Size.Byte; offsetStart = 1; break; size = S7Size.LWord;
case 'W': size = S7Size.Word; offsetStart = 1; break; offsetStart = 2;
case 'D': size = S7Size.DWord; offsetStart = 1; break; }
default: else
// No size prefix => bit-level address requires explicit .bit. Size stays Bit; {
// ParseOffsetAndOptionalBit will demand the dot. switch (first)
size = S7Size.Bit; {
offsetStart = 0; case 'B': size = S7Size.Byte; offsetStart = 1; break;
break; case 'W': size = S7Size.Word; offsetStart = 1; break;
case 'D': size = S7Size.DWord; offsetStart = 1; break;
default:
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
// ParseOffsetAndOptionalBit will demand the dot.
size = S7Size.Bit;
offsetStart = 0;
break;
}
} }
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original); var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset); return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
} }
/// <summary>
/// Parse a <c>V</c>-area address (S7-200 / S7-200 Smart / LOGO! V-memory). Same width
/// suffixes as M/I/Q (<c>VB</c>, <c>VW</c>, <c>VD</c>, <c>V0.0</c>) but rewritten as
/// a DataBlock access so the rest of the driver — which speaks S7.Net's DB-centric
/// API — needs no special-casing downstream.
/// </summary>
private static S7ParsedAddress ParseV(string rest, string original, S7NetCpuType? cpuType)
{
var dbNumber = VMemoryDbNumberFor(cpuType, original);
// Reuse the M/I/Q grammar — V's size suffixes are identical (B/W/D/LD or .bit).
var parsed = ParseMIQ(S7Area.Memory, rest, original);
return parsed with { Area = S7Area.DataBlock, DbNumber = dbNumber };
}
/// <summary>
/// Map a CPU family to the underlying DB number that backs V-memory. Returns DB1
/// for S7-200, S7-200 Smart, and LOGO! 0BA8 (the only LOGO! the S7.Net <c>CpuType</c>
/// enum surfaces). Throws for families that have no V-area concept.
/// </summary>
private static int VMemoryDbNumberFor(S7NetCpuType? cpuType, string original)
{
if (cpuType is null)
throw new FormatException(
$"S7 V-memory address '{original}' requires a CPU family (S7-200 / S7-200 Smart / LOGO!) — " +
"the CPU-agnostic Parse overload cannot resolve V-memory to a DB number");
return cpuType.Value switch
{
S7NetCpuType.S7200 => 1,
S7NetCpuType.S7200Smart => 1,
// LOGO! 8 / 0BA8 firmware bands typically expose VM as DB1 over S7comm. Older
// 0BA editions can differ; the mapping is centralised here for easy extension
// once a site provides a non-DB1 firmware band to test against.
S7NetCpuType.Logo0BA8 => 1,
_ => throw new FormatException(
$"S7 V-memory address '{original}' is only valid on S7-200 / S7-200 Smart / LOGO! " +
$"(got CpuType={cpuType.Value}); use explicit DB{{n}}.DB... addressing on this family"),
};
}
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original) private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
{ {
if (rest.Length == 0) if (rest.Length == 0)

View File

@@ -0,0 +1,241 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Block-read coalescing planner for the S7 driver (PR-S7-B2). Where the
/// <see cref="S7ReadPacker"/> coalesces N scalar tags into ⌈N/19⌉
/// <c>Plc.ReadMultipleVarsAsync</c> PDUs, this planner takes one further pass:
/// it groups same-area, same-DB tags by contiguous byte range and folds them
/// into a single <c>Plc.ReadBytesAsync</c> covering the merged span. The
/// response is sliced client-side per tag so the per-tag decode path is
/// unchanged.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why coalesce</b>: Reading <c>DB1.DBW0</c> + <c>DB1.DBW2</c> +
/// <c>DB1.DBW4</c> as three multi-var items still uses three slots in a
/// single PDU; coalescing into one 6-byte byte-range read drops the per-item
/// framing entirely and makes the request fit in fewer (sometimes zero
/// additional) PDUs. On a typical contiguous DB the wire-level reduction is
/// 50:1 for 50 contiguous DBWs.
/// </para>
/// <para>
/// <b>Gap-merge threshold</b>: The planner merges adjacent tag ranges when
/// the gap between them is at most the <c>gapMergeBytes</c> argument to
/// <see cref="Plan"/>. The default <see cref="DefaultGapMergeBytes"/> is
/// 16 bytes — over-fetching 16 bytes is cheaper than one extra PDU
/// (240-byte default PDU envelope, ~18 bytes per request frame). Operators
/// can tune the threshold per driver instance via
/// <see cref="S7DriverOptions.BlockCoalescingGapBytes"/>.
/// </para>
/// <para>
/// <b>Opaque-size opt-out</b>: STRING / WSTRING / CHAR / WCHAR and DTL /
/// DT / S5TIME / TIME / TOD / DATE-as-DateTime tags carry a header (or
/// have a per-tag width that varies with <c>StringLength</c>) and are
/// flagged <c>OpaqueSize=true</c>. The planner emits these as standalone
/// single-tag ranges and never merges them into a sibling block — the
/// per-tag decode path needs an exact byte slice and a wrong slice from
/// a coalesced read would silently corrupt every neighbour.
/// </para>
/// <para>
/// <b>Order-preserving</b>: Each <see cref="BlockReadRange"/> carries a list
/// of <see cref="TagSlice"/> values pointing back at the original
/// caller-index. The driver's <c>ReadAsync</c> uses the index to write the
/// decoded value into the correct slot of the result array, so caller
/// ordering of the input <c>fullReferences</c> is preserved across the
/// coalescing step.
/// </para>
/// </remarks>
internal static class S7BlockCoalescingPlanner
{
/// <summary>Default gap-merge threshold in bytes.</summary>
internal const int DefaultGapMergeBytes = 16;
/// <summary>
/// One coalesced byte-range request. The driver issues a single
/// <c>Plc.ReadBytesAsync</c> covering <see cref="StartByte"/>..
/// <see cref="StartByte"/>+<see cref="ByteCount"/>; each entry in
/// <see cref="Tags"/> carries the offset within the response buffer to
/// slice for that tag.
/// </summary>
internal sealed record BlockReadRange(
S7Area Area,
int DbNumber,
int StartByte,
int ByteCount,
IReadOnlyList<TagSlice> Tags);
/// <summary>
/// One tag's slot inside a <see cref="BlockReadRange"/>. <see cref="OffsetInBlock"/>
/// is the byte offset within the coalesced buffer; <see cref="ByteCount"/> is the
/// per-tag width that the slice covers.
/// </summary>
/// <param name="CallerIndex">Original index in the caller's <c>fullReferences</c> list.</param>
/// <param name="OffsetInBlock">Byte offset into <see cref="BlockReadRange"/>'s buffer.</param>
/// <param name="ByteCount">Bytes the tag claims from the buffer.</param>
internal sealed record TagSlice(int CallerIndex, int OffsetInBlock, int ByteCount);
/// <summary>
/// Input row. Captures everything the planner needs to make a coalescing
/// decision without needing the full <see cref="S7TagDefinition"/> graph.
/// </summary>
/// <param name="CallerIndex">Caller-supplied stable index used to thread the decoded value back.</param>
/// <param name="Area">Memory area; M and DB never merge into the same range.</param>
/// <param name="DbNumber">DB number when <see cref="Area"/> is DataBlock; 0 otherwise.</param>
/// <param name="StartByte">Byte offset in the area where the tag's storage begins.</param>
/// <param name="ByteCount">On-wire byte width of the tag.</param>
/// <param name="OpaqueSize">
/// True for tags whose effective decode width is variable / header-prefixed
/// (STRING/WSTRING/CHAR/WCHAR and structured timestamps DTL/DT/etc.) so the
/// planner skips them — they emit standalone reads and never merge with
/// neighbours.
/// </param>
internal sealed record TagSpec(
int CallerIndex,
S7Area Area,
int DbNumber,
int StartByte,
int ByteCount,
bool OpaqueSize);
/// <summary>
/// Plan a list of byte-range reads from <paramref name="tags"/>. Same-area /
/// same-DB rows are sorted by <see cref="TagSpec.StartByte"/> then merged
/// greedily when the gap between their byte ranges is &lt;=
/// <paramref name="gapMergeBytes"/>. Opaque-size rows always emit as their
/// own single-tag range and never extend a sibling block.
/// </summary>
/// <remarks>
/// Order of returned ranges is not significant — the driver issues them
/// sequentially against the same connection gate so wire-level ordering is
/// determined by the loop, not by this list. The planner DOES preserve
/// the caller-index inside each range so the per-tag decode result lands
/// in the correct slot of the response array.
/// </remarks>
internal static List<BlockReadRange> Plan(IReadOnlyList<TagSpec> tags, int gapMergeBytes = DefaultGapMergeBytes)
{
if (gapMergeBytes < 0)
throw new ArgumentOutOfRangeException(nameof(gapMergeBytes), "Gap-merge threshold must be non-negative.");
var ranges = new List<BlockReadRange>(tags.Count);
if (tags.Count == 0) return ranges;
// Phase 1: opaque rows emit as standalone single-tag ranges. Strip them
// out of the merge candidate set so neighbour ranges don't accidentally
// straddle a STRING header / DTL block.
var mergeable = new List<TagSpec>(tags.Count);
foreach (var t in tags)
{
if (t.OpaqueSize)
{
ranges.Add(new BlockReadRange(
t.Area, t.DbNumber, t.StartByte, t.ByteCount,
[new TagSlice(t.CallerIndex, OffsetInBlock: 0, t.ByteCount)]));
}
else
{
mergeable.Add(t);
}
}
// Phase 2: bucket by (Area, DbNumber). Memory M and DataBlock DB1 (etc.)
// share neither the wire request type nor an addressable space, so they
// can never coalesce.
var groups = mergeable.GroupBy(t => (t.Area, t.DbNumber));
foreach (var group in groups)
{
// Sort ascending by start byte so the greedy merge below is O(n).
// Stable secondary sort on caller index keeps tag-slice ordering
// deterministic for tags with identical byte offsets.
var sorted = group
.OrderBy(t => t.StartByte)
.ThenBy(t => t.CallerIndex)
.ToList();
var blockStart = sorted[0].StartByte;
var blockEnd = sorted[0].StartByte + sorted[0].ByteCount;
var blockSlices = new List<TagSlice>
{
new(sorted[0].CallerIndex, 0, sorted[0].ByteCount),
};
for (var i = 1; i < sorted.Count; i++)
{
var t = sorted[i];
var gap = t.StartByte - blockEnd;
// gap < 0 means the next tag overlaps with the current block — treat
// as zero-gap merge (overlap is fine, the slice just reuses earlier
// bytes). gap <= threshold = merge; otherwise close the current
// block and start a new one.
if (gap <= gapMergeBytes)
{
var newEnd = Math.Max(blockEnd, t.StartByte + t.ByteCount);
blockSlices.Add(new TagSlice(t.CallerIndex, t.StartByte - blockStart, t.ByteCount));
blockEnd = newEnd;
}
else
{
ranges.Add(new BlockReadRange(
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
blockStart = t.StartByte;
blockEnd = t.StartByte + t.ByteCount;
blockSlices = [new TagSlice(t.CallerIndex, 0, t.ByteCount)];
}
}
ranges.Add(new BlockReadRange(
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
}
return ranges;
}
/// <summary>
/// True when <paramref name="tag"/>'s on-wire width is variable / header-prefixed.
/// Such tags MUST NOT participate in block coalescing because the slice into a
/// coalesced byte buffer would land at a wrong offset for any neighbour.
/// </summary>
internal static bool IsOpaqueSize(S7TagDefinition tag)
{
// Variable-width string types — STRING/WSTRING carry a 2-byte (or 4-byte)
// header and the actual length depends on the runtime value, not the
// declared StringLength. CHAR/WCHAR are fixed-width (1 / 2 bytes) but
// routed via the per-tag string codec path, so coalescing them would
// bypass the codec; treat them as opaque to keep the decode surface
// unchanged.
if (tag.DataType is S7DataType.String or S7DataType.WString
or S7DataType.Char or S7DataType.WChar)
return true;
// Structured timestamps — DTL is 12 bytes, DT is 8 bytes BCD-encoded;
// both decode through S7DateTimeCodec and would silently mis-decode if
// the slice landed mid-block. S5TIME/TIME/TOD/DATE are fixed-width 2/4
// bytes but currently flow through the per-tag codec path; treat them
// all as opaque so the planner emits a single-tag range and the existing
// codec dispatch stays the source of truth for date/time decode.
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime
or S7DataType.S5Time or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
return true;
// Arrays opt out: per-tag width is N × elementBytes, the slice must be
// exact. Routing them as opaque keeps the array-aware byte-range read
// path in S7Driver.ReadOneAsync.
if (tag.ElementCount is int n && n > 1)
return true;
return false;
}
/// <summary>
/// Byte width of a packable scalar tag for byte-range coalescing. Mirrors the
/// size suffix the address grammar carried (<see cref="S7Size.Bit"/>=1 byte
/// because reading a single bit still requires reading the containing byte;
/// bit-extraction happens in the slice step).
/// </summary>
internal static int ScalarByteCount(S7Size size) => size switch
{
S7Size.Bit => 1,
S7Size.Byte => 1,
S7Size.Word => 2,
S7Size.DWord => 4,
S7Size.LWord => 8,
_ => throw new InvalidOperationException($"Unknown S7Size {size}"),
};
}

View File

@@ -0,0 +1,358 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Byte-level codecs for the six Siemens S7 date/time-shaped types: DTL, DATE_AND_TIME
/// (DT), S5TIME, TIME, TIME_OF_DAY (TOD), DATE. Pulled out of <see cref="S7Driver"/> so
/// the encoding rules are unit-testable against golden byte vectors without standing
/// up a Plc instance — same pattern as <see cref="S7StringCodec"/>.
/// </summary>
/// <remarks>
/// Wire formats (all big-endian, matching S7's native byte order):
/// <list type="bullet">
/// <item>
/// <b>DTL</b> (12 bytes): year UInt16 BE / month / day / day-of-week / hour /
/// minute / second (1 byte each) / nanoseconds UInt32 BE. Year range 1970-2554.
/// </item>
/// <item>
/// <b>DATE_AND_TIME (DT)</b> (8 bytes BCD): year-since-1990 / month / day / hour /
/// minute / second (1 BCD byte each) + ms (3 BCD digits packed in 1.5 bytes) +
/// day-of-week (1 BCD digit, 1=Sunday..7=Saturday). Years 90-99 → 1990-1999;
/// years 00-89 → 2000-2089.
/// </item>
/// <item>
/// <b>S5TIME</b> (16 bits): bits 15..14 reserved (0), bits 13..12 timebase
/// (00=10ms, 01=100ms, 10=1s, 11=10s), bits 11..0 = 3-digit BCD count (0-999).
/// Total range 0..9990s.
/// </item>
/// <item>
/// <b>TIME</b> (Int32 ms BE): signed milliseconds. Negative durations allowed.
/// </item>
/// <item>
/// <b>TOD</b> (UInt32 ms BE): milliseconds since midnight, 0..86399999.
/// </item>
/// <item>
/// <b>DATE</b> (UInt16 BE): days since 1990-01-01. Range 0..65535 (1990-2168).
/// </item>
/// </list>
/// <para>
/// <b>Uninitialized PLC bytes</b>: an all-zero DTL or DT buffer (year 0 / month 0)
/// is rejected as <see cref="InvalidDataException"/> rather than decoded as
/// year-0001 garbage — operators see "BadOutOfRange" instead of a misleading
/// valid-but-wrong timestamp.
/// </para>
/// </remarks>
public static class S7DateTimeCodec
{
// ---- DTL (12 bytes) ----
/// <summary>Wire size of an S7 DTL value.</summary>
public const int DtlSize = 12;
/// <summary>
/// Decode a 12-byte DTL buffer into a DateTime. Throws
/// <see cref="InvalidDataException"/> when the buffer is uninitialized
/// (all-zero year+month) or when components are out of range.
/// </summary>
public static DateTime DecodeDtl(ReadOnlySpan<byte> bytes)
{
if (bytes.Length != DtlSize)
throw new InvalidDataException($"S7 DTL expected {DtlSize} bytes, got {bytes.Length}");
int year = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(0, 2));
int month = bytes[2];
int day = bytes[3];
// bytes[4] = day-of-week (1=Sunday..7=Saturday); ignored on read — the .NET
// DateTime carries its own and the PLC value can be inconsistent on uninit data.
int hour = bytes[5];
int minute = bytes[6];
int second = bytes[7];
uint nanos = BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(8, 4));
if (year == 0 && month == 0 && day == 0)
throw new InvalidDataException("S7 DTL is uninitialized (all-zero year/month/day)");
if (year is < 1970 or > 2554)
throw new InvalidDataException($"S7 DTL year {year} out of range 1970..2554");
if (month is < 1 or > 12)
throw new InvalidDataException($"S7 DTL month {month} out of range 1..12");
if (day is < 1 or > 31)
throw new InvalidDataException($"S7 DTL day {day} out of range 1..31");
if (hour > 23) throw new InvalidDataException($"S7 DTL hour {hour} out of range 0..23");
if (minute > 59) throw new InvalidDataException($"S7 DTL minute {minute} out of range 0..59");
if (second > 59) throw new InvalidDataException($"S7 DTL second {second} out of range 0..59");
if (nanos > 999_999_999)
throw new InvalidDataException($"S7 DTL nanoseconds {nanos} out of range 0..999999999");
// .NET DateTime resolution is 100 ns ticks (1 tick = 100 ns).
var dt = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified);
return dt.AddTicks(nanos / 100);
}
/// <summary>Encode a DateTime as a 12-byte DTL buffer.</summary>
public static byte[] EncodeDtl(DateTime value)
{
if (value.Year is < 1970 or > 2554)
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DTL year must be 1970..2554");
var buf = new byte[DtlSize];
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), (ushort)value.Year);
buf[2] = (byte)value.Month;
buf[3] = (byte)value.Day;
// S7 day-of-week: 1=Sunday..7=Saturday. .NET DayOfWeek: Sunday=0..Saturday=6.
buf[4] = (byte)((int)value.DayOfWeek + 1);
buf[5] = (byte)value.Hour;
buf[6] = (byte)value.Minute;
buf[7] = (byte)value.Second;
// Sub-second portion → nanoseconds. 1 tick = 100 ns, so ticks % 10_000_000 gives
// the fractional second in ticks; multiply by 100 for nanoseconds.
long fracTicks = value.Ticks % TimeSpan.TicksPerSecond;
uint nanos = (uint)(fracTicks * 100);
BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(8, 4), nanos);
return buf;
}
// ---- DATE_AND_TIME / DT (8 bytes BCD) ----
/// <summary>Wire size of an S7 DATE_AND_TIME value.</summary>
public const int DtSize = 8;
/// <summary>
/// Decode an 8-byte DATE_AND_TIME (BCD) buffer into a DateTime. Year encoding:
/// 90..99 → 1990..1999, 00..89 → 2000..2089 (per Siemens spec).
/// </summary>
public static DateTime DecodeDt(ReadOnlySpan<byte> bytes)
{
if (bytes.Length != DtSize)
throw new InvalidDataException($"S7 DATE_AND_TIME expected {DtSize} bytes, got {bytes.Length}");
int yy = FromBcd(bytes[0]);
int month = FromBcd(bytes[1]);
int day = FromBcd(bytes[2]);
int hour = FromBcd(bytes[3]);
int minute = FromBcd(bytes[4]);
int second = FromBcd(bytes[5]);
// bytes[6] and high nibble of bytes[7] = milliseconds (3 BCD digits).
// Low nibble of bytes[7] = day-of-week (1=Sunday..7=Saturday); ignored on read.
int msHigh = (bytes[6] >> 4) & 0xF;
int msMid = bytes[6] & 0xF;
int msLow = (bytes[7] >> 4) & 0xF;
if (msHigh > 9 || msMid > 9 || msLow > 9)
throw new InvalidDataException($"S7 DT ms BCD digits invalid: {msHigh:X}{msMid:X}{msLow:X}");
int ms = msHigh * 100 + msMid * 10 + msLow;
if (yy == 0 && month == 0 && day == 0)
throw new InvalidDataException("S7 DT is uninitialized (all-zero year/month/day)");
int year = yy >= 90 ? 1900 + yy : 2000 + yy;
if (month is < 1 or > 12) throw new InvalidDataException($"S7 DT month {month} out of range 1..12");
if (day is < 1 or > 31) throw new InvalidDataException($"S7 DT day {day} out of range 1..31");
if (hour > 23) throw new InvalidDataException($"S7 DT hour {hour} out of range 0..23");
if (minute > 59) throw new InvalidDataException($"S7 DT minute {minute} out of range 0..59");
if (second > 59) throw new InvalidDataException($"S7 DT second {second} out of range 0..59");
return new DateTime(year, month, day, hour, minute, second, ms, DateTimeKind.Unspecified);
}
/// <summary>Encode a DateTime as an 8-byte DATE_AND_TIME (BCD) buffer.</summary>
public static byte[] EncodeDt(DateTime value)
{
if (value.Year is < 1990 or > 2089)
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DATE_AND_TIME year must be 1990..2089");
int yy = value.Year >= 2000 ? value.Year - 2000 : value.Year - 1900;
int ms = value.Millisecond;
// S7 day-of-week: 1=Sunday..7=Saturday.
int dow = (int)value.DayOfWeek + 1;
var buf = new byte[DtSize];
buf[0] = ToBcd(yy);
buf[1] = ToBcd(value.Month);
buf[2] = ToBcd(value.Day);
buf[3] = ToBcd(value.Hour);
buf[4] = ToBcd(value.Minute);
buf[5] = ToBcd(value.Second);
// ms = 3 digits packed across bytes [6] (high+mid nibbles) and [7] high nibble.
buf[6] = (byte)(((ms / 100) << 4) | ((ms / 10) % 10));
buf[7] = (byte)((((ms % 10) & 0xF) << 4) | (dow & 0xF));
return buf;
}
// ---- S5TIME (16 bits BCD) ----
/// <summary>Wire size of an S7 S5TIME value.</summary>
public const int S5TimeSize = 2;
/// <summary>
/// Decode a 2-byte S5TIME buffer into a TimeSpan. Layout:
/// <c>0000 TTBB BBBB BBBB</c> where TT is the timebase (00=10ms, 01=100ms,
/// 10=1s, 11=10s) and BBB is the 3-digit BCD count (0..999).
/// </summary>
public static TimeSpan DecodeS5Time(ReadOnlySpan<byte> bytes)
{
if (bytes.Length != S5TimeSize)
throw new InvalidDataException($"S7 S5TIME expected {S5TimeSize} bytes, got {bytes.Length}");
int hi = bytes[0];
int lo = bytes[1];
int tb = (hi >> 4) & 0x3;
int d2 = hi & 0xF;
int d1 = (lo >> 4) & 0xF;
int d0 = lo & 0xF;
if (d2 > 9 || d1 > 9 || d0 > 9)
throw new InvalidDataException($"S7 S5TIME BCD digits invalid: {d2:X}{d1:X}{d0:X}");
int count = d2 * 100 + d1 * 10 + d0;
long unitMs = tb switch
{
0 => 10L,
1 => 100L,
2 => 1000L,
3 => 10_000L,
_ => throw new InvalidDataException($"S7 S5TIME timebase {tb} invalid"),
};
return TimeSpan.FromMilliseconds(count * unitMs);
}
/// <summary>
/// Encode a TimeSpan as a 2-byte S5TIME. Picks the smallest timebase that fits
/// <paramref name="value"/> in 999 units. Rejects negative or &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));
}
}

View File

@@ -1,3 +1,5 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using S7.Net; using S7.Net;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -53,6 +55,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary> /// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
private const uint StatusBadDeviceFailure = 0x80550000u; private const uint StatusBadDeviceFailure = 0x80550000u;
/// <summary>
/// Hard upper bound on <see cref="S7TagDefinition.ElementCount"/>. The S7 PDU envelope
/// for negotiated default 240-byte and extended 960-byte payloads cannot fit a single
/// byte-range read larger than ~960 bytes, so a Float64 array of more than ~120
/// elements is already lossy. 8000 is an order-of-magnitude generous ceiling that still
/// rejects obvious config typos (e.g. ElementCount = 65535) at init time.
/// </summary>
internal const int MaxArrayElements = 8000;
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
@@ -76,6 +87,31 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
private DriverHealth _health = new(DriverState.Unknown, null, null); private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed; private bool _disposed;
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
//
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
// RPC and integration tests can verify wire-level reduction without needing
// access to the underlying S7.Net PDU stream. Names match the
// "<DriverType>.<Counter>" convention adopted for the modbus and opcuaclient
// drivers — see decision #154.
private long _totalBlockReads; // Plc.ReadBytesAsync calls issued by the coalesced path
private long _totalMultiVarBatches; // Plc.ReadMultipleVarsAsync calls issued
private long _totalSingleReads; // per-tag ReadOneAsync fallbacks
/// <summary>
/// Total <c>Plc.ReadBytesAsync</c> calls the coalesced byte-range path issued.
/// Test-only entry point for the integration assertion that 50 contiguous DBWs
/// coalesce into exactly 1 byte-range read.
/// </summary>
internal long TotalBlockReads => Interlocked.Read(ref _totalBlockReads);
/// <summary>
/// Total <c>Plc.ReadMultipleVarsAsync</c> batches issued. For a fully-coalesced
/// contiguous workload this stays at 0 — every tag flows through the byte-range
/// path instead.
/// </summary>
internal long TotalMultiVarBatches => Interlocked.Read(ref _totalMultiVarBatches);
public string DriverInstanceId => driverInstanceId; public string DriverInstanceId => driverInstanceId;
public string DriverType => "S7"; public string DriverType => "S7";
@@ -84,6 +120,34 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
_health = new DriverHealth(DriverState.Initializing, null, null); _health = new DriverHealth(DriverState.Initializing, null, null);
try try
{ {
// Parse + validate every tag before opening the TCP socket so config bugs
// (bad address, oversized array, unsupported array element) surface as
// FormatException without waiting on a connect timeout. Per the v1 driver-config
// story this lets the Admin UI's "Save" round-trip stay sub-second on bad input.
_tagsByName.Clear();
_parsedByName.Clear();
foreach (var t in _options.Tags)
{
// Pass CpuType so V-memory addresses (S7-200 / S7-200 Smart / LOGO!) resolve
// against the device's family-specific DB mapping.
var parsed = S7AddressParser.Parse(t.Address, _options.CpuType); // throws FormatException
if (t.ElementCount is int n && n > 1)
{
// Array sanity: cap at S7 PDU realistic limit, reject variable-width
// element types and BOOL (packed-bit layout) up-front so a config typo
// fails at init instead of surfacing as BadInternalError on every read.
if (n > MaxArrayElements)
throw new FormatException(
$"S7 tag '{t.Name}' ElementCount {n} exceeds S7 PDU realistic limit ({MaxArrayElements})");
if (!IsArrayElementSupported(t.DataType))
throw new FormatException(
$"S7 tag '{t.Name}' DataType {t.DataType} not supported as an array element " +
$"(variable-width string types and BOOL packed-bit arrays are a follow-up)");
}
_tagsByName[t.Name] = t;
_parsedByName[t.Name] = parsed;
}
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot); var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout / // S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself // Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
@@ -97,18 +161,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
Plc = plc; Plc = plc;
// Parse every tag's address once at init so config typos fail fast here instead
// of surfacing as BadInternalError on every Read against the bad tag. The parser
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
_tagsByName.Clear();
_parsedByName.Clear();
foreach (var t in _options.Tags)
{
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
_tagsByName[t.Name] = t;
_parsedByName[t.Name] = parsed;
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
// Kick off the probe loop once the connection is up. Initial HostState stays // Kick off the probe loop once the connection is up. Initial HostState stays
@@ -179,6 +231,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
// Phase 1: classify each request into (a) unknown / not-found, (b) packable
// scalar (Bool/Byte/Int16/UInt16/Int32/UInt32/Float32/Float64) which can
// potentially coalesce into a byte-range read, or (c) per-tag fallback
// (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed
// the block-coalescing planner first (PR-S7-B2); whatever survives as a
// singleton range falls through to the multi-var packer (PR-S7-B1).
var packableIndexes = new List<int>(fullReferences.Count);
var fallbackIndexes = new List<int>();
for (var i = 0; i < fullReferences.Count; i++) for (var i = 0; i < fullReferences.Count; i++)
{ {
var name = fullReferences[i]; var name = fullReferences[i];
@@ -187,39 +247,415 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now); results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue; continue;
} }
try var addr = _parsedByName[name];
if (S7ReadPacker.IsPackable(tag, addr)) packableIndexes.Add(i);
else fallbackIndexes.Add(i);
}
// Phase 2a: block-read coalescing — group same-area / same-DB packable
// tags into contiguous byte ranges (gap-merge threshold from
// S7DriverOptions.BlockCoalescingGapBytes, default 16). Multi-tag ranges
// dispatch via Plc.ReadBytesAsync; singleton ranges fall through to the
// multi-var packer below.
var singletons = new List<int>();
if (packableIndexes.Count > 0)
{
var specs = new List<S7BlockCoalescingPlanner.TagSpec>(packableIndexes.Count);
foreach (var idx in packableIndexes)
{ {
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false); var tag = _tagsByName[fullReferences[idx]];
results[i] = new DataValueSnapshot(value, 0u, now, now); var addr = _parsedByName[fullReferences[idx]];
_health = new DriverHealth(DriverState.Healthy, now, null); specs.Add(new S7BlockCoalescingPlanner.TagSpec(
CallerIndex: idx,
Area: addr.Area,
DbNumber: addr.DbNumber,
StartByte: addr.ByteOffset,
ByteCount: S7BlockCoalescingPlanner.ScalarByteCount(addr.Size),
OpaqueSize: false));
} }
catch (NotSupportedException) var ranges = S7BlockCoalescingPlanner.Plan(specs, _options.BlockCoalescingGapBytes);
foreach (var range in ranges)
{ {
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now); if (range.Tags.Count == 1)
{
// Singleton — let the multi-var packer batch it with other
// singletons in the same ReadAsync call. Cheaper than its
// own one-tag ReadBytesAsync round-trip.
singletons.Add(range.Tags[0].CallerIndex);
}
else
{
await ReadCoalescedRangeAsync(plc, range, fullReferences, results, now, cancellationToken)
.ConfigureAwait(false);
}
} }
catch (global::S7.Net.PlcException pex) }
// Phase 2b: bin-pack residual singletons through ReadMultipleVarsAsync.
// On a per-batch S7.Net failure the whole batch falls back to ReadOneAsync
// per tag — that way one bad item doesn't poison the rest of the batch
// and each tag still gets its own per-item StatusCode (BadDeviceFailure
// for PUT/GET refusal, BadCommunicationError for transport faults).
if (singletons.Count > 0)
{
var budget = S7ReadPacker.ItemBudget(S7ReadPacker.DefaultPduSize);
var batches = S7ReadPacker.BinPack(singletons, budget);
foreach (var batch in batches)
{ {
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on await ReadBatchAsync(plc, batch, fullReferences, results, now, cancellationToken)
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a .ConfigureAwait(false);
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
// transient fault — per driver-specs.md §5.
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
} }
} }
// Phase 3: per-tag fallback for everything that can't pack into a single
// DataItem. Keeps the existing decode path as the source of truth for
// string/date/array/64-bit semantics.
foreach (var i in fallbackIndexes)
{
var tag = _tagsByName[fullReferences[i]];
results[i] = await ReadOneAsSnapshotAsync(plc, tag, now, cancellationToken)
.ConfigureAwait(false);
}
} }
finally { _gate.Release(); } finally { _gate.Release(); }
return results; return results;
} }
/// <summary>
/// Issue one coalesced <c>Plc.ReadBytesAsync</c> covering
/// <paramref name="range"/> and slice the response per tag. On a transport
/// fault the whole range falls back to per-tag <see cref="ReadOneAsSnapshotAsync"/>
/// so a single bad slot doesn't poison N-1 good neighbours.
/// </summary>
private async Task ReadCoalescedRangeAsync(
global::S7.Net.Plc plc,
S7BlockCoalescingPlanner.BlockReadRange range,
IReadOnlyList<string> fullReferences,
DataValueSnapshot[] results,
DateTime now,
CancellationToken ct)
{
byte[]? buf;
try
{
Interlocked.Increment(ref _totalBlockReads);
buf = await plc.ReadBytesAsync(MapArea(range.Area), range.DbNumber, range.StartByte, range.ByteCount, ct)
.ConfigureAwait(false);
}
catch (Exception)
{
// Block read fault → fan out per-tag so a bad address in the block
// surfaces its own StatusCode and good neighbours can still retry
// through the per-tag fallback path.
foreach (var slice in range.Tags)
{
var tag = _tagsByName[fullReferences[slice.CallerIndex]];
results[slice.CallerIndex] = await ReadOneAsSnapshotAsync(plc, tag, now, ct).ConfigureAwait(false);
}
return;
}
if (buf is null || buf.Length != range.ByteCount)
{
// Short / truncated PDU — same fan-out semantics as a transport fault.
foreach (var slice in range.Tags)
{
results[slice.CallerIndex] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
}
return;
}
foreach (var slice in range.Tags)
{
var name = fullReferences[slice.CallerIndex];
var tag = _tagsByName[name];
var addr = _parsedByName[name];
try
{
var value = DecodeScalarFromBlock(buf, slice.OffsetInBlock, tag, addr);
results[slice.CallerIndex] = new DataValueSnapshot(value, 0u, now, now);
}
catch (Exception ex)
{
results[slice.CallerIndex] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
_health = new DriverHealth(DriverState.Healthy, now, null, BuildDiagnostics());
}
/// <summary>
/// Decode one packable scalar from a coalesced byte buffer. Mirrors the
/// reinterpret table in <see cref="S7ReadPacker.DecodePackedValue"/> so the
/// coalesced and per-tag-batch paths produce identical .NET types for the
/// same wire bytes.
/// </summary>
private static object DecodeScalarFromBlock(byte[] buf, int offset, S7TagDefinition tag, S7ParsedAddress addr)
{
return (tag.DataType, addr.Size) switch
{
(S7DataType.Bool, S7Size.Bit) => ((buf[offset] >> addr.BitOffset) & 0x1) == 1,
(S7DataType.Byte, S7Size.Byte) => buf[offset],
(S7DataType.UInt16, S7Size.Word) => BinaryPrimitives.ReadUInt16BigEndian(buf.AsSpan(offset, 2)),
(S7DataType.Int16, S7Size.Word) => BinaryPrimitives.ReadInt16BigEndian(buf.AsSpan(offset, 2)),
(S7DataType.UInt32, S7Size.DWord) => BinaryPrimitives.ReadUInt32BigEndian(buf.AsSpan(offset, 4)),
(S7DataType.Int32, S7Size.DWord) => BinaryPrimitives.ReadInt32BigEndian(buf.AsSpan(offset, 4)),
(S7DataType.Float32, S7Size.DWord) =>
BitConverter.UInt32BitsToSingle(BinaryPrimitives.ReadUInt32BigEndian(buf.AsSpan(offset, 4))),
(S7DataType.Float64, S7Size.LWord) =>
BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(buf.AsSpan(offset, 8))),
_ => throw new System.IO.InvalidDataException(
$"S7 block-decode: tag '{tag.Name}' declared {tag.DataType} but address parsed Size={addr.Size}"),
};
}
/// <summary>
/// Snapshot of the wire-level coalescing counters surfaced through
/// <see cref="DriverHealth.Diagnostics"/>. Names follow the
/// <c>"&lt;DriverType&gt;.&lt;Counter&gt;"</c> convention so the driver-diagnostics
/// RPC can render them in the Admin UI alongside Modbus / OPC UA Client
/// metrics without a per-driver special-case.
/// </summary>
private IReadOnlyDictionary<string, double> BuildDiagnostics() => new Dictionary<string, double>
{
["S7.TotalBlockReads"] = Interlocked.Read(ref _totalBlockReads),
["S7.TotalMultiVarBatches"] = Interlocked.Read(ref _totalMultiVarBatches),
["S7.TotalSingleReads"] = Interlocked.Read(ref _totalSingleReads),
};
/// <summary>
/// Read one packed batch via <c>Plc.ReadMultipleVarsAsync</c>. On batch
/// success each <c>DataItem.Value</c> decodes into its tag's snapshot
/// slot; on batch failure each tag in the batch falls back to
/// <see cref="ReadOneAsSnapshotAsync"/> so the failure fans out per-tag instead
/// of poisoning the whole batch with one StatusCode.
/// </summary>
private async Task ReadBatchAsync(
global::S7.Net.Plc plc,
IReadOnlyList<int> batchIndexes,
IReadOnlyList<string> fullReferences,
DataValueSnapshot[] results,
DateTime now,
CancellationToken ct)
{
var items = new List<global::S7.Net.Types.DataItem>(batchIndexes.Count);
foreach (var idx in batchIndexes)
{
var name = fullReferences[idx];
items.Add(S7ReadPacker.BuildDataItem(_tagsByName[name], _parsedByName[name]));
}
try
{
Interlocked.Increment(ref _totalMultiVarBatches);
var responses = await plc.ReadMultipleVarsAsync(items, ct).ConfigureAwait(false);
// S7.Net mutates the input list in place and also returns it; iterate by
// index against the input list so we are agnostic to either contract.
for (var k = 0; k < batchIndexes.Count; k++)
{
var idx = batchIndexes[k];
var tag = _tagsByName[fullReferences[idx]];
var raw = (responses != null && k < responses.Count ? responses[k] : items[k]).Value;
if (raw is null)
{
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
continue;
}
try
{
var decoded = S7ReadPacker.DecodePackedValue(tag, raw);
results[idx] = new DataValueSnapshot(decoded, 0u, now, now);
}
catch (Exception ex)
{
results[idx] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
_health = new DriverHealth(DriverState.Healthy, now, null, BuildDiagnostics());
}
catch (Exception)
{
// Batch-level fault: most likely a single bad address poisoned the
// multi-var response. Fall back to ReadOneAsync per tag in the batch so
// good tags still surface a value and the offender gets its own StatusCode.
foreach (var idx in batchIndexes)
{
var tag = _tagsByName[fullReferences[idx]];
results[idx] = await ReadOneAsSnapshotAsync(plc, tag, now, ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// Single-tag read wrapped as a <see cref="DataValueSnapshot"/> with the same
/// exception-to-StatusCode mapping the legacy per-tag loop applied. Shared
/// between the fallback path and the post-batch retry path so the failure
/// surface stays identical.
/// </summary>
private async Task<DataValueSnapshot> ReadOneAsSnapshotAsync(
global::S7.Net.Plc plc, S7TagDefinition tag, DateTime now, CancellationToken ct)
{
try
{
Interlocked.Increment(ref _totalSingleReads);
var value = await ReadOneAsync(plc, tag, ct).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Healthy, now, null);
return new DataValueSnapshot(value, 0u, now, now);
}
catch (NotSupportedException)
{
return new DataValueSnapshot(null, StatusBadNotSupported, null, now);
}
catch (global::S7.Net.PlcException pex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
return new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
}
}
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct) private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
{ {
var addr = _parsedByName[tag.Name]; var addr = _parsedByName[tag.Name];
// 1-D array path: one byte-range read covering N×elementBytes, sliced client-side.
// Init-time validation guarantees only fixed-width element types reach here.
if (tag.ElementCount is int n && n > 1)
{
var elemBytes = ArrayElementBytes(tag.DataType);
var totalBytes = checked(n * elemBytes);
if (addr.Size == S7Size.Bit)
throw new System.IO.InvalidDataException(
$"S7 Read type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
$"parsed as bit-access; arrays require byte-addressing");
var arrBytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, totalBytes, ct)
.ConfigureAwait(false);
if (arrBytes is null || arrBytes.Length != totalBytes)
throw new System.IO.InvalidDataException(
$"S7.Net returned {arrBytes?.Length ?? 0} bytes for array '{tag.Address}' (n={n}), expected {totalBytes}");
return SliceArray(arrBytes, tag.DataType, n, elemBytes);
}
// String-shaped types (STRING/WSTRING/CHAR/WCHAR): S7.Net's string-keyed ReadAsync
// has no syntax for these, so the driver issues a raw byte read and decodes via
// S7StringCodec. Wire order is big-endian for the WSTRING/WCHAR UTF-16 payload.
if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar)
{
if (addr.Size == S7Size.Bit)
throw new System.IO.InvalidDataException(
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)");
var (area, dbNum, off) = (addr.Area, addr.DbNumber, addr.ByteOffset);
switch (tag.DataType)
{
case S7DataType.Char:
{
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 1, ct).ConfigureAwait(false);
if (b is null || b.Length != 1)
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for CHAR '{tag.Address}', expected 1");
return S7StringCodec.DecodeChar(b);
}
case S7DataType.WChar:
{
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 2, ct).ConfigureAwait(false);
if (b is null || b.Length != 2)
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WCHAR '{tag.Address}', expected 2");
return S7StringCodec.DecodeWChar(b);
}
case S7DataType.String:
{
var max = tag.StringLength;
var size = S7StringCodec.StringBufferSize(max);
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false);
if (b is null || b.Length != size)
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for STRING '{tag.Address}', expected {size}");
return S7StringCodec.DecodeString(b, max);
}
case S7DataType.WString:
{
var max = tag.StringLength;
var size = S7StringCodec.WStringBufferSize(max);
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false);
if (b is null || b.Length != size)
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WSTRING '{tag.Address}', expected {size}");
return S7StringCodec.DecodeWString(b, max);
}
}
}
// Date/time-shaped types (DTL/DT/S5TIME/TIME/TOD/DATE): S7.Net has no native size
// suffix for any of these, so the driver issues a raw byte read at the address's
// ByteOffset and decodes via S7DateTimeCodec. All require byte-addressing — bit-
// access against a date/time tag is a config bug worth surfacing as a hard error.
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time
or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
{
if (addr.Size == S7Size.Bit)
throw new System.IO.InvalidDataException(
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as bit-access; date/time types require byte-addressing");
int size = tag.DataType switch
{
S7DataType.Dtl => S7DateTimeCodec.DtlSize,
S7DataType.DateAndTime => S7DateTimeCodec.DtSize,
S7DataType.S5Time => S7DateTimeCodec.S5TimeSize,
S7DataType.Time => S7DateTimeCodec.TimeSize,
S7DataType.TimeOfDay => S7DateTimeCodec.TodSize,
S7DataType.Date => S7DateTimeCodec.DateSize,
_ => throw new InvalidOperationException(),
};
var b = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, size, ct).ConfigureAwait(false);
if (b is null || b.Length != size)
throw new System.IO.InvalidDataException(
$"S7.Net returned {b?.Length ?? 0} bytes for {tag.DataType} '{tag.Address}', expected {size}");
return tag.DataType switch
{
S7DataType.Dtl => S7DateTimeCodec.DecodeDtl(b),
S7DataType.DateAndTime => S7DateTimeCodec.DecodeDt(b),
// S5TIME/TIME/TOD surface as Int32 ms — DriverDataType has no Duration type;
// OPC UA clients see a millisecond integer matching the IEC-1131 convention.
S7DataType.S5Time => (int)S7DateTimeCodec.DecodeS5Time(b).TotalMilliseconds,
S7DataType.Time => (int)S7DateTimeCodec.DecodeTime(b).TotalMilliseconds,
S7DataType.TimeOfDay => (int)S7DateTimeCodec.DecodeTod(b).TotalMilliseconds,
S7DataType.Date => S7DateTimeCodec.DecodeDate(b),
_ => throw new InvalidOperationException(),
};
}
// 64-bit types: S7.Net's string-based ReadAsync has no LWord size suffix, so issue an
// 8-byte ReadBytesAsync and convert big-endian in-process. Wire order on S7 is BE.
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
{
if (addr.Size != S7Size.LWord)
throw new System.IO.InvalidDataException(
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
var bytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, 8, ct)
.ConfigureAwait(false);
if (bytes is null || bytes.Length != 8)
throw new System.IO.InvalidDataException($"S7.Net returned {bytes?.Length ?? 0} bytes for '{tag.Address}', expected 8");
return tag.DataType switch
{
S7DataType.Int64 => BinaryPrimitives.ReadInt64BigEndian(bytes),
S7DataType.UInt64 => BinaryPrimitives.ReadUInt64BigEndian(bytes),
S7DataType.Float64 => BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes)),
_ => throw new InvalidOperationException(),
};
}
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on // S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum // the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below // specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
@@ -238,10 +674,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32), (S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32), (S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"), (S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
_ => throw new System.IO.InvalidDataException( _ => throw new System.IO.InvalidDataException(
@@ -250,6 +682,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
}; };
} }
/// <summary>Map driver-internal <see cref="S7Area"/> to S7.Net's <see cref="global::S7.Net.DataType"/>.</summary>
private static global::S7.Net.DataType MapArea(S7Area area) => area switch
{
S7Area.DataBlock => global::S7.Net.DataType.DataBlock,
S7Area.Memory => global::S7.Net.DataType.Memory,
S7Area.Input => global::S7.Net.DataType.Input,
S7Area.Output => global::S7.Net.DataType.Output,
S7Area.Timer => global::S7.Net.DataType.Timer,
S7Area.Counter => global::S7.Net.DataType.Counter,
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
};
// ---- IWritable ---- // ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync( public async Task<IReadOnlyList<WriteResult>> WriteAsync(
@@ -299,6 +743,102 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct) private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
{ {
// 1-D array path: pack all N elements into a single buffer then push via WriteBytesAsync.
// Init-time validation guarantees only fixed-width element types reach here.
if (tag.ElementCount is int n && n > 1)
{
var addr = _parsedByName[tag.Name];
if (addr.Size == S7Size.Bit)
throw new InvalidOperationException(
$"S7 Write type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
$"parsed as bit-access; arrays require byte-addressing");
if (value is null)
throw new ArgumentNullException(nameof(value));
var elemBytes = ArrayElementBytes(tag.DataType);
var buf = PackArray(value, tag.DataType, n, elemBytes, tag.Name);
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
return;
}
// String-shaped types: encode via S7StringCodec then push via WriteBytesAsync. The
// codec rejects out-of-range lengths and non-ASCII for CHAR — we let the resulting
// ArgumentException bubble out so the WriteAsync caller maps it to BadInternalError.
if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar)
{
var addr = _parsedByName[tag.Name];
if (addr.Size == S7Size.Bit)
throw new InvalidOperationException(
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)");
byte[] payload = tag.DataType switch
{
S7DataType.Char => S7StringCodec.EncodeChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))),
S7DataType.WChar => S7StringCodec.EncodeWChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))),
S7DataType.String => S7StringCodec.EncodeString(Convert.ToString(value) ?? string.Empty, tag.StringLength),
S7DataType.WString => S7StringCodec.EncodeWString(Convert.ToString(value) ?? string.Empty, tag.StringLength),
_ => throw new InvalidOperationException(),
};
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false);
return;
}
// Date/time-shaped types: encode via S7DateTimeCodec and push as raw bytes. S5TIME /
// TIME / TOD accept an integer-ms input (matching the read surface); DTL / DT / DATE
// accept a DateTime. ArgumentException from the codec bubbles to BadInternalError.
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time
or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
{
var addr = _parsedByName[tag.Name];
if (addr.Size == S7Size.Bit)
throw new InvalidOperationException(
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as bit-access; date/time types require byte-addressing");
if (value is null)
throw new ArgumentNullException(nameof(value));
byte[] payload = tag.DataType switch
{
S7DataType.Dtl => S7DateTimeCodec.EncodeDtl(Convert.ToDateTime(value)),
S7DataType.DateAndTime => S7DateTimeCodec.EncodeDt(Convert.ToDateTime(value)),
S7DataType.S5Time => S7DateTimeCodec.EncodeS5Time(value is TimeSpan ts1 ? ts1 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))),
S7DataType.Time => S7DateTimeCodec.EncodeTime(value is TimeSpan ts2 ? ts2 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))),
S7DataType.TimeOfDay => S7DateTimeCodec.EncodeTod(value is TimeSpan ts3 ? ts3 : TimeSpan.FromMilliseconds(Convert.ToInt64(value))),
S7DataType.Date => S7DateTimeCodec.EncodeDate(Convert.ToDateTime(value)),
_ => throw new InvalidOperationException(),
};
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false);
return;
}
// 64-bit types: S7.Net has no LWord-aware WriteAsync(string, object) overload, so emit
// the value as 8 big-endian bytes via WriteBytesAsync. Wire order on S7 is BE so a
// BinaryPrimitives.Write*BigEndian round-trips with the matching ReadOneAsync path.
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
{
var addr = _parsedByName[tag.Name];
if (addr.Size != S7Size.LWord)
throw new InvalidOperationException(
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
var buf = new byte[8];
switch (tag.DataType)
{
case S7DataType.Int64:
BinaryPrimitives.WriteInt64BigEndian(buf, Convert.ToInt64(value));
break;
case S7DataType.UInt64:
BinaryPrimitives.WriteUInt64BigEndian(buf, Convert.ToUInt64(value));
break;
case S7DataType.Float64:
BinaryPrimitives.WriteUInt64BigEndian(buf, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(value)));
break;
}
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
return;
}
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to // S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. // match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned // Our S7DataType lets the caller pass short/int/float; convert to the unsigned
@@ -313,10 +853,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)), S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)), S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"), S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"), _ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
}; };
@@ -334,11 +870,12 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
var folder = builder.Folder("S7", "S7"); var folder = builder.Folder("S7", "S7");
foreach (var t in _options.Tags) foreach (var t in _options.Tags)
{ {
var isArr = t.ElementCount is int ec && ec > 1;
folder.Variable(t.Name, t.Name, new DriverAttributeInfo( folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name, FullName: t.Name,
DriverDataType: MapDataType(t.DataType), DriverDataType: MapDataType(t.DataType),
IsArray: false, IsArray: isArr,
ArrayDim: null, ArrayDim: isArr ? (uint)t.ElementCount!.Value : null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false, IsHistorized: false,
IsAlarm: false, IsAlarm: false,
@@ -347,16 +884,198 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <summary>
/// True when <paramref name="t"/> can be used as an array element. Variable-width string
/// types and BOOL (packed-bit layout) are rejected — both need bespoke addressing
/// beyond a flat <c>N × elementBytes</c> byte-range read and ship as a follow-up.
/// </summary>
internal static bool IsArrayElementSupported(S7DataType t) => t is
S7DataType.Byte or
S7DataType.Int16 or S7DataType.UInt16 or
S7DataType.Int32 or S7DataType.UInt32 or
S7DataType.Int64 or S7DataType.UInt64 or
S7DataType.Float32 or S7DataType.Float64 or
S7DataType.Date or S7DataType.Time or S7DataType.TimeOfDay;
/// <summary>
/// On-wire bytes per array element for the supported fixed-width element types. DATE
/// is a 16-bit days-since-1990 counter, TIME and TOD are 32-bit ms counters.
/// </summary>
internal static int ArrayElementBytes(S7DataType t) => t switch
{
S7DataType.Byte => 1,
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Date => 2,
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32
or S7DataType.Time or S7DataType.TimeOfDay => 4,
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
_ => throw new InvalidOperationException($"S7 array element bytes undefined for {t}"),
};
/// <summary>
/// Slice a flat S7 byte buffer into a typed array using the existing big-endian scalar
/// codec for each element. Returns the typed array boxed as <c>object</c> so the
/// <see cref="DataValueSnapshot"/> surface can carry it without further conversion.
/// </summary>
internal static object SliceArray(byte[] bytes, S7DataType t, int n, int elemBytes)
{
switch (t)
{
case S7DataType.Byte:
{
var a = new byte[n];
Buffer.BlockCopy(bytes, 0, a, 0, n);
return a;
}
case S7DataType.Int16:
{
var a = new short[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
return a;
}
case S7DataType.UInt16:
{
var a = new ushort[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
return a;
}
case S7DataType.Int32:
{
var a = new int[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
return a;
}
case S7DataType.UInt32:
{
var a = new uint[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
return a;
}
case S7DataType.Int64:
{
var a = new long[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
return a;
}
case S7DataType.UInt64:
{
var a = new ulong[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
return a;
}
case S7DataType.Float32:
{
var a = new float[n];
for (var i = 0; i < n; i++)
a[i] = BitConverter.UInt32BitsToSingle(BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4)));
return a;
}
case S7DataType.Float64:
{
var a = new double[n];
for (var i = 0; i < n; i++)
a[i] = BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8)));
return a;
}
case S7DataType.Date:
{
var a = new DateTime[n];
for (var i = 0; i < n; i++)
a[i] = S7DateTimeCodec.DecodeDate(bytes.AsSpan(i * elemBytes, 2));
return a;
}
case S7DataType.Time:
{
// Surface as Int32 ms — matches the scalar Time read path (driver-specs §5).
var a = new int[n];
for (var i = 0; i < n; i++)
a[i] = (int)S7DateTimeCodec.DecodeTime(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
return a;
}
case S7DataType.TimeOfDay:
{
var a = new int[n];
for (var i = 0; i < n; i++)
a[i] = (int)S7DateTimeCodec.DecodeTod(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
return a;
}
default:
throw new InvalidOperationException($"S7 array slice undefined for {t}");
}
}
/// <summary>
/// Pack a caller-supplied array (object) into the on-wire S7 byte layout for
/// <paramref name="elementType"/>. Accepts both the strongly-typed array
/// (<c>short[]</c>, <c>int[]</c>, ...) and a generic <c>System.Array</c> / <c>IEnumerable</c>
/// so OPC UA Variant-boxed values flow through unchanged.
/// </summary>
internal static byte[] PackArray(object value, S7DataType elementType, int n, int elemBytes, string tagName)
{
if (value is not System.Collections.IEnumerable enumerable)
throw new ArgumentException($"S7 Write tag '{tagName}' is array but value is not enumerable (got {value.GetType().Name})", nameof(value));
var buf = new byte[n * elemBytes];
var i = 0;
foreach (var raw in enumerable)
{
if (i >= n)
throw new ArgumentException($"S7 Write tag '{tagName}': value has more than ElementCount={n} elements", nameof(value));
var span = buf.AsSpan(i * elemBytes, elemBytes);
switch (elementType)
{
case S7DataType.Byte: span[0] = Convert.ToByte(raw); break;
case S7DataType.Int16: BinaryPrimitives.WriteInt16BigEndian(span, Convert.ToInt16(raw)); break;
case S7DataType.UInt16: BinaryPrimitives.WriteUInt16BigEndian(span, Convert.ToUInt16(raw)); break;
case S7DataType.Int32: BinaryPrimitives.WriteInt32BigEndian(span, Convert.ToInt32(raw)); break;
case S7DataType.UInt32: BinaryPrimitives.WriteUInt32BigEndian(span, Convert.ToUInt32(raw)); break;
case S7DataType.Int64: BinaryPrimitives.WriteInt64BigEndian(span, Convert.ToInt64(raw)); break;
case S7DataType.UInt64: BinaryPrimitives.WriteUInt64BigEndian(span, Convert.ToUInt64(raw)); break;
case S7DataType.Float32: BinaryPrimitives.WriteUInt32BigEndian(span, BitConverter.SingleToUInt32Bits(Convert.ToSingle(raw))); break;
case S7DataType.Float64: BinaryPrimitives.WriteUInt64BigEndian(span, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(raw))); break;
case S7DataType.Date:
S7DateTimeCodec.EncodeDate(Convert.ToDateTime(raw)).CopyTo(span);
break;
case S7DataType.Time:
S7DateTimeCodec.EncodeTime(raw is TimeSpan ts ? ts : TimeSpan.FromMilliseconds(Convert.ToInt32(raw))).CopyTo(span);
break;
case S7DataType.TimeOfDay:
S7DateTimeCodec.EncodeTod(raw is TimeSpan tod ? tod : TimeSpan.FromMilliseconds(Convert.ToInt64(raw))).CopyTo(span);
break;
default:
throw new InvalidOperationException($"S7 array pack undefined for {elementType}");
}
i++;
}
if (i != n)
throw new ArgumentException($"S7 Write tag '{tagName}': value had {i} elements, expected ElementCount={n}", nameof(value));
return buf;
}
private static DriverDataType MapDataType(S7DataType t) => t switch private static DriverDataType MapDataType(S7DataType t) => t switch
{ {
S7DataType.Bool => DriverDataType.Boolean, S7DataType.Bool => DriverDataType.Boolean,
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32, S7DataType.Int16 => DriverDataType.Int16,
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1 S7DataType.UInt16 => DriverDataType.UInt16,
S7DataType.Int32 => DriverDataType.Int32,
S7DataType.UInt32 => DriverDataType.UInt32,
S7DataType.Int64 => DriverDataType.Int64,
S7DataType.UInt64 => DriverDataType.UInt64,
S7DataType.Float32 => DriverDataType.Float32, S7DataType.Float32 => DriverDataType.Float32,
S7DataType.Float64 => DriverDataType.Float64, S7DataType.Float64 => DriverDataType.Float64,
S7DataType.String => DriverDataType.String, S7DataType.String => DriverDataType.String,
S7DataType.WString => DriverDataType.String,
S7DataType.Char => DriverDataType.String,
S7DataType.WChar => DriverDataType.String,
S7DataType.DateTime => DriverDataType.DateTime, S7DataType.DateTime => DriverDataType.DateTime,
S7DataType.Dtl => DriverDataType.DateTime,
S7DataType.DateAndTime => DriverDataType.DateTime,
S7DataType.Date => DriverDataType.DateTime,
// S5TIME/TIME/TOD have no Duration type in DriverDataType — surface as Int32 ms
// (matching the IEC-1131 representation).
S7DataType.S5Time => DriverDataType.Int32,
S7DataType.Time => DriverDataType.Int32,
S7DataType.TimeOfDay => DriverDataType.Int32,
_ => DriverDataType.Int32, _ => DriverDataType.Int32,
}; };

View File

@@ -63,6 +63,24 @@ public sealed class S7DriverOptions
/// Running ↔ Stopped transitions. /// Running ↔ Stopped transitions.
/// </summary> /// </summary>
public S7ProbeOptions Probe { get; init; } = new(); public S7ProbeOptions Probe { get; init; } = new();
/// <summary>
/// Block-read coalescing gap-merge threshold (bytes). When two same-DB tags are
/// within this many bytes of each other the planner folds them into a single
/// <c>Plc.ReadBytesAsync</c> request and slices the response client-side. The
/// default <see cref="S7BlockCoalescingPlanner.DefaultGapMergeBytes"/> = 16 bytes
/// trades a minor over-fetch for one fewer PDU round-trip — over-fetching 16
/// bytes is cheaper than the ~30-byte S7 request frame.
/// </summary>
/// <remarks>
/// Raise the threshold for chatty PLCs where PDU round-trips dominate latency
/// (S7-1200 with default 240-byte PDU); lower it when DBs are sparsely populated
/// so the over-fetch cost outweighs the saved PDU. Setting to 0 disables gap
/// merging entirely — only literally adjacent ranges (gap == 0) coalesce.
/// STRING / WSTRING / CHAR / WCHAR / structured-timestamp / array tags always
/// opt out of merging regardless of this knob.
/// </remarks>
public int BlockCoalescingGapBytes { get; init; } = S7BlockCoalescingPlanner.DefaultGapMergeBytes;
} }
public sealed class S7ProbeOptions public sealed class S7ProbeOptions
@@ -95,13 +113,23 @@ public sealed class S7ProbeOptions
/// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output) /// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output)
/// coils that drive edge-triggered routines in the PLC program. /// coils that drive edge-triggered routines in the PLC program.
/// </param> /// </param>
/// <param name="ElementCount">
/// Optional 1-D array length. <c>null</c> (or <c>1</c>) = scalar tag; <c>&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( public sealed record S7TagDefinition(
string Name, string Name,
string Address, string Address,
S7DataType DataType, S7DataType DataType,
bool Writable = true, bool Writable = true,
int StringLength = 254, int StringLength = 254,
bool WriteIdempotent = false); bool WriteIdempotent = false,
int? ElementCount = null);
public enum S7DataType public enum S7DataType
{ {
@@ -116,5 +144,23 @@ public enum S7DataType
Float32, Float32,
Float64, Float64,
String, String,
/// <summary>S7 WSTRING: 4-byte header (max-len + actual-len, both UInt16 big-endian) followed by N×2 UTF-16BE bytes; total wire length = 4 + 2 × StringLength.</summary>
WString,
/// <summary>S7 CHAR: single ASCII byte.</summary>
Char,
/// <summary>S7 WCHAR: two bytes UTF-16 big-endian.</summary>
WChar,
DateTime, DateTime,
/// <summary>S7 DTL — 12-byte structured timestamp with year/mon/day/dow/h/m/s/ns; year range 1970-2554.</summary>
Dtl,
/// <summary>S7 DATE_AND_TIME (DT) — 8-byte BCD timestamp; year range 1990-2089.</summary>
DateAndTime,
/// <summary>S7 S5TIME — 16-bit BCD duration with 2-bit timebase; range 0..9990s. Surfaced as Int32 ms.</summary>
S5Time,
/// <summary>S7 TIME — signed Int32 ms big-endian. Surfaced as Int32 ms (negative durations allowed).</summary>
Time,
/// <summary>S7 TIME_OF_DAY (TOD) — UInt32 ms since midnight big-endian; range 0..86399999. Surfaced as Int32 ms.</summary>
TimeOfDay,
/// <summary>S7 DATE — UInt16 days since 1990-01-01 big-endian. Surfaced as DateTime.</summary>
Date,
} }

View File

@@ -0,0 +1,190 @@
using S7.Net;
using S7.Net.Types;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Multi-variable PDU packer for S7 reads. Replaces the per-tag <c>Plc.ReadAsync</c>
/// loop with batched <c>Plc.ReadMultipleVarsAsync</c> calls so that N scalar tags fit
/// into ⌈N / 19⌉ PDU round-trips on a default 240-byte negotiated PDU instead of N.
/// </summary>
/// <remarks>
/// <para>
/// <b>Packing budget</b>: Siemens S7 read response budget is
/// <c>negotiatedPduSize - 18 - 12·N</c>, where the 18 bytes cover the response
/// header / parameter headers and 12 bytes per item carry the per-variable item
/// response (return code + data header + value). For a 240-byte PDU the absolute
/// ceiling is ~19 items per request before the response overflows; we apply that
/// as a conservative cap regardless of negotiated PDU since S7.Net does not
/// expose the negotiated size and 240 is the default for every CPU family.
/// </para>
/// <para>
/// <b>Packable types only</b>: only fixed-width scalars where the wire layout
/// maps 1-to-1 onto an <see cref="VarType"/> the multi-var path natively decodes
/// (Bool, Byte, Int16/UInt16, Int32/UInt32, Float32, Float64). Strings, dates,
/// arrays, 64-bit ints, and UDT-shaped types stay on the per-tag
/// <c>ReadOneAsync</c> path because their decode requires
/// <c>Plc.ReadBytesAsync</c> + bespoke codec rather than a single
/// <see cref="DataItem"/>.
/// </para>
/// </remarks>
internal static class S7ReadPacker
{
/// <summary>
/// Default negotiated S7 PDU size (bytes). Every S7 CPU family negotiates 240 by
/// default; the extended-PDU 480 / 960 byte settings need an explicit COTP
/// parameter that S7.Net does not expose. Stay conservative.
/// </summary>
internal const int DefaultPduSize = 240;
/// <summary>
/// Per-item response overhead in bytes — return code + data type code + length
/// field. The S7 spec calls this 4 bytes minimum but rounds up to 12 once the
/// payload alignment + worst-case 8-byte LReal value field are included.
/// </summary>
internal const int PerItemResponseBytes = 12;
/// <summary>Fixed response-header bytes regardless of item count.</summary>
internal const int ResponseHeaderBytes = 18;
/// <summary>
/// Maximum items per PDU at the default 240-byte negotiated size. Derived from
/// <c>floor((240 - 18) / 12) = 18.5</c> rounded down to 18 plus 1 for a
/// response-header slack the S7 spec rounds up; the practical Siemens limit
/// documented in TIA Portal is 19 items per <c>PUT</c>/<c>GET</c> call so we cap
/// at 19 and rely on the budget calculation only when a non-default PDU is in
/// play.
/// </summary>
internal const int MaxItemsPerPdu240 = 19;
/// <summary>
/// Compute how many items can fit in one <c>Plc.ReadMultipleVarsAsync</c>
/// call at the given negotiated PDU size, capped at the practical Siemens
/// ceiling of 19 items.
/// </summary>
internal static int ItemBudget(int negotiatedPduSize)
{
if (negotiatedPduSize <= ResponseHeaderBytes + PerItemResponseBytes)
return 1;
var byBudget = (negotiatedPduSize - ResponseHeaderBytes) / PerItemResponseBytes;
return Math.Min(byBudget, MaxItemsPerPdu240);
}
/// <summary>
/// True if the tag can be packed into a single <see cref="DataItem"/> for
/// <c>Plc.ReadMultipleVarsAsync</c>. Returns false for everything that
/// needs a custom byte-range decode (strings, dates, arrays, UDTs, 64-bit ints
/// where S7.Net's <see cref="VarType"/> has no entry).
/// </summary>
internal static bool IsPackable(S7TagDefinition tag, S7ParsedAddress addr)
{
if (tag.ElementCount is int n && n > 1) return false; // arrays go through ReadOneAsync
return tag.DataType switch
{
S7DataType.Bool when addr.Size == S7Size.Bit => true,
S7DataType.Byte when addr.Size == S7Size.Byte => true,
S7DataType.Int16 or S7DataType.UInt16 when addr.Size == S7Size.Word => true,
S7DataType.Int32 or S7DataType.UInt32 when addr.Size == S7Size.DWord => true,
S7DataType.Float32 when addr.Size == S7Size.DWord => true,
S7DataType.Float64 when addr.Size == S7Size.LWord => true,
// Int64 / UInt64 have no native VarType; S7.Net's multi-var path can't decode
// them without falling back to byte-range reads. Route to ReadOneAsync.
_ => false,
};
}
/// <summary>
/// Build a <see cref="DataItem"/> for a packable tag. <see cref="VarType"/> is
/// chosen so that S7.Net's multi-var path decodes the wire bytes into a .NET type
/// this driver can reinterpret without a second PLC round-trip
/// (Word→ushort, DWord→uint, etc.).
/// </summary>
internal static DataItem BuildDataItem(S7TagDefinition tag, S7ParsedAddress addr)
{
var dataType = MapArea(addr.Area);
var varType = tag.DataType switch
{
S7DataType.Bool => VarType.Bit,
S7DataType.Byte => VarType.Byte,
// Int16 read via Word (UInt16 wire) and reinterpreted to short in
// DecodePackedValue; gives identical wire behaviour to the single-tag path.
S7DataType.Int16 => VarType.Word,
S7DataType.UInt16 => VarType.Word,
S7DataType.Int32 => VarType.DWord,
S7DataType.UInt32 => VarType.DWord,
S7DataType.Float32 => VarType.Real,
S7DataType.Float64 => VarType.LReal,
_ => throw new InvalidOperationException(
$"S7ReadPacker: tag '{tag.Name}' DataType {tag.DataType} is not packable; IsPackable check skipped"),
};
return new DataItem
{
DataType = dataType,
VarType = varType,
DB = addr.DbNumber,
StartByteAdr = addr.ByteOffset,
BitAdr = (byte)addr.BitOffset,
Count = 1,
};
}
/// <summary>
/// Convert the boxed value S7.Net's multi-var path returns into the .NET type
/// declared by <paramref name="tag"/>. Mirrors the reinterpret table in
/// <c>S7Driver.ReadOneAsync</c> so packed reads and single-tag reads produce
/// identical snapshots for the same input.
/// </summary>
internal static object DecodePackedValue(S7TagDefinition tag, object raw)
{
return (tag.DataType, raw) switch
{
(S7DataType.Bool, bool b) => b,
(S7DataType.Byte, byte by) => by,
(S7DataType.UInt16, ushort u16) => u16,
(S7DataType.Int16, ushort u16) => unchecked((short)u16),
(S7DataType.UInt32, uint u32) => u32,
(S7DataType.Int32, uint u32) => unchecked((int)u32),
(S7DataType.Float32, float f) => f,
(S7DataType.Float64, double d) => d,
// S7.Net occasionally hands back the underlying integer type for Real/LReal
// when the bytes were marshalled raw — reinterpret defensively.
(S7DataType.Float32, uint u32) => BitConverter.UInt32BitsToSingle(u32),
(S7DataType.Float64, ulong u64) => BitConverter.UInt64BitsToDouble(u64),
_ => throw new System.IO.InvalidDataException(
$"S7ReadPacker: tag '{tag.Name}' declared {tag.DataType} but multi-var returned {raw.GetType().Name}"),
};
}
/// <summary>
/// Bin-pack <paramref name="indices"/> into batches of at most
/// <paramref name="itemBudget"/> items. Order within each batch matches the
/// input order so the per-item response from S7.Net maps back 1-to-1.
/// </summary>
internal static List<List<int>> BinPack(IReadOnlyList<int> indices, int itemBudget)
{
var batches = new List<List<int>>();
var current = new List<int>(itemBudget);
foreach (var idx in indices)
{
current.Add(idx);
if (current.Count >= itemBudget)
{
batches.Add(current);
current = new List<int>(itemBudget);
}
}
if (current.Count > 0) batches.Add(current);
return batches;
}
private static DataType MapArea(S7Area area) => area switch
{
S7Area.DataBlock => DataType.DataBlock,
S7Area.Memory => DataType.Memory,
S7Area.Input => DataType.Input,
S7Area.Output => DataType.Output,
S7Area.Timer => DataType.Timer,
S7Area.Counter => DataType.Counter,
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
};
}

View File

@@ -0,0 +1,166 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Byte-level codecs for the four Siemens S7 string-shaped types: STRING, WSTRING,
/// CHAR, WCHAR. Pulled out of <see cref="S7Driver"/> so the encoding rules are
/// unit-testable against golden byte vectors without standing up a Plc instance.
/// </summary>
/// <remarks>
/// Wire formats (all big-endian, matching S7's native byte order):
/// <list type="bullet">
/// <item>
/// <b>STRING</b>: 2-byte header (<c>maxLen</c> byte, <c>actualLen</c> byte) +
/// N ASCII bytes. Total slot size on the PLC = <c>2 + maxLen</c>. Bytes past
/// <c>actualLen</c> are unspecified — the codec ignores them on read.
/// </item>
/// <item>
/// <b>WSTRING</b>: 4-byte header (<c>maxLen</c> UInt16 BE, <c>actualLen</c>
/// UInt16 BE) + N × 2 UTF-16BE bytes. Total slot size on the PLC =
/// <c>4 + 2 × maxLen</c>.
/// </item>
/// <item>
/// <b>CHAR</b>: 1 ASCII byte.
/// </item>
/// <item>
/// <b>WCHAR</b>: 2 UTF-16BE bytes.
/// </item>
/// </list>
/// <para>
/// <b>Header-bug clamp</b>: certain S7 firmware revisions write
/// <c>actualLen &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;
}
}

View File

@@ -23,6 +23,7 @@
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/> <InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,7 +1,9 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text;
using TwinCAT; using TwinCAT;
using TwinCAT.Ads; using TwinCAT.Ads;
using TwinCAT.Ads.SumCommand;
using TwinCAT.Ads.TypeSystem; using TwinCAT.Ads.TypeSystem;
using TwinCAT.TypeSystem; using TwinCAT.TypeSystem;
@@ -24,40 +26,182 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
private readonly AdsClient _client = new(); private readonly AdsClient _client = new();
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new(); private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
// Per-parent-symbol RMW locks. Keys are bounded by the writable-bit-tag cardinality
// and are intentionally never removed — a leaking-but-bounded dictionary is simpler
// than tracking liveness, matching the AbCip / Modbus / FOCAS pattern from #181.
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitWriteLocks = new();
// PR 2.2 — handle cache. Per-tag read/write resolves a symbolic path to an ADS
// variable handle once, then issues every subsequent op against the handle. Smaller
// AMS payloads (4-byte handle vs N-byte path) + skips name resolution in the runtime.
// Lifetime is process-scoped: cleared on reconnect (EnsureConnected path), wiped on
// a Symbol-Version-Invalid retry, and disposed on Dispose. PR 2.3 will wire a
// proactive Symbol Version invalidation listener so stale handles after an online
// change get evicted before the next read fails — until then, operators can call
// FlushOptionalCachesAsync to wipe manually.
private readonly ConcurrentDictionary<string, uint> _handleCache = new();
private bool _wasConnected;
private readonly object _connectionStateGate = new();
// PR 2.3 — proactive Symbol-Version invalidation listener. The Beckhoff stack
// surfaces a high-level <see cref="AdsClient.AdsSymbolVersionChanged"/> event
// (built on top of the SymbolVersion ADS notification, IndexGroup 0xF008) that
// fires when the PLC's symbol table version counter increments — i.e. on full
// re-initialisations after a download / activate. Registered after the AMS
// session is up so the device server actually accepts the registration; we
// unregister + clear the handle on Dispose. _symbolVersionRegistered guards
// against double-registration if EnsureSymbolVersionListenerAsync is called
// re-entrantly through ConnectAsync on a reconnect.
//
// Spec deviation: the original PR 2.3 plan called for a raw
// AddDeviceNotificationAsync(AdsReservedIndexGroup.SymbolVersion, ...). Beckhoff
// wrap that in IAdsSymbolChangedProvider on AdsClient so we get a typed
// <see cref="AdsSymbolVersionChangedEventArgs"/> + Dispose-aware unregister
// for free — same wire effect, smaller surface area.
private bool _symbolVersionRegistered;
private long _symbolVersionBumps;
// Test-only counter — number of CreateVariableHandleAsync calls actually issued
// (i.e. cache misses). Integration tests assert this stays at the unique-symbol
// count after a second pass over the same set.
internal int HandleCreateCount;
/// <summary>Test-only — current size of the handle cache.</summary>
internal int HandleCacheCount => _handleCache.Count;
/// <summary>Test-only — total Symbol-Version bumps observed since process start.</summary>
internal long SymbolVersionBumps => Interlocked.Read(ref _symbolVersionBumps);
public AdsTwinCATClient() public AdsTwinCATClient()
{ {
_client.AdsNotificationEx += OnAdsNotificationEx; _client.AdsNotificationEx += OnAdsNotificationEx;
_client.AdsSymbolVersionChanged += OnAdsSymbolVersionChanged;
} }
public bool IsConnected => _client.IsConnected; public bool IsConnected => _client.IsConnected;
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken) public async Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{ {
if (_client.IsConnected) return Task.CompletedTask; if (_client.IsConnected)
{
// Idempotent. Still ensure the Symbol-Version listener is registered — first
// ConnectAsync may have lost the registration if the AMS session dropped.
await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false);
return;
}
_client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds); _client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
var netId = AmsNetId.Parse(address.NetId); var netId = AmsNetId.Parse(address.NetId);
// PR 2.2 — a fresh AMS session invalidates every cached handle (handle space is
// per-session in the ADS device server). Clear before reconnect so any read that
// raced with a transient drop never reuses a stale handle from the prior session.
// Note: the handles for the prior session are gone with that session — no need to
// call DeleteVariableHandleAsync, which would just fail with a transport error.
var wasConnected = false;
lock (_connectionStateGate)
{
wasConnected = _wasConnected;
_wasConnected = false;
}
if (wasConnected || !_handleCache.IsEmpty)
_handleCache.Clear();
// PR 2.3 — a reconnect drops the device-side notification registration. Mark
// the listener as needing re-registration so EnsureSymbolVersionListenerAsync
// re-arms it against the new session.
_symbolVersionRegistered = false;
_client.Connect(netId, address.Port); _client.Connect(netId, address.Port);
return Task.CompletedTask;
lock (_connectionStateGate) _wasConnected = _client.IsConnected;
// PR 2.3 — register the Symbol-Version listener now that the AMS session is up.
// Best-effort: a registration failure here doesn't fail the connect (the
// DeviceSymbolVersionInvalid evict-and-retry path from PR 2.2 stays as the safety
// net), it just means we won't get proactive cache invalidation until next reconnect.
await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// PR 2.3 — register the Beckhoff <c>AdsSymbolVersionChanged</c> event listener
/// against the current AMS session. Idempotent: a second call while
/// <see cref="_symbolVersionRegistered"/> is <c>true</c> is a no-op so reconnect
/// paths can call this freely without double-arming. Failures swallowed because
/// the PR 2.2 reactive evict-and-retry path is still in place — proactive
/// invalidation is an optimisation, not a correctness requirement.
/// </summary>
private async Task EnsureSymbolVersionListenerAsync(CancellationToken cancellationToken)
{
if (_symbolVersionRegistered) return;
try
{
await _client.RegisterSymbolVersionChangedAsync(OnAdsSymbolVersionChanged, cancellationToken)
.ConfigureAwait(false);
_symbolVersionRegistered = true;
}
catch (OperationCanceledException) { throw; }
catch
{
// Best-effort. The reactive evict-and-retry path (PR 2.2) catches the same
// staleness; this is just an optimisation that lets us preempt the wasted
// request that would otherwise come back DeviceSymbolVersionInvalid.
}
}
/// <summary>
/// PR 2.3 — Beckhoff fires this when the PLC's symbol-version counter increments,
/// which happens on every full re-initialisation (download, activate-config, etc.).
/// Every cached handle is invalid against the new symbol table, so we wipe the
/// cache here. In-flight reads that already hold a handle will fall through to the
/// PR 2.2 <see cref="AdsErrorCode.DeviceSymbolVersionInvalid"/> evict-and-retry path,
/// which is exactly what we want — the proactive wipe just preempts the wasted
/// round-trip on the next read for any symbol that didn't already have an in-flight op.
/// </summary>
private void OnAdsSymbolVersionChanged(object? sender, AdsSymbolVersionChangedEventArgs e)
{
Interlocked.Increment(ref _symbolVersionBumps);
// Snapshot cache for best-effort wire-side cleanup, then clear so the next
// EnsureHandleAsync re-resolves. Wire deletes are fire-and-forget — the device
// server has already invalidated these handles, so the deletes typically just
// bounce back with an error code we don't care about.
var snapshot = _handleCache.ToArray();
_handleCache.Clear();
foreach (var kv in snapshot)
{
try { _ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None); }
catch { /* best-effort; the new symbol-table version makes these handles dead anyway */ }
}
} }
public async Task<(object? value, uint status)> ReadValueAsync( public async Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, string symbolPath,
TwinCATDataType type, TwinCATDataType type,
int? bitIndex, int? bitIndex,
int[]? arrayDimensions,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
try try
{ {
var clrType = MapToClrType(type); var clrType = MapToClrType(type);
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken) var readType = IsWholeArray(arrayDimensions) ? clrType.MakeArrayType() : clrType;
// PR 2.2 — handle-based read. EnsureHandleAsync resolves through the cache;
// SymbolVersionInvalid evicts + retries once with a fresh handle.
var (rawValue, errorCode) = await ReadByHandleWithRetryAsync(symbolPath, readType, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (errorCode != AdsErrorCode.NoError)
return (null, TwinCATStatusMapper.MapAdsError((uint)errorCode));
if (result.ErrorCode != AdsErrorCode.NoError) var value = rawValue;
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode)); if (IsWholeArray(arrayDimensions))
{
value = PostProcessArray(type, value);
return (value, TwinCATStatusMapper.Good);
}
var value = result.Value;
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool) if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit); value = ExtractBit(value, bit);
value = PostProcessIecTime(type, value);
return (value, TwinCATStatusMapper.Good); return (value, TwinCATStatusMapper.Good);
} }
@@ -67,25 +211,139 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
} }
} }
/// <summary>
/// Resolve <paramref name="symbolPath"/> to a cached ADS variable handle (or create one
/// on first use) and dispatch a <see cref="AdsClient.ReadAnyAsync(uint, Type, CancellationToken)"/>.
/// On <see cref="AdsErrorCode.DeviceSymbolVersionInvalid"/> evicts the cached handle
/// + retries once with a freshly-created handle — covers the online-change race where
/// the symbol survives but its descriptor moves.
/// </summary>
private async Task<(object? value, AdsErrorCode errorCode)> ReadByHandleWithRetryAsync(
string symbolPath, Type readType, CancellationToken cancellationToken)
{
var handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
var result = await _client.ReadAnyAsync(handle, readType, cancellationToken).ConfigureAwait(false);
if (result.ErrorCode == AdsErrorCode.DeviceSymbolVersionInvalid)
{
EvictHandle(symbolPath);
handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
result = await _client.ReadAnyAsync(handle, readType, cancellationToken).ConfigureAwait(false);
}
return (result.Value, result.ErrorCode);
}
/// <summary>
/// Mirror of <see cref="ReadByHandleWithRetryAsync"/> for writes. Returns the final
/// <see cref="AdsErrorCode"/>; the caller maps that to an OPC UA status.
/// </summary>
private async Task<AdsErrorCode> WriteByHandleWithRetryAsync(
string symbolPath, object value, CancellationToken cancellationToken)
{
var handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
var result = await _client.WriteAnyAsync(handle, value, cancellationToken).ConfigureAwait(false);
if (result.ErrorCode == AdsErrorCode.DeviceSymbolVersionInvalid)
{
EvictHandle(symbolPath);
handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
result = await _client.WriteAnyAsync(handle, value, cancellationToken).ConfigureAwait(false);
}
return result.ErrorCode;
}
/// <summary>
/// Lookup-or-create the cached ADS handle for <paramref name="symbolPath"/>. The
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> guarantees publication safety,
/// but two concurrent callers on a cold key may both call
/// <see cref="AdsClient.CreateVariableHandleAsync(string, CancellationToken)"/>.
/// The loser's handle leaks for the lifetime of the process — acceptable cost
/// given how narrow the race window is, and matched by the libplctag / S7 driver
/// handle-cache patterns.
/// </summary>
internal async ValueTask<uint> EnsureHandleAsync(string symbolPath, CancellationToken cancellationToken)
{
if (_handleCache.TryGetValue(symbolPath, out var existing))
return existing;
Interlocked.Increment(ref HandleCreateCount);
var result = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
throw new AdsErrorException(
$"CreateVariableHandleAsync failed for '{symbolPath}'", result.ErrorCode);
// GetOrAdd on a hit returns the winning handle; a loser-side DeleteVariableHandle here
// would race against an in-flight read using that handle elsewhere in this method, so
// we accept the small leak (one-time, per cold key) instead.
return _handleCache.GetOrAdd(symbolPath, result.Handle);
}
/// <summary>
/// Evict a single cached handle. Best-effort delete on the wire — the runtime may
/// already have invalidated the handle (Symbol-Version-Invalid path), so we swallow
/// transport / ADS errors here.
/// </summary>
private void EvictHandle(string symbolPath)
{
if (!_handleCache.TryRemove(symbolPath, out var handle)) return;
try
{
// Fire-and-forget delete — the cache key is gone, the wire-side cleanup is
// strictly courtesy. If the device server is in a state where the handle is
// already dead, the delete will fail and we don't care.
_ = _client.DeleteVariableHandleAsync(handle, CancellationToken.None);
}
catch
{
// Best-effort.
}
}
private static bool IsWholeArray(int[]? arrayDimensions) =>
arrayDimensions is { Length: > 0 } && arrayDimensions.All(d => d > 0);
/// <summary>Apply per-element IEC TIME/DATE post-processing to a flat array result.</summary>
private static object? PostProcessArray(TwinCATDataType type, object? value)
{
if (value is not Array arr) return value;
var elementProjector = type switch
{
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
or TwinCATDataType.Date or TwinCATDataType.DateTime
=> (Func<object?, object?>)(v => PostProcessIecTime(type, v)),
_ => null,
};
if (elementProjector is null) return arr;
// IEC time post-processing changes the CLR element type (uint -> TimeSpan / DateTime).
// Project into an object[] so the array element type matches the projected values.
var projected = new object?[arr.Length];
for (var i = 0; i < arr.Length; i++)
projected[i] = elementProjector(arr.GetValue(i));
return projected;
}
public async Task<uint> WriteValueAsync( public async Task<uint> WriteValueAsync(
string symbolPath, string symbolPath,
TwinCATDataType type, TwinCATDataType type,
int? bitIndex, int? bitIndex,
int[]? arrayDimensions,
object? value, object? value,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (bitIndex is int && type == TwinCATDataType.Bool) if (IsWholeArray(arrayDimensions))
throw new NotSupportedException( return TwinCATStatusMapper.BadNotSupported; // PR-1.4 ships read-only whole-array
"BOOL-within-word writes require read-modify-write; tracked in task #181.");
if (bitIndex is int bit && type == TwinCATDataType.Bool)
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
.ConfigureAwait(false);
try try
{ {
var converted = ConvertForWrite(type, value); var converted = ConvertForWrite(type, value);
var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken) // PR 2.2 — handle-based write with SymbolVersionInvalid evict-and-retry.
var errorCode = await WriteByHandleWithRetryAsync(symbolPath, converted, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return result.ErrorCode == AdsErrorCode.NoError return errorCode == AdsErrorCode.NoError
? TwinCATStatusMapper.Good ? TwinCATStatusMapper.Good
: TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode); : TwinCATStatusMapper.MapAdsError((uint)errorCode);
} }
catch (AdsErrorException ex) catch (AdsErrorException ex)
{ {
@@ -93,6 +351,71 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
} }
} }
/// <summary>
/// Read-modify-write a single bit within an integer parent word. <paramref name="symbolPath"/>
/// is the bit-selector path (e.g. <c>Flags.3</c>); the parent is the same path with the
/// <c>.N</c> suffix stripped and is read/written as a UDINT — TwinCAT handles narrower
/// parents (BYTE/WORD) implicitly through the UDINT projection.
/// </summary>
/// <remarks>
/// Concurrent bit writers against the same parent are serialised through a per-parent
/// <see cref="SemaphoreSlim"/> to prevent torn reads/writes. Mirrors the AbCip / Modbus /
/// FOCAS bit-RMW pattern.
/// </remarks>
private async Task<uint> WriteBitInWordAsync(
string symbolPath, int bit, object? value, CancellationToken cancellationToken)
{
var parentPath = TryGetParentSymbolPath(symbolPath);
if (parentPath is null) return TwinCATStatusMapper.BadNotSupported;
var setBit = Convert.ToBoolean(value);
var rmwLock = _bitWriteLocks.GetOrAdd(parentPath, _ => new SemaphoreSlim(1, 1));
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// PR 2.2 — RMW round-trip flows through the same handle cache so that the
// parent word's resolved handle is reused on subsequent bit writes too.
var (rawCurrent, readErr) = await ReadByHandleWithRetryAsync(parentPath, typeof(uint), cancellationToken)
.ConfigureAwait(false);
if (readErr != AdsErrorCode.NoError)
return TwinCATStatusMapper.MapAdsError((uint)readErr);
var current = Convert.ToUInt32(rawCurrent ?? 0u);
var updated = ApplyBit(current, bit, setBit);
var writeErr = await WriteByHandleWithRetryAsync(parentPath, updated, cancellationToken)
.ConfigureAwait(false);
return writeErr == AdsErrorCode.NoError
? TwinCATStatusMapper.Good
: TwinCATStatusMapper.MapAdsError((uint)writeErr);
}
catch (AdsErrorException ex)
{
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
}
finally
{
rmwLock.Release();
}
}
/// <summary>
/// Strip the trailing <c>.N</c> bit selector from a TwinCAT symbol path. Returns
/// <c>null</c> when the path has no parent (single segment / leading dot).
/// </summary>
internal static string? TryGetParentSymbolPath(string symbolPath)
{
var dot = symbolPath.LastIndexOf('.');
return dot <= 0 ? null : symbolPath.Substring(0, dot);
}
/// <summary>Set or clear bit <paramref name="bit"/> in <paramref name="word"/>.</summary>
internal static uint ApplyBit(uint word, int bit, bool setBit)
{
var mask = 1u << bit;
return setBit ? (word | mask) : (word & ~mask);
}
public async Task<bool> ProbeAsync(CancellationToken cancellationToken) public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{ {
try try
@@ -143,6 +466,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var value = args.Value; var value = args.Value;
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool) if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit); value = ExtractBit(value, bit);
value = PostProcessIecTime(reg.Type, value);
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ } try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
} }
@@ -166,12 +490,50 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
foreach (ISymbol symbol in loader.Symbols) foreach (ISymbol symbol in loader.Symbols)
{ {
if (cancellationToken.IsCancellationRequested) yield break; if (cancellationToken.IsCancellationRequested) yield break;
var mapped = MapSymbolTypeName(symbol.DataType?.Name); var mapped = ResolveSymbolDataType(symbol.DataType);
var readOnly = !IsSymbolWritable(symbol); var readOnly = !IsSymbolWritable(symbol);
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly); yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
} }
} }
/// <summary>
/// Resolve an IEC atomic <see cref="TwinCATDataType"/> for a TwinCAT symbol's data type.
/// ENUMs surface as their underlying integer (the enum's <c>BaseType</c>); ALIAS chains
/// are walked recursively via <see cref="IAliasType.BaseType"/> until an atomic primitive
/// is reached. POINTER / REFERENCE / INTERFACE / UNION / STRUCT / FB / array types remain
/// out of scope and surface as <c>null</c> so the caller skips them.
/// </summary>
/// <remarks>
/// Recursion is bounded at <see cref="MaxAliasDepth"/> as a defence against pathological
/// cycles in the type graph — TwinCAT shouldn't emit those, but this is cheap insurance.
/// </remarks>
internal const int MaxAliasDepth = 16;
internal static TwinCATDataType? ResolveSymbolDataType(IDataType? dataType)
{
var current = dataType;
for (var depth = 0; current is not null && depth < MaxAliasDepth; depth++)
{
switch (current.Category)
{
case DataTypeCategory.Primitive:
case DataTypeCategory.String:
return MapSymbolTypeName(current.Name);
case DataTypeCategory.Enum:
case DataTypeCategory.Alias:
// IEnumType : IAliasType, so BaseType walk handles both. For an enum the
// base type is the underlying integer; for alias chains it's the next link.
if (current is IAliasType alias) { current = alias.BaseType; continue; }
return null;
default:
// POINTER / REFERENCE / INTERFACE / UNION / STRUCT / ARRAY / FB / Program —
// explicitly out of scope at this PR.
return null;
}
}
return null;
}
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
{ {
"BOOL" or "BIT" => TwinCATDataType.Bool, "BOOL" or "BIT" => TwinCATDataType.Bool,
@@ -203,13 +565,180 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return true; return true;
} }
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
{
if (reads.Count == 0) return Array.Empty<(object?, uint)>();
// PR 2.2 deviation: bulk path stays on symbolic Sum-command (SumInstancePathAnyTypeRead /
// SumWriteBySymbolPath). Beckhoff also exposes SumHandleRead / SumWriteByHandle, but
// wiring the cached handles into them changes the request layout substantially +
// would either need to reuse handles created on the per-tag path (tying lifetimes)
// or maintain a parallel handle batch — neither pulls weight against PR 2.1's already
// 10-20× win. Tracked as a follow-up for the Phase-2 perf sweep.
// Build the (path, AnyTypeSpecifier) request envelope. SumInstancePathAnyTypeRead
// batches all paths into a single ADS Sum-read round-trip (IndexGroup 0xF080 = read
// multiple items by symbol name with ANY-type marshalling).
var typeSpecs = new List<(string instancePath, AnyTypeSpecifier spec)>(reads.Count);
foreach (var r in reads)
typeSpecs.Add((r.SymbolPath, BuildAnyTypeSpecifier(r.Type, r.StringLength)));
var sumCmd = new SumInstancePathAnyTypeRead(_client, typeSpecs);
try
{
var sumResult = await sumCmd.ReadAsync(cancellationToken).ConfigureAwait(false);
// ResultSumValues2.ValueResults is a per-item array with Source / Value /
// ErrorCode. Even when the overall ADS request succeeds, individual sub-items can
// carry their own ADS error (e.g. SymbolNotFound).
var output = new (object? value, uint status)[reads.Count];
var valueResults = sumResult.ValueResults;
for (var i = 0; i < reads.Count; i++)
{
var vr = valueResults[i];
if (vr.ErrorCode != 0)
{
output[i] = (null, TwinCATStatusMapper.MapAdsError((uint)vr.ErrorCode));
continue;
}
var raw = vr.Value;
output[i] = (PostProcessIecTime(reads[i].Type, raw), TwinCATStatusMapper.Good);
}
return output;
}
catch (AdsErrorException ex)
{
// Whole-batch failure (no symbol-server ack, router unreachable, etc.). Map the
// overall ADS status onto every entry so callers see uniform status — partial-
// success marshalling lives in the success branch above.
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
var failed = new (object? value, uint status)[reads.Count];
for (var i = 0; i < reads.Count; i++) failed[i] = (null, status);
return failed;
}
}
public async Task<IReadOnlyList<uint>> WriteValuesAsync(
IReadOnlyList<TwinCATBulkWriteItem> writes, CancellationToken cancellationToken)
{
if (writes.Count == 0) return Array.Empty<uint>();
// SumWriteBySymbolPath internally requests symbol handles + issues a single sum-write
// (IndexGroup 0xF081) carrying all values. One AMS round-trip for N writes.
var paths = new List<string>(writes.Count);
var values = new object[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
paths.Add(writes[i].SymbolPath);
values[i] = ConvertForWrite(writes[i].Type, writes[i].Value);
}
var sumCmd = new SumWriteBySymbolPath(_client, paths);
try
{
var result = await sumCmd.WriteAsync(values, cancellationToken).ConfigureAwait(false);
var output = new uint[writes.Count];
var subErrors = result.SubErrors;
for (var i = 0; i < writes.Count; i++)
{
// SubErrors can be null when the overall request failed before sub-dispatch —
// surface the OverallError on every slot in that case.
var code = subErrors is { Length: > 0 } && i < subErrors.Length
? (uint)subErrors[i]
: (uint)result.ErrorCode;
output[i] = TwinCATStatusMapper.MapAdsError(code);
}
return output;
}
catch (AdsErrorException ex)
{
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
var failed = new uint[writes.Count];
for (var i = 0; i < writes.Count; i++) failed[i] = status;
return failed;
}
}
/// <summary>
/// Build an <see cref="AnyTypeSpecifier"/> for one bulk-read entry. STRING uses ASCII +
/// the supplied <paramref name="stringLength"/>; WSTRING uses Unicode (UTF-16). All other
/// types resolve to a primitive CLR type via <see cref="MapToClrType"/>. IEC time/date
/// symbols flow as their underlying UDINT (matching the per-tag path in
/// <see cref="ReadValueAsync"/>) and are post-processed CLR-side after the sum-read.
/// </summary>
private static AnyTypeSpecifier BuildAnyTypeSpecifier(TwinCATDataType type, int stringLength) =>
type switch
{
TwinCATDataType.String => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.ASCII),
TwinCATDataType.WString => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.Unicode),
_ => new AnyTypeSpecifier(MapToClrType(type)),
};
public void Dispose() public void Dispose()
{ {
_client.AdsNotificationEx -= OnAdsNotificationEx; _client.AdsNotificationEx -= OnAdsNotificationEx;
// PR 2.3 — unregister the Symbol-Version listener. Best-effort: by the time we're
// disposing, the AMS session is already shutting down so the device server may
// refuse the unregister. Either way, AdsClient.Dispose tears the underlying
// notification subscription down regardless.
if (_symbolVersionRegistered)
{
try { _client.UnregisterSymbolVersionChanged(OnAdsSymbolVersionChanged); }
catch { /* best-effort */ }
_symbolVersionRegistered = false;
}
_client.AdsSymbolVersionChanged -= OnAdsSymbolVersionChanged;
_notifications.Clear(); _notifications.Clear();
// PR 2.2 — release every cached handle on the wire as a good citizen. Best-effort
// and bounded to a short window so a hung router doesn't block process shutdown:
// each delete is fire-and-forget, errors swallowed. The session itself is about to
// tear down anyway, so the device server will reclaim everything regardless.
foreach (var kv in _handleCache)
{
try
{
_ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None);
}
catch
{
// Per-entry failures are expected on a closing connection.
}
}
_handleCache.Clear();
_client.Dispose(); _client.Dispose();
} }
/// <summary>
/// PR 2.2 — flush all process-scoped optional caches (handle cache today). A
/// proactive Symbol Version invalidation listener arrives in PR 2.3 — until then,
/// operators / 2.3-aware callers can wipe the cache manually after a known online
/// change.
/// </summary>
public Task FlushOptionalCachesAsync()
{
// Best-effort delete on the wire — a held handle won't survive a redeploy anyway,
// but cleaning up matches the Dispose convention.
var snapshot = _handleCache.ToArray();
_handleCache.Clear();
foreach (var kv in snapshot)
{
try
{
_ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None);
}
catch
{
// Best-effort.
}
}
return Task.CompletedTask;
}
private sealed class NotificationRegistration( private sealed class NotificationRegistration(
string symbolPath, string symbolPath,
TwinCATDataType type, TwinCATDataType type,
@@ -249,7 +778,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
_ => typeof(int), _ => typeof(int),
}; };
private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch internal static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
{ {
TwinCATDataType.Bool => Convert.ToBoolean(value), TwinCATDataType.Bool => Convert.ToBoolean(value),
TwinCATDataType.SInt => Convert.ToSByte(value), TwinCATDataType.SInt => Convert.ToSByte(value),
@@ -263,11 +792,79 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
TwinCATDataType.Real => Convert.ToSingle(value), TwinCATDataType.Real => Convert.ToSingle(value),
TwinCATDataType.LReal => Convert.ToDouble(value), TwinCATDataType.LReal => Convert.ToDouble(value),
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty, TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
TwinCATDataType.Time or TwinCATDataType.Date // IEC durations (TIME / TOD) accept TimeSpan / Duration-as-Double-ms / raw UDINT.
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value), // IEC timestamps (DATE / DT) accept DateTime (UTC) / raw UDINT seconds-since-epoch.
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DurationToUDInt(value),
TwinCATDataType.Date or TwinCATDataType.DateTime => DateTimeToUDInt(value),
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."), _ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
}; };
// IEC 61131-3 epoch is 1970-01-01 UTC for DATE / DT; TIME / TOD are unsigned ms counters.
private static readonly DateTime IecEpochUtc = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>
/// Convert the raw UDINT wire value for IEC TIME/DATE/DT/TOD into the native CLR type
/// surfaced upstream — TimeSpan for durations, DateTime (UTC) for timestamps. Other
/// types pass through unchanged.
/// </summary>
internal static object? PostProcessIecTime(TwinCATDataType type, object? value)
{
if (value is null) return null;
var raw = TryGetUInt32(value);
if (raw is null) return value;
return type switch
{
// TIME / TOD — UDINT milliseconds.
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
=> TimeSpan.FromMilliseconds(raw.Value),
// DT — UDINT seconds since 1970-01-01 UTC.
TwinCATDataType.DateTime
=> IecEpochUtc.AddSeconds(raw.Value),
// DATE — UDINT seconds since 1970-01-01 UTC, but TwinCAT runtimes pin the time
// component to midnight; pass through the same conversion so we get a date-only
// value at midnight UTC.
TwinCATDataType.Date
=> IecEpochUtc.AddSeconds(raw.Value),
_ => value,
};
}
private static uint? TryGetUInt32(object value) => value switch
{
uint u => u,
int i when i >= 0 => (uint)i,
ushort us => (uint)us,
short s when s >= 0 => (uint)s,
long l when l >= 0 && l <= uint.MaxValue => (uint)l,
ulong ul when ul <= uint.MaxValue => (uint)ul,
_ => null,
};
private static uint DurationToUDInt(object? value) => value switch
{
TimeSpan ts => (uint)Math.Max(0, ts.TotalMilliseconds),
// OPC UA Duration on the wire is a Double in milliseconds.
double d => (uint)Math.Max(0, d),
float f => (uint)Math.Max(0, f),
_ => Convert.ToUInt32(value),
};
private static uint DateTimeToUDInt(object? value)
{
if (value is DateTime dt)
{
var utc = dt.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
: dt.ToUniversalTime();
var seconds = (long)(utc - IecEpochUtc).TotalSeconds;
if (seconds < 0 || seconds > uint.MaxValue)
throw new ArgumentOutOfRangeException(nameof(value),
"DATE/DT value out of UDINT epoch range (1970-01-01..2106-02-07 UTC).");
return (uint)seconds;
}
return Convert.ToUInt32(value);
}
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
{ {
short s => (s & (1 << bit)) != 0, short s => (s & (1 << bit)) != 0,

View File

@@ -22,25 +22,64 @@ public interface ITwinCATClient : IDisposable
/// <summary> /// <summary>
/// Read a symbolic value. Returns a boxed .NET value matching the requested /// Read a symbolic value. Returns a boxed .NET value matching the requested
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the /// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good). /// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good). When
/// <paramref name="arrayDimensions"/> is non-null + non-empty, the symbol is treated
/// as a whole-array read and the boxed value is a flat 1-D CLR
/// <see cref="Array"/> sized to <c>product(arrayDimensions)</c>.
/// </summary> /// </summary>
Task<(object? value, uint status)> ReadValueAsync( Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, string symbolPath,
TwinCATDataType type, TwinCATDataType type,
int? bitIndex, int? bitIndex,
int[]? arrayDimensions,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Write a symbolic value. Returns the mapped OPC UA status for the operation /// Write a symbolic value. Returns the mapped OPC UA status for the operation
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>). /// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
/// <paramref name="arrayDimensions"/> mirrors <see cref="ReadValueAsync"/>; PR-1.4
/// ships read-only whole-array support so writers may surface <c>BadNotSupported</c>.
/// </summary> /// </summary>
Task<uint> WriteValueAsync( Task<uint> WriteValueAsync(
string symbolPath, string symbolPath,
TwinCATDataType type, TwinCATDataType type,
int? bitIndex, int? bitIndex,
int[]? arrayDimensions,
object? value, object? value,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Bulk-read N scalar symbols in a single AMS request via Beckhoff's ADS Sum-command
/// family (IndexGroup <c>0xF080..0xF084</c>). The result is a parallel array preserving
/// <paramref name="reads"/> ordering — element <c>i</c>'s outcome maps to request <c>i</c>.
/// Empty input returns an empty result without a wire round-trip.
/// </summary>
/// <remarks>
/// <para>This is the throughput-optimised path used by <see cref="TwinCATDriver.ReadAsync"/>
/// to replace the per-tag <see cref="ReadValueAsync"/> loop — one ADS sum-read for N
/// symbols beats N individual round-trips by ~10× on the typical PLC link.</para>
///
/// <para>Whole-array reads + bit-extracted BOOL reads stay on the per-tag path because
/// the Sum-command surface only marshals scalars + bitIndex needs CLR-side post-processing.
/// Callers should pre-filter or fall back as appropriate.</para>
/// </remarks>
Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads,
CancellationToken cancellationToken);
/// <summary>
/// Bulk-write N scalar symbols in a single AMS request via Beckhoff's
/// <c>SumWriteBySymbolPath</c>. Result is a parallel status array preserving
/// <paramref name="writes"/> ordering. Empty input returns an empty result.
/// </summary>
/// <remarks>
/// Whole-array writes + bit-RMW writes are not in scope for the bulk path — those continue
/// through the per-tag <see cref="WriteValueAsync"/> path. The driver layer pre-filters.
/// </remarks>
Task<IReadOnlyList<uint>> WriteValuesAsync(
IReadOnlyList<TwinCATBulkWriteItem> writes,
CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable. /// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop. /// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
@@ -75,6 +114,16 @@ public interface ITwinCATClient : IDisposable
/// decide whether to drill in via their own walker. /// decide whether to drill in via their own walker.
/// </summary> /// </summary>
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken); IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
/// <summary>
/// PR 2.2 — wipe process-scoped optional caches (today: the ADS variable-handle
/// cache backing per-tag reads / writes). Surfaces so operators + the future PR
/// 2.3 Symbol-Version invalidation listener can flush stale handles after a known
/// online change without forcing a full reconnect. Safe to call mid-traffic — in-flight
/// reads continue to use the handles they already hold; the next read for a symbol
/// will re-resolve. Best-effort wire-side delete; failures are swallowed.
/// </summary>
Task FlushOptionalCachesAsync();
} }
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary> /// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
@@ -98,3 +147,19 @@ public interface ITwinCATClientFactory
{ {
ITwinCATClient Create(); ITwinCATClient Create();
} }
/// <summary>One element of an <see cref="ITwinCATClient.ReadValuesAsync"/> request — the symbol path
/// + the IEC type for marshalling. Strings carry an explicit <paramref name="StringLength"/> for
/// fixed-size <c>STRING(n)</c> declarations (defaults to <c>80</c> matching IEC 61131-3).</summary>
public sealed record TwinCATBulkReadItem(
string SymbolPath,
TwinCATDataType Type,
int StringLength = 80);
/// <summary>One element of an <see cref="ITwinCATClient.WriteValuesAsync"/> request.
/// Mirror of <see cref="TwinCATBulkReadItem"/> with the value to push.</summary>
public sealed record TwinCATBulkWriteItem(
string SymbolPath,
TwinCATDataType Type,
object? Value,
int StringLength = 80);

View File

@@ -37,12 +37,16 @@ public static class TwinCATDataTypeExtensions
TwinCATDataType.SInt or TwinCATDataType.USInt TwinCATDataType.SInt or TwinCATDataType.USInt
or TwinCATDataType.Int or TwinCATDataType.UInt or TwinCATDataType.Int or TwinCATDataType.UInt
or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32, or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32,
TwinCATDataType.LInt or TwinCATDataType.ULInt => DriverDataType.Int32, // matches Int64 gap TwinCATDataType.LInt => DriverDataType.Int64,
TwinCATDataType.ULInt => DriverDataType.UInt64,
TwinCATDataType.Real => DriverDataType.Float32, TwinCATDataType.Real => DriverDataType.Float32,
TwinCATDataType.LReal => DriverDataType.Float64, TwinCATDataType.LReal => DriverDataType.Float64,
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String, TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
TwinCATDataType.Time or TwinCATDataType.Date // IEC 61131-3 TIME / TOD are durations (ms); DATE / DT are absolute timestamps.
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.Int32, // The wire form is UDINT but the driver post-processes into TimeSpan / DateTime so the
// address space surfaces native UA Duration / DateTime instead of opaque integers.
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DriverDataType.Duration,
TwinCATDataType.Date or TwinCATDataType.DateTime => DriverDataType.DateTime,
TwinCATDataType.Structure => DriverDataType.String, TwinCATDataType.Structure => DriverDataType.String,
_ => DriverDataType.Int32, _ => DriverDataType.Int32,
}; };

View File

@@ -108,6 +108,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// ---- IReadable ---- // ---- IReadable ----
/// <summary>
/// Read the supplied tag references in as few AMS round-trips as possible.
/// Tags resolved to the same <c>DeviceHostAddress</c> are bucketed + sent as one
/// ADS Sum-read (<see cref="ITwinCATClient.ReadValuesAsync"/>) — N tags in one
/// request beats N individual <c>ReadValueAsync</c> calls by ~10× for typical PLC
/// loads. Tags with bit-extracted BOOL or whole-array shape stay on the per-tag
/// path because the sum-read surface only marshals scalars.
/// </summary>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync( public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken) IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{ {
@@ -115,6 +123,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count]; var results = new DataValueSnapshot[fullReferences.Count];
// Resolve tag definitions + bucket bulk-eligible reads by device. Anything that
// doesn't fit the bulk surface (unknown ref, bit BOOL, whole-array) is processed
// through the per-tag path inline so we still return a full result array in
// request order.
var bulkBuckets = new Dictionary<string, List<(int origIndex, string symbol, TwinCATTagDefinition def, int? bitIndex)>>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < fullReferences.Count; i++) for (var i = 0; i < fullReferences.Count; i++)
{ {
var reference = fullReferences[i]; var reference = fullReferences[i];
@@ -123,31 +137,66 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now); results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
continue; continue;
} }
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) if (!_devices.TryGetValue(def.DeviceHostAddress, out _))
{ {
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now); results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
continue; continue;
} }
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
var bitIndex = parsed?.BitIndex;
var isWholeArray = def.ArrayDimensions is { Length: > 0 };
var isBitBool = bitIndex is int && def.DataType == TwinCATDataType.Bool;
if (isWholeArray || isBitBool)
{
// Per-tag fallback path — preserves bit-extract / whole-array logic in
// AdsTwinCATClient.ReadValueAsync.
results[i] = await ReadOneAsync(reference, def, symbolName, bitIndex, cancellationToken, now)
.ConfigureAwait(false);
continue;
}
if (!bulkBuckets.TryGetValue(def.DeviceHostAddress, out var bucket))
{
bucket = new List<(int, string, TwinCATTagDefinition, int?)>();
bulkBuckets[def.DeviceHostAddress] = bucket;
}
bucket.Add((i, symbolName, def, bitIndex));
}
// One sum-read per device bucket. Ordering inside a bucket is preserved by the
// (origIndex, ...) tuple — the result array entry comes from the parallel index.
foreach (var (hostAddress, bucket) in bulkBuckets)
{
if (!_devices.TryGetValue(hostAddress, out var device)) continue;
try try
{ {
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var items = new TwinCATBulkReadItem[bucket.Count];
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; for (var k = 0; k < bucket.Count; k++)
var (value, status) = await client.ReadValueAsync( items[k] = new TwinCATBulkReadItem(bucket[k].symbol, bucket[k].def.DataType);
symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now); var bulk = await client.ReadValuesAsync(items, cancellationToken).ConfigureAwait(false);
if (status == TwinCATStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null); for (var k = 0; k < bucket.Count; k++)
else {
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, var (origIndex, _, def, _) = bucket[k];
$"ADS status {status:X8} reading {reference}"); var (value, status) = bulk[k];
results[origIndex] = new DataValueSnapshot(value, status, now, now);
if (status == TwinCATStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
else
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"ADS status {status:X8} reading {fullReferences[origIndex]}");
}
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now); foreach (var (origIndex, _, _, _) in bucket)
results[origIndex] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
} }
} }
@@ -155,14 +204,53 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
return results; return results;
} }
private async Task<DataValueSnapshot> ReadOneAsync(
string reference, TwinCATTagDefinition def, string symbolName, int? bitIndex,
CancellationToken cancellationToken, DateTime timestamp)
{
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
return new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, timestamp);
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, bitIndex, def.ArrayDimensions, cancellationToken).ConfigureAwait(false);
if (status == TwinCATStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, timestamp, null);
else
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"ADS status {status:X8} reading {reference}");
return new DataValueSnapshot(value, status, timestamp, timestamp);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, timestamp);
}
}
// ---- IWritable ---- // ---- IWritable ----
/// <summary>
/// Write the supplied requests, bucketing scalar writes by device + dispatching
/// each bucket as one ADS Sum-write. Bit-RMW BOOL writes + whole-array writes use
/// the per-tag <see cref="ITwinCATClient.WriteValueAsync"/> path so the per-parent
/// RMW lock stays in play.
/// </summary>
public async Task<IReadOnlyList<WriteResult>> WriteAsync( public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken) IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(writes); ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count]; var results = new WriteResult[writes.Count];
// Bucket scalar writes by device. Bit-BOOL + whole-array writes route through the
// per-tag fallback below.
var bulkBuckets = new Dictionary<string, List<(int origIndex, string symbol, TwinCATTagDefinition def, object? value)>>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < writes.Count; i++) for (var i = 0; i < writes.Count; i++)
{ {
var w = writes[i]; var w = writes[i];
@@ -176,38 +264,68 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable); results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable);
continue; continue;
} }
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) if (!_devices.TryGetValue(def.DeviceHostAddress, out _))
{ {
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown); results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
continue; continue;
} }
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
var bitIndex = parsed?.BitIndex;
var isWholeArray = def.ArrayDimensions is { Length: > 0 };
var isBitBool = bitIndex is int && def.DataType == TwinCATDataType.Bool;
if (isWholeArray || isBitBool)
{
results[i] = await WriteOneAsync(def, symbolName, bitIndex, w.Value, cancellationToken)
.ConfigureAwait(false);
continue;
}
if (!bulkBuckets.TryGetValue(def.DeviceHostAddress, out var bucket))
{
bucket = new List<(int, string, TwinCATTagDefinition, object?)>();
bulkBuckets[def.DeviceHostAddress] = bucket;
}
bucket.Add((i, symbolName, def, w.Value));
}
foreach (var (hostAddress, bucket) in bulkBuckets)
{
if (!_devices.TryGetValue(hostAddress, out var device)) continue;
try try
{ {
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var items = new TwinCATBulkWriteItem[bucket.Count];
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; for (var k = 0; k < bucket.Count; k++)
var status = await client.WriteValueAsync( items[k] = new TwinCATBulkWriteItem(bucket[k].symbol, bucket[k].def.DataType, bucket[k].value);
symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(status); var bulk = await client.WriteValuesAsync(items, cancellationToken).ConfigureAwait(false);
for (var k = 0; k < bucket.Count; k++)
results[bucket[k].origIndex] = new WriteResult(bulk[k]);
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (NotSupportedException nse)
{
results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException) catch (Exception ex) when (ex is FormatException or InvalidCastException)
{ {
results[i] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch); foreach (var (origIndex, _, _, _) in bucket)
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
} }
catch (OverflowException) catch (OverflowException)
{ {
results[i] = new WriteResult(TwinCATStatusMapper.BadOutOfRange); foreach (var (origIndex, _, _, _) in bucket)
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
}
catch (NotSupportedException nse)
{
foreach (var (origIndex, _, _, _) in bucket)
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError); foreach (var (origIndex, _, _, _) in bucket)
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
} }
} }
@@ -215,6 +333,40 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
return results; return results;
} }
private async Task<WriteResult> WriteOneAsync(
TwinCATTagDefinition def, string symbolName, int? bitIndex, object? value, CancellationToken cancellationToken)
{
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
return new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var status = await client.WriteValueAsync(
symbolName, def.DataType, bitIndex, def.ArrayDimensions, value, cancellationToken).ConfigureAwait(false);
return new WriteResult(status);
}
catch (OperationCanceledException) { throw; }
catch (NotSupportedException nse)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
return new WriteResult(TwinCATStatusMapper.BadNotSupported);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
return new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
}
catch (OverflowException)
{
return new WriteResult(TwinCATStatusMapper.BadOutOfRange);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return new WriteResult(TwinCATStatusMapper.BadCommunicationError);
}
}
// ---- ITagDiscovery ---- // ---- ITagDiscovery ----
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
@@ -231,11 +383,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice) foreach (var tag in tagsForDevice)
{ {
var (isArray, arrayDim) = ResolveArrayShape(tag.ArrayDimensions);
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name, FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(), DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: false, IsArray: isArray,
ArrayDim: null, ArrayDim: arrayDim,
SecurityClass: tag.Writable SecurityClass: tag.Writable
? SecurityClassification.Operate ? SecurityClassification.Operate
: SecurityClassification.ViewOnly, : SecurityClassification.ViewOnly,
@@ -310,6 +463,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{ {
if (!_tagsByName.TryGetValue(reference, out var def)) continue; if (!_tagsByName.TryGetValue(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue; if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
// Whole-array tags don't fit the per-element AdsNotificationEx callback shape —
// skip the native path so the OPC UA layer falls through to a polled snapshot.
if (def.ArrayDimensions is { Length: > 0 }) continue;
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
@@ -428,6 +584,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
return device.Client; return device.Client;
} }
/// <summary>
/// Project a TwinCAT <see cref="TwinCATTagDefinition.ArrayDimensions"/> shape onto the
/// core <see cref="DriverAttributeInfo"/> 1-D surface. Multi-dim arrays flatten to the
/// product element count — the OPC UA address-space layer surfaces the rank via its own
/// <c>ArrayDimensions</c> metadata at variable build time.
/// </summary>
internal static (bool isArray, uint? arrayDim) ResolveArrayShape(int[]? dimensions)
{
if (dimensions is null || dimensions.Length == 0) return (false, null);
long product = 1;
foreach (var d in dimensions)
{
if (d <= 0) return (false, null); // invalid shape; surface as scalar to fail safe
product *= d;
if (product > uint.MaxValue) return (true, uint.MaxValue);
}
return (true, (uint)product);
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);

View File

@@ -43,8 +43,12 @@ public sealed record TwinCATDeviceOptions(
string? DeviceName = null); string? DeviceName = null);
/// <summary> /// <summary>
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT /// One TwinCAT-backed OPC UA variable. <c>SymbolPath</c> is the full TwinCAT symbolic name
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>). /// (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>). When
/// <c>ArrayDimensions</c> is non-null + non-empty the symbol is treated as a whole-array
/// read of <c>product(dims)</c> elements rather than a single scalar — PR-1.4 ships read-
/// only whole-array support; multi-dim shapes flatten to the product on the wire and the
/// OPC UA layer reflects the rank via its own <c>ArrayDimensions</c> metadata.
/// </summary> /// </summary>
public sealed record TwinCATTagDefinition( public sealed record TwinCATTagDefinition(
string Name, string Name,
@@ -52,7 +56,8 @@ public sealed record TwinCATTagDefinition(
string SymbolPath, string SymbolPath,
TwinCATDataType DataType, TwinCATDataType DataType,
bool Writable = true, bool Writable = true,
bool WriteIdempotent = false); bool WriteIdempotent = false,
int[]? ArrayDimensions = null);
public sealed class TwinCATProbeOptions public sealed class TwinCATProbeOptions
{ {

View File

@@ -26,6 +26,9 @@
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests"/> <InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests"/>
<!-- PR 2.2 — integration tier needs visibility into AdsTwinCATClient + its
HandleCreateCount / HandleCacheCount counters to assert the live cache. -->
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -178,6 +178,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex), NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex), BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(displayName), DisplayName = new LocalizedText(displayName),
// Per Task #231 — surface the driver-supplied tag description as the OPC UA
// Description attribute on the Variable node. Drivers that don't carry
// descriptions pass null, leaving Description unset (the stack defaults to
// an empty LocalizedText, matching prior behaviour).
Description = string.IsNullOrEmpty(attributeInfo.Description)
? null
: new LocalizedText(attributeInfo.Description),
DataType = MapDataType(attributeInfo.DriverDataType), DataType = MapDataType(attributeInfo.DriverDataType),
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar, ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
// Historized attributes get the HistoryRead access bit so the stack dispatches // Historized attributes get the HistoryRead access bit so the stack dispatches
@@ -310,6 +317,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
DriverDataType.Float64 => DataTypeIds.Double, DriverDataType.Float64 => DataTypeIds.Double,
DriverDataType.String => DataTypeIds.String, DriverDataType.String => DataTypeIds.String,
DriverDataType.DateTime => DataTypeIds.DateTime, DriverDataType.DateTime => DataTypeIds.DateTime,
DriverDataType.Duration => DataTypeIds.Duration,
_ => DataTypeIds.BaseDataType, _ => DataTypeIds.BaseDataType,
}; };

View File

@@ -0,0 +1,76 @@
using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// PR abcip-3.2 — wall-clock comparison of Symbolic vs Logical reads on a running
/// <c>ab_server</c> (or a real ControlLogix). Skipped when <c>ab_server</c> isn't
/// reachable, same gating rule as <see cref="AbCipReadSmokeTests"/>.
/// </summary>
/// <remarks>
/// <para>This is a <em>scaffold</em>: it builds + runs against the existing test fixture,
/// but the libplctag .NET 1.5.x wrapper does not yet expose a public knob for instance-ID
/// addressing (see <c>docs/drivers/AbCip-Performance.md</c> §"Addressing mode"). On a live
/// fixture the two paths therefore measure the same wire behaviour today; the assertion
/// just sanity-checks that both modes complete + produce well-formed snapshots, with timing
/// emitted to the test output for inspection. When the wrapper exposes the attribute
/// publicly (or libplctag native gains hot-update of cip_addr) the assertion can be
/// tightened to require Logical &lt; Symbolic on N-tag scans.</para>
///
/// <para>Marked <c>[Trait("Category", "Bench")]</c> so a future <c>--filter</c> rule can
/// opt out of bench tests in CI runs that only want the smoke set.</para>
/// </remarks>
[Trait("Category", "Bench")]
[Trait("Requires", "AbServer")]
public sealed class AbCipAddressingModeBenchTests
{
[AbServerFact]
public async Task Symbolic_and_Logical_modes_both_read_seeded_DInt_and_emit_timing()
{
var profile = KnownProfiles.ControlLogix;
var fixture = new AbServerFixture(profile);
await fixture.InitializeAsync();
try
{
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
var symElapsed = await ReadOnceAsync(deviceUri, profile.Family, AddressingMode.Symbolic);
var logElapsed = await ReadOnceAsync(deviceUri, profile.Family, AddressingMode.Logical);
// Wall-clock timing is captured for human inspection; the assertion just confirms
// both completed. The actual symbolic-vs-logical comparison is qualitative until
// the libplctag wrapper exposes logical-segment addressing publicly — see class doc.
Console.WriteLine($"Symbolic read elapsed: {symElapsed.TotalMilliseconds:F2} ms");
Console.WriteLine($"Logical read elapsed: {logElapsed.TotalMilliseconds:F2} ms");
symElapsed.ShouldBeGreaterThan(TimeSpan.Zero);
logElapsed.ShouldBeGreaterThan(TimeSpan.Zero);
}
finally
{
await fixture.DisposeAsync();
}
}
private static async Task<TimeSpan> ReadOnceAsync(string deviceUri, AbCipPlcFamily family, AddressingMode mode)
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(deviceUri, family, AddressingMode: mode)],
Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
Timeout = TimeSpan.FromSeconds(5),
}, $"drv-bench-{mode}");
await drv.InitializeAsync("{}", CancellationToken.None);
var sw = Stopwatch.StartNew();
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
sw.Stop();
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
return sw.Elapsed;
}
}

View File

@@ -0,0 +1,100 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
/// <summary>
/// PR abcip-3.3 — golden-box-tier MultiPacket read-strategy test against Logix Emulate.
/// Exercises the sparse-UDT case the strategy is designed for: a 50-member UDT instance
/// where the OPC UA client subscribed to 5 members. Asserts the driver routes the read
/// through the MultiPacket planner (<see cref="AbCipDriver.DeviceState.MultiPacketGroupsExecuted"/>
/// counter increments) and returns Good StatusCodes for every member.
/// </summary>
/// <remarks>
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c> for
/// the L5X export that seeds this; ship the project once Emulate is on the integration
/// host):</para>
/// <list type="bullet">
/// <item>UDT <c>Tank_50</c> with 50 DINT members <c>M0</c>..<c>M49</c> — a deliberately
/// oversized UDT so a 5-member subscription is sparse enough for the
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/> default of 0.25 to
/// pick MultiPacket.</item>
/// <item>Controller-scope tag <c>Tank1 : Tank_50</c> with each <c>M{i}</c> seeded to
/// <c>i * 10</c> so each subscribed member returns a distinct value.</item>
/// </list>
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. With the default ab_server the
/// test skips cleanly — ab_server lacks UDT / Multi-Service-Packet emulation depth so a
/// wire-level pass against it would be vacuous regardless. Note: the libplctag .NET
/// wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling, so the
/// driver's MultiPacket runtime today issues N member reads sequentially. The planner-tier
/// dispatch is what's under test here — the wire-level bundling lands when the upstream
/// wrapper exposes the 0x0A service primitive (see
/// <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy").</para>
/// </remarks>
[Collection("AbServerEmulate")]
[Trait("Category", "Integration")]
[Trait("Tier", "Emulate")]
public sealed class AbCipEmulateMultiPacketReadTests
{
[AbServerFact]
public async Task Sparse_5_of_50_member_subscription_dispatches_through_MultiPacket()
{
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
?? throw new InvalidOperationException(
"AB_SERVER_ENDPOINT must be set to the Logix Emulate instance " +
"(e.g. '10.0.0.42:44818') when AB_SERVER_PROFILE=emulate.");
// Build a 50-member declared UDT — the planner needs the full member set to compute
// the subscribed-fraction in the Auto heuristic and to place MultiPacket member offsets.
var members = new AbCipStructureMember[50];
for (var i = 0; i < 50; i++)
members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt);
var options = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(
HostAddress: $"ab://{endpoint}/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
ReadStrategy: ReadStrategy.MultiPacket)],
Tags = [
new AbCipTagDefinition(
Name: "Tank1",
DeviceHostAddress: $"ab://{endpoint}/1,0",
TagPath: "Tank1",
DataType: AbCipDataType.Structure,
Members: members),
],
Timeout = TimeSpan.FromSeconds(5),
};
await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-multipacket-smoke");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Sparse pick: 5 of 50 = 0.10 < default threshold 0.25 → MultiPacket planner. Force
// the strategy explicitly above so the test isn't sensitive to threshold drift.
var refs = new[] { "Tank1.M0", "Tank1.M3", "Tank1.M7", "Tank1.M22", "Tank1.M49" };
var snapshots = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(5);
foreach (var s in snapshots) s.StatusCode.ShouldBe(AbCipStatusMapper.Good);
// Plan-stats counter assertion — the device-level counter increments once per parent
// UDT routed through the MultiPacket path. Sibling counter for WholeUdt must stay zero.
var deviceState = drv.GetDeviceState($"ab://{endpoint}/1,0");
deviceState.ShouldNotBeNull();
deviceState!.MultiPacketGroupsExecuted.ShouldBeGreaterThan(0);
deviceState.WholeUdtGroupsExecuted.ShouldBe(0);
// Sanity-check the seeded values land at the right indices: M{i} == i * 10 in the
// emulate fixture's startup routine.
Convert.ToInt32(snapshots[0].Value).ShouldBe(0);
Convert.ToInt32(snapshots[1].Value).ShouldBe(30);
Convert.ToInt32(snapshots[2].Value).ShouldBe(70);
Convert.ToInt32(snapshots[3].Value).ShouldBe(220);
Convert.ToInt32(snapshots[4].Value).ShouldBe(490);
}
}

View File

@@ -0,0 +1,375 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-3.2 — coverage for the per-device <c>AddressingMode</c> toggle.
/// Asserts (a) <see cref="AddressingMode.Auto"/> resolves to
/// <see cref="AddressingMode.Symbolic"/> at the device level, (b) explicit
/// <see cref="AddressingMode.Logical"/> threads through every
/// <see cref="AbCipTagCreateParams"/> the driver builds, (c) Logical against an unsupported
/// family (Micro800) emits a warning + falls back to Symbolic, (d) the Driver-config DTO
/// round-trips the mode, and (e) family compatibility is captured by
/// <see cref="AbCipPlcFamilyProfile.SupportsLogicalAddressing"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipAddressingModeTests
{
// ---- Auto resolves to Symbolic ----
[Fact]
public async Task Default_AddressingMode_resolves_to_Symbolic_on_DeviceState()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
[Fact]
public async Task Auto_AddressingMode_resolves_to_Symbolic_on_DeviceState()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Auto),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
// ---- Logical threads through to AbCipTagCreateParams ----
[Fact]
public async Task Logical_AddressingMode_threads_through_into_create_params()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory,
enumeratorFactory: new EmptyEnumeratorFactoryStub());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Logical);
}
[Fact]
public async Task Symbolic_AddressingMode_explicitly_set_threads_through()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Symbolic),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
// ---- Logical against unsupported family falls back with warning ----
[Fact]
public async Task Logical_on_Micro800_falls_back_to_Symbolic_with_warning()
{
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.6/",
PlcFamily: AbCipPlcFamily.Micro800,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.6/")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Micro800");
warnings[0].ShouldContain("Logical");
}
[Fact]
public async Task Logical_on_Micro800_carries_Symbolic_into_create_params()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.6/",
PlcFamily: AbCipPlcFamily.Micro800,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.6/", "Speed", AbCipDataType.DInt),
],
OnWarning = _ => { },
}, "drv-1", tagFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
[Fact]
public async Task Logical_on_ControlLogix_does_not_warn()
{
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
warnings.ShouldBeEmpty();
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Logical);
}
// ---- Family-profile compatibility flags ----
[Fact]
public void Family_profiles_advertise_logical_support_correctly()
{
AbCipPlcFamilyProfile.ControlLogix.SupportsLogicalAddressing.ShouldBeTrue();
AbCipPlcFamilyProfile.CompactLogix.SupportsLogicalAddressing.ShouldBeTrue();
AbCipPlcFamilyProfile.GuardLogix.SupportsLogicalAddressing.ShouldBeTrue();
AbCipPlcFamilyProfile.Micro800.SupportsLogicalAddressing.ShouldBeFalse();
}
// ---- DTO round-trip ----
[Fact]
public async Task DTO_round_trips_AddressingMode_Logical_through_config_json()
{
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"AddressingMode": "Logical"
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
await drv.InitializeAsync(json, CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Logical);
}
[Fact]
public async Task DTO_round_trips_AddressingMode_Symbolic_through_config_json()
{
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"AddressingMode": "Symbolic"
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
await drv.InitializeAsync(json, CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
[Fact]
public async Task DTO_omitted_AddressingMode_falls_back_to_Auto_then_Symbolic()
{
// No AddressingMode in JSON → DTO field is null → factory parses fallback Auto →
// device-level resolution lands on Symbolic.
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix"
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
await drv.InitializeAsync(json, CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
// ---- Logical-mode triggers a one-time symbol walk ----
[Fact]
public async Task Logical_mode_first_read_triggers_symbol_walk_once()
{
var enumStub = new RecordingEnumeratorFactory(
new AbCipDiscoveredTag("Speed", null, AbCipDataType.DInt, false),
new AbCipDiscoveredTag("Counter", null, AbCipDataType.DInt, false));
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Counter", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory, enumeratorFactory: enumStub);
await drv.InitializeAsync("{}", CancellationToken.None);
// First read fires the walk
await drv.ReadAsync(["Speed"], CancellationToken.None);
// Second read must NOT walk again
await drv.ReadAsync(["Counter"], CancellationToken.None);
enumStub.CreateCount.ShouldBe(1);
var device = drv.GetDeviceState("ab://10.0.0.5/1,0")!;
device.LogicalWalkComplete.ShouldBeTrue();
device.LogicalInstanceMap.ShouldContainKey("Speed");
device.LogicalInstanceMap.ShouldContainKey("Counter");
}
[Fact]
public async Task Symbolic_mode_does_not_trigger_symbol_walk()
{
var enumStub = new RecordingEnumeratorFactory();
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Symbolic),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory, enumeratorFactory: enumStub);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
enumStub.CreateCount.ShouldBe(0);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.LogicalWalkComplete.ShouldBeFalse();
}
// ---- Stubs ----
private sealed class EmptyEnumeratorFactoryStub : IAbCipTagEnumeratorFactory
{
public IAbCipTagEnumerator Create() => new EmptyStub();
private sealed class EmptyStub : IAbCipTagEnumerator
{
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
public void Dispose() { }
}
}
private sealed class RecordingEnumeratorFactory : IAbCipTagEnumeratorFactory
{
private readonly AbCipDiscoveredTag[] _seed;
public int CreateCount;
public RecordingEnumeratorFactory(params AbCipDiscoveredTag[] seed) => _seed = seed;
public IAbCipTagEnumerator Create()
{
CreateCount++;
return new SeededStub(_seed);
}
private sealed class SeededStub(AbCipDiscoveredTag[] seed) : IAbCipTagEnumerator
{
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var tag in seed)
yield return tag;
await Task.CompletedTask;
}
public void Dispose() { }
}
}
}

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,329 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-3.1 — coverage for the per-device CIP <c>ConnectionSize</c> override.
/// Asserts (a) the value flows from <see cref="AbCipDeviceOptions"/> into every
/// <see cref="AbCipTagCreateParams"/> the driver builds, (b) the family default kicks in
/// when the override is unset, (c) values outside the Kepware-supported range are rejected
/// at <c>InitializeAsync</c>, and (d) the legacy-firmware warning fires when a CompactLogix
/// narrow-cap device is configured above 511 bytes.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipConnectionSizeTests
{
// ---- options threading ----
[Fact]
public async Task Custom_ConnectionSize_flows_from_device_options_into_create_params()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
ConnectionSize: 1500),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition(
Name: "Speed",
DeviceHostAddress: "ab://10.0.0.5/1,0",
TagPath: "Speed",
DataType: AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(1500);
}
[Fact]
public async Task Unset_ConnectionSize_falls_back_to_ControlLogix_family_default()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.ConnectionSize
.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(4002);
}
[Fact]
public async Task Unset_ConnectionSize_falls_back_to_CompactLogix_family_default()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.CompactLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(504);
}
[Fact]
public async Task Unset_ConnectionSize_falls_back_to_Micro800_family_default()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.6/", AbCipPlcFamily.Micro800)],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.6/", "Speed", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(488);
}
// ---- range validation ----
[Theory]
[InlineData(499)]
[InlineData(0)]
[InlineData(-1)]
[InlineData(4003)]
[InlineData(10000)]
public async Task Out_of_range_ConnectionSize_throws_at_InitializeAsync(int badSize)
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
ConnectionSize: badSize),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => drv.InitializeAsync("{}", CancellationToken.None));
ex.Message.ShouldContain("ConnectionSize");
}
[Theory]
[InlineData(500)]
[InlineData(504)]
[InlineData(2000)]
[InlineData(4002)]
public async Task In_range_ConnectionSize_initialises_cleanly(int goodSize)
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
ConnectionSize: goodSize),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(goodSize);
}
// ---- legacy-firmware warning ----
[Fact]
public async Task Oversized_ConnectionSize_on_CompactLogix_emits_legacy_warning()
{
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.CompactLogix,
ConnectionSize: 1500),
],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("CompactLogix");
warnings[0].ShouldContain("1500");
warnings[0].ShouldContain("Forward Open");
}
[Fact]
public async Task Within_legacy_cap_on_CompactLogix_does_not_warn()
{
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.CompactLogix,
ConnectionSize: 504),
],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
warnings.ShouldBeEmpty();
}
[Fact]
public async Task Oversized_ConnectionSize_on_ControlLogix_does_not_warn()
{
// ControlLogix profile default is 4002 (Large Forward Open) — the warning is only
// meaningful when the family default is in the legacy-cap bucket. FW20+ ControlLogix
// happily accepts 1500-byte connections, so no warning fires.
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
ConnectionSize: 1500),
],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
warnings.ShouldBeEmpty();
}
[Fact]
public async Task Oversized_ConnectionSize_on_Micro800_emits_legacy_warning()
{
// Micro800 default is 488 (well under the legacy cap), so any over-511 override
// triggers the same family-mismatch warning.
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.6/",
PlcFamily: AbCipPlcFamily.Micro800,
ConnectionSize: 1000),
],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Micro800");
}
// ---- DeviceState resolved ConnectionSize ----
[Fact]
public async Task DeviceState_ConnectionSize_reflects_override_when_set()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix, ConnectionSize: 2000),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(2000);
}
[Fact]
public async Task DeviceState_ConnectionSize_reflects_family_default_when_unset()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.CompactLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(504);
}
// ---- AbCipConnectionSize constants ----
[Fact]
public void Constants_match_documented_Kepware_range()
{
AbCipConnectionSize.Min.ShouldBe(500);
AbCipConnectionSize.Max.ShouldBe(4002);
AbCipConnectionSize.LegacyFirmwareCap.ShouldBe(511);
}
// ---- DriverConfig DTO path (DriverFactoryRegistry-bound deployments) ----
[Fact]
public async Task Driver_factory_threads_ConnectionSize_through_config_json()
{
// The bootstrapper-driven path deserialises driver config from JSON in the central
// DB (sp_PublishGeneration → DriverInstance.DriverConfig). The DTO must surface
// ConnectionSize so production deployments don't lose the override at the wire.
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"ConnectionSize": 1500
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
// CreateInstance returns a fully-built driver; we kick InitializeAsync to surface the
// resolved DeviceState.ConnectionSize.
await drv.InitializeAsync(json, CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(1500);
}
}

View File

@@ -0,0 +1,179 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task #231 — verifies that tag/member descriptions parsed from L5K and L5X exports thread
/// through <see cref="AbCipTagDefinition.Description"/> /
/// <see cref="AbCipStructureMember.Description"/> + land on
/// <see cref="DriverAttributeInfo.Description"/> on the produced address-space variables, so
/// downstream OPC UA Variable nodes carry the source-project comment as their Description
/// attribute.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDescriptionThreadingTests
{
private const string DeviceHost = "ab://10.0.0.5/1,0";
[Fact]
public void L5kParser_captures_member_description_from_attribute_block()
{
const string body = """
DATATYPE MyUdt
MEMBER Speed : DINT (Description := "Belt speed in RPM");
END_DATATYPE
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var member = doc.DataTypes.Single().Members.Single();
member.Name.ShouldBe("Speed");
member.Description.ShouldBe("Belt speed in RPM");
}
[Fact]
public void L5xParser_captures_member_description_child_node()
{
const string xml = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller>
<DataTypes>
<DataType Name="MyUdt">
<Members>
<Member Name="Speed" DataType="DINT" Dimension="0" ExternalAccess="Read/Write">
<Description><![CDATA[Belt speed in RPM]]></Description>
</Member>
</Members>
</DataType>
</DataTypes>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(xml));
doc.DataTypes.Single().Members.Single().Description.ShouldBe("Belt speed in RPM");
}
[Fact]
public void L5kIngest_threads_tag_and_member_descriptions_into_AbCipTagDefinition()
{
const string body = """
DATATYPE MotorBlock
MEMBER Speed : DINT (Description := "Setpoint RPM");
MEMBER Status : DINT;
END_DATATYPE
TAG
Motor1 : MotorBlock (Description := "Conveyor motor 1") := [];
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var tag = result.Tags.Single();
tag.Description.ShouldBe("Conveyor motor 1");
tag.Members.ShouldNotBeNull();
var members = tag.Members!.ToDictionary(m => m.Name);
members["Speed"].Description.ShouldBe("Setpoint RPM");
members["Status"].Description.ShouldBeNull();
}
[Fact]
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_atomic_tag()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(DeviceHost)],
Tags =
[
new AbCipTagDefinition(
Name: "Speed",
DeviceHostAddress: DeviceHost,
TagPath: "Motor1.Speed",
DataType: AbCipDataType.DInt,
Description: "Belt speed in RPM"),
new AbCipTagDefinition(
Name: "NoDescription",
DeviceHostAddress: DeviceHost,
TagPath: "X",
DataType: AbCipDataType.DInt),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
.ShouldBe("Belt speed in RPM");
// Tags without descriptions leave Info.Description null (back-compat path).
builder.Variables.Single(v => v.BrowseName == "NoDescription").Info.Description
.ShouldBeNull();
}
[Fact]
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_UDT_members()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(DeviceHost)],
Tags =
[
new AbCipTagDefinition(
Name: "Motor1",
DeviceHostAddress: DeviceHost,
TagPath: "Motor1",
DataType: AbCipDataType.Structure,
Members:
[
new AbCipStructureMember(
Name: "Speed",
DataType: AbCipDataType.DInt,
Description: "Setpoint RPM"),
new AbCipStructureMember(
Name: "Status",
DataType: AbCipDataType.DInt),
]),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
.ShouldBe("Setpoint RPM");
builder.Variables.Single(v => v.BrowseName == "Status").Info.Description
.ShouldBeNull();
}
// ---- helpers ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}

View File

@@ -165,6 +165,55 @@ public sealed class AbCipDriverReadTests
p.TagName.ShouldBe("Program:P.Counter"); p.TagName.ShouldBe("Program:P.Counter");
} }
[Fact]
public async Task Slice_tag_reads_one_array_and_decodes_n_elements()
{
// PR abcip-1.3 — `Data[0..3]` slice routes through AbCipArrayReadPlanner: one libplctag
// tag-create at TagName="Data[0]" with ElementCount=4, single PLC read, contiguous
// buffer decoded at element stride into one snapshot whose Value is an object?[].
var (drv, factory) = NewDriver(
new AbCipTagDefinition("DataSlice", "ab://10.0.0.5/1,0", "Data[0..3]", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p =>
{
var t = new FakeAbCipTag(p);
t.ValuesByOffset[0] = 10;
t.ValuesByOffset[4] = 20;
t.ValuesByOffset[8] = 30;
t.ValuesByOffset[12] = 40;
return t;
};
var snapshots = await drv.ReadAsync(["DataSlice"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
var values = snapshots.Single().Value.ShouldBeOfType<object?[]>();
values.ShouldBe(new object?[] { 10, 20, 30, 40 });
// Exactly ONE libplctag tag was created — anchored at the slice start with
// ElementCount=4. Without the planner this would have been four scalar reads.
factory.Tags.Count.ShouldBe(1);
factory.Tags.ShouldContainKey("Data[0]");
factory.Tags["Data[0]"].CreationParams.ElementCount.ShouldBe(4);
factory.Tags["Data[0]"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Slice_tag_with_unsupported_element_type_returns_BadNotSupported()
{
// BOOL slices can't be laid out from the declaration alone (Logix packs BOOLs into a
// hidden host byte). The planner refuses; the driver surfaces BadNotSupported instead
// of attempting a best-effort decode.
var (drv, _) = NewDriver(
new AbCipTagDefinition("BoolSlice", "ab://10.0.0.5/1,0", "Flags[0..7]", AbCipDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["BoolSlice"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
snapshots.Single().Value.ShouldBeNull();
}
[Fact] [Fact]
public async Task Cancellation_propagates_from_read() public async Task Cancellation_propagates_from_read()
{ {
@@ -211,4 +260,79 @@ public sealed class AbCipDriverReadTests
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags["Nope"].Disposed.ShouldBeTrue(); factory.Tags["Nope"].Disposed.ShouldBeTrue();
} }
// PR abcip-1.2 — STRINGnn variant decoding. Threading <see cref="AbCipTagDefinition.StringLength"/>
// through libplctag's StringMaxCapacity attribute lets STRING_20 / STRING_40 / STRING_80 UDTs
// decode against the right DATA-array size; null preserves the default 82-byte STRING.
[Fact]
public async Task StringLength_threads_into_TagCreateParams_StringMaxCapacity()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Banner", "ab://10.0.0.5/1,0", "Banner", AbCipDataType.String,
StringLength: 40));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = "hello" };
await drv.ReadAsync(["Banner"], CancellationToken.None);
factory.Tags["Banner"].CreationParams.StringMaxCapacity.ShouldBe(40);
}
[Fact]
public async Task StringLength_null_leaves_StringMaxCapacity_null_for_back_compat()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("LegacyStr", "ab://10.0.0.5/1,0", "LegacyStr", AbCipDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = "world" };
await drv.ReadAsync(["LegacyStr"], CancellationToken.None);
factory.Tags["LegacyStr"].CreationParams.StringMaxCapacity.ShouldBeNull();
}
[Fact]
public async Task StringLength_ignored_for_non_String_data_types()
{
// StringLength on a DINT-typed tag must not flow into StringMaxCapacity — libplctag would
// otherwise re-shape the buffer and corrupt the read. EnsureTagRuntimeAsync gates on the
// declared DataType.
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt,
StringLength: 80));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 7 };
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.StringMaxCapacity.ShouldBeNull();
}
[Fact]
public async Task UDT_member_StringLength_threads_through_to_member_runtime()
{
// STRINGnn members of a UDT — declaration-driven fan-out copies StringLength from
// AbCipStructureMember onto the synthesised member AbCipTagDefinition; the per-member
// runtime then receives the right StringMaxCapacity.
var udt = new AbCipTagDefinition(
Name: "Recipe",
DeviceHostAddress: "ab://10.0.0.5/1,0",
TagPath: "Recipe",
DataType: AbCipDataType.Structure,
Members: [
new AbCipStructureMember("Name", AbCipDataType.String, StringLength: 20),
new AbCipStructureMember("Description", AbCipDataType.String, StringLength: 80),
new AbCipStructureMember("Code", AbCipDataType.DInt),
]);
var (drv, factory) = NewDriver(udt);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = "x" };
await drv.ReadAsync(["Recipe.Name", "Recipe.Description", "Recipe.Code"], CancellationToken.None);
factory.Tags["Recipe.Name"].CreationParams.StringMaxCapacity.ShouldBe(20);
factory.Tags["Recipe.Description"].CreationParams.StringMaxCapacity.ShouldBe(80);
factory.Tags["Recipe.Code"].CreationParams.StringMaxCapacity.ShouldBeNull();
}
} }

View File

@@ -124,8 +124,11 @@ public sealed class AbCipDriverTests
{ {
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean); AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32); AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
AbCipDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
AbCipDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32); AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64); AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String); AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
AbCipDataType.Dt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
} }
} }

View File

@@ -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();
}
}
}
}

View File

@@ -0,0 +1,412 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-3.3 — coverage for the per-device <see cref="ReadStrategy"/> selector. Three
/// resolution layers under test: (a) <see cref="AbCipDriver.ResolveReadStrategy"/> at
/// device init (MultiPacket-against-Micro800 fall-back, plain pass-through otherwise),
/// (b) <see cref="AbCipMultiPacketReadPlanner.ChooseStrategyForGroup"/> sparsity heuristic
/// (Auto-mode dispatch), (c) end-to-end <see cref="AbCipDriver.ReadAsync"/> dispatch
/// verified by the per-device WholeUdt / MultiPacket counters.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipReadStrategyTests
{
private const string Device = "ab://10.0.0.5/1,0";
private const string Micro = "ab://10.0.0.6/";
// ---- Device init resolution ----
[Fact]
public async Task Default_ReadStrategy_resolves_to_Auto_on_DeviceState()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.Auto);
}
[Fact]
public async Task User_forced_WholeUdt_passes_through_init()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix,
ReadStrategy: ReadStrategy.WholeUdt)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
}
[Fact]
public async Task User_forced_MultiPacket_on_ControlLogix_passes_through_init()
{
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix,
ReadStrategy: ReadStrategy.MultiPacket)],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket);
warnings.ShouldBeEmpty();
}
[Fact]
public async Task User_forced_MultiPacket_on_Micro800_falls_back_to_WholeUdt_with_warning()
{
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800,
ReadStrategy: ReadStrategy.MultiPacket)],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Micro800");
warnings[0].ShouldContain("Multi-Service Packet");
}
[Fact]
public async Task Auto_on_Micro800_stays_Auto_at_init_planner_caps_to_WholeUdt_per_batch()
{
// Auto resolution does not warn on non-packing families — the per-batch planner caps
// the strategy to WholeUdt at dispatch time. Keeping Auto here means a future PR can
// change the family-cap policy in one place without touching device init.
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800,
ReadStrategy: ReadStrategy.Auto)],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.Auto);
warnings.ShouldBeEmpty();
}
// ---- Heuristic ----
[Fact]
public void Heuristic_picks_MultiPacket_when_subscribed_fraction_below_threshold()
{
// 5 of 50 subscribed = 0.10, threshold = 0.25 → MultiPacket
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(5, 50, 0.25).ShouldBe(ReadStrategy.MultiPacket);
}
[Fact]
public void Heuristic_picks_WholeUdt_when_subscribed_fraction_above_threshold()
{
// 40 of 50 subscribed = 0.80, threshold = 0.25 → WholeUdt
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(40, 50, 0.25).ShouldBe(ReadStrategy.WholeUdt);
}
[Fact]
public void Heuristic_at_threshold_boundary_picks_WholeUdt()
{
// Strictly less than → MultiPacket; equal → WholeUdt. Deterministic boundary behaviour
// so tests can pin exact picks without hand-wringing about float comparison drift.
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(10, 40, 0.25).ShouldBe(ReadStrategy.WholeUdt);
}
[Fact]
public void Heuristic_with_zero_total_members_defaults_to_WholeUdt()
{
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 0, 0.25).ShouldBe(ReadStrategy.WholeUdt);
}
[Fact]
public void Heuristic_clamps_threshold_below_zero_to_zero()
{
// Negative threshold collapses to "never MultiPacket" — even a 0-of-N read picks WholeUdt.
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 10, -0.5).ShouldBe(ReadStrategy.WholeUdt);
}
[Fact]
public void Heuristic_clamps_threshold_above_one_to_one()
{
// Threshold > 1 saturates so any subscribed fraction triggers MultiPacket.
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(9, 10, 5.0).ShouldBe(ReadStrategy.MultiPacket);
}
// ---- Driver-level dispatch / counters ----
private static AbCipTagDefinition BuildLargeUdt(string name, int memberCount)
{
var members = new AbCipStructureMember[memberCount];
for (var i = 0; i < memberCount; i++)
members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt);
return new AbCipTagDefinition(name, Device, name, AbCipDataType.Structure, Members: members);
}
private static AbCipDriverOptions BuildOptions(ReadStrategy strategy, double threshold = 0.25,
AbCipPlcFamily family = AbCipPlcFamily.ControlLogix, params AbCipTagDefinition[] tags)
{
var host = family == AbCipPlcFamily.Micro800 ? Micro : Device;
// Re-bind tag DeviceHostAddress when family flips so single-test reuse keeps
// working — the supplied tags are built against Device by default.
var rebuiltTags = tags.Select(t => new AbCipTagDefinition(
t.Name, host, t.TagPath, t.DataType, t.Writable, t.WriteIdempotent,
t.Members, t.SafetyTag, t.StringLength, t.Description)).ToArray();
return new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, family, ReadStrategy: strategy,
MultiPacketSparsityThreshold: threshold)],
Probe = new AbCipProbeOptions { Enabled = false },
Tags = rebuiltTags,
};
}
[Fact]
public async Task Auto_with_sparse_subscription_dispatches_through_MultiPacket()
{
// 5 subscribed of 50 = 0.10 < 0.25 → MultiPacket
var udt = BuildLargeUdt("Tank", 50);
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt);
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(options, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray();
await drv.ReadAsync(refs, CancellationToken.None);
var state = drv.GetDeviceState(Device)!;
state.MultiPacketGroupsExecuted.ShouldBe(1);
state.WholeUdtGroupsExecuted.ShouldBe(0);
}
[Fact]
public async Task Auto_with_dense_subscription_dispatches_through_WholeUdt()
{
// 40 subscribed of 50 = 0.80 > 0.25 → WholeUdt
var udt = BuildLargeUdt("Tank", 50);
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt);
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(options, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray();
await drv.ReadAsync(refs, CancellationToken.None);
var state = drv.GetDeviceState(Device)!;
state.WholeUdtGroupsExecuted.ShouldBe(1);
state.MultiPacketGroupsExecuted.ShouldBe(0);
}
[Fact]
public async Task User_forced_MultiPacket_dispatches_through_MultiPacket_regardless_of_density()
{
// 40-of-50 dense reads still hit MultiPacket when the user forces it.
var udt = BuildLargeUdt("Tank", 50);
var options = BuildOptions(ReadStrategy.MultiPacket, threshold: 0.25, tags: udt);
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(options, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray();
await drv.ReadAsync(refs, CancellationToken.None);
var state = drv.GetDeviceState(Device)!;
state.MultiPacketGroupsExecuted.ShouldBe(1);
state.WholeUdtGroupsExecuted.ShouldBe(0);
}
[Fact]
public async Task User_forced_WholeUdt_dispatches_through_WholeUdt_regardless_of_sparsity()
{
// 1 sparse read of 50 still hits WholeUdt when the user forces it. Note: the WholeUdt
// planner demotes 1-member groups to fallback because a single member doesn't beat the
// whole-UDT-buffer cost. Verify ReadCount on the parent's runtime stays zero — the
// member runtime did the work.
var udt = BuildLargeUdt("Tank", 50);
var options = BuildOptions(ReadStrategy.WholeUdt, threshold: 0.25, tags: udt);
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(options, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Tank.M0"], CancellationToken.None);
var state = drv.GetDeviceState(Device)!;
state.MultiPacketGroupsExecuted.ShouldBe(0);
// 1-member groups skip WholeUdt grouping per the existing planner contract — the
// counter increments only when the planner emits a group, not for the per-tag fallback.
state.WholeUdtGroupsExecuted.ShouldBe(0);
}
[Fact]
public async Task Threshold_tunable_higher_value_picks_MultiPacket_for_denser_reads()
{
// 12 of 50 = 0.24, threshold = 0.5 → MultiPacket (would have been WholeUdt at 0.25).
var udt = BuildLargeUdt("Tank", 50);
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.5, tags: udt);
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(options, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var refs = Enumerable.Range(0, 12).Select(i => $"Tank.M{i}").ToArray();
await drv.ReadAsync(refs, CancellationToken.None);
drv.GetDeviceState(Device)!.MultiPacketGroupsExecuted.ShouldBe(1);
}
[Fact]
public async Task Auto_on_Micro800_caps_to_WholeUdt_even_when_sparse()
{
// Family doesn't support request packing → Auto must NEVER pick MultiPacket.
var udt = BuildLargeUdt("Tank", 50);
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25,
family: AbCipPlcFamily.Micro800, tags: udt);
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(options, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray();
await drv.ReadAsync(refs, CancellationToken.None);
var state = drv.GetDeviceState(Micro)!;
state.MultiPacketGroupsExecuted.ShouldBe(0);
state.WholeUdtGroupsExecuted.ShouldBe(1);
}
// ---- Family-profile compatibility ----
[Fact]
public void Family_profiles_advertise_request_packing_correctly()
{
AbCipPlcFamilyProfile.ControlLogix.SupportsRequestPacking.ShouldBeTrue();
AbCipPlcFamilyProfile.CompactLogix.SupportsRequestPacking.ShouldBeTrue();
AbCipPlcFamilyProfile.GuardLogix.SupportsRequestPacking.ShouldBeTrue();
AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
}
// ---- DTO round-trip ----
[Fact]
public async Task DTO_round_trips_ReadStrategy_MultiPacket_through_config_json()
{
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"ReadStrategy": "MultiPacket",
"MultiPacketSparsityThreshold": 0.5
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
await drv.InitializeAsync(json, CancellationToken.None);
var state = drv.GetDeviceState(Device)!;
state.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket);
state.Options.MultiPacketSparsityThreshold.ShouldBe(0.5);
}
[Fact]
public async Task DTO_round_trips_ReadStrategy_WholeUdt_through_config_json()
{
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"ReadStrategy": "WholeUdt"
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
await drv.InitializeAsync(json, CancellationToken.None);
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
}
[Fact]
public async Task DTO_omitted_ReadStrategy_falls_back_to_Auto_with_default_threshold()
{
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix"
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
await drv.InitializeAsync(json, CancellationToken.None);
var state = drv.GetDeviceState(Device)!;
state.ReadStrategy.ShouldBe(ReadStrategy.Auto);
state.Options.MultiPacketSparsityThreshold.ShouldBe(0.25);
}
// ---- Planner output shape (sanity) ----
[Fact]
public void MultiPacketPlanner_groups_subscribed_members_by_parent()
{
var udt = BuildLargeUdt("Tank", 50);
var tagsByName = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
{
["Tank"] = udt,
};
for (var i = 0; i < 50; i++)
{
tagsByName[$"Tank.M{i}"] = new AbCipTagDefinition(
$"Tank.M{i}", Device, $"Tank.M{i}", AbCipDataType.DInt);
}
var refs = new[] { "Tank.M0", "Tank.M3", "Tank.M7" };
var plan = AbCipMultiPacketReadPlanner.Build(refs, tagsByName);
plan.Batches.Count.ShouldBe(1);
plan.Batches[0].ParentName.ShouldBe("Tank");
plan.Batches[0].Members.Count.ShouldBe(3);
plan.Fallbacks.ShouldBeEmpty();
}
[Fact]
public void MultiPacketPlanner_does_not_demote_singletons_unlike_WholeUdt_planner()
{
// A 1-of-N read is the canonical sparse case — MultiPacket emits a Batch with one
// member where WholeUdt would demote to fallback. This is the load-bearing difference.
var udt = BuildLargeUdt("Tank", 50);
var tagsByName = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
{
["Tank"] = udt,
["Tank.M0"] = new AbCipTagDefinition("Tank.M0", Device, "Tank.M0", AbCipDataType.DInt),
};
var plan = AbCipMultiPacketReadPlanner.Build(["Tank.M0"], tagsByName);
plan.Batches.Count.ShouldBe(1);
plan.Batches[0].Members.Count.ShouldBe(1);
plan.Fallbacks.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,141 @@
using System.Runtime.CompilerServices;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Issue #233 — RebrowseAsync forces a re-walk of the controller symbol table without
/// restarting the driver. Tests cover the call-counting contract (each invocation issues
/// a fresh enumeration pass), the IDriverControl interface implementation, and that the
/// UDT template cache is dropped so stale shapes don't survive a program-download.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipRebrowseTests
{
[Fact]
public async Task RebrowseAsync_runs_enumerator_once_per_call()
{
var factory = new CountingEnumeratorFactory(
new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false));
await using var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
factory.CreateCount.ShouldBe(1);
factory.EnumerationCount.ShouldBe(1);
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
factory.CreateCount.ShouldBe(2);
factory.EnumerationCount.ShouldBe(2);
}
[Fact]
public async Task RebrowseAsync_emits_discovered_tags_through_supplied_builder()
{
var factory = new CountingEnumeratorFactory(
new AbCipDiscoveredTag("NewTag", null, AbCipDataType.DInt, ReadOnly: false));
await using var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var builder = new RecordingBuilder();
await drv.RebrowseAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.Info.FullName).ShouldContain("NewTag");
}
[Fact]
public async Task RebrowseAsync_clears_template_cache()
{
await using var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.TemplateCache.Put("ab://10.0.0.5/1,0", 42, new AbCipUdtShape("T", 4, []));
drv.TemplateCache.Count.ShouldBe(1);
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
drv.TemplateCache.Count.ShouldBe(0);
}
[Fact]
public async Task AbCipDriver_implements_IDriverControl()
{
await using var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
drv.ShouldBeAssignableTo<IDriverControl>();
}
// ---- helpers ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
/// <summary>
/// Tracks both <see cref="Create"/> calls (one per discovery / rebrowse pass) and
/// <see cref="EnumerationCount"/> (incremented when the resulting enumerator is
/// actually iterated). Two consecutive RebrowseAsync calls must bump both counters.
/// </summary>
private sealed class CountingEnumeratorFactory : IAbCipTagEnumeratorFactory
{
private readonly AbCipDiscoveredTag[] _tags;
public int CreateCount { get; private set; }
public int EnumerationCount { get; private set; }
public CountingEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
public IAbCipTagEnumerator Create()
{
CreateCount++;
return new CountingEnumerator(this);
}
private sealed class CountingEnumerator(CountingEnumeratorFactory outer) : IAbCipTagEnumerator
{
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
outer.EnumerationCount++;
await Task.CompletedTask;
foreach (var t in outer._tags) yield return t;
}
public void Dispose() { }
}
}
}

View File

@@ -123,6 +123,61 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag"); AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
} }
[Fact]
public void Slice_basic_inclusive_range()
{
var p = AbCipTagPath.TryParse("Data[0..15]");
p.ShouldNotBeNull();
p.Slice.ShouldNotBeNull();
p.Slice!.Start.ShouldBe(0);
p.Slice.End.ShouldBe(15);
p.Slice.Count.ShouldBe(16);
p.BitIndex.ShouldBeNull();
p.Segments.Single().Name.ShouldBe("Data");
p.Segments.Single().Subscripts.ShouldBeEmpty();
p.ToLibplctagName().ShouldBe("Data[0..15]");
// Slice array name omits the `..End` so libplctag sees an anchored read at the start
// index; pair with ElementCount to cover the whole range.
p.ToLibplctagSliceArrayName().ShouldBe("Data[0]");
}
[Fact]
public void Slice_with_program_scope_and_member_chain()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors.Data[3..7]");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Data"]);
p.Slice!.Start.ShouldBe(3);
p.Slice.End.ShouldBe(7);
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors.Data[3..7]");
p.ToLibplctagSliceArrayName().ShouldBe("Program:MainProgram.Motors.Data[3]");
}
[Fact]
public void Slice_zero_length_single_element_allowed()
{
// [5..5] is a one-element slice — degenerate but legal (a single read of one element).
var p = AbCipTagPath.TryParse("Data[5..5]");
p.ShouldNotBeNull();
p.Slice!.Count.ShouldBe(1);
}
[Theory]
[InlineData("Data[5..3]")] // M < N
[InlineData("Data[-1..5]")] // negative start
[InlineData("Data[0..15].Member")] // slice + sub-element
[InlineData("Data[0..15].3")] // slice + bit index
[InlineData("Data[0..15,1]")] // slice cannot be multi-dim
[InlineData("Data[0..15,2..3]")] // multi-dim slice not supported
[InlineData("Data[..5]")] // missing start
[InlineData("Data[5..]")] // missing end
[InlineData("Data[a..5]")] // non-numeric start
public void Invalid_slice_shapes_return_null(string input)
{
AbCipTagPath.TryParse(input).ShouldBeNull();
}
[Fact] [Fact]
public void ToLibplctagName_recomposes_round_trip() public void ToLibplctagName_recomposes_round_trip()
{ {

View File

@@ -167,6 +167,83 @@ public sealed class AbCipUdtMemberTests
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt"); builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
} }
[Fact]
public async Task AOI_typed_tag_groups_members_under_directional_subfolders()
{
// PR abcip-2.6 — when any member carries a non-Local AoiQualifier, the tag is treated
// as an AOI instance: Input / Output / InOut members get grouped under sub-folders so
// the browse tree mirrors Studio 5000's AOI parameter tabs. Plain UDT tags (every member
// Local) keep the pre-2.6 flat layout.
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition(
Name: "Valve_001",
DeviceHostAddress: "ab://10.0.0.5/1,0",
TagPath: "Valve_001",
DataType: AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Cmd", AbCipDataType.Bool, AoiQualifier: AoiQualifier.Input),
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false, AoiQualifier: AoiQualifier.Output),
new AbCipStructureMember("Buffer", AbCipDataType.DInt, AoiQualifier: AoiQualifier.InOut),
new AbCipStructureMember("LocalVar", AbCipDataType.DInt, AoiQualifier: AoiQualifier.Local),
]),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
// Sub-folders for each directional bucket land in the recorder; the AOI parent folder
// and the Local member's lack of a sub-folder confirm only directional members get
// bucketed. Folder names are intentionally simple (Inputs / Outputs / InOut) — clients
// that browse "Valve_001/Inputs/Cmd" see exactly that path.
builder.Folders.Select(f => f.BrowseName).ShouldContain("Valve_001");
builder.Folders.Select(f => f.BrowseName).ShouldContain("Inputs");
builder.Folders.Select(f => f.BrowseName).ShouldContain("Outputs");
builder.Folders.Select(f => f.BrowseName).ShouldContain("InOut");
// Variables emitted under the right full names — full reference still {Tag}.{Member}
// so the read/write paths stay unchanged from the flat-UDT case.
var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList();
variables.ShouldContain(("Cmd", "Valve_001.Cmd"));
variables.ShouldContain(("Status", "Valve_001.Status"));
variables.ShouldContain(("Buffer", "Valve_001.Buffer"));
variables.ShouldContain(("LocalVar", "Valve_001.LocalVar"));
}
[Fact]
public async Task Plain_UDT_keeps_flat_layout_when_every_member_is_Local()
{
// Plain UDTs (no Usage attributes anywhere) stay on the pre-2.6 flat layout — no
// Inputs/Outputs/InOut sub-folders should appear since there are no directional members.
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Tank1", "ab://10.0.0.5/1,0", "Tank1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Level", AbCipDataType.Real),
new AbCipStructureMember("Pressure", AbCipDataType.Real),
]),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Inputs");
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Outputs");
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("InOut");
}
[Fact] [Fact]
public async Task UDT_members_mixed_with_flat_tags_coexist() public async Task UDT_members_mixed_with_flat_tags_coexist()
{ {

View File

@@ -0,0 +1,175 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class CsvTagImporterTests
{
private const string DeviceHost = "ab://10.10.10.1/0,1";
[Fact]
public void Imports_Kepware_format_controller_tag_with_RW_access()
{
const string csv = """
Tag Name,Address,Data Type,Respect Data Type,Client Access,Scan Rate,Description,Scaling
Motor1_Speed,Motor1_Speed,DINT,1,Read/Write,100,Drive speed setpoint,None
""";
var importer = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost };
var result = importer.Import(csv);
result.Tags.Count.ShouldBe(1);
var t = result.Tags[0];
t.Name.ShouldBe("Motor1_Speed");
t.TagPath.ShouldBe("Motor1_Speed");
t.DataType.ShouldBe(AbCipDataType.DInt);
t.Writable.ShouldBeTrue();
t.Description.ShouldBe("Drive speed setpoint");
t.DeviceHostAddress.ShouldBe(DeviceHost);
}
[Fact]
public void Read_Only_access_yields_non_writable_tag()
{
const string csv = """
Tag Name,Address,Data Type,Client Access,Description
Sensor,Sensor,REAL,Read Only,Pressure sensor
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Single().Writable.ShouldBeFalse();
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Real);
}
[Fact]
public void Blank_rows_and_section_markers_are_skipped()
{
const string csv = """
; Kepware Server Tag Export
Tag Name,Address,Data Type,Client Access
; group: Motors
Motor1,Motor1,DINT,Read/Write
Motor2,Motor2,DINT,Read/Write
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Count.ShouldBe(2);
result.Tags.Select(t => t.Name).ShouldBe(["Motor1", "Motor2"]);
result.SkippedBlankCount.ShouldBeGreaterThan(0);
}
[Fact]
public void Quoted_field_with_embedded_comma_is_parsed()
{
const string csv = """
Tag Name,Address,Data Type,Client Access,Description
Motor1,Motor1,DINT,Read/Write,"Speed, RPM"
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Single().Description.ShouldBe("Speed, RPM");
}
[Fact]
public void Quoted_field_with_escaped_quote_is_parsed()
{
const string csv = "Tag Name,Address,Data Type,Client Access,Description\r\n"
+ "Tag1,Tag1,DINT,Read Only,\"He said \"\"hi\"\"\"\r\n";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Single().Description.ShouldBe("He said \"hi\"");
}
[Fact]
public void NamePrefix_is_applied()
{
const string csv = """
Tag Name,Address,Data Type,Client Access
Speed,Speed,DINT,Read/Write
""";
var result = new CsvTagImporter
{
DefaultDeviceHostAddress = DeviceHost,
NamePrefix = "PLC1_",
}.Import(csv);
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
result.Tags.Single().TagPath.ShouldBe("Speed");
}
[Fact]
public void Unknown_data_type_falls_through_as_Structure()
{
const string csv = """
Tag Name,Address,Data Type,Client Access
Mystery,Mystery,SomeUnknownType,Read/Write
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
[Fact]
public void Throws_when_DefaultDeviceHostAddress_missing()
{
const string csv = "Tag Name,Address,Data Type,Client Access\nA,A,DINT,Read/Write\n";
Should.Throw<InvalidOperationException>(() => new CsvTagImporter().Import(csv));
}
[Fact]
public void Round_trip_load_export_reparse_is_stable()
{
var original = new[]
{
new AbCipTagDefinition("Motor1", DeviceHost, "Motor1", AbCipDataType.DInt,
Writable: true, Description: "Drive speed"),
new AbCipTagDefinition("Sensor", DeviceHost, "Sensor", AbCipDataType.Real,
Writable: false, Description: "Pressure, kPa"),
new AbCipTagDefinition("Tag3", DeviceHost, "Program:Main.Tag3", AbCipDataType.Bool,
Writable: true, Description: null),
};
var csv = CsvTagExporter.ToCsv(original);
var reparsed = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv).Tags;
reparsed.Count.ShouldBe(original.Length);
for (var i = 0; i < original.Length; i++)
{
reparsed[i].Name.ShouldBe(original[i].Name);
reparsed[i].TagPath.ShouldBe(original[i].TagPath);
reparsed[i].DataType.ShouldBe(original[i].DataType);
reparsed[i].Writable.ShouldBe(original[i].Writable);
reparsed[i].Description.ShouldBe(original[i].Description);
}
}
[Fact]
public void Reordered_columns_are_honoured_via_header_lookup()
{
const string csv = """
Description,Address,Tag Name,Client Access,Data Type
Drive speed,Motor1,Motor1,Read/Write,DINT
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
var t = result.Tags.Single();
t.Name.ShouldBe("Motor1");
t.TagPath.ShouldBe("Motor1");
t.DataType.ShouldBe(AbCipDataType.DInt);
t.Description.ShouldBe("Drive speed");
t.Writable.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,250 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class L5kIngestTests
{
private const string DeviceHost = "ab://10.10.10.1/0,1";
[Fact]
public void Atomic_controller_scope_tag_becomes_AbCipTagDefinition()
{
const string body = """
TAG
Motor1_Speed : DINT (ExternalAccess := Read/Write) := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var ingest = new L5kIngest { DefaultDeviceHostAddress = DeviceHost };
var result = ingest.Ingest(doc);
result.Tags.Count.ShouldBe(1);
var tag = result.Tags[0];
tag.Name.ShouldBe("Motor1_Speed");
tag.DeviceHostAddress.ShouldBe(DeviceHost);
tag.TagPath.ShouldBe("Motor1_Speed");
tag.DataType.ShouldBe(AbCipDataType.DInt);
tag.Writable.ShouldBeTrue();
tag.Members.ShouldBeNull();
}
[Fact]
public void Program_scope_tag_uses_Program_prefix_and_compound_name()
{
const string body = """
PROGRAM MainProgram
TAG
StepIndex : DINT := 0;
END_TAG
END_PROGRAM
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.Tags.Count.ShouldBe(1);
result.Tags[0].Name.ShouldBe("MainProgram.StepIndex");
result.Tags[0].TagPath.ShouldBe("Program:MainProgram.StepIndex");
}
[Fact]
public void Alias_tag_is_skipped()
{
const string body = """
TAG
Real : DINT := 0;
Aliased : DINT (AliasFor := "Real");
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.SkippedAliasCount.ShouldBe(1);
result.Tags.Count.ShouldBe(1);
result.Tags.ShouldAllBe(t => t.Name != "Aliased");
}
[Fact]
public void ExternalAccess_None_tag_is_skipped()
{
const string body = """
TAG
Hidden : DINT (ExternalAccess := None) := 0;
Visible : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.SkippedNoAccessCount.ShouldBe(1);
result.Tags.Single().Name.ShouldBe("Visible");
}
[Fact]
public void ExternalAccess_ReadOnly_tag_becomes_non_writable()
{
const string body = """
TAG
Sensor : REAL (ExternalAccess := Read Only) := 0.0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.Tags.Single().Writable.ShouldBeFalse();
}
[Fact]
public void UDT_typed_tag_picks_up_member_layout_from_DATATYPE_block()
{
const string body = """
DATATYPE TankUDT
MEMBER Level : REAL := 0.0;
MEMBER Active : BOOL := 0;
END_DATATYPE
TAG
Tank1 : TankUDT := [0.0, 0];
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var tag = result.Tags.Single();
tag.Name.ShouldBe("Tank1");
tag.DataType.ShouldBe(AbCipDataType.Structure);
tag.Members.ShouldNotBeNull();
tag.Members!.Count.ShouldBe(2);
tag.Members[0].Name.ShouldBe("Level");
tag.Members[0].DataType.ShouldBe(AbCipDataType.Real);
tag.Members[1].Name.ShouldBe("Active");
tag.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
}
[Fact]
public void Unknown_datatype_falls_through_as_structure_with_no_members()
{
const string body = """
TAG
Mystery : SomeUnknownType := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var tag = result.Tags.Single();
tag.DataType.ShouldBe(AbCipDataType.Structure);
tag.Members.ShouldBeNull();
}
[Fact]
public void Ingest_throws_when_DefaultDeviceHostAddress_missing()
{
var doc = new L5kDocument(new[] { new L5kTag("X", "DINT", null, null, null, null) }, Array.Empty<L5kDataType>());
Should.Throw<InvalidOperationException>(() => new L5kIngest().Ingest(doc));
}
[Fact]
public void AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
{
// PR abcip-2.6 — L5K AOI parameters carry a Usage := Input / Output / InOut attribute.
// Ingest must map those values onto AbCipStructureMember.AoiQualifier so the discovery
// layer can group AOI members under sub-folders. Plain DATATYPE members get Local.
const string body = """
ADD_ON_INSTRUCTION_DEFINITION ValveAoi
PARAMETERS
PARAMETER Cmd : BOOL (Usage := Input) := 0;
PARAMETER Status : DINT (Usage := Output) := 0;
PARAMETER Buffer : DINT (Usage := InOut) := 0;
PARAMETER Local1 : DINT := 0;
END_PARAMETERS
END_ADD_ON_INSTRUCTION_DEFINITION
DATATYPE PlainUdt
MEMBER Speed : DINT := 0;
END_DATATYPE
TAG
Valve_001 : ValveAoi;
Tank1 : PlainUdt;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var aoiTag = result.Tags.Single(t => t.Name == "Valve_001");
aoiTag.Members.ShouldNotBeNull();
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
aoiTag.Members.Single(m => m.Name == "Local1").AoiQualifier.ShouldBe(AoiQualifier.Local);
// Plain UDT members default to Local — no Usage attribute to map.
var plainTag = result.Tags.Single(t => t.Name == "Tank1");
plainTag.Members.ShouldNotBeNull();
plainTag.Members!.Single().AoiQualifier.ShouldBe(AoiQualifier.Local);
}
[Fact]
public void L5x_AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
{
// Same mapping as the L5K case above, exercised through the L5X parser to confirm both
// formats land at the same downstream representation.
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller Name="C">
<AddOnInstructionDefinitions>
<AddOnInstructionDefinition Name="MyAoi">
<Parameters>
<Parameter Name="Cmd" DataType="BOOL" Usage="Input" />
<Parameter Name="Status" DataType="DINT" Usage="Output" />
<Parameter Name="Buffer" DataType="DINT" Usage="InOut" />
</Parameters>
</AddOnInstructionDefinition>
</AddOnInstructionDefinitions>
<Tags>
<Tag Name="Valve_001" TagType="Base" DataType="MyAoi" />
</Tags>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var aoiTag = result.Tags.Single();
aoiTag.Members.ShouldNotBeNull();
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
}
[Fact]
public void NamePrefix_is_applied_to_imported_tags()
{
const string body = """
TAG
Speed : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest
{
DefaultDeviceHostAddress = DeviceHost,
NamePrefix = "PLC1_",
}.Ingest(doc);
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
result.Tags.Single().TagPath.ShouldBe("Speed"); // path on the PLC stays unchanged
}
}

View File

@@ -0,0 +1,198 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class L5kParserTests
{
[Fact]
public void Controller_scope_TAG_block_parses_name_datatype_externalaccess()
{
const string body = """
TAG
Motor1_Speed : DINT (Description := "Motor 1 set point", ExternalAccess := Read/Write) := 0;
Tank_Level : REAL (ExternalAccess := Read Only) := 0.0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(2);
doc.Tags[0].Name.ShouldBe("Motor1_Speed");
doc.Tags[0].DataType.ShouldBe("DINT");
doc.Tags[0].ProgramScope.ShouldBeNull();
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
doc.Tags[0].AliasFor.ShouldBeNull();
doc.Tags[1].Name.ShouldBe("Tank_Level");
doc.Tags[1].DataType.ShouldBe("REAL");
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
}
[Fact]
public void Program_scope_TAG_block_carries_program_name()
{
const string body = """
PROGRAM MainProgram (Class := Standard)
TAG
StepIndex : DINT := 0;
Running : BOOL := 0;
END_TAG
END_PROGRAM
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(2);
doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram");
doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]);
}
[Fact]
public void Alias_tag_is_flagged()
{
const string body = """
TAG
Motor1 : DINT := 0;
Motor1_Alias : DINT (AliasFor := "Motor1", ExternalAccess := Read/Write);
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var alias = doc.Tags.Single(t => t.Name == "Motor1_Alias");
alias.AliasFor.ShouldBe("Motor1");
}
[Fact]
public void DATATYPE_block_collects_member_lines()
{
const string body = """
DATATYPE TankUDT (FamilyType := NoFamily)
MEMBER Level : REAL (ExternalAccess := Read/Write) := 0.0;
MEMBER Pressure : REAL := 0.0;
MEMBER Active : BOOL := 0;
END_DATATYPE
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.DataTypes.Count.ShouldBe(1);
var udt = doc.DataTypes[0];
udt.Name.ShouldBe("TankUDT");
udt.Members.Count.ShouldBe(3);
udt.Members[0].Name.ShouldBe("Level");
udt.Members[0].DataType.ShouldBe("REAL");
udt.Members[0].ExternalAccess.ShouldBe("Read/Write");
udt.Members[1].Name.ShouldBe("Pressure");
udt.Members[2].Name.ShouldBe("Active");
udt.Members[2].DataType.ShouldBe("BOOL");
}
[Fact]
public void DATATYPE_member_with_array_dim_keeps_type_clean()
{
const string body = """
DATATYPE BatchUDT
MEMBER Recipe : DINT[16] := 0;
MEMBER Name : STRING := "";
END_DATATYPE
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var udt = doc.DataTypes[0];
var recipe = udt.Members.First(m => m.Name == "Recipe");
recipe.DataType.ShouldBe("DINT");
recipe.ArrayDim.ShouldBe(16);
var nameMember = udt.Members.First(m => m.Name == "Name");
nameMember.DataType.ShouldBe("STRING");
nameMember.ArrayDim.ShouldBeNull();
}
[Fact]
public void Block_comments_are_stripped_before_parsing()
{
const string body = """
(* This is a long
multi-line comment with TAG and END_TAG inside, parser must skip *)
TAG
Real_Tag : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(1);
doc.Tags[0].Name.ShouldBe("Real_Tag");
}
[Fact]
public void Unknown_sections_are_skipped_silently()
{
const string body = """
CONFIG SomeConfig (Class := Standard)
ConfigData := 0;
END_CONFIG
MOTION_GROUP Motion1
Member := whatever;
END_MOTION_GROUP
TAG
Real_Tag : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(1);
doc.Tags[0].Name.ShouldBe("Real_Tag");
}
[Fact]
public void AOI_definition_block_collects_parameters_with_Usage()
{
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION blocks with PARAMETER entries carrying
// Usage := Input / Output / InOut. The parser surfaces them as L5kDataType members so
// AOI-typed tags pick up a layout the same way UDT-typed tags do.
const string body = """
ADD_ON_INSTRUCTION_DEFINITION MyValveAoi (Revision := "1.0")
PARAMETERS
PARAMETER Cmd : BOOL (Usage := Input) := 0;
PARAMETER Status : DINT (Usage := Output, ExternalAccess := Read Only) := 0;
PARAMETER Buffer : DINT (Usage := InOut) := 0;
PARAMETER Internal : DINT := 0;
END_PARAMETERS
LOCAL_TAGS
Working : DINT := 0;
END_LOCAL_TAGS
END_ADD_ON_INSTRUCTION_DEFINITION
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
aoi.Members.Count.ShouldBe(4);
aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input");
aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output");
aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut");
aoi.Members.Single(m => m.Name == "Internal").Usage.ShouldBeNull();
}
[Fact]
public void Multi_line_TAG_entry_is_concatenated()
{
const string body = """
TAG
Motor1 : DINT (Description := "Long description spanning",
ExternalAccess := Read/Write) := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(1);
doc.Tags[0].Description.ShouldBe("Long description spanning");
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
}
}

Some files were not shown because too many files have changed in this diff Show More