Compare commits

...

182 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
Joseph Doherty
21e0fdd4cd Docs audit — fill gaps so the top-level docs/ reference matches shipped code
Audit of docs/ against src/ surfaced shipped features without current-reference
coverage (FOCAS CLI, Core.Scripting+VirtualTags, Core.ScriptedAlarms,
Core.AlarmHistorian), an out-of-date driver count + capability matrix, ADR-002's
virtual-tag dispatch not reflected in data-path docs, broken cross-references,
and OpcUaServerReqs declaring OPC-020..022 that were never scoped. This commit
closes all of those so operators + integrators can stay inside docs/ without
falling back to v2/implementation/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:42:42 -04:00
Joseph Doherty
5fc596a9a1 E2E test script — Galaxy (MXAccess) driver: read / write / subscribe / alarms / history
Seven-stage e2e script covering every Galaxy-specific capability surface:
IReadable + IWritable + ISubscribable + IAlarmSource + IHistoryProvider.
Unlike the other drivers there is no per-protocol CLI — Galaxy's proxy
lives in-process with the server + talks to OtOpcUaGalaxyHost over a
named pipe (MXAccess COM is 32-bit-only), so every stage runs through
`otopcua-cli` against the published OPC UA address space.

## Stages

1. Probe                   — otopcua-cli read on the source NodeId
2. Source read             — capture value for downstream comparison
3. Virtual-tag bridge      — Phase 7 VirtualTag (source × 2) through
                             CachedTagUpstreamSource
4. Subscribe-sees-change   — data-change events propagate
5. Reverse bridge          — opc-ua write → Galaxy; soft-passes if the
                             attribute's Galaxy-side ACL forbids writes
                             (`BadUserAccessDenied` / `BadNotWritable`)
6. Alarm fires             — scripted-alarm Condition fires with Active
                             state when source crosses threshold
7. History read            — historyread returns samples from the Aveva
                             Historian → IHistoryProvider path

## Two new helpers in _common.ps1

- `Test-AlarmFiresOnThreshold` — start `otopcua-cli alarms --refresh`
  in the background on a Condition NodeId, drive the source change,
  assert captured stdout contains `ALARM` + `Active`. Uses the same
  Start-Process + temp-file pattern as `Test-SubscribeSeesChange` since
  the alarms command runs until Ctrl+C (no built-in --duration).
- `Test-HistoryHasSamples` — call `otopcua-cli historyread` over a
  configurable lookback window, parse `N values returned.` marker, fail
  if below MinSamples. Works for driver-sourced, virtual, or scripted-
  alarm historized nodes.

## Wiring

- `test-all.ps1` picks up the optional `galaxy` sidecar section and
  runs the script with the configured NodeIds + wait windows.
- `e2e-config.sample.json` adds a `galaxy` section seeded with the
  Phase 7 defaults (`p7-smoke-tag-source` / `-vt-derived` /
  `-al-overtemp`) — matches `scripts/smoke/seed-phase-7-smoke.sql`.
- `scripts/e2e/README.md` expected-matrix gains a Galaxy row.

## Prereqs

- OtOpcUaGalaxyHost running (NSSM-wrapped) with the Galaxy + MXAccess
  runtime available
- `seed-phase-7-smoke.sql` applied with a live Galaxy attribute
  substituted into `dbo.Tag.TagConfig`
- OtOpcUa server running against the `p7-smoke` cluster
- Non-elevated shell (Galaxy.Host pipe ACL denies Admins)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:59:06 -04:00
05d2a7fd00 Merge pull request 'Task #222 partial — unblock AB Legacy PCCC via cip-path workaround (5/5 stages)' (#223) from task-222-ablegacy-pccc-unblock into v2 2026-04-21 12:52:59 -04:00
Joseph Doherty
95c7e0b490 Task #222 partial — unblock AB Legacy PCCC via cip-path workaround (5/5 stages)
Replaced the "ab_server PCCC upstream-broken" skip gate with the actual
root cause: libplctag's ab_server rejects empty CIP routing paths at the
unconnected-send layer before the PCCC dispatcher runs. Real SLC/
MicroLogix/PLC-5 hardware accepts empty paths (no backplane); ab_server
does not. With `/1,0` in place, N (Int16), F (Float32), and L (Int32)
file reads + writes round-trip cleanly across all three compose profiles.

## Fixture changes

- `AbLegacyServerFixture.cs`:
  - Drop `AB_LEGACY_TRUST_WIRE` env var + the reachable-but-untrusted
    skip branch. Fixture now only skips on TCP unreachability.
  - Add `AB_LEGACY_CIP_PATH` env var (default `1,0`) + expose `CipPath`
    property. Set `AB_LEGACY_CIP_PATH=` (empty) against real hardware.
  - Shorter skip messages on the `[AbLegacyFact]` / `[AbLegacyTheory]`
    attributes — one reason: endpoint not reachable.

- `AbLegacyReadSmokeTests.cs`:
  - Device URI built from `sim.CipPath` instead of hardcoded empty path.
  - New `AB_LEGACY_COMPOSE_PROFILE` env var filters the parametric
    theory to the running container's family. Only one container binds
    `:44818` at a time, so cross-family params would otherwise fail.
  - `Slc500_write_then_read_round_trip` skips cleanly when the running
    profile isn't `slc500`.

## E2E + seed + docs

- `scripts/e2e/test-ablegacy.ps1` — drop the `AB_LEGACY_TRUST_WIRE`
  skip gate; synopsis calls out the `/1,0` vs empty cip-path split
  between the Docker fixture and real hardware.
- `scripts/e2e/e2e-config.sample.json` — sample gateway flipped from
  the hardware placeholder (`192.168.1.10`) to the Docker fixture
  (`127.0.0.1/1,0`); comment rewritten.
- `scripts/e2e/README.md` — AB Legacy expected-matrix row goes from
  SKIP to PASS.
- `scripts/smoke/seed-ablegacy-smoke.sql` — default HostAddress points
  at the Docker fixture + header / footer text reflect the new state.
- `tests/.../Docker/README.md` — "Known limitations" section rewritten
  to describe the cip-path gate (not a dispatcher gap); env-var table
  picks up `AB_LEGACY_CIP_PATH` + `AB_LEGACY_COMPOSE_PROFILE`.
- `docs/drivers/AbLegacy-Test-Fixture.md` + `docs/drivers/README.md`
  + `docs/DriverClis.md` — flip status from blocked to functional;
  residual bit-file-write gap (B3:0/5 → 0x803D0000) documented.

## Residual gap

Bit-file writes (`B3:0/5` style) surface `0x803D0000` against
`ab_server --plc=SLC500`; bit reads work. Non-blocking for smoke
coverage — N/F/L round-trip is enough. Real hardware / RSEmulate 500
for bit-write fidelity. Documented in `Docker/README.md` §"Known
limitations" + the `AbLegacy-Test-Fixture.md` follow-ups list.

## Verified

- Full-solution build: 0 errors, 334 pre-existing warnings.
- Integration suite passes per-profile with
  `AB_LEGACY_COMPOSE_PROFILE=<slc500|micrologix|plc5>` + matching
  compose container up.
- All four non-hardware e2e scripts (Modbus / AB CIP / AB Legacy / S7)
  now 5/5 against the respective docker-compose fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:38:43 -04:00
e1f172c053 Merge pull request 'Task #220 — AB CIP + S7 live-boot verification (5/5 stages each)' (#222) from task-220-exitgate-abcip-s7 into v2 2026-04-21 12:04:51 -04:00
Joseph Doherty
6d290adb37 Task #220 — AB CIP + S7 live-boot verification (5/5 stages each)
Replicated the Modbus #218 bring-up against the AB CIP + S7 seeds to
confirm the factories + seeds shipped in #217 actually work end-to-end.
Both pass 5/5 e2e stages with `OpcUaServer:AnonymousRoles=[WriteOperate]`
(the #221 knob).

## AB CIP (against ab_server controllogix fixture, port 44818)

```
=== AB CIP e2e summary: 5/5 passed ===
[PASS] Probe
[PASS] Driver loopback
[PASS] Server bridge           (driver → server → client)
[PASS] OPC UA write bridge     (client → server → driver)
[PASS] Subscribe sees change
```

Server log: `DriverInstance abcip-smoke-drv (AbCip) registered +
initialized` ✓.

## S7 (against python-snap7 s7_1500 fixture, port 1102)

```
=== S7 e2e summary: 5/5 passed ===
[PASS] Probe
[PASS] Driver loopback
[PASS] Server bridge
[PASS] OPC UA write bridge
[PASS] Subscribe sees change
```

Server log: `DriverInstance s7-smoke-drv (S7) registered + initialized` ✓.

## Seed fixes so bring-up is idempotent

Live-boot exposed two seed-level papercuts when applying multiple
smoke seeds in sequence:

1. **SA credential collision.** `UX_ClusterNodeCredential_Value` is a
   unique index on `(Kind, Value) WHERE Enabled=1`, so `sa` can only
   bind to one node at a time. Each seed's DELETE block only dropped
   the credential tied to ITS node — seeding AbCip after Modbus blew
   up with `Cannot insert duplicate key` on the sa binding. Added a
   global `DELETE FROM dbo.ClusterNodeCredential WHERE Kind='SqlLogin'
   AND Value='sa'` before the per-cluster INSERTs. Production deployments
   using non-SA logins aren't affected.

2. **DashboardPort 5000 → 15050.** `HealthEndpointsHost` uses
   `HttpListener`, which rejects port 5000 on Windows without a
   `netsh http add urlacl` grant or admin rights. 15050 is unreserved
   + loopback-safe per the HealthEndpointsHost remarks. Applied to all
   four smoke seeds (Modbus was patched at runtime in #218; now baked
   into the seed).

## AB Legacy status

Not live-boot verified — ab_server PCCC dispatcher is upstream-broken
(#222). The factory + seed ship ready for hardware; the seed's DELETE
+ DashboardPort fixes land in this PR so when real SLC/MicroLogix/PLC-5
arrives the sql just applies.

## Closes #220

Umbrella #209 was already closed; #220 was the final child.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:02:40 -04:00
cc8a6c9ec1 Merge pull request 'Task #219 — OpcUaServerOptions.AnonymousRoles (5/5 e2e stages pass)' (#221) from task-219-anonymous-roles into v2 2026-04-21 11:51:56 -04:00
Joseph Doherty
2ec6aa480e Task #219 — OpcUaServerOptions.AnonymousRoles (5/5 e2e stages pass)
Anonymous OPC UA sessions had no roles (`UserIdentity()`), so
`WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, [])`
rejected every write with `BadUserAccessDenied`. The reverse-write
stage of the Modbus e2e script surfaced this: stages 1-3 + 5 pass
forward-direction, stage 4 (OPC UA client → server → driver → PLC)
blew up with `0x801F0000` even with the factory + seed perfectly
wired.

Adds a single config knob:

    "OpcUaServer": {
      "AnonymousRoles": ["WriteOperate"]
    }

Default empty preserves the pre-existing production-safe behaviour
(anonymous reads FreeAccess tags, rejected on everything else). When
non-empty, `OtOpcUaServer.OnImpersonateUser` wraps the anonymous token
in a `RoleBasedIdentity("(anonymous)", "Anonymous", AnonymousRoles)`
so the server-layer write guard sees the configured roles.

Wire-through:
 - OpcUaServerOptions.AnonymousRoles (new)
 - OpcUaApplicationHost passes it to OtOpcUaServer ctor
 - OtOpcUaServer new anonymousRoles ctor param + OnImpersonateUser
   branch
 - Program.cs reads `OpcUaServer:AnonymousRoles` section from config

Env override syntax: `OpcUaServer__AnonymousRoles__0=WriteOperate`.

## Verified live

Booted server against `seed-modbus-smoke.sql` with
`OpcUaServer__AnonymousRoles__0=WriteOperate` + pymodbus fixture →
`test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"`:

    === Modbus e2e summary: 5/5 passed ===
    [PASS] Probe
    [PASS] Driver loopback
    [PASS] Server bridge            (driver → server → client)
    [PASS] OPC UA write bridge      (client → server → driver)
    [PASS] Subscribe sees change

All five stages green end-to-end. Issue #219 closed by this PR; the
Modbus-seed update to set AnonymousRoles lives in the follow-up #220
live-boot PR (same AnonymousRoles value applies to every driver since
the classification is a driver-constant, not per-tag).

Full-solution build: 0 errors, only pre-existing xUnit1051 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:49:41 -04:00
682c1c5e75 Merge pull request 'Task #209 exit gate — seed-creds fix + live Modbus verification (4/5 stages)' (#218) from task-209-exitgate-seed-creds into v2 2026-04-21 11:32:22 -04:00
Joseph Doherty
e8172f9452 Task #209 exit gate — seed-creds fix + live Modbus verification (4/5 stages)
Booted the server against the Modbus seed end-to-end to exercise the
factory wiring shipped in #216 + #217. Surfaced two real issues with
the seeds themselves; fixed both:

1. **Missing ClusterNodeCredential.** `sp_GetCurrentGenerationForCluster`
   enforces `ClusterNodeCredential.Value = SUSER_SNAME()` and aborts
   with `RAISERROR('Unauthorized: caller sa is not bound to NodeId')`.
   All four seed scripts now insert the binding row alongside the
   ClusterNode row. Without this, the server fails bootstrap with
   `BootstrapException: Central DB unreachable and no local cache
   available` (the Unauthorized error gets swallowed on top of the
   HTTP fallback path).

2. **Config cache gitignore.** Running the server from the repo root
   writes `config_cache.db` + `config_cache-log.db` next to the cwd,
   outside the existing `src/.../Server/config_cache.db` pattern. Add
   a `config_cache*.db` pattern so any future run location is covered.

## Verified live against Modbus

Booted server against `seed-modbus-smoke.sql` → pymodbus standard
fixture → ran `scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"`:

    === Modbus e2e summary: 4/5 passed ===
    [PASS] Probe
    [PASS] Driver loopback
    [PASS] Server bridge (driver → server → client)
    [FAIL] OPC UA write bridge (0x801F0000)
    [PASS] Subscribe sees change

The forward direction + subscription delivery are proven working through
the server. The reverse-write failure is a seed-or-ACL issue — server
log shows no exception on the write path, so the client-side status is
coming from the stack's type/ACL guards. Tracking as a follow-up issue
so the remaining three factory wirings can be smoke-booted against the
same pattern.

Note for future runs: two stale v1 `ZB.MOM.WW.LmxOpcUa.Host.exe`
processes from `C:\publish\lmxopcua\instance{1,2}\` squat on ports
4840 + 4841 on this dev box; kill them first or bump the seed's
DashboardPort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:30:00 -04:00
3af746c4b6 Merge pull request 'Tasks #211 #212 #213 — AbCip / S7 / AbLegacy server-side factories + seed SQL' (#217) from task-211-212-213-factories into v2 2026-04-21 11:17:47 -04:00
Joseph Doherty
7ba783de77 Tasks #211 #212 #213 — AbCip / S7 / AbLegacy server-side factories + seed SQL
Parent: #209. Follow-up to #210 (Modbus). Registers the remaining three
non-Galaxy driver factories so a Config DB `DriverType` in
{`AbCip`, `S7`, `AbLegacy`} actually boots a live driver instead of
being silently skipped by DriverInstanceBootstrapper.

Each factory follows the same shape as ModbusDriverFactoryExtensions +
the existing Galaxy + FOCAS patterns:
 - Static `Register(DriverFactoryRegistry)` entry point.
 - Internal `CreateInstance(driverInstanceId, driverConfigJson)` —
   deserialises a DTO, strict-parses enum fields (fail-fast with an
   explicit "expected one of" list), composes the driver's options object,
   returns a new driver.
 - DriverType keys: `"AbCip"`, `"S7"`, `"AbLegacy"` (case-insensitive at
   the registry layer).

DTO surfaces cover every option the respective driver's Options class
exposes — devices, tags, probe, timeouts, per-driver quirks
(AbCip `EnableControllerBrowse` / `EnableAlarmProjection`, S7 Rack/Slot/
CpuType, AbLegacy PlcFamily).

Seed SQL (mirrors `seed-modbus-smoke.sql` shape):
 - `seed-abcip-smoke.sql` — `abcip-smoke` cluster + ControlLogix device +
   `TestDINT:DInt` tag, pointing at the ab_server compose fixture
   (`ab://127.0.0.1:44818/1,0`).
 - `seed-s7-smoke.sql` — `s7-smoke` cluster + S71500 CPU + `DB1.DBW0:Int16`
   tag at the python-snap7 fixture (`127.0.0.1:1102`, non-priv port).
 - `seed-ablegacy-smoke.sql` — `ablegacy-smoke` cluster + SLC 500 + `N7:5`
   tag. Hardware-gated per #222; placeholder gateway to be replaced with
   real SLC/MicroLogix/PLC-5/RSEmulate before running.

Build plumbing:
 - Each driver project now ProjectReferences `Core` (was
   `Core.Abstractions`-only). `DriverFactoryRegistry` lives in `Core.Hosting`
   so the factory extensions can't compile without it. Matches the FOCAS +
   Galaxy.Proxy reference shape.
 - `Server.csproj` adds the three new driver ProjectReferences so Program.cs
   resolves the symbols at compile-time + ships the assemblies at runtime.

Full-solution build: 0 errors, 334 pre-existing xUnit1051 warnings only.

Live boot verification of all four (Modbus + these three) happens in the
exit-gate PR — factories + seeds are pre-conditions and are being
shipped first so the exit-gate PR can scope to "does the server publish
the expected NodeIds + does the e2e script pass."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:15:38 -04:00
35d24c2f80 Merge pull request 'Task #210 — Modbus server-side factory + seed SQL' (#216) from task-210-modbus-factory-seed into v2 2026-04-21 11:08:20 -04:00
Joseph Doherty
55245a962e Task #210 — Modbus server-side factory + seed SQL (closes first of #209 umbrella)
Parent: #209. Adds the server-side wiring so a Config DB `DriverType='Modbus'`
row actually boots a Modbus driver instance + publishes its tags under OPC UA
NodeIds, instead of being silently skipped by DriverInstanceBootstrapper.

Changes:
 - `ModbusDriverFactoryExtensions` (new) — mirrors
   `GalaxyProxyDriverFactoryExtensions` + `FocasDriverFactoryExtensions`.
   `DriverTypeName="Modbus"`, `CreateInstance` deserialises
   `ModbusDriverConfigDto` (Host/Port/UnitId/TimeoutMs/Probe/Tags) to a full
   `ModbusDriverOptions` and hands back a `ModbusDriver`. Strict enum parsing
   (Region / DataType / ByteOrder / StringByteOrder) — unknown values fail
   fast with an explicit "expected one of" error rather than at first read.
 - `Program.cs` — register the factory after Galaxy + FOCAS.
 - `Driver.Modbus.csproj` — add `Core` project reference (the DI-free factory
   needs `DriverFactoryRegistry` from `Core.Hosting`). Matches the FOCAS
   driver's reference shape.
 - `Server.csproj` — add the `Driver.Modbus` ProjectReference so the
   Program.cs registration compiles against the same assembly the server
   loads at runtime.
 - `scripts/smoke/seed-modbus-smoke.sql` (new) — one-cluster smoke seed
   modelled on `seed-phase-7-smoke.sql`. Creates a `modbus-smoke` cluster +
   `modbus-smoke-node` + Draft generation + Namespace + UnsArea/UnsLine/
   Equipment + one Modbus `DriverInstance` pointing at the pymodbus standard
   fixture (`127.0.0.1:5020`) + one Tag at `HR[200]:UInt16`, ending in
   `EXEC sp_PublishGeneration`. HR[100] is deliberately *not* used because
   pymodbus `standard.json` runs an auto-increment action on that register.

Full-solution build: 0 errors, only the pre-existing xUnit1051 warnings.

AB CIP / S7 / AB Legacy factories follow in their own PRs per #211 / #212 /
#213. Live boot verification happens in the exit-gate PR once all four
factories are in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:06:08 -04:00
16d9592a8a Merge pull request 'Task #253 follow-up — fix test-all.ps1 StrictMode crash on missing JSON keys' (#215) from task-253d-e2e-debug-harness into v2 2026-04-21 10:57:34 -04:00
Joseph Doherty
2666a598ae Task #253 follow-up — fix test-all.ps1 StrictMode crash on missing JSON keys
Running `test-all.ps1` end-to-end with a partial sidecar (only modbus/
abcip/s7 populated, no focas/twincat/phase7) crashed:

    [FAIL] modbus runner crashed: The property 'opcUaUrl' cannot be
    found on this object. Verify that the property exists.

Root cause: `_common.ps1` sets `Set-StrictMode -Version 3.0`, which
turns missing-property access on PSCustomObject into a throw. Every
`$config.<driver>.<optional-field> ?? $default` and `if
($config.<missing-section>)` check is therefore unsafe against a
normal JSON where optional fields are omitted.

Fix: switch to `ConvertFrom-Json -AsHashtable` and add a `Get-Or`
helper. Hashtables tolerate `.ContainsKey()` / indexer access even
under StrictMode, so the per-driver sections now read:

    $modbus = Get-Or $config "modbus"
    if ($modbus) {
        ... -OpcUaUrl (Get-Or $modbus "opcUaUrl" $OpcUaUrl) ...
    }

Verified end-to-end with live docker-compose fixtures:
 - Modbus / AB CIP / S7 each run to completion, report 2/5 PASS (the
   driver-only stages) and FAIL the 3 server-bridge stages (expected —
   server-side factory wiring is blocked on #209).
 - The FINAL MATRIX header renders cleanly with SKIP rows for the
   drivers not present in the sidecar + FAIL rows for the present ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:55:15 -04:00
5834d62906 Merge pull request 'Task #253 follow-up — driver-side e2e debug: port fixes + HR[200] scratch register' (#214) from task-253c-e2e-debug-driver-side into v2 2026-04-21 10:34:22 -04:00
Joseph Doherty
fe981b0b7f Task #253 follow-up — driver-side e2e debug: port fixes + HR[200] scratch register
Ran the driver CLIs against the live docker-compose fixtures to debug
what actually works. Two real bugs surfaced:

1. `e2e-config.sample.json` pointed at the wrong simulator ports:
     - Modbus: 5502 → **5020** (pymodbus compose binding)
     - S7:      102 → **1102** (python-snap7, non-priv port)
     - AbCip:   no port → now explicit **:44818**
   `test-modbus.ps1` default `-ModbusHost` also shipped with 5502;
   fixed to 5020.

2. Modbus loopback was off-by-2 because pymodbus `standard.json` makes
   HR[100] an auto-increment register (value ticks on every poll).
   Switched `test-modbus.ps1` to **HR[200]** (scratch range in the
   profile) + updated sample config `bridgeNodeId` to match.

Also fixed the AbCip probe: `-t @raw_cpu_type` was rejected by the
driver's TagPath parser ("malformed TagPath"). Probe now uses the
user-supplied `-TagPath` for every family. Works against both
ab_server and real controllers.

Verified driver-side against live containers:
 - Modbus  5020:  probe ✓, HR[200] write+read round-trip ✓
 - AB CIP  44818: probe ✓, TestDINT write+read round-trip ✓
 - S7      1102:  probe ✓, DB1.DBW0 write+read round-trip ✓

## Known blocker (stages 3-5)

README now flags — and the 4 child issues under umbrella #209 track —
that `src/ZB.MOM.WW.OtOpcUa.Server/Program.cs:98-104` only registers
Galaxy + FOCAS driver factories. `DriverInstanceBootstrapper` silently
skips any `DriverType` without a registered factory, so stages 3-5
(anything crossing the OPC UA server) can't work today even with a
perfect Config DB seed. Issues #210-213 scope the factory + seed SQL
work per driver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:31:55 -04:00
7b1c910806 Merge pull request 'Task #253 follow-up — bidirectional + subscribe-sees-change e2e stages' (#208) from task-253b-e2e-bidirectional into v2 2026-04-21 10:11:08 -04:00
Joseph Doherty
a9b585ac5b Task #253 follow-up — bidirectional + subscribe-sees-change e2e stages
The original three-stage design (probe / driver-loopback / forward-
bridge) only proved driver-write → server-read. It missed:

 - OPC UA write → server → driver → PLC (the reverse direction)
 - server-side data-change notifications actually firing (a stale
   subscription can still let a read-after-the-fact return the new
   value and look fine)

Extend _common.ps1 with two helpers:

 - Test-OpcUaWriteBridge: otopcua-cli write the NodeId -> wait 3s ->
   driver CLI read the PLC side, assert equality.
 - Test-SubscribeSeesChange: Start-Process otopcua-cli subscribe in the
   background with --duration N, settle 2s, driver-side write, wait for
   the subscription window to close, assert captured stdout contains
   the new value.

Wire both into test-modbus / test-abcip / test-ablegacy / test-s7 /
test-focas / test-twincat after the existing forward-bridge stage.
Update README to describe the five-stage design + note that the
published NodeId must be writable for stages 4 + 5.

Also prepend UTF-8 BOM to every script in scripts/e2e so Windows
PowerShell 5.1 parsers agree on em-dash byte sequences the way
PowerShell 7 already does. The scripts still #Requires -Version 7.0 —
the BOM is purely defensive for IDE / CI step parsers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:08:52 -04:00
097f92fdb8 Merge pull request 'Task #253 — E2E CLI test scripts + FOCAS test-client CLI' (#207) from task-253-e2e-cli-test-scripts into v2 2026-04-21 09:58:34 -04:00
Joseph Doherty
8d92e00e38 Task #253 — E2E CLI test scripts + FOCAS test-client CLI
The driver-layer integration tests confirm the driver sees the PLC, and
the Client.CLI tests confirm the client sees the server. Nothing glued
them end-to-end until this PR.

- scripts/e2e/_common.ps1: shared helpers — CLI invocation (published-
  binary OR `dotnet run` fallback), Test-Probe / Test-DriverLoopback /
  Test-ServerBridge (all return @{Passed;Reason} hashtables).
- scripts/e2e/test-<modbus|abcip|ablegacy|s7|focas|twincat>.ps1: per-
  driver three-stage script (probe → driver-loopback → server-bridge).
  AB Legacy / FOCAS / TwinCAT are gated behind *_TRUST_WIRE env vars
  since they need real hardware (#222) or a licensed runtime (#221).
- scripts/e2e/test-phase7-virtualtags.ps1: writes a Modbus HR, reads
  the server-side VirtualTag (VT = input * 2) back via OPC UA, triggers
  + clears a scripted alarm. Exercises the Phase 7 CachedTagUpstreamSource
  + ScriptedAlarmEngine path.
- scripts/e2e/test-all.ps1: reads e2e-config.json sidecar, runs each
  present driver, prints a FINAL MATRIX (PASS/FAIL/SKIP). Missing
  sections SKIP rather than fail hard.
- scripts/e2e/e2e-config.sample.json: commented sample — each dev's
  NodeIds are local-seed-specific so e2e-config.json is .gitignore-d.
- scripts/e2e/README.md: full walkthrough — prereqs, three-stage design,
  env-var gates, expected matrix, why this is separate from `dotnet test`.

Tasks #249-#251 shipped Modbus/AbCip/AbLegacy/S7/TwinCAT CLIs but left
FOCAS out. Since test-focas.ps1 needs it, the 6th CLI ships here:

- src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli: probe/read/write/subscribe
  commands, AssemblyName `otopcua-focas-cli`. WriteCommand.ParseValue
  handles the full FocasDataType enum (Bit/Byte/Int16/Int32/Float32/
  Float64/String — no UInt variants; the FOCAS protocol exposes signed
  PMC + Fanuc-Float only). Default DataType is Int16 to match the PMC
  register convention.

Full-solution build clean (0 errors). FOCAS CLI wired into
ZB.MOM.WW.OtOpcUa.slnx. No .Tests project for the FOCAS CLI yet —
symmetric with how ProbeCommand has no unit-testable pure logic in the
other 5 CLIs either; WriteCommand.ParseValue parity will land in a
follow-up to keep this PR scoped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:51:13 -04:00
1507486b45 Merge pull request 'Task #252 — docs/ index + parent doc for the driver CLI suite' (#206) from task-252-driver-cli-index into v2 2026-04-21 08:57:24 -04:00
Joseph Doherty
adce4e7727 Task #252 — docs/ index + parent doc for the driver CLI suite
Per-CLI runbooks (Driver.{Modbus,AbCip,AbLegacy,S7,TwinCAT}.Cli.md) shipped
with #249-#251 but docs/README.md's Client tooling table never grew entries
for them and there was no parent doc pulling the suite together.

Adds:
  - docs/DriverClis.md — short parent. Index table, shared-commands callout
    (probe / read / write / subscribe), Driver.Cli.Common infrastructure
    note (what's shared, marginal cost of adding a sixth CLI), typical
    cross-CLI workflows (commissioning, bug reproduction, recipe-write
    validation, byte-order debugging), known gaps that cross-ref the
    per-CLI docs (AB Legacy ab_server upstream gap, S7 PUT/GET enable,
    TwinCAT AMS router, UDT-write refusal), tracking pointer to #249-251.
  - docs/README.md — Client tooling table grows 6 rows (DriverClis parent
    + 5 per-CLI). Also corrects the Client.CLI.md row: it's otopcua-cli,
    not lmxopcua-cli (renamed in #208).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:55:17 -04:00
4446a3ce5b Merge pull request 'Task #251 — S7 + TwinCAT test-client CLIs (driver CLI suite complete)' (#205) from task-251-s7-twincat-cli into v2 2026-04-21 08:47:03 -04:00
Joseph Doherty
4dc685a365 Task #251 — S7 + TwinCAT test-client CLIs (driver CLI suite complete)
Final two of the five driver test clients. Pattern carried forward from
#249 (Modbus) + #250 (AB CIP, AB Legacy) — each CLI inherits Driver.Cli.Common
for DriverCommandBase + SnapshotFormatter and adds a protocol-specific
CommandBase + 4 commands (probe / read / write / subscribe).

New projects:
  - src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ — otopcua-s7-cli.
    S7CommandBase carries host/port/cpu/rack/slot/timeout. Handles all S7
    atomic types (Bool, Byte, Int16..UInt64, Float32/64, String, DateTime).
    DateTime parses via RoundtripKind so "2026-04-21T12:34:56Z" works.
  - src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ — otopcua-twincat-cli.
    TwinCATCommandBase carries ams-net-id + ams-port + --poll-only toggle
    (flips UseNativeNotifications=false). Covers the full IEC 61131-3
    atomic set: Bool, SInt/USInt, Int/UInt, DInt/UDInt, LInt/ULInt, Real,
    LReal, String, WString, Time/Date/DateTime/TimeOfDay. Structure writes
    refused as out-of-scope (same as AB CIP). IEC time/date variants marshal
    as UDINT on the wire per IEC spec. Subscribe banner announces "ADS
    notification" vs "polling" so the mechanism is obvious in bug reports.

Tests (49 new, 122 cumulative driver-CLI):
  - S7: 22 tests. Every S7DataType has a happy-path + bounds case. DateTime
    round-trips an ISO-8601 string. Tag-name synthesis round-trips every
    S7 address form (DB / M / I / Q, bit/word/dword, strings).
  - TwinCAT: 27 tests. Full IEC type matrix including WString UTF-8 pass-
    through + the four IEC time/date variants landing on UDINT. Structure
    rejection case. Tag-name synthesis for Program scope, GVL scope, nested
    UDT members, and array elements.

Docs:
  - docs/Driver.S7.Cli.md — address grammar cheat sheet + the PUT/GET-must-
    be-enabled gotcha every S7-1200/1500 operator hits.
  - docs/Driver.TwinCAT.Cli.md — AMS router prerequisite (XAR / standalone
    Router NuGet / remote AMS route) + per-command examples.

Wiring:
  - ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests).

Full-solution build clean. Both --help outputs verified end-to-end.

Driver CLI suite complete: 5 CLIs (otopcua-{modbus,abcip,ablegacy,s7,twincat}-cli)
sharing a common base + formatter. 122 CLI tests cumulative. Every driver family
shipped in v2 now has a shell-level ad-hoc validation tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:44:53 -04:00
ff50aac59f Merge pull request 'Task #250 — AB CIP + AB Legacy test-client CLIs' (#204) from task-250-abcip-ablegacy-cli into v2 2026-04-21 08:34:49 -04:00
Joseph Doherty
b2065f8730 Task #250 — AB CIP + AB Legacy test-client CLIs
Second + third of the four driver test clients. Both follow the same shape as
otopcua-modbus-cli (#249) and consume Driver.Cli.Common for DriverCommandBase +
SnapshotFormatter.

New projects:
  - src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ — otopcua-abcip-cli.
    AbCipCommandBase carries gateway (ab://host[:port]/cip-path) + family
    (ControlLogix/CompactLogix/Micro800/GuardLogix) + timeout.
    Commands: probe, read, write, subscribe.
    Value parser covers every AbCipDataType atomic type (Bool, SInt..LInt,
    USInt..ULInt, Real, LReal, String, Dt); Structure writes refused as
    out-of-scope for the CLI.
  - src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ — otopcua-ablegacy-cli.
    AbLegacyCommandBase carries gateway + plc-type (Slc500/MicroLogix/Plc5/
    LogixPccc) + timeout.
    Commands: probe (default address N7:0), read, write, subscribe.
    Value parser covers Bit, Int, Long, Float, AnalogInt, String, and the
    three sub-element types (TimerElement / CounterElement / ControlElement
    all land on int32 at the wire).

Tests (35 new, 73 cumulative across the driver CLI family):
  - AB CIP: 17 tests — ParseValue happy-paths for every Logix atomic type,
    failure cases (non-numeric / bool garbage), tag-name synthesis.
  - AB Legacy: 18 tests — ParseValue coverage (Bit / Int / AnalogInt / Long /
    Float / String / sub-elements), PCCC address round-trip in tag names
    including bit-within-word + sub-element syntax.

Docs:
  - docs/Driver.AbCip.Cli.md — family ↔ CIP-path cheat sheet + examples per
    command + typical workflows.
  - docs/Driver.AbLegacy.Cli.md — PCCC address primer (file letters → CLI
    --type) + known ab_server upstream gap cross-ref to #224 close-out.

Wiring:
  - ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests).

Full-solution build clean. `otopcua-abcip-cli --help` + `otopcua-ablegacy-cli
--help` verified end-to-end.

Next up (#251): S7 + TwinCAT CLIs, same pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:32:43 -04:00
9020b5854c Merge pull request 'Task #249 — Driver test-client CLIs: shared lib + Modbus CLI first' (#203) from task-249-driver-cli-common-modbus into v2 2026-04-21 08:17:20 -04:00
Joseph Doherty
5dac2e9375 Task #249 — Driver test-client CLIs: shared lib + Modbus CLI first
Mirrors the v1 otopcua-cli value prop (ad-hoc shell-level PLC validation) for
the Modbus-TCP driver, and lays down the shared scaffolding that AB CIP, AB
Legacy, S7, and TwinCAT CLIs will build on.

New projects:
  - src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ — DriverCommandBase (verbose
    flag + Serilog config) + SnapshotFormatter (single-tag + table +
    write-result renders with invariant-culture value formatting + OPC UA
    status-code shortnames + UTC-normalised timestamps).
  - src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ — otopcua-modbus-cli executable.
    Commands: probe, read, write, subscribe. ModbusCommandBase carries the
    host/port/unit-id flags + builds ModbusDriverOptions with Probe.Enabled
    =false (CLI runs are one-shot; driver-internal keep-alive would race).

Commands + coverage:
  - probe              single FC03 + GetHealth() + pretty-print
  - read               region × address × type synth into one driver tag
  - write              same shape + --value parsed per --type
  - subscribe          polled-subscription stream until Ctrl+C

Tests (38 total):
  - 16 SnapshotFormatterTests covering: status-code shortnames, unknown
    codes fall back to hex, null value + timestamp placeholders, bool
    lowercase, float invariant culture, string quoting, write-result shape,
    aligned table columns, mismatched-length rejection, UTC normalisation.
  - 22 Modbus CLI tests:
      · ReadCommandTests.SynthesiseTagName (5 theory cases)
      · WriteCommandParseValueTests (17 cases: bool aliases, unknown rejected,
        Int16 bounds, UInt16/Bcd16 type, Float32/64 invariant culture,
        String passthrough, BitInRegister, Int32 MinValue, non-numeric reject)

Wiring:
  - ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests).
  - docs/Driver.Modbus.Cli.md — operator-facing runbook with examples per
    command + output format + typical workflows.

Regression: full-solution build clean; shared-lib tests 16/0, Modbus CLI tests
22/0.

Next up: repeat the pattern for AB CIP (shares ~40% more with Modbus via
libplctag), then AB Legacy, S7, TwinCAT. The shared base stays as-is unless
one of those exposes a gap the Modbus-first pass missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:15:14 -04:00
b644b26310 Merge pull request 'Task #224 close — AB Legacy PCCC fixture: AB_LEGACY_TRUST_WIRE opt-in' (#202) from task-224-close-ablegacy-fixture into v2 2026-04-21 04:19:49 -04:00
Joseph Doherty
012c6a4e7a Task #224 close — AB Legacy PCCC fixture: add AB_LEGACY_TRUST_WIRE opt-in for wire-level runs
The ab_server Docker simulator accepts TCP at :44818 when started with
--plc=SLC500 but its PCCC dispatcher is a confirmed upstream gap
(verified 2026-04-21 with --debug=5: zero request logs when libplctag
issues a read, every read surfaces BadCommunicationError 0x80050000).

Previous behavior — when Docker was up, the three smoke tests ran and
all failed on every integration-host run. Noise, not signal.

New behavior — AbLegacyServerFixture gates on a new env var
AB_LEGACY_TRUST_WIRE:

  Endpoint reachable? | TRUST_WIRE set? | Result
  --------------------+-----------------+------------------------------
  No                  | —               | Skip ("not reachable")
  Yes                 | No              | Skip ("ab_server PCCC gap")
  Yes                 | 1 / true        | Run

The fixture's new skip reason explicitly names the upstream gap + the
resolution paths (upstream bug / RSEmulate golden-box / real hardware
via task #222 lab rig). Operators with a real SLC 5/05 / MicroLogix
1100/1400 / PLC-5 or an Emulate box set AB_LEGACY_ENDPOINT + TRUST_WIRE
and the smoke tests round-trip cleanly.

Updated docs:
  - tests/.../Docker/README.md — new env-var table + three-case gate matrix
  - Known limitations section refreshed to "confirmed upstream gap"

Verified locally:
  - Docker down: 2 skipped.
  - Docker up + TRUST_WIRE unset: 2 skipped (upstream-gap message).
  - Docker up + TRUST_WIRE=1: 4 run, 4 fail BadCommunicationError (ab_server gap as expected).
  - Unit suite: 96 passed / 0 failed (regression-clean).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 04:17:46 -04:00
ae07fea630 Merge pull request 'Task #242 finish — UnsTab drag-drop interactive E2E tests un-skip + pass' (#201) from task-242-finish-interactive-tests into v2 2026-04-21 02:33:26 -04:00
Joseph Doherty
c41831794a Task #242 finish — UnsTab drag-drop interactive Playwright E2E tests un-skip + pass
Closes the scope-out left by the #242 partial. Root cause of the blazor.web.js
zero-byte response turned out to be two co-operating harness bugs:

1) The static-asset manifest was discoverable but the runtime needs
   UseStaticWebAssets to be called so the StaticWebAssetsLoader composes a
   PhysicalFileProvider per ContentRoot declared in
   staticwebassets.development.json (Admin source wwwroot + obj/compressed +
   the framework NuGet cache). Without that call MapStaticAssets resolves the
   route but has no ContentRoot map — so every asset serves zero bytes.

2) The EF InMemory DB name was being re-generated on every DbContext
   construction (the lambda body called Guid.NewGuid() inline), so the seed
   scope, Blazor circuit scope, and test-assertion scopes all got separate
   stores. Capturing the name as a stable string per fixture instance fixes
   the "cluster not found → page stays at Loading…" symptom.

Fixes:
  - AdminWebAppFactory:
      * ApplicationName set on WebApplicationOptions so UseStaticWebAssets
        discovers the manifest.
      * builder.WebHost.UseStaticWebAssets() wired explicitly (matches what
        `dotnet run` does via MSBuild targets).
      * dbName captured once per fixture; the options lambda reads the
        captured string instead of re-rolling a Guid.
  - UnsTabDragDropE2ETests: the two [Fact(Skip=...)] tests un-skip.

Suite state: 3 passed, 0 skipped, 0 failed. Task #242 closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:31:26 -04:00
3e3c7206dd Merge pull request 'Task #242 partial — UnsTab interactive E2E test bodies + harness upgrades (Skip-guarded)' (#200) from task-242-unstab-interactive-partial into v2 2026-04-21 02:11:48 -04:00
Joseph Doherty
4e96f228b2 Task #242 partial — UnsTab interactive E2E test bodies + harness upgrades (tests Skip-guarded pending blazor.web.js asset plumbing)
Carries the interactive drag-drop + 409 concurrent-edit test bodies (full Playwright
flows against the real @ondragstart/@ondragover/@ondrop handlers + modal + EF state
round-trip), plus several harness upgrades that push the in-process
WebApplication-based fixture closer to a working Blazor Server circuit. The
interactive tests are marked [Fact(Skip=...)] pending resolution of one remaining
blocker documented in the class docstring.

Harness upgrades (AdminWebAppFactory):
  - Environment set to Development so 500s surface exception stacks (rather than
    the generic error page) during future diagnosis.
  - ContentRootPath pointed at the Admin assembly dir so wwwroot + manifest files
    resolve.
  - Wired SignalR hubs (/hubs/fleet, /hubs/alerts) so ClusterDetail's HubConnection
    negotiation no longer 500s at first render.
  - Services property exposed so tests can open scoped DI contexts against the
    running host (scheduled peer-edit simulation, post-commit state assertion).

Remaining blocker (reason for Skip):
  /_framework/blazor.web.js returns HTTP 200 with a zero-byte body. The asset's
  route is declared in OtOpcUa.Admin.staticwebassets.endpoints.json, but the
  underlying file is shipped by the framework NuGet package
  (Microsoft.AspNetCore.App.Internal.Assets/_framework/blazor.web.js) rather than
  copied into the Admin wwwroot. MapStaticAssets can't resolve it without wiring
  a composite FileProvider or the WebRootPath machinery. Three viable next-session
  approaches listed in the class docstring:
    (a) Composite FileProvider mapping /_framework/* → NuGet cache.
    (b) Subprocess harness spawning real dotnet run of Admin project with an
        InMemory-DB override (closest to production composition).
    (c) MSBuild ItemGroup in the test csproj that copies framework files into the
        test output + ContentRoot=test assembly dir with UseStaticFiles.

Scaffolding smoke test (Admin_host_serves_HTTP_via_Playwright_scaffolding) stays
green unchanged.

Suite state: 1 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:09:44 -04:00
443474f58f Merge pull request 'Task #220 — Wire FOCAS into DriverFactoryRegistry bootstrap pipeline' (#199) from task-220-focas-factory-registration into v2 2026-04-21 01:10:40 -04:00
Joseph Doherty
dfe3731c73 Task #220 — Wire FOCAS into DriverFactoryRegistry bootstrap pipeline
Closes the non-hardware gap surfaced in the #220 audit: FOCAS had full Tier-C
architecture (Driver.FOCAS + Driver.FOCAS.Host + Driver.FOCAS.Shared, supervisor,
post-mortem MMF, NSSM scripts, 239 tests) but no factory registration, so config-DB
DriverInstance rows of type "FOCAS" would fail at bootstrap with "unknown driver
type". Hardware-gated FwlibHostedBackend (real Fwlib32 P/Invoke inside the Host
process) stays deferred under #222 lab-rig.

Ships:
  - FocasDriverFactoryExtensions.Register(registry) mirroring the Galaxy pattern.
    JSON schema selects backend via "Backend" field:
      "ipc" (default) — IpcFocasClientFactory → named-pipe FocasIpcClient →
                        Driver.FOCAS.Host process (Tier-C isolation)
      "fwlib"         — direct in-process FwlibFocasClientFactory (P/Invoke)
      "unimplemented" — UnimplementedFocasClientFactory (fail-fast on use —
                        useful for staging DriverInstance rows pre-Host-deploy)
  - Devices / Tags / Probe / Timeout / Series feed into FocasDriverOptions.
    Series validated eagerly at top-level so typos fail at bootstrap, not first
    read. Tag DataType + Series enum values surface clear errors listing valid
    options.
  - Program.cs adds FocasDriverFactoryExtensions.Register alongside Galaxy.
  - Driver.FOCAS.csproj references Core (for DriverFactoryRegistry).
  - Server.csproj adds Driver.FOCAS ProjectReference so the factory type is
    reachable from Program.cs.

Tests: 13 new FocasDriverFactoryExtensionsTests covering: registry entry,
case-insensitive lookup, ipc backend with full config, ipc defaults, missing
PipeName/SharedSecret errors, fwlib backend short-path, unimplemented backend,
unknown-backend error, unknown-Series error, tag missing DataType, null/ws args,
duplicate-register throws.

Regression: 202 FOCAS + 13 FOCAS.Host + 24 FOCAS.Shared + 239 Server all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:08:25 -04:00
6863cc4652 Merge pull request 'Task #219 follow-up — close AlarmConditionState child-NodeId + Part 9 event-propagation gaps' (#198) from task-219-followup-alarm-wiring into v2 2026-04-21 00:24:41 -04:00
Joseph Doherty
8221fac8c1 Task #219 follow-up — close AlarmConditionState child-NodeId + event-propagation gaps
PR #197 surfaced two integration-level wiring gaps in DriverNodeManager's
MarkAsAlarmCondition path; this commit fixes both and upgrades the integration
test to assert them end-to-end.

Fix 1 — addressable child nodes: AlarmConditionState inherits ~50 typed children
(Severity / Message / ActiveState / AckedState / EnabledState / …). The stack
was leaving them with Foundation-namespace NodeIds (type-declaration defaults) or
shared ns=0 counter allocations, so client Read on a child returned
BadNodeIdUnknown. Pass assignNodeIds=true to alarm.Create, then walk the condition
subtree and rewrite each descendant's NodeId symbolically as
  {condition-full-ref}.{symbolic-path}
in the node manager's namespace. Stable, unique, and collision-free across
multiple alarm instances in the same driver.

Fix 2 — event propagation to Server.EventNotifier: OPC UA Part 9 event
propagation relies on the alarm condition being reachable from Objects/Server
via HasNotifier. Call CustomNodeManager2.AddRootNotifier(alarm) after registering
the condition so subscriptions placed on Server-object EventNotifier receive the
ReportEvent calls ConditionSink emits per-transition.

Test upgrades in AlarmSubscribeIntegrationTests:
  - Driver_alarm_transition_updates_server_side_AlarmConditionState_node — now
    asserts Severity == 700, Message text, and ActiveState.Id == true through
    the OPC UA client (previously scoped out as BadNodeIdUnknown).
  - New: Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier
    subscribes an OPC UA event monitor on ObjectIds.Server, fires a driver
    transition, and waits for the AlarmConditionType event to be delivered,
    asserting Message + Severity fields. Previously scoped out as "Part 9 event
    propagation out of reach."

Regression checks: 239 server tests pass (+1 new event-subscription test),
195 Core tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:22:02 -04:00
bc44711dca Merge pull request 'Task #219 — Server-integration test coverage for IAlarmSource dispatch path' (#197) from task-219-alarm-history-integration into v2 2026-04-20 23:36:26 -04:00
Joseph Doherty
acf31fd943 Task #219 — Server-integration test coverage for IAlarmSource dispatch path
Adds AlarmSubscribeIntegrationTests alongside HistoryReadIntegrationTests so both
optional driver capabilities — IHistoryProvider (already covered) and IAlarmSource
(new) — have end-to-end coverage that boots the full OPC UA stack and exercises the
wiring path from driver event → GenericDriverNodeManager forwarder → DriverNodeManager
ConditionSink through a real Session.

Two tests:
  1. Driver_alarm_transition_updates_server_side_AlarmConditionState_node — a fake
     IAlarmSource declares an IsAlarm=true variable, calls MarkAsAlarmCondition in
     DiscoverAsync, and fires OnAlarmEvent for that source. Verifies the
     client can browse the alarm condition node at FullReference + ".Condition"
     and reads the DisplayName back through Session.Read.
  2. Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace —
     two IsAlarm variables each produce their own addressable AlarmConditionState,
     proving the CapturingHandle per-variable registration works.

Scoped-out (documented in the class docstring): the stack exposes AlarmConditionState's
inherited children (Severity / Message / ActiveState / …) with Foundation-namespace
NodeIds that DriverNodeManager does not add to its predefined-node index, so reading
those child attributes through a client returns BadNodeIdUnknown. OPC UA Part 9 event
propagation (subscribe-on-Server + ConditionRefresh) is likewise out of reach until
the node manager wires HasNotifier + child-node registration. The existing Core-level
GenericDriverNodeManagerTests cover the in-memory alarm-sink fan-out semantics.

Full Server.Tests suite: 238 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:33:45 -04:00
7e143e293b Merge pull request 'Driver-instance bootstrap pipeline (#248) — DriverInstance rows materialise as live IDriver instances' (#196) from phase-7-fu-248-driver-bootstrap into v2 2026-04-20 22:52:12 -04:00
Joseph Doherty
2cb22598d6 Drop accidentally-committed LiteDB cache file + add to .gitignore
The previous commit (#248 wiring) inadvertently picked up
src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db — generated by the live smoke
re-run that proved the bootstrapper works. Remove from tracking + ignore
going forward so future runs don't dirty the working tree.
2026-04-20 22:49:48 -04:00
Joseph Doherty
3d78033ea4 Driver-instance bootstrap pipeline (#248) — DriverInstance rows materialise as live IDriver instances
Closes the gap surfaced by Phase 7 live smoke (#240): DriverInstance rows in
the central config DB had no path to materialise as live IDriver instances in
DriverHost, so virtual-tag scripts read BadNodeIdUnknown for every tag.

## DriverFactoryRegistry (Core.Hosting)
Process-singleton type-name → factory map. Each driver project's static
Register call pre-loads its factory at Program.cs startup; the bootstrapper
looks up by DriverInstance.DriverType + invokes with (DriverInstanceId,
DriverConfig JSON). Case-insensitive; duplicate-type registration throws.

## GalaxyProxyDriverFactoryExtensions.Register (Driver.Galaxy.Proxy)
Static helper — no Microsoft.Extensions.DependencyInjection dep, keeps the
driver project free of DI machinery. Parses DriverConfig JSON for PipeName +
SharedSecret + ConnectTimeoutMs. DriverInstanceId from the row wins over JSON
per the schema's UX_DriverInstance_Generation_LogicalId.

## DriverInstanceBootstrapper (Server)
After NodeBootstrap loads the published generation: queries DriverInstance
rows scoped to that generation, looks up the factory per row, constructs +
DriverHost.RegisterAsync (which calls InitializeAsync). Per plan decision
#12 (driver isolation), failure of one driver doesn't prevent others —
logs ERR + continues + returns the count actually registered. Unknown
DriverType (factory not registered) logs WRN + skips so a missing-assembly
deployment doesn't take down the whole server.

## Wired into OpcUaServerService.ExecuteAsync
After NodeBootstrap.LoadCurrentGenerationAsync, before
PopulateEquipmentContentAsync + Phase7Composer.PrepareAsync. The Phase 7
chain now sees a populated DriverHost so CachedTagUpstreamSource has an
upstream feed.

## Live evidence on the dev box
Re-ran the Phase 7 smoke from task #240. Pre-#248 vs post-#248:
  Equipment namespace snapshots loaded for 0/0 driver(s)  ← before
  Equipment namespace snapshots loaded for 1/1 driver(s)  ← after

Galaxy.Host pipe ACL denied our SID (env-config issue documented in
docs/ServiceHosting.md, NOT a code issue) — the bootstrapper logged it as
"failed to initialize, driver state will reflect Faulted" and continued past
the failure exactly per plan #12. The rest of the pipeline (Equipment walker
+ Phase 7 composer) ran to completion.

## Tests — 5 new DriverFactoryRegistryTests
Register + TryGet round-trip, case-insensitive lookup, duplicate-type throws,
null-arg guards, RegisteredTypes snapshot. Pure functions; no DI/DB needed.
The bootstrapper's DB-query path is exercised by the live smoke (#240) which
operators run before each release.
2026-04-20 22:49:25 -04:00
48a43ac96e Merge pull request 'Phase 7 follow-up #240 — Live OPC UA E2E smoke runbook + seed + first-run evidence' (#195) from phase-7-fu-240-e2e-smoke into v2 2026-04-20 22:34:43 -04:00
Joseph Doherty
98a8031772 Phase 7 follow-up #240 — Live OPC UA E2E smoke runbook + seed + first-run evidence
Closes the live-smoke validation Phase 7 deferred to. Ships:

## docs/v2/implementation/phase-7-e2e-smoke.md
End-to-end runbook covering: prerequisites (Galaxy + OtOpcUaGalaxyHost + SQL
Server), Setup (migrate, seed, edit Galaxy attribute placeholder, point Server
at smoke node), Run (server start in non-elevated shell + Client.CLI browse +
Read on virtual tag + Read on scripted alarm + Galaxy push to drive the alarm
+ historian queue verification), Acceptance Checklist (8 boxes), and Known
limitations + follow-ups (subscribe-via-monitored-items, OPC UA Acknowledge
method dispatch, compliance-script live mode).

## scripts/smoke/seed-phase-7-smoke.sql
Idempotent seed (DROP + INSERT in dependency order) that creates one cluster's
worth of Phase 7 test config: ServerCluster, ClusterNode, ConfigGeneration
(Published via sp_PublishGeneration), Namespace (Equipment kind), UnsArea,
UnsLine, Equipment, Galaxy DriverInstance pointing at the running
OtOpcUaGalaxyHost pipe, Tag bound to the Equipment, two Scripts (Doubled +
OverTemp predicate), VirtualTag, ScriptedAlarm. Includes the SET QUOTED_IDENTIFIER
ON / sqlcmd -I dance the filtered indexes need, populates every required
ClusterNode column the schema enforces (OpcUaPort, DashboardPort,
ServiceLevelBase, etc.), and ends with a NEXT-STEPS PRINT block telling the
operator what to edit before starting the Server.

## First-run evidence on the dev box

Running the seed + starting the Server (non-elevated shell, Galaxy.Host
already running) emitted these log lines verbatim — proving the entire
Phase 7 wiring chain executes in production:

  Bootstrapped from central DB: generation 1
  Phase 7 historian sink: no driver provides IAlarmHistorianWriter — using NullAlarmHistorianSink
  VirtualTagEngine loaded 1 tag(s), 1 upstream subscription(s)
  ScriptedAlarmEngine loaded 1 alarm(s)
  Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)

Each line corresponds to a piece shipped in #243 / #244 / #245 / #246 / #247.
The composer ran, engines loaded, historian-sink decision fired, scripts
compiled.

## Surfaced — pre-Phase-7 deployment-wiring gaps (NOT Phase 7 regressions)

1. Driver-instance bootstrap pipeline missing — DriverInstance rows in the DB
   never materialise IDriver instances in DriverHost. Filed as task #248.
2. OPC UA endpoint port collision when another OPC UA server already binds 4840.
   Operator concern; documented in the runbook prereqs.

Both predate Phase 7 + are orthogonal. Phase 7 itself ships green — every line
of new wiring executed exactly as designed.

## Phase 7 production wiring chain — VALIDATED end-to-end

-  #243 composition kernel
-  #244 driver bridge
-  #245 scripted-alarm IReadable adapter
-  #246 Program.cs wire-in
-  #247 Galaxy.Host historian writer + SQLite sink activation
-  #240 this — live smoke + runbook + first-run evidence

Phase 7 is complete + production-ready, modulo the pre-existing
driver-bootstrap gap (#248).
2026-04-20 22:32:33 -04:00
efdf04320a Merge pull request 'Phase 7 follow-up #247 — Galaxy.Host historian writer + SQLite sink activation' (#194) from phase-7-fu-247-galaxy-historian-writer into v2 2026-04-20 22:21:01 -04:00
Joseph Doherty
bb10ba7108 Phase 7 follow-up #247 — Galaxy.Host historian writer + SQLite sink activation
Closes the historian leg of Phase 7. Scripted alarm transitions now batch-flow
through the existing Galaxy.Host pipe + queue durably in a local SQLite store-
and-forward when Galaxy is the registered driver, instead of being dropped into
NullAlarmHistorianSink.

## GalaxyHistorianWriter (Driver.Galaxy.Proxy.Ipc)

IAlarmHistorianWriter implementation. Translates AlarmHistorianEvent →
HistorianAlarmEventDto (Stream D contract), batches via the existing
GalaxyIpcClient.CallAsync round-trip on MessageKind.HistorianAlarmEventRequest /
Response, maps per-event HistorianAlarmEventOutcomeDto bytes back to
HistorianWriteOutcome (Ack/RetryPlease/PermanentFail) so the SQLite drain
worker knows what to ack vs dead-letter vs retry. Empty-batch fast path.
Pipe-level transport faults (broken pipe, host crash) bubble up as
GalaxyIpcException which the SQLite sink's drain worker translates to
whole-batch RetryPlease per its catch contract.

## GalaxyProxyDriver implements IAlarmHistorianWriter

Marker interface lets Phase7Composer discover it via type check at compose
time. WriteBatchAsync delegates to a thin GalaxyHistorianWriter wrapping the
driver's existing _client. Throws InvalidOperationException if InitializeAsync
hasn't connected yet — the SQLite drain worker treats that as a transient
batch failure and retries.

## Phase7Composer.ResolveHistorianSink

Replaces the injected sink dep when any registered driver implements
IAlarmHistorianWriter. Constructs SqliteStoreAndForwardSink at
%ProgramData%/OtOpcUa/alarm-historian-queue.db (falls back to %TEMP% when
ProgramData unavailable, e.g. dev), starts the 2s drain timer, owns the sink
disposable for clean teardown. When no driver provides the writer, keeps the
NullAlarmHistorianSink wired by Program.cs (#246).

DisposeAsync now also disposes the owned SQLite sink in the right order:
bridge → engines → owned sink → injected fallback.

## Tests — 7 new GalaxyHistorianWriterMappingTests

ToDto round-trips every field; preserves null Comment; per-byte outcome enum
mapping (Ack / RetryPlease / PermanentFail) via [Theory]; unknown byte throws;
ctor null-guard. The IPC round-trip itself is covered by the live Host suite
(task #240) which constructs a real pipe.

Server.Phase7 tests: 34/34 still pass; Galaxy.Proxy tests: 25/25 (+7 = 32 total).

## Phase 7 production wiring chain — COMPLETE
-  #243 composition kernel
-  #245 scripted-alarm IReadable adapter
-  #244 driver bridge
-  #246 Program.cs wire-in
-  #247 this — Galaxy.Host historian writer + SQLite sink activation

What unblocks now: task #240 live OPC UA E2E smoke. With a Galaxy driver
registered, scripted alarm transitions flow end-to-end through the engine →
SQLite queue → drain worker → Galaxy.Host IPC → Aveva Historian alarm schema.
Without Galaxy, NullSink keeps the engines functional and the queue dormant.
2026-04-20 22:18:39 -04:00
42f3b17c4a Merge pull request 'Phase 7 follow-up #246 — Phase7Composer + Program.cs wire-in' (#193) from phase-7-fu-246-program-wireup into v2 2026-04-20 22:08:18 -04:00
Joseph Doherty
7352db28a6 Phase 7 follow-up #246 — Phase7Composer + Program.cs wire-in
Activates the Phase 7 engines in production. Loads Script + VirtualTag +
ScriptedAlarm rows from the bootstrapped generation, wires the engines through
the Phase7EngineComposer kernel (#243), starts the DriverSubscriptionBridge feed
(#244), and late-binds the resulting IReadable sources to OpcUaApplicationHost
before OPC UA server start.

## Phase7Composer (Server.Phase7)

Singleton orchestrator. PrepareAsync loads the three Phase 7 row sets in one
DB scope, builds CachedTagUpstreamSource, calls Phase7EngineComposer.Compose,
constructs DriverSubscriptionBridge with one DriverFeed per registered
ISubscribable driver (path-to-fullRef map built from EquipmentNamespaceContent
via MapPathsToFullRefs), starts the bridge.

DisposeAsync tears down in the right order: bridge first (no more events fired
into the cache), then engines (cascades + timers stop), then any disposable sink.

MapPathsToFullRefs: deterministic path convention is
  /{areaName}/{lineName}/{equipmentName}/{tagName}
matching exactly what EquipmentNodeWalker emits into the OPC UA browse tree, so
script literals against the operator-visible UNS tree work without translation.
Tags missing EquipmentId or pointing at unknown Equipment are skipped silently
(Galaxy SystemPlatform-style tags + dangling references handled).

## OpcUaApplicationHost.SetPhase7Sources

New late-bind setter. Throws InvalidOperationException if called after
StartAsync because OtOpcUaServer + DriverNodeManagers capture the field values
at construction; mutation post-start would silently fail.

## OpcUaServerService

After bootstrap loads the current generation, calls phase7Composer.PrepareAsync
+ applicationHost.SetPhase7Sources before applicationHost.StartAsync. StopAsync
disposes Phase7Composer first so the bridge stops feeding the cache before the
OPC UA server tears down its node managers (avoids in-flight cascades surfacing
as noisy shutdown warnings).

## Program.cs

Registers IAlarmHistorianSink as NullAlarmHistorianSink.Instance (task #247
swaps in the real Galaxy.Host-writer-backed SqliteStoreAndForwardSink), Serilog
root logger, and Phase7Composer singleton.

## Tests — 5 new Phase7ComposerMappingTests = 34 Phase 7 tests total

Maps tag → walker UNS path, skips null EquipmentId, skips unknown Equipment
reference, multiple tags under same equipment map distinctly, empty content
yields empty map. Pure functions; no DI/DB needed.

The real PrepareAsync DB query path can't be exercised without SQL Server in
the test environment — it's exercised by the live E2E smoke (task #240) which
unblocks once #247 lands.

## Phase 7 production wiring chain status
-  #243 composition kernel
-  #245 scripted-alarm IReadable adapter
-  #244 driver bridge
-  #246 this — Program.cs wire-in
- 🟡 #247 — Galaxy.Host SqliteStoreAndForwardSink writer adapter (replaces NullSink)
- 🟡 #240 — live E2E smoke (unblocks once #247 lands)
2026-04-20 22:06:03 -04:00
8388ddc033 Merge pull request 'Phase 7 follow-up #244 — DriverSubscriptionBridge' (#192) from phase-7-fu-244-driver-bridge into v2 2026-04-20 21:55:15 -04:00
Joseph Doherty
e11350cf80 Phase 7 follow-up #244 — DriverSubscriptionBridge
Pumps live driver OnDataChange notifications into CachedTagUpstreamSource so
ctx.GetTag in user scripts sees the freshest driver value. The last missing piece
between #243 (composition kernel) and #246 (Program.cs wire-in).

## DriverSubscriptionBridge

IAsyncDisposable. Per DriverFeed: groups all paths for one ISubscribable into a
single SubscribeAsync call (consolidating polled drivers' work + giving
native-subscription drivers one watch list), keeps a per-feed reverse map from
driver-opaque fullRef back to script-side UNS path, hooks OnDataChange to
translate + push into the cache. DisposeAsync awaits UnsubscribeAsync per active
subscription + unhooks every handler so events post-dispose are silent.

Empty PathToFullRef map → feed skipped (no SubscribeAsync call). Subscribe failure
on any feed unhooks that feed's handler + propagates so misconfiguration aborts
bridge start cleanly. Double-Start throws InvalidOperationException; double-Dispose
is idempotent.

OTOPCUA0001 suppressed at the two ISubscribable call sites with comments
explaining the carve-out: bridge is the lifecycle-coordinator for Phase 7
subscriptions (one Subscribe at engine compose, one Unsubscribe at shutdown),
not the per-call hot-path. Driver Read dispatch still goes through CapabilityInvoker
via DriverNodeManager.

## Tests — 9 new = 29 Phase 7 tests total

DriverSubscriptionBridgeTests covers: SubscribeAsync called with distinct fullRefs,
OnDataChange pushes to cache keyed by UNS path, unmapped fullRef ignored, empty
PathToFullRef skips Subscribe, DisposeAsync unsubscribes + unhooks (post-dispose
events don't push), StartAsync called twice throws, DisposeAsync idempotent,
Subscribe failure unhooks handler + propagates, ctor null guards.

## Phase 7 production wiring chain status
- #243  composition kernel
- #245  scripted-alarm IReadable adapter
- #244  this — driver bridge
- #246 pending — Program.cs Compose call + SqliteStoreAndForwardSink lifecycle
- #240 pending — live E2E smoke (unblocks once #246 lands)
2026-04-20 21:53:05 -04:00
a5bd60768d Merge pull request 'Phase 7 follow-up #245 — ScriptedAlarmReadable adapter over engine state' (#191) from phase-7-fu-245-alarm-readable into v2 2026-04-20 21:32:57 -04:00
Joseph Doherty
d6a8bb1064 Phase 7 follow-up #245 — ScriptedAlarmReadable adapter over engine state
Task #245 — exposes each scripted alarm's current ActiveState as IReadable so
OPC UA variable reads on Source=ScriptedAlarm nodes return the live predicate
truth instead of BadNotFound.

## ScriptedAlarmReadable

Wraps ScriptedAlarmEngine + implements IReadable:
- Known alarm + Active → DataValueSnapshot(true, Good)
- Known alarm + Inactive → DataValueSnapshot(false, Good)
- Unknown alarm id → DataValueSnapshot(null, BadNodeIdUnknown) — surfaces
  misconfiguration rather than silently reading false
- Batch reads preserve request order

Phase7EngineComposer.Compose now returns this as ScriptedAlarmReadable when
ScriptedAlarm rows are present. ScriptedAlarmSource (IAlarmSource for the event
stream) stays in place — the IReadable is a separate adapter over the same engine.

## Tests — 6 new + 1 updated composer test = 19 total Phase 7 tests

ScriptedAlarmReadableTests covers: inactive + active predicate → bool snapshot,
unknown alarm id → BadNodeIdUnknown, batch order preservation, null-engine +
null-fullReferences guards. The active-predicate test uses ctx.GetTag on a seeded
upstream value to drive a real cascade through the engine.

Updated Phase7EngineComposerTests to assert ScriptedAlarmReadable is non-null
when alarms compose, null when only virtual tags.

## Follow-ups remaining
- #244 — driver-bridge feed populating CachedTagUpstreamSource
- #246 — Program.cs Compose call + SqliteStoreAndForwardSink lifecycle
2026-04-20 21:30:56 -04:00
f3053580a0 Merge pull request 'Phase 7 follow-up #243 — CachedTagUpstreamSource + Phase7EngineComposer' (#190) from phase-7-fu-243-compose into v2 2026-04-20 21:25:46 -04:00
Joseph Doherty
f64a8049d8 Phase 7 follow-up #243 — CachedTagUpstreamSource + Phase7EngineComposer
Ships the composition kernel that maps Config DB rows (Script / VirtualTag /
ScriptedAlarm) to the runtime definitions VirtualTagEngine + ScriptedAlarmEngine
consume, builds the engine instances, and wires OnEvent → historian-sink routing.

## src/ZB.MOM.WW.OtOpcUa.Server/Phase7/

- CachedTagUpstreamSource — implements both Core.VirtualTags.ITagUpstreamSource and
  Core.ScriptedAlarms.ITagUpstreamSource (identical shape, distinct namespaces) on one
  concrete type so the composer can hand one instance to both engines. Thread-safe
  ConcurrentDictionary value cache with synchronous ReadTag + fire-on-write
  Push(path, snapshot) that fans out to every observer registered via SubscribeTag.
  Unknown-path reads return a BadNodeIdUnknown-quality snapshot (status 0x80340000)
  so scripts branch on quality naturally.
- Phase7EngineComposer.Compose(scripts, virtualTags, scriptedAlarms, upstream,
  alarmStateStore, historianSink, rootScriptLogger, loggerFactory) — single static
  entry point that:
  * Indexes scripts by ScriptId, resolves VirtualTag.ScriptId + ScriptedAlarm.PredicateScriptId
    to full SourceCode
  * Projects DB rows to VirtualTagDefinition + ScriptedAlarmDefinition (mapping
    DataType string → DriverDataType enum, AlarmType string → AlarmKind enum,
    Severity 1..1000 → AlarmSeverity bucket matching the OPC UA Part 9 bands
    that AbCipAlarmProjection + OpcUaClient MapSeverity already use)
  * Constructs VirtualTagEngine + loads definitions (throws InvalidOperationException
    with the list of scripts that failed to compile — aggregated like Streams B+C)
  * Constructs ScriptedAlarmEngine + loads definitions + wires OnEvent →
    IAlarmHistorianSink.EnqueueAsync using ScriptedAlarmEvent.Emission as the event
    kind + Condition.LastAckUser/LastAckComment for audit fields
  * Returns Phase7ComposedSources with Disposables list the caller owns

Empty Phase 7 config returns Phase7ComposedSources.Empty so deployments without
scripts / alarms behave exactly as pre-Phase-7. Non-null sources flow into
OpcUaApplicationHost's virtualReadable / scriptedAlarmReadable plumbing landed by
task #239 — DriverNodeManager then dispatches reads by NodeSourceKind per PR #186.

## Tests — 12/12

CachedTagUpstreamSourceTests (6):
- Unknown-path read returns BadNodeIdUnknown-quality snapshot
- Push-then-Read returns cached value
- Push fans out to subscribers in registration order
- Push to one path doesn't fire another path's observer
- Dispose of subscription handle stops fan-out
- Satisfies both Core.VirtualTags + Core.ScriptedAlarms ITagUpstreamSource interfaces

Phase7EngineComposerTests (6):
- Empty rows → Phase7ComposedSources.Empty (both sources null)
- VirtualTag rows → VirtualReadable non-null + Disposables populated
- Missing script reference throws InvalidOperationException with the missing ScriptId
  in the message
- Disabled VirtualTag row skipped by projection
- TimerIntervalMs → TimeSpan.FromMilliseconds round-trip
- Severity 1..1000 maps to Low/Medium/High/Critical at 250/500/750 boundaries
  (matches AbCipAlarmProjection + OpcUaClient.MapSeverity banding)

## Scope — what this PR does NOT do

The composition kernel is the tricky part; the remaining wiring is three narrower
follow-ups that each build on this PR:

- task #244 — driver-bridge feed that populates CachedTagUpstreamSource from live
  driver subscriptions. Without this, ctx.GetTag returns BadNodeIdUnknown even when
  the driver has a fresh value.
- task #245 — ScriptedAlarmReadable adapter exposing each alarm's current Active
  state as IReadable. Phase7EngineComposer.Compose currently returns
  ScriptedAlarmReadable=null so reads on Source=ScriptedAlarm variables return
  BadNotFound per the ADR-002 "misconfiguration not silent fallback" signal.
- task #246 — Program.cs call to Phase7EngineComposer.Compose with config rows
  loaded from the sealed-cache DB read, plus SqliteStoreAndForwardSink lifecycle
  wiring at %ProgramData%/OtOpcUa/alarm-historian-queue.db with the Galaxy.Host
  IPC writer from Stream D.

Task #240 (live OPC UA E2E smoke) depends on all three follow-ups landing.
2026-04-20 21:23:31 -04:00
c7f0855427 Merge pull request 'Phase 7 follow-ups #239 (plumbing) + #241 (diff-proc extension)' (#189) from phase-7-fu-239-bootstrap into v2 2026-04-20 21:10:06 -04:00
Joseph Doherty
63b31e240e Phase 7 follow-ups #239 (plumbing) + #241 (diff-proc extension)
Two complementary pieces that together unblock the last Phase 7 exit-gate deferrals.

## #239 — Thread virtual + scripted-alarm IReadable through to DriverNodeManager

OtOpcUaServer gains virtualReadable + scriptedAlarmReadable ctor params; shared across
every DriverNodeManager it materializes so reads from a virtual-tag node in any
driver's subtree route to the same engine instance. Nulls preserve pre-Phase-7
behaviour (existing tests + drivers untouched).

OpcUaApplicationHost mirrors the same params and forwards them to OtOpcUaServer.

This is the minimum viable wiring — the actual VirtualTagEngine + ScriptedAlarmEngine
instantiation (loading Script/VirtualTag/ScriptedAlarm rows from the sealed cache,
building an ITagUpstreamSource bridge to DriverNodeManager reads, compiling each
script via ScriptEvaluator) lands in task #243. Without that follow-up, deployments
composed with null sources behave exactly as they did before Phase 7 — address-space
nodes with Source=Virtual return BadNotFound per ADR-002, which is the designed
"misconfiguration, not silent fallback" behaviour from PR #186.

## #241 — sp_ComputeGenerationDiff V3 adds Script / VirtualTag / ScriptedAlarm sections

Migration 20260420232000_ExtendComputeGenerationDiffWithPhase7. Same CHECKSUM-based
Modified detection the existing sections use. Logical ids: ScriptId / VirtualTagId /
ScriptedAlarmId. Script CHECKSUM covers Name + SourceHash + Language — source edits
surface as Modified because SourceHash changes; renames surface as Modified on Name
alone; identical (hash + name + language) = Unchanged. VirtualTag + ScriptedAlarm
CHECKSUMs cover their content columns.

ScriptedAlarmState is deliberately excluded — it's logical-id keyed outside the
generation scope per plan decision #14 (ack state follows alarm identity across
Modified generations); diffing it between generations is semantically meaningless.

Down() restores V2 (the NodeAcl-extended proc from migration 20260420000001).

## No new test count — both pieces are proven by existing suites

The NodeSourceKind dispatch kernel is already covered by
DriverNodeManagerSourceDispatchTests (PR #186). The diff-proc extension is exercised
by the existing Admin DiffViewer pipeline test suite once operators publish Phase 7
drafts; a Phase 7 end-to-end diff assertion lands with task #240.
2026-04-20 21:07:59 -04:00
78f388b761 Merge pull request 'Admin.E2ETests scaffolding — Playwright + Kestrel + InMemory DB + test auth' (#188) from phase-6-4-uns-drag-drop-e2e into v2 2026-04-20 20:58:08 -04:00
Joseph Doherty
d78741cfdf Admin.E2ETests scaffolding — Playwright + Kestrel + InMemory DB + test auth
Ships the E2E infrastructure filed against task #199 (UnsTab drag-drop Playwright
smoke). The Blazor Server interactive-render assertion through a test-owned pipeline
needs a dedicated diagnosis pass — filed as task #242 — but the Playwright harness
lands here so that follow-up starts from a known-good scaffolding rather than
setting up the project from scratch.

## New project tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests

- AdminWebAppFactory — boots the Admin pipeline with Kestrel on a free loopback port,
  swaps the SQL DbContext for EF Core InMemory, replaces the LDAP cookie auth with
  TestAuthHandler, mirrors the Razor-components/auth/antiforgery pipeline, and seeds
  a cluster + draft generation with areas warsaw / berlin and a line-a1 in warsaw.
  Not a WebApplicationFactory<Program> because WAF's TestServer transport doesn't
  coexist cleanly with Kestrel-on-a-real-port, which Playwright needs.
- TestAuthHandler — stamps every request with a FleetAdmin claim so tests hit
  authenticated routes without the LDAP bind.
- PlaywrightFixture — one Chromium launch shared across tests; throws
  PlaywrightBrowserMissingException when the binary isn't installed so tests can
  Assert.Skip rather than fail hard.
- UnsTabDragDropE2ETests.Admin_host_serves_HTTP_via_Playwright_scaffolding — proves
  the full stack comes up: Kestrel bind, InMemory DbContext, test auth, Playwright
  navigation, Razor route pipeline responds with HTML < 500. One passing test.

## Prerequisite

Chromium must be installed locally:
  pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium

Absent the browser, the suite Assert.Skip's cleanly — CI without the install step
still reports green. Once installed, `dotnet test` runs the scaffolding smoke in ~12s.

## Follow-up (task #242)

Diagnose why `/clusters/{id}/draft/{gen}` → UNS-tab click → drag-drop flow times out
under the test-owned Program.cs replica. Candidate causes: route-ordering difference,
missing SignalR hub mapping timing, JS interop asset differences, culture middleware.
Once the interactive circuit boots, add:
- happy-path drag-drop assertion (source row → target area → Confirm → assert re-parent)
- 409 conflict variant (preview → external DB mutation → Confirm → assert red-header modal)
2026-04-20 20:55:57 -04:00
c08ae0d032 Merge pull request 'Phase 7 Stream H — exit gate compliance script + closeout doc' (#187) from phase-7-stream-h-exit-gate into v2 2026-04-20 20:27:18 -04:00
Joseph Doherty
82e4e8c8de Phase 7 Stream H — exit gate compliance script + closeout doc
Ships the check-everything PowerShell script + the human-readable exit-gate doc that
closes Phase 7 (scripting runtime + virtual tags + scripted alarms + historian sink
+ Admin UI + address-space integration).

## scripts/compliance/phase-7-compliance.ps1

Mirrors the Phase 6.x compliance pattern. Checks:
- Stream A: Roslyn sandbox wiring, ForbiddenTypeAnalyzer, DependencyExtractor,
  ScriptLogCompanionSink, Deadband helper
- Stream B: VirtualTagEngine, DependencyGraph (iterative Tarjan),
  SemaphoreSlim async-safe cascade, TimerTriggerScheduler, VirtualTagSource
- Stream C: Part9StateMachine, AlarmConditionState GxP audit Comments,
  MessageTemplate {TagPath}, AlarmPredicateContext SetVirtualTag rejection,
  ScriptedAlarmSource IAlarmSource, IAlarmStateStore + in-memory store
- Stream D: BackoffLadder 1-60s, DefaultDeadLetterRetention (30 days),
  HistorianWriteOutcome enum, Galaxy.Host IPC contracts
- Stream E: Four new entities + check constraints + Phase 7 migration
- Stream F: Five Admin services + ScriptEditor + ScriptsTab + AlarmsHistorian
  page + Monaco loader + DraftEditor wire-up + declared-inputs-only contract
- Stream G: NodeSourceKind discriminator + walker VirtualTag/ScriptedAlarm emission
  + DriverNodeManager SelectReadable + IsWriteAllowedBySource
- Deferred (flagged, not blocking): SealedBootstrap composition, live end-to-end
  smoke, sp_ComputeGenerationDiff extension
- Cross-cutting: full-solution dotnet test (regression check against 1300 baseline)

## docs/v2/implementation/exit-gate-phase-7.md

Summarises shipped PRs (Streams A-G + G follow-up = 8 PRs, ~197 tests), lists the
compliance checks covered, names the deferred follow-ups with task IDs, and points
at the compliance script for verification.

## Exit-gate local run

2191 tests green (baseline 1300), 0 failures, 55 compliance checks PASS,
3 deferred (with follow-up task IDs).

Phase 7 ships.
2026-04-20 20:25:11 -04:00
4e41f196b2 Merge pull request 'Phase 7 Stream G follow-up — DriverNodeManager dispatch routing by NodeSourceKind' (#186) from phase-7-stream-g-followup-dispatch into v2 2026-04-20 20:14:25 -04:00
Joseph Doherty
f0851af6b5 Phase 7 Stream G follow-up — DriverNodeManager dispatch routing by NodeSourceKind
Honors the ADR-002 discriminator at OPC UA Read/Write dispatch time. Virtual tag
reads route to the VirtualTagEngine-backed IReadable; scripted alarm reads route
to the ScriptedAlarmEngine-backed IReadable; driver reads continue to route to the
driver's own IReadable (no regression for any existing driver test).

## Changes

DriverNodeManager ctor gains optional `virtualReadable` + `scriptedAlarmReadable`
parameters. When callers omit them (every existing driver test) the manager behaves
exactly as before. SealedBootstrap wires the engines' IReadable adapters once the
Phase 7 composition root is added.

Per-variable NodeSourceKind tracked in `_sourceByFullRef` during Variable() registration
alongside the existing `_writeIdempotentByFullRef` / `_securityByFullRef` maps.

OnReadValue now picks the IReadable by source kind via the new internal
SelectReadable helper. When the engine-backed IReadable isn't wired (virtual tag
node but no engine provided), returns BadNotFound rather than silently falling
back to the driver — surfaces a misconfiguration instead of masking it.

OnWriteValue gates on IsWriteAllowedBySource which returns true only for Driver.
Plan decision #6: virtual tags + scripted alarms reject direct OPC UA writes with
BadUserAccessDenied. Scripts write virtual tags via `ctx.SetVirtualTag`; operators
ack alarms via the Part 9 method nodes.

## Tests — 7/7 (internal helpers exposed via InternalsVisibleTo)

DriverNodeManagerSourceDispatchTests covers:
- Driver source routes to driver IReadable
- Virtual source routes to virtual IReadable
- ScriptedAlarm source routes to alarm IReadable
- Virtual source with null virtual IReadable returns null (→ BadNotFound)
- ScriptedAlarm source with null alarm IReadable returns null
- Driver source with null driver IReadable returns null (preserves BadNotReadable)
- IsWriteAllowedBySource: only Driver=true (Virtual=false, ScriptedAlarm=false)

Full solution builds clean. Phase 7 test total now 197 green.
2026-04-20 20:12:17 -04:00
6df069b083 Merge pull request 'Phase 7 Stream F — Admin UI for scripts + test harness + historian diagnostics' (#185) from phase-7-stream-f-admin-ui into v2 2026-04-20 20:01:40 -04:00
Joseph Doherty
0687bb2e2d Phase 7 Stream F — Admin UI for scripts + test harness + historian diagnostics
Adds the draft-editor tab + page surface for authoring Phase 7 virtual tags and
scripted alarms, plus the /alarms/historian operator diagnostics page. Monaco loads
from CDN via a progressive-enhancement JS shim — the textarea works immediately so
the page is functional even if the CDN is unreachable.

## New services (Admin)

- ScriptService — CRUD for Script entity. SHA-256 SourceHash recomputed on save so
  Core.Scripting's CompiledScriptCache hits on re-publish of unchanged source + misses
  when the source actually changes.
- VirtualTagService — CRUD for VirtualTag, with Enabled toggle.
- ScriptedAlarmService — CRUD for ScriptedAlarm + lookup of persistent ScriptedAlarmState
  (logical-id-keyed per plan decision #14).
- ScriptTestHarnessService — pre-publish dry-run. Enforces plan decision #22: only
  inputs the DependencyExtractor identifies can be supplied. Missing / extra synthetic
  inputs surface as dedicated outcomes. Captures SetVirtualTag writes + Serilog events
  from the script so the operator can see both the output + the log output before
  publishing.
- HistorianDiagnosticsService — surfaces the local-process IAlarmHistorianSink state
  on /alarms/historian. Null sink reports Disabled + swallows retry. Live
  SqliteStoreAndForwardSink reports real queue depth + last-error + drain state and
  routes the Retry-dead-lettered button through.

## New UI

- ScriptsTab.razor (inside DraftEditor tabs) — list + create/edit/delete scripts with
  Monaco editor + dependency preview + test-harness run panel showing output + writes
  + log emissions.
- ScriptEditor.razor — reusable Monaco-backed textarea. Loads editor from CDN via
  wwwroot/js/monaco-loader.js. Textarea stays authoritative for Blazor binding; Monaco
  mirrors into it on every keystroke.
- AlarmsHistorian.razor (/alarms/historian) — queue depth + dead-letter depth + drain
  state badge + last-error banner + Retry-dead-lettered button.
- DraftEditor.razor — new "Scripts" tab.

## DI wiring

All five services registered in Program.cs. Null historian sink bound at Admin
composition time (real SqliteStoreAndForwardSink lives in the Server process).

## Tests — 13/13

Phase7ServicesTests covers:
- ScriptService: Add generates logical id + hash, Update recomputes hash on source
  change, Update same-source keeps hash (cache-hit preservation), Delete is idempotent
- VirtualTagService: round-trips trigger flags, Enabled toggle works
- ScriptedAlarmService: HistorizeToAveva defaults true per plan decision #15
- ScriptTestHarness: successful run captures output + writes, rejects missing /
  extra inputs, rejects non-literal paths, compile errors surface as Threw
- HistorianDiagnosticsService: null sink reports Disabled + retry returns 0
2026-04-20 19:59:18 -04:00
4d4f08af0d Merge pull request 'Phase 7 Stream G — Address-space integration (NodeSourceKind + walker emits VirtualTag/ScriptedAlarm)' (#184) from phase-7-stream-g-addressspace-integration into v2 2026-04-20 19:43:08 -04:00
Joseph Doherty
f1f53e1789 Phase 7 Stream G — Address-space integration (NodeSourceKind + walker emits VirtualTag/ScriptedAlarm)
Per ADR-002, adds the Driver/Virtual/ScriptedAlarm discriminator to DriverAttributeInfo
so the DriverNodeManager's dispatch layer can route Read/Write/Subscribe to the right
runtime subsystem — drivers (unchanged), VirtualTagEngine (Phase 7 Stream B), or
ScriptedAlarmEngine (Phase 7 Stream C).

## Changes
- NodeSourceKind enum added to Core.Abstractions (Driver=0/Virtual=1/ScriptedAlarm=2).
- DriverAttributeInfo gains Source / VirtualTagId / ScriptedAlarmId parameters — all
  default so existing call sites (every driver) compile unchanged.
- EquipmentNamespaceContent gains VirtualTags + ScriptedAlarms optional collections.
- EquipmentNodeWalker emits:
  - Virtual-tag variables — Source=Virtual, VirtualTagId set, Historize flag honored
  - Scripted-alarm variables — Source=ScriptedAlarm, ScriptedAlarmId set, IsAlarm=true
    (triggers node-manager AlarmConditionState materialization)
  - Skips disabled virtual tags + scripted alarms

## Tests — 13/13 in EquipmentNodeWalkerTests (5 new)
- Virtual-tag variables carry Source=Virtual + VirtualTagId + Historize flag
- Scripted-alarm variables carry Source=ScriptedAlarm + IsAlarm=true + Boolean type
- Disabled rows skipped
- Null VirtualTags/ScriptedAlarms collections safe (back-compat for non-Phase-7 callers)
- Driver tags default Source=Driver (ensures no discriminator regression)

## Next
Stream G follow-up: DriverNodeManager dispatch (Read/Write/Subscribe routing by
NodeSourceKind), SealedBootstrap wiring of VirtualTagEngine + ScriptedAlarmEngine,
end-to-end integration test.
2026-04-20 19:41:01 -04:00
e97db2d108 Merge pull request 'Phase 7 Stream E — Config DB schema for scripts, virtual tags, scripted alarms, and alarm state' (#183) from phase-7-stream-e-config-db into v2 2026-04-20 19:24:53 -04:00
303 changed files with 39891 additions and 500 deletions

7
.gitignore vendored
View File

@@ -30,3 +30,10 @@ packages/
.claude/ .claude/
.local/ .local/
# LiteDB local config cache (Phase 6.1 Stream D — runtime artifact, not source)
src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db
# E2E sidecar config — NodeIds are specific to each dev's local seed (see scripts/e2e/README.md)
scripts/e2e/e2e-config.json
config_cache*.db

View File

@@ -24,6 +24,13 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/> <Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/> <Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/> <Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/> <Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
@@ -36,6 +43,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
@@ -43,6 +51,12 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/> <Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>

View File

@@ -67,6 +67,53 @@ Drivers that want hierarchical alarm subscriptions propagate `EventNotifier.Subs
The OPC UA `ConditionRefresh` service queues the current state of every retained condition back to the requesting monitored items. `DriverNodeManager` iterates the node manager's `AlarmConditionState` collection and queues each condition whose `Retain.Value == true` — matching the Part 9 requirement. The OPC UA `ConditionRefresh` service queues the current state of every retained condition back to the requesting monitored items. `DriverNodeManager` iterates the node manager's `AlarmConditionState` collection and queues each condition whose `Retain.Value == true` — matching the Part 9 requirement.
## Alarm historian sink
Distinct from the live `IAlarmSource` stream and the Part 9 `AlarmConditionState` materialization above, qualifying alarm transitions are **also** persisted to a durable event log for downstream AVEVA Historian ingestion. This is a separate subsystem from the `IHistoryProvider` capability used by `HistoryReadEvents` (see [HistoricalDataAccess.md](HistoricalDataAccess.md#alarm-event-history-vs-ihistoryprovider)): the sink is a *producer* path (server → Historian) that runs independently of any client HistoryRead call.
### `IAlarmHistorianSink`
`src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs` defines the intake contract:
```csharp
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
HistorianSinkStatus GetStatus();
```
`EnqueueAsync` is fire-and-forget from the producer's perspective — it must never block the emitting thread. The event payload (`AlarmHistorianEvent` — same file) is source-agnostic: `AlarmId`, `EquipmentPath`, `AlarmName`, `AlarmTypeName` (Part 9 subtype name), `Severity`, `EventKind` (free-form transition string — `Activated` / `Cleared` / `Acknowledged` / `Confirmed` / `Shelved` / …), `Message`, `User`, `Comment`, `TimestampUtc`.
The sink scope is defined to span every alarm source (plan decision #15: scripted, Galaxy-native, AB CIP ALMD, any future `IAlarmSource`), gated per-alarm by a `HistorizeToAveva` toggle on the producer. Today only `Phase7EngineComposer.RouteToHistorianAsync` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs`) is wired — it subscribes to `ScriptedAlarmEngine.OnEvent` and marshals each emission into `AlarmHistorianEvent`. Galaxy-native alarms continue to reach AVEVA Historian via the driver's direct `aahClientManaged` path and do not flow through the sink; the AB CIP ALMD path remains unwired pending a producer-side integration.
### `SqliteStoreAndForwardSink`
Default production implementation (`src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs`). A local SQLite queue absorbs every `EnqueueAsync` synchronously; a background `Timer` drains batches asynchronously to an `IAlarmHistorianWriter` so operator actions are never blocked on historian reachability.
Queue schema (single table `Queue`): `RowId PK autoincrement`, `AlarmId`, `EnqueuedUtc`, `PayloadJson` (serialized `AlarmHistorianEvent`), `AttemptCount`, `LastAttemptUtc`, `LastError`, `DeadLettered` (bool), plus `IX_Queue_Drain (DeadLettered, RowId)`. Default capacity `1_000_000` non-dead-lettered rows; oldest rows evict with a WARN log past the cap.
Drain cadence: `StartDrainLoop(tickInterval)` arms a periodic timer. `DrainOnceAsync` reads up to `batchSize` rows (default 100) in `RowId` order and forwards them through `IAlarmHistorianWriter.WriteBatchAsync`, which returns one `HistorianWriteOutcome` per row:
| Outcome | Action |
|---|---|
| `Ack` | Row deleted. |
| `PermanentFail` | Row flipped to `DeadLettered = 1` with reason. Peers in the batch retry independently. |
| `RetryPlease` | `AttemptCount` bumped; row stays queued. Drain worker enters `BackingOff`. |
Writer-side exceptions treat the whole batch as `RetryPlease`.
Backoff ladder on `RetryPlease` (hard-coded): 1s → 2s → 5s → 15s → 60s cap. Reset to 0 on any batch with no retries. `CurrentBackoff` exposes the current step for instrumentation; the drain timer itself fires on `tickInterval`, so the ladder governs write cadence rather than timer period.
Dead-letter retention defaults to 30 days (plan decision #21). `PurgeAgedDeadLetters` runs each drain pass and deletes rows whose `LastAttemptUtc` is past the cutoff. `RetryDeadLettered()` is an operator action that clears `DeadLettered` + resets `AttemptCount` on every dead-lettered row so they rejoin the main queue.
### Composition and writer resolution
`Phase7Composer.ResolveHistorianSink` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs`) scans the registered drivers for one that implements `IAlarmHistorianWriter`. Today that is `GalaxyProxyDriver` via `GalaxyHistorianWriter` (`src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyHistorianWriter.cs`), which forwards batches over the Galaxy.Host pipe to the `aahClientManaged` alarm schema. When a writer is found, a `SqliteStoreAndForwardSink` is instantiated against `%ProgramData%/OtOpcUa/alarm-historian-queue.db` with a 2 s drain tick and the writer attached. When no driver provides a writer the fallback is the DI-registered `NullAlarmHistorianSink` (`src/ZB.MOM.WW.OtOpcUa.Server/Program.cs`), which silently discards and reports `HistorianDrainState.Disabled`.
### Status and observability
`GetStatus()` returns `HistorianSinkStatus(QueueDepth, DeadLetterDepth, LastDrainUtc, LastSuccessUtc, LastError, DrainState)` — two `COUNT(*)` scalars plus last-drain telemetry. `DrainState` is one of `Disabled` / `Idle` / `Draining` / `BackingOff`.
The Admin UI `/alarms/historian` page surfaces this through `HistorianDiagnosticsService` (`src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs`), which also exposes `TryRetryDeadLettered` — it calls through to `SqliteStoreAndForwardSink.RetryDeadLettered` when the live sink is the SQLite implementation and returns 0 otherwise.
## Key source files ## Key source files
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs` — capability contract + `AlarmEventArgs` - `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs` — capability contract + `AlarmEventArgs`
@@ -74,3 +121,8 @@ The OPC UA `ConditionRefresh` service queues the current state of every retained
- `src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs``CapturingBuilder` + alarm forwarder - `src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs``CapturingBuilder` + alarm forwarder
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs``VariableHandle.MarkAsAlarmCondition` + `ConditionSink` - `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs``VariableHandle.MarkAsAlarmCondition` + `ConditionSink`
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs` — Galaxy-specific alarm-event production - `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs` — Galaxy-specific alarm-event production
- `src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs` — historian sink intake contract + `AlarmHistorianEvent` + `HistorianSinkStatus` + `IAlarmHistorianWriter`
- `src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs` — durable queue + drain worker + backoff ladder + dead-letter retention
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs``RouteToHistorianAsync` wires scripted-alarm emissions into the sink
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs``ResolveHistorianSink` selects `SqliteStoreAndForwardSink` vs `NullAlarmHistorianSink`
- `src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs` — Admin UI `/alarms/historian` status + retry-dead-lettered operator action

View File

@@ -35,7 +35,7 @@ The driver's mapping is authoritative — when a field type is ambiguous (a `LRE
## SecurityClassification — metadata, not ACL ## SecurityClassification — metadata, not ACL
`SecurityClassification` is driver-reported metadata only. Drivers never enforce write permissions themselves — the classification flows into the Server project where `WriteAuthzPolicy.IsAllowed(classification, userRoles)` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs`) gates the write against the session's LDAP-derived roles, and (Phase 6.2) the `AuthorizationGate` + permission trie apply on top. This is the "ACL at server layer" invariant recorded in `feedback_acl_at_server_layer.md`. `SecurityClassification` is driver-reported metadata only. Drivers never enforce write permissions themselves — the classification flows into the Server project where `WriteAuthzPolicy.IsAllowed(classification, userRoles)` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs`) gates the write against the session's LDAP-derived roles, and (Phase 6.2) the `AuthorizationGate` + permission trie apply on top. This is the "ACL at server layer" invariant documented in `docs/security.md`.
The classification values mirror the v1 Galaxy model so existing Galaxy galaxies keep their published semantics: The classification values mirror the v1 Galaxy model so existing Galaxy galaxies keep their published semantics:

106
docs/Driver.AbCip.Cli.md Normal file
View File

@@ -0,0 +1,106 @@
# `otopcua-abcip-cli` — AB CIP test client
Ad-hoc probe / read / write / subscribe tool for ControlLogix / CompactLogix /
Micro800 / GuardLogix PLCs, talking to the **same** `AbCipDriver` the OtOpcUa
server uses (libplctag under the hood).
Second of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
TwinCAT). Shares `Driver.Cli.Common` with the others.
## Build + run
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help
```
## Common flags
| Flag | Default | Purpose |
|---|---|---|
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix |
| `--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 |
Family ↔ CIP-path cheat sheet:
- **ControlLogix / CompactLogix / GuardLogix** — `1,0` (slot 0 of chassis)
- **Micro800** — empty path, just `ab://host/`
- **Sub-slot Logix** (rare) — `1,3` for slot 3
## Commands
### `probe` — is the PLC up?
```powershell
# ControlLogix — read the canonical libplctag system tag
otopcua-abcip-cli probe -g ab://10.0.0.5/1,0 -t @raw_cpu_type --type DInt
# Micro800 — point at a user-supplied global
otopcua-abcip-cli probe -g ab://10.0.0.6/ -f Micro800 -t _SYSVA_CLOCK_HOUR --type DInt
```
### `read` — single Logix tag
```powershell
# Controller scope
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real
# Program scope
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Program:Main.Counter" --type DInt
# Array element
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Recipe[3]" --type Real
# UDT member (dotted path)
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Motor01.Speed" --type Real
```
### `write` — single Logix tag
Same shape as `read` plus `-v`. Values parse per `--type` using invariant
culture. Booleans accept `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`.
Structure (UDT) writes need the member layout declared in a real driver config
and are refused by the CLI.
```powershell
otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -v 3.14
otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t StartCommand --type Bool -v true
```
### `subscribe` — watch a tag until Ctrl+C
```powershell
otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i 500
```
## Typical workflows
- **"Is the PLC reachable?"** → `probe`.
- **"Did my recipe write land?"** → `write` + `read` back.
- **"Why is tag X flipping?"** → `subscribe`.
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
read the status code — safety tags surface `BadNotWritable` / CIP errors,
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.

161
docs/Driver.AbLegacy.Cli.md Normal file
View File

@@ -0,0 +1,161 @@
# `otopcua-ablegacy-cli` — AB Legacy (PCCC) test client
Ad-hoc probe / read / write / subscribe tool for SLC 500 / MicroLogix 1100 /
MicroLogix 1400 / PLC-5 devices, talking to the **same** `AbLegacyDriver` the
OtOpcUa server uses (libplctag PCCC back-end).
Third of four driver test-client CLIs. Shares `Driver.Cli.Common` with the
others.
## Build + run
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- --help
```
## Common flags
| Flag | Default | Purpose |
|---|---|---|
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
| `-P` / `--plc-type` | `Slc500` | Slc500 / MicroLogix / Plc5 / LogixPccc |
| `--timeout-ms` | `5000` | Per-operation timeout |
| `--verbose` | off | Serilog debug output |
Family ↔ CIP-path cheat sheet:
- **SLC 5/05 / PLC-5** — `1,0`
- **MicroLogix 1100 / 1400** — empty path (`ab://host/`) — they use direct EIP
with no backplane
- **LogixPccc** — `1,0` (Logix controller accessed via the PCCC compatibility
layer; rare)
## PCCC address primer
File letters imply data type; type flag still required so the CLI knows how to
parse your `--value`.
| File | Type | CLI `--type` |
|---|---|---|
| `N` | signed int16 | `Int` |
| `F` | float32 | `Float` |
| `B` | bit-packed (`B3:0/3` addresses bit 3 of word 0) | `Bit` |
| `L` | long int32 (SLC 5/05+ only) | `Long` |
| `A` | analog int (semantically like N) | `AnalogInt` |
| `ST` | ASCII string (82-byte + length header) | `String` |
| `T` | timer sub-element (`T4:0.ACC` / `.PRE` / `.EN` / `.DN`) | `TimerElement` |
| `C` | counter sub-element (`C5:0.ACC` / `.PRE` / `.CU` / `.CD` / `.DN`) | `CounterElement` |
| `R` | control sub-element (`R6:0.LEN` / `.POS` / `.EN` / `.DN` / `.ER`) | `ControlElement` |
## Commands
### `probe`
```powershell
# SLC 5/05 — default probe address N7:0
otopcua-ablegacy-cli probe -g ab://192.168.1.20/1,0
# MicroLogix 1100 — status file first word
otopcua-ablegacy-cli probe -g ab://192.168.1.30/ -P MicroLogix -a S:0
```
### `read`
```powershell
# Integer
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a N7:10 -t Int
# Float
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a F8:0 -t Float
# Bit-within-word
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit
# Long (SLC 5/05+)
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a L19:0 -t Long
# Timer ACC
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a T4:0.ACC -t TimerElement
```
### `write`
```powershell
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a N7:10 -t Int -v 42
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a F8:0 -t Float -v 3.14
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit -v on
```
Writes to timer / counter / control sub-elements land at the wire level but
the PLC's runtime semantics (EN/DN edge-triggering, preset reload) are
PLC-managed — use with caution.
### `subscribe`
```powershell
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
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC
dispatcher doesn't actually respond — see
[`tests/...AbLegacy.IntegrationTests/Docker/README.md`](../tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md).
Point `--gateway` at real hardware or an RSEmulate 500 box for end-to-end
wire-level validation. The CLI itself is correct regardless of which endpoint
you target.

158
docs/Driver.FOCAS.Cli.md Normal file
View File

@@ -0,0 +1,158 @@
# `otopcua-focas-cli` — Fanuc FOCAS test client
Ad-hoc probe / read / write / subscribe tool for Fanuc CNCs via the FOCAS/2
protocol. Uses the **same** `FocasDriver` the OtOpcUa server does — PMC R/G/F
file registers, axis bits, parameters, and macro variables — all through
`FocasAddressParser` syntax.
Sixth of the driver test-client CLIs, added alongside the Tier-C isolation
work tracked in task #220.
## Architecture note
FOCAS is a Tier-C driver: `Fwlib32.dll` is a proprietary 32-bit Fanuc library
with a documented habit of crashing its hosting process on network errors.
The target runtime deployment splits the driver into an in-process
`FocasProxyDriver` (.NET 10 x64) and an out-of-process `Driver.FOCAS.Host`
(.NET 4.8 x86 Windows service) that owns the DLL — see
[v2/implementation/focas-isolation-plan.md](v2/implementation/focas-isolation-plan.md)
and
[v2/implementation/phase-6-1-resilience-and-observability.md](v2/implementation/phase-6-1-resilience-and-observability.md)
for topology + supervisor / respawn / back-pressure design.
The CLI skips the proxy and loads `FocasDriver` directly (via
`FwlibFocasClientFactory`, which P/Invokes `Fwlib32.dll` in the CLI's own
process). There is **no public simulator** for FOCAS; a meaningful probe
requires a real CNC + a licensed `Fwlib32.dll` on `PATH` (or next to the
executable). On a dev box without the DLL, every wire call surfaces as
`BadCommunicationError` — still useful as a "CLI wire-up is correct" signal.
## Build + run
```powershell
dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli -- --help
```
Or publish a self-contained binary:
```powershell
dotnet publish src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli -c Release -o publish/focas-cli
publish/focas-cli/otopcua-focas-cli.exe --help
```
## Common flags
Every command accepts:
| Flag | Default | Purpose |
|---|---|---|
| `-h` / `--cnc-host` | **required** | CNC IP address or hostname |
| `-p` / `--cnc-port` | `8193` | FOCAS TCP port (FOCAS-over-EIP default) |
| `-s` / `--series` | `Unknown` | CNC series — `Unknown` / `Zero_i_D` / `Zero_i_F` / `Zero_i_MF` / `Zero_i_TF` / `Sixteen_i` / `Thirty_i` / `ThirtyOne_i` / `ThirtyTwo_i` / `PowerMotion_i` |
| `--timeout-ms` | `2000` | Per-operation timeout |
| `--verbose` | off | Serilog debug output |
## Addressing
`FocasAddressParser` syntax — the same format the server + `FocasTagDefinition`
use. Common shapes:
| Address | Meaning |
|---|---|
| `R100` | PMC R-file word register 100 |
| `X0.0` | PMC X-file bit 0 of byte 0 |
| `G50.3` | PMC G-file bit 3 of byte 50 |
| `F1.4` | PMC F-file bit 4 of byte 1 |
| `PARAM:1815/0` | Parameter 1815, axis 0 |
| `MACRO:500` | Macro variable 500 |
## Data types
`Bit`, `Byte`, `Int16`, `Int32`, `Float32`, `Float64`, `String`. Default is
`Int16` (matches PMC R-file word width).
## Commands
### `probe` — is the CNC reachable?
Opens a FOCAS session, reads one sample address, prints driver health.
```powershell
# Default: read R100 as Int16
otopcua-focas-cli probe -h 192.168.1.50
# Explicit series + address
otopcua-focas-cli probe -h 192.168.1.50 -s ThirtyOne_i --address R200 --type Int16
```
### `read` — single address
```powershell
# PMC R-file word
otopcua-focas-cli read -h 192.168.1.50 -a R100 -t Int16
# PMC X-bit
otopcua-focas-cli read -h 192.168.1.50 -a X0.0 -t Bit
# Parameter (axis 0)
otopcua-focas-cli read -h 192.168.1.50 -a PARAM:1815/0 -t Int32
# Macro variable
otopcua-focas-cli read -h 192.168.1.50 -a MACRO:500 -t Float64
```
### `write` — single value
Values parse per `--type` with invariant culture. Booleans accept
`true` / `false` / `1` / `0` / `yes` / `no` / `on` / `off`.
```powershell
otopcua-focas-cli write -h 192.168.1.50 -a R100 -t Int16 -v 42
otopcua-focas-cli write -h 192.168.1.50 -a G50.3 -t Bit -v on
otopcua-focas-cli write -h 192.168.1.50 -a MACRO:500 -t Float64 -v 3.14
```
PMC G/R writes land on a running machine — be careful which file you hit.
Parameter writes may require the CNC to be in MDI mode with the
parameter-write switch enabled.
**Writes are non-idempotent by default** — a timeout after the CNC already
applied the write will NOT auto-retry (plan decisions #44 + #45).
### `subscribe` — watch an address until Ctrl+C
FOCAS has no push model; the shared `PollGroupEngine` handles the tick
loop.
```powershell
otopcua-focas-cli subscribe -h 192.168.1.50 -a R100 -t Int16 -i 500
```
## Output format
Identical to the other driver CLIs via `SnapshotFormatter`:
- `probe` / `read` emit a multi-line block: `Tag / Value / Status /
Source Time / Server Time`. `probe` prefixes it with `CNC`, `Series`,
`Health`, and `Last error` lines.
- `write` emits one line: `Write <address>: 0x... (Good |
BadCommunicationError | …)`.
- `subscribe` emits one line per change: `[HH:mm:ss.fff] <address> =
<value> (<status>)`.
## Typical workflows
**"Is the CNC alive?"** → `probe`.
**"Does my parameter write land?"** → `write` + `read` back against the
same address. Check the parameter-write switch + MDI mode if the write
fails.
**"Why did this macro flip?"** → `subscribe` to the macro, let the
operator reproduce the cycle, watch the HH:mm:ss.fff timeline.
**"Is the Fwlib32 DLL wired up?"** → `probe` against any host. A
`DllNotFoundException` surfacing as `BadCommunicationError` with a
matching `Last error` line means the driver is loading but the DLL is
missing; anything else means a transport-layer problem.

121
docs/Driver.Modbus.Cli.md Normal file
View File

@@ -0,0 +1,121 @@
# `otopcua-modbus-cli` — Modbus-TCP test client
Ad-hoc probe / read / write / subscribe tool for talking to Modbus-TCP devices
through the **same** `ModbusDriver` the OtOpcUa server uses. Mirrors the v1
OPC UA `otopcua-cli` shape so the muscle memory carries over: drop to a shell,
point at a PLC, watch registers move.
First of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
TwinCAT). Built on the shared `ZB.MOM.WW.OtOpcUa.Driver.Cli.Common` library
so each downstream CLI inherits verbose/log wiring + snapshot formatting
without copy-paste.
## Build + run
```powershell
dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -- --help
```
Or publish a self-contained binary:
```powershell
dotnet publish src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -c Release -o publish/modbus-cli
publish/modbus-cli/otopcua-modbus-cli.exe --help
```
## Common flags
Every command accepts:
| Flag | Default | Purpose |
|---|---|---|
| `-h` / `--host` | **required** | Modbus-TCP server hostname or IP |
| `-p` / `--port` | `502` | TCP port |
| `-U` / `--unit-id` | `1` | Modbus unit / slave ID |
| `--timeout-ms` | `2000` | Per-PDU timeout |
| `--disable-reconnect` | off | Turn off mid-transaction reconnect-and-retry |
| `--verbose` | off | Serilog debug output |
## Commands
### `probe` — is the PLC up?
Connects, reads one holding register, prints driver health. Fastest sanity
check after swapping a network cable or deploying a new device.
```powershell
otopcua-modbus-cli probe -h 192.168.1.10
otopcua-modbus-cli probe -h 192.168.1.10 --probe-address 100 # device locks HR[0]
```
### `read` — single register / coil / string
Synthesises a one-tag driver config on the fly from `--region` + `--address`
+ `--type` flags.
```powershell
# Holding register as UInt16
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16
# Float32 with word-swap (CDAB) — common on Siemens / some AB families
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 --byte-order WordSwap
# Single bit out of a packed holding register
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 10 -t BitInRegister --bit-index 3
# 40-char ASCII string — DirectLOGIC packs the first char in the low byte
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 300 -t String --string-length 40 --string-byte-order LowByteFirst
# Discrete input / coil
otopcua-modbus-cli read -h 192.168.1.10 -r DiscreteInputs -a 5 -t Bool
```
### `write` — single value
Same flag shape as `read` plus `-v` / `--value`. Values parse per `--type`
using invariant culture (period as decimal separator). Booleans accept
`true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`.
```powershell
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16 -v 42
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 -v 3.14
otopcua-modbus-cli write -h 192.168.1.10 -r Coils -a 5 -t Bool -v on
```
**Writes are non-idempotent by default** — a timeout after the device
already applied the write will NOT auto-retry. This matches the driver's
production contract (plan decisions #44 + #45).
### `subscribe` — watch a register until Ctrl+C
Uses the driver's `ISubscribable` surface (polling under the hood via
`PollGroupEngine`). Prints every data-change event with a timestamp.
```powershell
otopcua-modbus-cli subscribe -h 192.168.1.10 -r HoldingRegisters -a 100 -t Int16 -i 500
```
## Output format
- `probe` / `read` emit a multi-line per-tag block: `Tag / Value / Status /
Source Time / Server Time`.
- `write` emits one line: `Write <tag>: 0x... (Good | BadCommunicationError | …)`.
- `subscribe` emits one line per change: `[HH:mm:ss.fff] <tag> = <value> (<status>)`.
Status codes are rendered as `0xXXXXXXXX (Name)` for the OPC UA shortlist
(`Good`, `BadCommunicationError`, `BadTimeout`, `BadNodeIdUnknown`,
`BadTypeMismatch`, `Uncertain`, …). Unknown codes fall back to bare hex.
## Typical workflows
**"Is the PLC alive?"** → `probe`.
**"Does my recipe write land?"** → `write` + `read` back against the same
address.
**"Why is tag X flipping?"** → `subscribe` + wait for the operator scenario.
**"What's the right byte order for this family?"** → `read` with
`--byte-order BigEndian`, then with `--byte-order WordSwap`. The one that
gives plausible values is the correct one for that device.

93
docs/Driver.S7.Cli.md Normal file
View File

@@ -0,0 +1,93 @@
# `otopcua-s7-cli` — Siemens S7 test client
Ad-hoc probe / read / write / subscribe tool for Siemens S7-300 / S7-400 /
S7-1200 / S7-1500 (and compatible soft-PLCs) over S7comm / ISO-on-TCP port 102.
Uses the **same** `S7Driver` the OtOpcUa server does (S7.Net under the hood).
Fourth of four driver test-client CLIs.
## Build + run
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
```
## Common flags
| Flag | Default | Purpose |
|---|---|---|
| `-h` / `--host` | **required** | PLC IP or hostname |
| `-p` / `--port` | `102` | ISO-on-TCP port (rarely changes) |
| `-c` / `--cpu` | `S71500` | S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 |
| `--rack` | `0` | Hardware rack (S7-400 distributed setups only) |
| `--slot` | `0` | CPU slot (S7-300 = 2, S7-400 = 2 or 3, S7-1200/1500 = 0) |
| `--timeout-ms` | `5000` | Per-operation timeout |
| `--verbose` | off | Serilog debug output |
## PUT/GET must be enabled
S7-1200 / S7-1500 ship with PUT/GET communication **disabled** by default.
Enable it in TIA Portal: *Device config → Protection & Security → Connection
mechanisms → "Permit access with PUT/GET communication from remote partner"*.
Without it the CLI's first read will surface `BadNotSupported`.
## S7 address grammar cheat sheet
| Form | Meaning |
|---|---|
| `DB1.DBW0` | DB number 1, word offset 0 |
| `DB1.DBD4` | DB number 1, dword offset 4 |
| `DB1.DBX2.3` | DB number 1, byte 2, bit 3 |
| `DB10.STRING[0]` | DB 10 string starting at offset 0 |
| `M0.0` | Merker bit 0.0 |
| `MW0` / `MD4` | Merker word / dword |
| `IW4` | Input word 4 |
| `QD8` | Output dword 8 |
## Commands
### `probe`
```powershell
# S7-1500 — default probe MW0
otopcua-s7-cli probe -h 192.168.1.30
# S7-300 (slot 2)
otopcua-s7-cli probe -h 192.168.1.31 -c S7300 --slot 2 -a DB1.DBW0
```
### `read`
```powershell
# DB word
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBW0 -t Int16
# Float32 from DB dword
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBD4 -t Float32
# Merker bit
otopcua-s7-cli read -h 192.168.1.30 -a M0.0 -t Bool
# 80-char S7 string
otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80
```
### `write`
```powershell
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBW0 -t Int16 -v 42
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBD4 -t Float32 -v 3.14
otopcua-s7-cli write -h 192.168.1.30 -a M0.0 -t Bool -v true
```
**Writes to M / Q are real** — they drive the PLC program. Be careful what you
flip on a running machine.
### `subscribe`
```powershell
otopcua-s7-cli subscribe -h 192.168.1.30 -a DB1.DBW0 -t Int16 -i 500
```
S7comm has no native push — the CLI polls through `PollGroupEngine` just like
Modbus / AB.

113
docs/Driver.TwinCAT.Cli.md Normal file
View File

@@ -0,0 +1,113 @@
# `otopcua-twincat-cli` — Beckhoff TwinCAT test client
Ad-hoc probe / read / write / subscribe tool for Beckhoff TwinCAT 2 / TwinCAT 3
runtimes via ADS. Uses the **same** `TwinCATDriver` the OtOpcUa server does
(`Beckhoff.TwinCAT.Ads` package). Native ADS notifications by default;
`--poll-only` falls back to the shared `PollGroupEngine`.
Fifth (final) of the driver test-client CLIs.
## Build + run
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli -- --help
```
## Prerequisite: AMS router
The `Beckhoff.TwinCAT.Ads` library needs a reachable AMS router to open ADS
sessions. Pick one:
1. **Local TwinCAT XAR** — install the free TwinCAT 3 XAR Engineering install
on the machine running the CLI; it ships the router.
2. **Beckhoff.TwinCAT.Ads.TcpRouter** — standalone NuGet router. Run in a
sidecar process when no XAR is installed.
3. **Remote AMS route** — any Windows box with TwinCAT installed, with an AMS
route authorised to the CLI host.
The CLI compiles + runs without a router, but every wire call fails with a
transport error until one is reachable.
## Common flags
| Flag | Default | Purpose |
|---|---|---|
| `-n` / `--ams-net-id` | **required** | AMS Net ID (e.g. `192.168.1.40.1.1`) |
| `-p` / `--ams-port` | `851` | AMS port (TwinCAT 3 PLC = 851, TwinCAT 2 = 801) |
| `--timeout-ms` | `5000` | Per-operation timeout |
| `--poll-only` | off | Disable native ADS notifications, use `PollGroupEngine` instead |
| `--verbose` | off | Serilog debug output |
## Data types
TwinCAT exposes the IEC 61131-3 atomic set: `Bool`, `SInt`, `USInt`, `Int`,
`UInt`, `DInt`, `UDInt`, `LInt`, `ULInt`, `Real`, `LReal`, `String`, `WString`,
`Time`, `Date`, `DateTime`, `TimeOfDay`. The four IEC time/date variants
marshal as `UDINT` on the wire — CLI takes a numeric raw value and lets the
caller interpret semantics.
## Commands
### `probe`
```powershell
# Local TwinCAT 3, probe a canonical global
otopcua-twincat-cli probe -n 127.0.0.1.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"
# Remote, probe a project variable
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s MAIN.bRunning --type Bool
```
### `read`
```powershell
# Bool symbol
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool
# Counter
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.Counter -t DInt
# Nested UDT member
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s Motor1.Status.Running -t Bool
# Array element
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "Recipe[3]" -t Real
# 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`
```powershell
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool -v true
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -v 42
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.sMessage -t WString -v "running"
```
Structure writes refused — drop to driver config JSON for those.
### `subscribe`
```powershell
# Native ADS notifications (default) — PLC pushes on its own cycle
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
# Fall back to polling for runtimes where native notifications are constrained
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500 --poll-only
```
The subscribe banner announces which mechanism is in play — "ADS notification"
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.

95
docs/DriverClis.md Normal file
View File

@@ -0,0 +1,95 @@
# Driver test-client CLIs
Six shell-level ad-hoc validation tools, one per native-protocol driver family.
Each mirrors the v1 `otopcua-cli` shape (probe / read / write / subscribe) against
the **same driver** the OtOpcUa server uses — so "does the CLI see it?" and
"does the server see it?" are the same question.
| CLI | Protocol | Docs |
|---|---|---|
| `otopcua-modbus-cli` | Modbus-TCP | [Driver.Modbus.Cli.md](Driver.Modbus.Cli.md) |
| `otopcua-abcip-cli` | CIP / EtherNet-IP (Logix symbolic) | [Driver.AbCip.Cli.md](Driver.AbCip.Cli.md) |
| `otopcua-ablegacy-cli` | PCCC (SLC / MicroLogix / PLC-5) | [Driver.AbLegacy.Cli.md](Driver.AbLegacy.Cli.md) |
| `otopcua-s7-cli` | S7comm / ISO-on-TCP | [Driver.S7.Cli.md](Driver.S7.Cli.md) |
| `otopcua-twincat-cli` | Beckhoff ADS | [Driver.TwinCAT.Cli.md](Driver.TwinCAT.Cli.md) |
| `otopcua-focas-cli` | Fanuc FOCAS/2 (CNC) | [Driver.FOCAS.Cli.md](Driver.FOCAS.Cli.md) |
The OPC UA client CLI lives separately and predates this suite —
see [Client.CLI.md](Client.CLI.md) for `otopcua-cli`.
## Shared commands
Every driver CLI exposes the same four verbs:
- **`probe`** — open a session, read one sentinel tag, print driver health.
Fastest "is the device talking?" check.
- **`read`** — synthesise a one-tag driver config from `--type` / `--address`
(or `--tag` / `--symbol`) flags, read once, print the snapshot. No extra
config file needed.
- **`write`** — same shape plus `--value`. Values parse per `--type` using
invariant culture. Booleans accept `true` / `false` / `1` / `0` / `yes` /
`no` / `on` / `off`. Writes are **non-idempotent by default** — a timeout
after the device already applied the write will not auto-retry (plan
decisions #44, #45).
- **`subscribe`** — long-running data-change stream until Ctrl+C. Uses native
push where available (TwinCAT ADS notifications) and falls back to polling
(`PollGroupEngine`) where the protocol has no push (Modbus, AB, S7, FOCAS).
## Shared infrastructure
All six CLIs depend on `src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/`:
- `DriverCommandBase``--verbose` + Serilog configuration + the abstract
`Timeout` surface every protocol-specific base overrides with its own
default.
- `SnapshotFormatter` — consistent output across every CLI: tag / value /
status / source-time / server-time for single reads, a 4-column table for
batches, `Write <tag>: 0x... (Name)` for writes, and one line per change
event for subscriptions. OPC UA status codes render as `0xXXXXXXXX (Name)`
with a shortlist for `Good` / `Bad*` / `Uncertain`; unknown codes fall
back to hex.
Writing a seventh CLI (hypothetical Galaxy / OPC UA Client) costs roughly
150 lines: a `{Family}CommandBase` + four thin command classes that hand
their flag values to the already-shipped driver.
## Typical cross-CLI workflows
- **Commissioning a new device** — `probe` first, then `read` a known-good
tag. If the device is up + talking the protocol, both pass; if the tag is
wrong you'll see the read fail with a protocol-specific error.
- **Reproducing a production bug** — `subscribe` to the tag the bug report
names, then have the operator run the scenario. You get an HH:mm:ss.fff
timeline of exactly when each value changed.
- **Validating a recipe write** — `write` + `read` back. If the server's
write path would have done anything different, the CLI would have too.
- **Byte-order / word-swap debugging** — `read` with one `--byte-order`,
then the other. The plausible result identifies the correct setting
for that device family. (Modbus, S7.)
## Known gaps
- **AB Legacy cip-path quirk** — libplctag's ab_server requires a
non-empty CIP routing path before forwarding to the PCCC dispatcher.
Pass `--gateway "ab://127.0.0.1:44818/1,0"` against the Docker
fixture; real SLC / MicroLogix / PLC-5 hardware accepts an empty
path (`ab://host:44818/`). Bit-file writes (`B3:0/5`) still surface
`0x803D0000` against ab_server — route operator-critical bit writes
to real hardware until upstream fixes this.
- **S7 PUT/GET communication** must be enabled in TIA Portal for any
S7-1200/1500. See [Driver.S7.Cli.md](Driver.S7.Cli.md).
- **TwinCAT AMS router** must be reachable (local XAR, standalone Router
NuGet, or authorised remote route). See
[Driver.TwinCAT.Cli.md](Driver.TwinCAT.Cli.md).
- **Structure / UDT writes** are refused by the AB CIP + TwinCAT CLIs —
whole-UDT writes need a declared member layout that belongs in a real
driver config, not a one-shot flag.
## Tracking
Tasks #249 / #250 / #251 shipped the original five. The FOCAS CLI followed
alongside the Tier-C isolation work on task #220 — no CLI-level test
project (hardware-gated). 122 unit tests cumulative across the first five
(16 shared-lib + 106 CLI-specific) — run
`dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests` +
`tests/ZB.MOM.WW.OtOpcUa.Driver.*.Cli.Tests` to re-verify.

View File

@@ -22,6 +22,12 @@ Supporting DTOs live alongside the interface in `Core.Abstractions`:
- `HistoricalEvent(EventId, SourceName?, EventTimeUtc, ReceivedTimeUtc, Message?, Severity)` - `HistoricalEvent(EventId, SourceName?, EventTimeUtc, ReceivedTimeUtc, Message?, Severity)`
- `HistoricalEventsResult(IReadOnlyList<HistoricalEvent> Events, byte[]? ContinuationPoint)` - `HistoricalEventsResult(IReadOnlyList<HistoricalEvent> Events, byte[]? ContinuationPoint)`
## Alarm event history vs. `IHistoryProvider`
`IHistoryProvider.ReadEventsAsync` is the **pull** path: an OPC UA client calls `HistoryReadEvents` against a notifier node and the driver walks its own backend event store to satisfy the request. The Galaxy driver's implementation reads from AVEVA Historian's event schema via `aahClientManaged`; every other driver leaves the default `NotSupportedException` in place.
There is also a separate **push** path for persisting alarm transitions from any `IAlarmSource` (and the Phase 7 scripted-alarm engine) into a durable event log, independent of any client HistoryRead call. That path is covered by `IAlarmHistorianSink` + `SqliteStoreAndForwardSink` in `src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/` and is documented in [AlarmTracking.md#alarm-historian-sink](AlarmTracking.md#alarm-historian-sink). The two paths are complementary — the sink populates an external historian's alarm schema; `ReadEventsAsync` reads from whatever event store the driver owns — and share neither interface nor dispatch.
## Dispatch through `CapabilityInvoker` ## Dispatch through `CapabilityInvoker`
All four HistoryRead surfaces are wrapped by `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`) with `DriverCapability.HistoryRead`. The Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability.HistoryRead)` provides timeout, circuit-breaker, and bulkhead defaults per the driver's stability tier (see [docs/v2/driver-stability.md](v2/driver-stability.md)). All four HistoryRead surfaces are wrapped by `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`) with `DriverCapability.HistoryRead`. The Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability.HistoryRead)` provides timeout, circuit-breaker, and bulkhead defaults per the driver's stability tier (see [docs/v2/driver-stability.md](v2/driver-stability.md)).

View File

@@ -51,6 +51,10 @@ Exceptions during teardown are swallowed per decision #12 — a driver throw mus
When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree, and for OPC UA Client where an upstream change may be localized. Null scope falls back to a full-tree diff. When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree, and for OPC UA Client where an upstream change may be localized. Null scope falls back to a full-tree diff.
## Virtual tags in the rebuild
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), virtual (scripted) tags live in the same address space as driver tags and flow through the same rebuild. `EquipmentNodeWalker` (`src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs`) emits virtual-tag children alongside driver-tag children with `DriverAttributeInfo.Source = NodeSourceKind.Virtual`, and `DriverNodeManager` registers each variable's source in `_sourceByFullRef` so the dispatch branches correctly after rebuild. Virtual-tag script changes published from the Admin UI land through the same generation-publish path — the `VirtualTagEngine` recompiles its script bundle when its config row changes and `DriverNodeManager` re-registers any added/removed virtual variables through the standard diff path. Subscription restoration after rebuild runs through each source's `ISubscribable` — either the driver's or `VirtualTagSource` — without special-casing.
## Active subscriptions survive rebuild ## Active subscriptions survive rebuild
Subscriptions for unchanged references stay live across rebuilds — their ref-count map is not disturbed. Clients monitoring a stable tag never see a data-change gap during a deploy, only clients monitoring a tag that was genuinely removed see the subscription drop. Subscriptions for unchanged references stay live across rebuilds — their ref-count map is not disturbed. Clients monitoring a stable tag never see a data-change gap during a deploy, only clients monitoring a tag that was genuinely removed see the subscription drop.

View File

@@ -29,12 +29,21 @@ The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess
| [DataTypeMapping.md](DataTypeMapping.md) | Per-driver `DriverAttributeInfo` → OPC UA variable types | | [DataTypeMapping.md](DataTypeMapping.md) | Per-driver `DriverAttributeInfo` → OPC UA variable types |
| [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` | | [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` |
| [HistoricalDataAccess.md](HistoricalDataAccess.md) | `IHistoryProvider` as a per-driver optional capability | | [HistoricalDataAccess.md](HistoricalDataAccess.md) | `IHistoryProvider` as a per-driver optional capability |
| [VirtualTags.md](VirtualTags.md) | `Core.Scripting` + `Core.VirtualTags` — Roslyn script sandbox, engine, dispatch alongside driver tags |
| [ScriptedAlarms.md](ScriptedAlarms.md) | `Core.ScriptedAlarms` — script-predicate `IAlarmSource` + Part 9 state machine |
Two Core subsystems are shipped without a dedicated top-level doc; see the section in the linked doc:
| Project | See |
|---------|-----|
| `Core.AlarmHistorian` | [AlarmTracking.md](AlarmTracking.md) § Alarm historian sink |
| `Analyzers` (Roslyn OTOPCUA0001) | [security.md](security.md) § OTOPCUA0001 Analyzer |
### Drivers ### Drivers
| Doc | Covers | | Doc | Covers |
|-----|--------| |-----|--------|
| [drivers/README.md](drivers/README.md) | Index of the seven shipped drivers + capability matrix | | [drivers/README.md](drivers/README.md) | Index of the eight shipped drivers + capability matrix |
| [drivers/Galaxy.md](drivers/Galaxy.md) | Galaxy driver — MXAccess bridge, Host/Proxy split, named-pipe IPC | | [drivers/Galaxy.md](drivers/Galaxy.md) | Galaxy driver — MXAccess bridge, Host/Proxy split, named-pipe IPC |
| [drivers/Galaxy-Repository.md](drivers/Galaxy-Repository.md) | Galaxy-specific discovery via the ZB SQL database | | [drivers/Galaxy-Repository.md](drivers/Galaxy-Repository.md) | Galaxy-specific discovery via the ZB SQL database |
@@ -54,8 +63,15 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
| Doc | Covers | | Doc | Covers |
|-----|--------| |-----|--------|
| [Client.CLI.md](Client.CLI.md) | `lmxopcua-cli` — command-line client | | [Client.CLI.md](Client.CLI.md) | `otopcua-cli` OPC UA command-line client |
| [Client.UI.md](Client.UI.md) | Avalonia desktop client | | [Client.UI.md](Client.UI.md) | Avalonia desktop client |
| [DriverClis.md](DriverClis.md) | Driver test-client CLIs — index + shared commands |
| [Driver.Modbus.Cli.md](Driver.Modbus.Cli.md) | `otopcua-modbus-cli` — Modbus-TCP |
| [Driver.AbCip.Cli.md](Driver.AbCip.Cli.md) | `otopcua-abcip-cli` — ControlLogix / CompactLogix / Micro800 / GuardLogix |
| [Driver.AbLegacy.Cli.md](Driver.AbLegacy.Cli.md) | `otopcua-ablegacy-cli` — SLC / MicroLogix / PLC-5 (PCCC) |
| [Driver.S7.Cli.md](Driver.S7.Cli.md) | `otopcua-s7-cli` — Siemens S7-300 / S7-400 / S7-1200 / S7-1500 |
| [Driver.TwinCAT.Cli.md](Driver.TwinCAT.Cli.md) | `otopcua-twincat-cli` — Beckhoff TwinCAT 2/3 ADS |
| [Driver.FOCAS.Cli.md](Driver.FOCAS.Cli.md) | `otopcua-focas-cli` — Fanuc FOCAS/2 CNC |
### Requirements ### Requirements

View File

@@ -2,6 +2,16 @@
`DriverNodeManager` (`src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`) wires the OPC UA stack's per-variable `OnReadValue` and `OnWriteValue` hooks to each driver's `IReadable` and `IWritable` capabilities. Every dispatch flows through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker / bulkhead) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. `DriverNodeManager` (`src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`) wires the OPC UA stack's per-variable `OnReadValue` and `OnWriteValue` hooks to each driver's `IReadable` and `IWritable` capabilities. Every dispatch flows through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker / bulkhead) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers.
## Driver vs virtual dispatch
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), a single `DriverNodeManager` routes reads and writes across both driver-sourced and virtual (scripted) tags. At discovery time each variable registers a `NodeSourceKind` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`) in the manager's `_sourceByFullRef` lookup; the read/write hooks pattern-match on that value to pick the backend:
- `NodeSourceKind.Driver` — dispatches to the driver's `IReadable` / `IWritable` through `CapabilityInvoker` (the rest of this doc).
- `NodeSourceKind.Virtual` — dispatches to `VirtualTagSource` (`src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which wraps `VirtualTagEngine`. Writes are rejected with `BadUserAccessDenied` before the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags.
- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim.
ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.
## OnReadValue ## OnReadValue
The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace: The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace:
@@ -20,7 +30,7 @@ The hook is synchronous — the async invoker call is bridged with `AsTask().Get
### Authorization (two layers) ### Authorization (two layers)
1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (feedback `feedback_acl_at_server_layer.md`). 1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (see `docs/security.md`).
2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`. 2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`.
### Dispatch ### Dispatch

125
docs/ScriptedAlarms.md Normal file
View File

@@ -0,0 +1,125 @@
# Scripted Alarms
`Core.ScriptedAlarms` is the Phase 7 subsystem that raises OPC UA Part 9 alarms from operator-authored C# predicates rather than from driver-native alarm streams. Scripted alarms are additive: Galaxy, AB CIP, FOCAS, and OPC UA Client drivers keep their native `IAlarmSource` implementations unchanged, and a `ScriptedAlarmSource` simply registers as another source in the same fan-out. Predicates read tags from any source (driver tags or virtual tags) through the shared `ITagUpstreamSource` and emit condition transitions through the engine's Part 9 state machine.
This file covers the engine internals — predicate evaluation, state machine, persistence, and the engine-to-`IAlarmSource` adapter. The server-side plumbing that turns those emissions into OPC UA `AlarmConditionState` nodes, applies retries, persists alarm transitions to the Historian, and routes operator acks through the session's `AlarmAck` permission lives in [AlarmTracking.md](AlarmTracking.md) and is not repeated here.
## Definition shape
`ScriptedAlarmDefinition` (`src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs`) is the runtime contract the engine consumes. The generation-publish path materialises these from the `ScriptedAlarm` + `Script` config tables via `Phase7EngineComposer.ProjectScriptedAlarms`.
| Field | Notes |
|---|---|
| `AlarmId` | Stable identity. Also the OPC UA `ConditionId` and the key in `IAlarmStateStore`. Convention: `{EquipmentPath}::{AlarmName}`. |
| `EquipmentPath` | UNS path the alarm hangs under in the address space. ACL scope inherits from the equipment node. |
| `AlarmName` | Browse-tree display name. |
| `Kind` | `AlarmKind``AlarmCondition`, `LimitAlarm`, `DiscreteAlarm`, or `OffNormalAlarm`. Controls only the OPC UA ObjectType the node surfaces as; the internal state machine is identical for all four. |
| `Severity` | `AlarmSeverity` enum (`Low` / `Medium` / `High` / `Critical`). Static per decision #13 — the predicate does not compute severity. The DB column is an OPC UA Part 9 1..1000 integer; `Phase7EngineComposer.MapSeverity` bands it into the four-value enum. |
| `MessageTemplate` | String with `{TagPath}` placeholders, resolved at emission time. See below. |
| `PredicateScriptSource` | Roslyn C# script returning `bool`. `true` = condition active; `false` = cleared. |
| `HistorizeToAveva` | When true, every emission is enqueued to `IAlarmHistorianSink`. Default true. Galaxy-native alarms default false since Galaxy historises them directly. |
| `Retain` | Part 9 retain flag — keep the condition visible after clear while un-acked/un-confirmed transitions remain. Default true. |
Illustrative definition:
```csharp
new ScriptedAlarmDefinition(
AlarmId: "Plant/Line1/Oven::OverTemp",
EquipmentPath: "Plant/Line1/Oven",
AlarmName: "OverTemp",
Kind: AlarmKind.LimitAlarm,
Severity: AlarmSeverity.High,
MessageTemplate: "Oven {Plant/Line1/Oven/Temp} exceeds limit {Plant/Line1/Oven/TempLimit}",
PredicateScriptSource: "return GetTag(\"Plant/Line1/Oven/Temp\").AsDouble() > GetTag(\"Plant/Line1/Oven/TempLimit\").AsDouble();");
```
## Predicate evaluation
Alarm predicates reuse the same Roslyn sandbox as virtual tags — `ScriptEvaluator<AlarmPredicateContext, bool>` compiles the source, `TimedScriptEvaluator` wraps it with the configured timeout (default from `TimedScriptEvaluator.DefaultTimeout`), and `DependencyExtractor` statically harvests the tag paths the script reads. The sandbox rules (forbidden types, cancellation, logging sinks) are documented in [VirtualTags.md](VirtualTags.md); ScriptedAlarms does not redefine them.
`AlarmPredicateContext` (`AlarmPredicateContext.cs`) is the script's `ScriptContext` subclass:
- `GetTag(path)` returns a `DataValueSnapshot` from the engine-maintained read cache. Missing path → `DataValueSnapshot(null, 0x80340000u, null, now)` (`BadNodeIdUnknown`). An empty path returns the same.
- `SetVirtualTag(path, value)` throws `InvalidOperationException`. Predicates must be side-effect free per plan decision #6; writes would couple alarm state to virtual-tag state in ways that are near-impossible to reason about. Operators see the rejection in `scripts-*.log`.
- `Now` and `Logger` are provided by the engine.
Evaluation cadence:
- On every upstream tag change that any alarm's input set references (`OnUpstreamChange``ReevaluateAsync`). The engine maintains an inverse index `tag path → alarm ids` (`_alarmsReferencing`); only affected alarms re-run.
- On a 5-second shelving-check timer (`_shelvingTimer`) for timed-shelve expiry.
- At `LoadAsync` for every alarm, to re-derive `ActiveState` per plan decision #14 (startup recovery).
If a predicate throws or times out, the engine logs the failure and leaves the prior `ActiveState` intact — it does not synthesise a clear. Operators investigating a broken predicate should never see a phantom clear preceding the error.
## Part 9 state machine
`Part9StateMachine` (`Part9StateMachine.cs`) is a pure `static` function set. Every transition takes the current `AlarmConditionState` plus the event, returns a new record and an `EmissionKind`. No I/O, no mutation, trivially unit-testable. Transitions map to OPC UA Part 9:
- `ApplyPredicate(current, predicateTrue, nowUtc)` — predicate re-evaluation. `Inactive → Active` sets `Acked = Unacknowledged` and `Confirmed = Unconfirmed`; `Active → Inactive` updates `LastClearedUtc` and consumes `OneShot` shelving. Disabled alarms no-op.
- `ApplyAcknowledge` / `ApplyConfirm` — operator ack/confirm. Require a non-empty user string (audit requirement). Each appends an `AlarmComment` with `Kind = "Acknowledge"` / `"Confirm"`.
- `ApplyOneShotShelve` / `ApplyTimedShelve(unshelveAtUtc)` / `ApplyUnshelve` — shelving transitions. `Timed` requires `unshelveAtUtc > nowUtc`.
- `ApplyEnable` / `ApplyDisable` — operator enable/disable. Disabled alarms ignore predicate results until re-enabled; on enable, `ActiveState` is re-derived from the next evaluation.
- `ApplyAddComment(text)` — append-only audit entry, no state change.
- `ApplyShelvingCheck(nowUtc)` — called by the 5s timer; promotes expired `Timed` shelving to `Unshelved` with a `system / AutoUnshelve` audit entry.
Two invariants the machine enforces:
1. **Disabled** alarms ignore every predicate evaluation — they never transition `ActiveState` / `AckedState` / `ConfirmedState` until re-enabled.
2. **Shelved** alarms still advance their internal state but emit `EmissionKind.Suppressed` instead of `Activated` / `Cleared`. The engine advances the state record (so startup recovery reflects reality) but `ScriptedAlarmSource` does not publish the suppressed transition to subscribers. `OneShot` expires on the next clear; `Timed` expires at `ShelvingState.UnshelveAtUtc`.
`EmissionKind` values: `None`, `Suppressed`, `Activated`, `Cleared`, `Acknowledged`, `Confirmed`, `Shelved`, `Unshelved`, `Enabled`, `Disabled`, `CommentAdded`.
## Message templates
`MessageTemplate` (`MessageTemplate.cs`) resolves `{path}` placeholders in the configured message at emission time. Syntax:
- `{path/with/slashes}` — brace-stripped contents are looked up via the engine's tag cache.
- No escaping. Literal braces in messages are not currently supported.
- `ExtractTokenPaths(template)` is called at `LoadAsync` so the engine subscribes to every referenced path (ensuring the value cache is populated before the first resolve).
Fallback rules: a resolved `DataValueSnapshot` with a non-zero `StatusCode`, a `null` `Value`, or an unknown path becomes `{?}`. The event still fires — the operator sees where the reference broke rather than having the alarm swallowed.
## State persistence
`IAlarmStateStore` (`IAlarmStateStore.cs`) is the persistence contract: `LoadAsync(alarmId)`, `LoadAllAsync`, `SaveAsync(state)`, `RemoveAsync(alarmId)`. `InMemoryAlarmStateStore` in the same file is the default for tests and dev deployments without a SQL backend. Stream E wires the production implementation against the `ScriptedAlarmState` config-DB table with audit logging through `Core.Abstractions.IAuditLogger`.
Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelving`, `LastTransitionUtc`, the `LastAck*` / `LastConfirm*` audit fields, and the append-only `Comments` list. `Active` is **not** trusted across restart — the engine re-runs the predicate at `LoadAsync` so operators never re-ack an alarm that was already acknowledged before an outage, and alarms whose condition cleared during downtime settle to `Inactive` without a spurious clear-event.
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
## Source integration
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
Two mapping notes specific to this adapter:
- `SubscribeAlarmsAsync` accepts a list of source-node-id filters, interpreted as Equipment-path prefixes. Empty list matches every alarm. Each emission is matched against every live subscription — the adapter keeps no per-subscription cursor.
- `IAlarmSource.AcknowledgeAsync` does not carry a user identity. The adapter defaults the audit user to `"opcua-client"` so callers using the base interface still produce an audit entry. The server's Part 9 method handlers (Stream G) call the engine's richer `AcknowledgeAsync` / `ConfirmAsync` / `OneShotShelveAsync` / `TimedShelveAsync` / `UnshelveAsync` / `AddCommentAsync` directly with the authenticated principal instead.
Emissions map into `AlarmEventArgs` as `AlarmType = Kind.ToString()`, `SourceNodeId = EquipmentPath`, `ConditionId = AlarmId`, `Message = resolved template string`, `Severity` carried verbatim, `SourceTimestampUtc = emission time`.
## Composition
`Phase7EngineComposer.Compose` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs`) is the single call site that instantiates the engine. It takes the generation's `Script` / `VirtualTag` / `ScriptedAlarm` rows, the shared `CachedTagUpstreamSource`, an `IAlarmStateStore`, and an `IAlarmHistorianSink`, and returns a `Phase7ComposedSources` the caller owns. When `scriptedAlarms.Count > 0`:
1. `ProjectScriptedAlarms` resolves each row's `PredicateScriptId` against the script dictionary and produces a `ScriptedAlarmDefinition` list. Unknown or disabled scripts throw immediately — the DB publish guarantees referential integrity but this is a belt-and-braces check.
2. A `ScriptedAlarmEngine` is constructed with the upstream source, the store, a shared `ScriptLoggerFactory` keyed to `scripts-*.log`, and the root Serilog logger.
3. `alarmEngine.OnEvent` is wired to `RouteToHistorianAsync`, which projects each emission into an `AlarmHistorianEvent` and enqueues it on the sink. Fire-and-forget — the SQLite store-and-forward sink is already non-blocking.
4. `LoadAsync(alarmDefs)` runs synchronously on the startup thread: it compiles every predicate, subscribes to the union of predicate inputs and message-template tokens, seeds the value cache, loads persisted state, re-derives `ActiveState` from a fresh predicate evaluation, and starts the 5s shelving timer. Compile failures are aggregated into one `InvalidOperationException` so operators see every bad predicate in one startup log line rather than one at a time.
5. A `ScriptedAlarmSource` is created for the event stream, and a `ScriptedAlarmReadable` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs`) is created for OPC UA variable reads on the alarm's active-state node (task #245) — unknown alarm ids return `BadNodeIdUnknown` rather than silently reading `false`.
Both engine and source are added to `Phase7ComposedSources.Disposables`, which `Phase7Composer` disposes on server shutdown.
## Key source files
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs` — orchestrator, cascade wiring, shelving timer, `OnEvent` emission
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs``IAlarmSource` adapter over the engine
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs` — runtime definition record
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs` — pure-function state machine + `TransitionResult` / `EmissionKind`
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs` — persisted state record + `AlarmComment` audit entry + `ShelvingState`
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs` — script-side `ScriptContext` (read-only, write rejected)
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs``AlarmKind` + the four Part 9 enums
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs``{path}` placeholder resolver
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs` — persistence contract + `InMemoryAlarmStateStore` default
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs` — composition, config-row projection, historian routing
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs``IReadable` adapter exposing `ActiveState` to OPC UA variable reads

View File

@@ -2,6 +2,15 @@
Driver-side data-change subscriptions live behind `ISubscribable` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs`). The interface is deliberately mechanism-agnostic: it covers native subscriptions (Galaxy MXAccess advisory, OPC UA monitored items on an upstream server, TwinCAT ADS notifications) and driver-internal polled subscriptions (Modbus, AB CIP, S7, FOCAS). Core sees the same event shape regardless — drivers fire `OnDataChange` and Core dispatches to the matching OPC UA monitored items. Driver-side data-change subscriptions live behind `ISubscribable` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs`). The interface is deliberately mechanism-agnostic: it covers native subscriptions (Galaxy MXAccess advisory, OPC UA monitored items on an upstream server, TwinCAT ADS notifications) and driver-internal polled subscriptions (Modbus, AB CIP, S7, FOCAS). Core sees the same event shape regardless — drivers fire `OnDataChange` and Core dispatches to the matching OPC UA monitored items.
## Driver vs virtual dispatch
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), `DriverNodeManager` routes subscriptions across both driver tags and virtual (scripted) tags through the same `ISubscribable` contract. The per-variable `NodeSourceKind` (registered from `DriverAttributeInfo` at discovery) selects the backend:
- `NodeSourceKind.Driver` — subscribes via the driver's `ISubscribable`, wrapped by `CapabilityInvoker` (the rest of this doc).
- `NodeSourceKind.Virtual` — subscribes via `VirtualTagSource` (`src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which forwards change events emitted by `VirtualTagEngine` as `OnDataChange`. The ref-counting, initial-value, and transfer-restoration behaviour below applies identically.
Because both kinds expose `ISubscribable`, Core's dispatch, ref-count map, and monitored-item fan-out are unchanged across the source branch.
## ISubscribable surface ## ISubscribable surface
```csharp ```csharp

142
docs/VirtualTags.md Normal file
View File

@@ -0,0 +1,142 @@
# Virtual Tags
Virtual tags are OPC UA variable nodes whose values are computed by operator-authored C# scripts against other tags (driver or virtual). They live in the Equipment browse tree alongside driver-sourced variables: a client browsing `Enterprise/Site/Area/Line/Equipment/` sees one flat child list that mixes both kinds, and a read / subscribe on a virtual node looks identical to one on a driver node from the wire. The separation is server-side — `NodeScopeResolver` tags each variable's `NodeSource` (`Driver` / `Virtual` / `ScriptedAlarm`), and `DriverNodeManager` dispatches reads to different backends accordingly. See [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) for the dispatch decision.
The runtime is split across two projects: `Core.Scripting` holds the Roslyn sandbox + evaluator primitives that are reused by both virtual tags and scripted alarms; `Core.VirtualTags` holds the engine that owns the dependency graph, the evaluation pipeline, and the `ISubscribable` adapter the server dispatches to.
## Roslyn script sandbox (`Core.Scripting`)
User scripts are compiled via `Microsoft.CodeAnalysis.CSharp.Scripting` against a `ScriptContext` subclass. `ScriptGlobals<TContext>` exposes the context as a field named `ctx`, so scripts read `ctx.GetTag("...")` / `ctx.SetVirtualTag("...", ...)` / `ctx.Now` / `ctx.Logger` and return a value.
### Compile pipeline (`ScriptEvaluator<TContext, TResult>`)
`ScriptEvaluator.Compile(source)` is a three-step gate:
1. **Roslyn compile** against `ScriptSandbox.Build(contextType)`. Throws `CompilationErrorException` on syntax / type errors.
2. **`ForbiddenTypeAnalyzer.Analyze`** walks the syntax tree post-compile and resolves every referenced symbol against the deny-list. Throws `ScriptSandboxViolationException` with every offending source span attached. This is defence-in-depth: `ScriptOptions` alone cannot block every BCL namespace because .NET type forwarding routes types through assemblies the allow-list does permit.
3. **Delegate materialization**`script.CreateDelegate()`. Failures here are Roslyn-internal; user scripts don't reach this step.
`ScriptSandbox.Build` allow-lists exactly: `System.Private.CoreLib` (primitives + `Math` + `Convert`), `System.Linq`, `Core.Abstractions` (for `DataValueSnapshot` / `DriverDataType`), `Core.Scripting` (for `ScriptContext` + `Deadband`), `Serilog` (for `ILogger`), and the concrete context type's assembly. Pre-imported namespaces: `System`, `System.Linq`, `ZB.MOM.WW.OtOpcUa.Core.Abstractions`, `ZB.MOM.WW.OtOpcUa.Core.Scripting`.
`ForbiddenTypeAnalyzer.ForbiddenNamespacePrefixes` currently denies `System.IO`, `System.Net`, `System.Diagnostics`, `System.Reflection`, `System.Threading.Thread`, `System.Runtime.InteropServices`, `Microsoft.Win32`. Matching is by prefix against the resolved symbol's containing namespace, so `System.Net` catches `System.Net.Http.HttpClient` and every subnamespace. `System.Environment` is explicitly allowed.
### Compile cache (`CompiledScriptCache<TContext, TResult>`)
`ConcurrentDictionary<string, Lazy<ScriptEvaluator<...>>>` keyed on `SHA-256(UTF8(source))` rendered to hex. `Lazy<T>` with `ExecutionAndPublication` mode means two threads racing a miss compile exactly once. Failed compiles evict the entry so a corrected retry can succeed (used during Admin UI authoring). No capacity bound — scripts are operator-authored and bounded by the config DB. Whitespace changes miss the cache on purpose. `Clear()` is called on config-publish.
### Per-evaluation timeout (`TimedScriptEvaluator<TContext, TResult>`)
Wraps `ScriptEvaluator` with a wall-clock budget. Default `DefaultTimeout = 250ms`. Implementation pushes the inner `RunAsync` onto `Task.Run` (so a CPU-bound script can't hog the calling thread before `WaitAsync` registers its timeout) then awaits `runTask.WaitAsync(Timeout, ct)`. A `TimeoutException` from `WaitAsync` is wrapped as `ScriptTimeoutException`. Caller-supplied `CancellationToken` cancellation wins over the timeout and propagates as `OperationCanceledException` — so a shutdown cancel is not misclassified. **Known leak:** when a CPU-bound script times out, the underlying `ScriptRunner` keeps running on its thread-pool thread until the Roslyn runtime returns (documented trade-off; out-of-process evaluation is a v3 concern).
### Script logger plumbing
`ScriptLoggerFactory.Create(scriptName)` returns a per-script Serilog logger with the `ScriptName` structured property bound (constant `ScriptLoggerFactory.ScriptNameProperty`). The root script logger is typically a rolling file sink to `scripts-*.log`. `ScriptLogCompanionSink` is attached to the root pipeline and mirrors script events at `Error` or higher into the main `opcua-*.log` at `Warning` level — operators see script errors in the primary log without drowning it in script-authored Info/Debug noise. Exceptions and the `ScriptName` property are preserved in the mirror.
### Static dependency extraction (`DependencyExtractor`)
Parses the script source with `CSharpSyntaxTree.ParseText` (script kind), walks invocation expressions, and records every `ctx.GetTag("literal")` and `ctx.SetVirtualTag("literal", ...)` call. The first argument **must** be a string literal — variables, concatenation, interpolation, and method-returned strings are rejected at publish with a `DependencyRejection` carrying the exact `TextSpan`. This is how the engine builds its change-trigger graph statically; scripts cannot smuggle a dependency past the extractor.
## Virtual tag engine (`Core.VirtualTags`)
### `VirtualTagDefinition`
One row per operator-authored tag. Fields: `Path` (UNS browse path; also the engine's internal id), `DataType` (`DriverDataType` enum; the evaluator coerces the script's return value to this and mismatch surfaces as `BadTypeMismatch`), `ScriptSource` (Roslyn C# script text), `ChangeTriggered` (re-evaluate on any input delta), `TimerInterval` (optional periodic cadence; null disables), `Historize` (route every evaluation result to `IHistoryWriter`). Change-trigger and timer are independent — a tag can be either, both, or neither.
### `VirtualTagContext`
Subclass of `ScriptContext`. Constructed fresh per evaluation over a per-run read cache — scripts cannot stash mutable state across runs on `ctx`. `GetTag(path)` serves from the cache; missing-path reads return a `BadNodeIdUnknown`-quality snapshot. `SetVirtualTag(path, value)` routes through the engine's `OnScriptSetVirtualTag` callback so cross-tag writes still participate in change-trigger cascades (writes to non-virtual / non-registered paths log a warning and drop). `Now` is an injectable clock; production wires `DateTime.UtcNow`, tests pin it.
### `DependencyGraph`
Directed graph of tag paths. Edges run from a virtual tag to each path it reads. Unregistered paths (driver tags) are implicit leaves; leaf validity is checked elsewhere against the authoritative catalog. Two operations:
- **`TopologicalSort()`** — Kahn's algorithm. Produces evaluation order such that every node appears after its registered (virtual) dependencies. Throws `DependencyCycleException` (with every cycle, not just one) on offense.
- **`TransitiveDependentsInOrder(nodeId)`** — DFS collects every reachable dependent of a changed upstream then sorts by topological rank. Used by the cascade dispatcher so a single upstream delta recomputes the full downstream closure in one serial pass without needing a second iteration.
Cycle detection uses an **iterative** Tarjan's SCC implementation (no recursion, deep graphs cannot stack-overflow). Cycles of length > 1 and self-loops both reject; leaf references cannot form cycles with internal nodes.
### `VirtualTagEngine` lifecycle
- **`Load(definitions)`** — clears prior state, compiles every script through `DependencyExtractor.Extract` + `ScriptEvaluator.Compile` (wrapped in `TimedScriptEvaluator`), registers each in `_tags` + `_graph`, runs `TopologicalSort` (cycle check), then for every upstream (non-virtual) path subscribes via `ITagUpstreamSource.SubscribeTag` and seeds `_valueCache` with `ReadTag`. Throws `InvalidOperationException` aggregating every compile failure at once so operators see the whole set; throws `DependencyCycleException` on cycles. Re-entrant — supports config-publish reloads by disposing the prior upstream subscriptions first.
- **`EvaluateAllAsync(ct)`** — evaluates every tag once in topological order. Called at startup so virtual tags have a defined initial value before subscriptions start.
- **`EvaluateOneAsync(path, ct)`** — single-tag evaluation. Entry point for `TimerTriggerScheduler` + tests.
- **`Read(path)`** — synchronous last-known-value lookup from `_valueCache`. Returns `BadNodeIdUnknown`-quality for unregistered paths.
- **`Subscribe(path, observer)`** — register a change observer; returns `IDisposable`. Does **not** emit a seed value.
- **`OnUpstreamChange(path, value)`** (internal, wired from the upstream subscription) — updates cache, notifies observers, launches `CascadeAsync` fire-and-forget so the driver's dispatcher isn't blocked.
Evaluations are **serial across all tags**`_evalGate` is a `SemaphoreSlim(1, 1)` held around every `EvaluateInternalAsync`. Parallelism is deferred (Phase 7 plan decision #19). Rationale: serial execution preserves the "earlier topological nodes computed before later dependents" invariant when two cascades race. Per-tag error isolation: a script exception or timeout sets that tag's quality to `BadInternalError` and logs a structured error; other tags keep evaluating. `OperationCanceledException` is re-thrown (shutdown path).
Result coercion: `CoerceResult` maps the script's return value to the declared `DriverDataType` via `Convert.ToXxx`. Coercion failure returns null which the outer pipeline maps to `BadInternalError`; `BadTypeMismatch` is documented in the definition shape (`VirtualTagDefinition.DataType` doc) rather than emitted distinctly today.
`IHistoryWriter.Record` fires per evaluation when `Historize = true`. The default `NullHistoryWriter` drops silently.
### `TimerTriggerScheduler`
Groups `VirtualTagDefinition`s by `TimerInterval`, one `System.Threading.Timer` per unique interval. Each tick evaluates the group's paths serially via `VirtualTagEngine.EvaluateOneAsync`. Errors per-tag log and continue. `Dispose()` cancels an internal `CancellationTokenSource` and disposes every timer. Independent of the change-trigger path — a tag with both triggers fires from both scheduling sources.
### `ITagUpstreamSource`
What the engine pulls driver-tag values from. Reads are **synchronous** because user scripts call `ctx.GetTag(path)` inline — a blocking wire call per evaluation would kill throughput. Implementations are expected to serve from a last-known-value cache populated by subscription callbacks. The server's production implementation is `CachedTagUpstreamSource` (see Composition below).
### `IHistoryWriter`
Fire-and-forget sink for evaluation results when `VirtualTagDefinition.Historize = true`. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Today no production writer is wired into the virtual-tag path; scripted-alarm emissions flow through `Core.AlarmHistorian` via `Phase7EngineComposer.RouteToHistorianAsync` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)).
## Dispatch integration
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B, there is a single `DriverNodeManager`. `VirtualTagSource` implements `IReadable` + `ISubscribable` over a `VirtualTagEngine`:
- `ReadAsync` fans each path through `engine.Read(...)`.
- `SubscribeAsync` calls `engine.Subscribe` per path and forwards each engine observer callback as an `OnDataChange` event; emits an initial-data callback per OPC UA convention.
- `UnsubscribeAsync` disposes every per-path engine subscription it holds.
- **`IWritable` is deliberately not implemented.** `DriverNodeManager.IsWriteAllowedBySource` rejects OPC UA client writes to virtual nodes with `BadUserAccessDenied` before any dispatch — scripts are the only write path via `ctx.SetVirtualTag`.
`DriverNodeManager.SelectReadable(source, ...)` picks the `IReadable` based on `NodeSourceKind`. See [ReadWriteOperations.md](ReadWriteOperations.md) and [Subscriptions.md](Subscriptions.md) for the broader dispatch framing.
## Upstream reads + history
`ITagUpstreamSource` and `IHistoryWriter` are the two ports the engine requires from its host. Both live in `Core.VirtualTags`. In the Server process:
- **`CachedTagUpstreamSource`** (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/CachedTagUpstreamSource.cs`) implements the interface (and the parallel `Core.ScriptedAlarms.ITagUpstreamSource` — identical shape, distinct namespace). A `ConcurrentDictionary<path, DataValueSnapshot>` cache. `Push(path, snapshot)` updates the cache and fans out synchronously to every observer. Reads of never-pushed paths return `BadNodeIdUnknown` quality (`UpstreamNotConfigured = 0x80340000`).
- **`DriverSubscriptionBridge`** (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/DriverSubscriptionBridge.cs`) feeds the cache. For each registered `ISubscribable` driver it batches a single `SubscribeAsync` for every fullRef the script graph references, installs an `OnDataChange` handler that translates driver-opaque fullRefs back to UNS paths via a reverse map, and pushes each delta into `CachedTagUpstreamSource`. Unsubscribes on dispose. The bridge suppresses `OTOPCUA0001` (the Roslyn analyzer that requires `ISubscribable` callers to go through `CapabilityInvoker`) on the documented basis that this is a lifecycle wiring, not per-evaluation hot path.
- **`IHistoryWriter`** — no production implementation is currently wired for virtual tags; `VirtualTagEngine` gets `NullHistoryWriter` by default from `Phase7EngineComposer`.
## Composition
`Phase7Composer` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs`) is an `IAsyncDisposable` injected into `OpcUaServerService`:
1. `PrepareAsync(generationId, ct)` — called after the bootstrap generation loads and before `OpcUaApplicationHost.StartAsync`. Reads the `Script` / `VirtualTag` / `ScriptedAlarm` rows for that generation from the config DB (`OtOpcUaConfigDbContext`). Empty-config fast path returns `Phase7ComposedSources.Empty`.
2. Constructs a `CachedTagUpstreamSource` + hands it to `Phase7EngineComposer.Compose`.
3. `Phase7EngineComposer.Compose` projects `VirtualTag` rows into `VirtualTagDefinition`s (joining `Script` rows by `ScriptId`), instantiates `VirtualTagEngine`, calls `Load`, wraps in `VirtualTagSource`.
4. Builds a `DriverFeed` per driver by mapping the driver's `EquipmentNamespaceContent` to `UNS path → driver fullRef` (path format `/{area}/{line}/{equipment}/{tag}` matching the `EquipmentNodeWalker` browse tree so script literals match the operator-visible UNS), then starts `DriverSubscriptionBridge`.
5. Returns `Phase7ComposedSources` with the `VirtualTagSource` cast as `IReadable`. `OpcUaServerService` passes it to `OpcUaApplicationHost` which threads it into `DriverNodeManager` as `virtualReadable`.
`DisposeAsync` tears down the bridge first (no more events into the cache), then the engines (cascades + timer ticks stop), then the owned SQLite historian sink if any.
Definition reload on config publish: `VirtualTagEngine.Load` is re-entrant — a future config-publish handler can call it with a new definition set. That handler is not yet wired; today engine composition happens once per service start against the bootstrapped generation.
## Key source files
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs` — abstract `ctx` API scripts see
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs` — generic globals wrapper naming the field `ctx`
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs` — assembly allow-list + imports
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs` — post-compile semantic deny-list
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs` — three-step compile pipeline
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs` — 250ms default timeout wrapper
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs` — SHA-256-keyed compile cache
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs` — static `ctx.GetTag` / `ctx.SetVirtualTag` inference
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs` — per-script Serilog logger
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogCompanionSink.cs` — error mirror to main log
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagDefinition.cs` — per-tag config record
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagContext.cs` — evaluation-scoped `ctx`
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs` — Kahn topo-sort + iterative Tarjan SCC
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs` — load / evaluate / cascade pipeline
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs` — periodic re-evaluation
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs` — driver-tag read + subscribe port
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs` — historize sink port + `NullHistoryWriter`
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs``IReadable` + `ISubscribable` adapter
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/CachedTagUpstreamSource.cs` — production `ITagUpstreamSource`
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/DriverSubscriptionBridge.cs` — driver `ISubscribable` → cache feed
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs` — row projection + engine instantiation
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs` — lifecycle host: load rows, compose, wire bridge
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs``SelectReadable` + `IsWriteAllowedBySource` dispatch kernel

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`,
@@ -93,11 +105,13 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
## Follow-up candidates ## Follow-up candidates
1. **Fix ab_server PCCC coverage upstream** — the scaffold lands the 1. **Expand ab_server PCCC coverage** — the smoke suite passes today
Docker infrastructure; the wire-level round-trip gap is in ab_server for N (Int16), F (Float32), and L (Int32) files across SLC500 /
itself. Filing a patch to `libplctag/libplctag` to expand PCCC MicroLogix / PLC-5 modes with the `/1,0` cip-path workaround in
server-side opcode coverage would make the scaffolded smoke tests place. Known residual gap: bit-file writes (`B3:0/5`) surface
pass without a golden-box tier. `0x803D0000`. Contributing a patch to `libplctag/libplctag` to close
this + documenting ab_server's empty-path rejection in its README
would remove the last Docker-vs-hardware divergences.
2. **Rockwell RSEmulate 500 golden-box tier** — Rockwell's real emulator 2. **Rockwell RSEmulate 500 golden-box tier** — Rockwell's real emulator
for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file
indirection), timer/counter decomposition, and real ladder execution indirection), timer/counter decomposition, and real ladder execution
@@ -114,7 +128,8 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs` - `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs`
— TCP probe + skip attributes + env-var parsing — TCP probe + skip attributes + env-var parsing
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs` - `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs`
three wire-level smoke tests (currently blocked by ab_server PCCC gap) — wire-level smoke tests; pass against the ab_server Docker fixture
with `AB_LEGACY_COMPOSE_PROFILE` set to the running container
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml` - `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml`
— compose profiles reusing AB CIP Dockerfile — compose profiles reusing AB CIP Dockerfile
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md` - `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md`

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

@@ -23,7 +23,7 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M
| [Galaxy](Galaxy.md) | `Driver.Galaxy.{Shared, Host, Proxy}` | C | MXAccess COM + `aahClientManaged` + SqlClient | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IRediscoverable, IHostConnectivityProbe | Out-of-process — Host is its own Windows service (.NET 4.8 x86 for the COM bitness constraint); Proxy talks to Host over a named pipe | | [Galaxy](Galaxy.md) | `Driver.Galaxy.{Shared, Host, Proxy}` | C | MXAccess COM + `aahClientManaged` + SqlClient | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IRediscoverable, IHostConnectivityProbe | Out-of-process — Host is its own Windows service (.NET 4.8 x86 for the COM bitness constraint); Proxy talks to Host over a named pipe |
| Modbus TCP | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver | | Modbus TCP | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver |
| Siemens S7 | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help | | Siemens S7 | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
| AB CIP | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader | | AB CIP | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
| AB Legacy | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB | | AB Legacy | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
| TwinCAT | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` | | TwinCAT | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
| FOCAS | `Driver.FOCAS` | C | FANUC FOCAS2 (`Fwlib32.dll` P/Invoke) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Tier C — FOCAS DLL has crash modes that warrant process isolation. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map | | FOCAS | `Driver.FOCAS` | C | FANUC FOCAS2 (`Fwlib32.dll` P/Invoke) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Tier C — FOCAS DLL has crash modes that warrant process isolation. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map |
@@ -44,7 +44,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
- [AB CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only - [AB CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only
- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped - [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102` - [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
- [AB Legacy](AbLegacy-Test-Fixture.md) — Docker scaffold via `ab_server` PCCC mode (task #224); wire-level round-trip currently blocked by ab_server's PCCC coverage gap, docs call out RSEmulate 500 + lab-rig resolution paths - [AB Legacy](AbLegacy-Test-Fixture.md) — Dockerized `ab_server` PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. `/1,0` cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (`B3:0/5`) still surface BadState — real HW / RSEmulate 500 for those
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness - [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped - [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step - [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step

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

@@ -1,6 +1,8 @@
# OPC UA Server — Component Requirements # OPC UA Server — Component Requirements
> **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). OPC-001…OPC-013 have been rewritten driver-agnostically — they now describe how the core OPC UA server composes multiple driver subtrees, enforces authorization, and invokes capabilities through the Polly-wrapped dispatch path. OPC-014 through OPC-022 are new and cover capability dispatch, per-host Polly isolation, idempotence-aware write retry, `AuthorizationGate`, `ServiceLevel` reporting, the alarm surface, history surface, server-certificate management, and the transport-security profile matrix. Galaxy-specific behavior has been moved out to `GalaxyRepositoryReqs.md` and `MxAccessClientReqs.md`. > **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). OPC-001…OPC-013 have been rewritten driver-agnostically — they now describe how the core OPC UA server composes multiple driver subtrees, enforces authorization, and invokes capabilities through the Polly-wrapped dispatch path. OPC-014 through OPC-019 are new and cover `AuthorizationGate` + permission trie, dynamic `ServiceLevel` reporting, session management, surgical address-space rebuild on generation apply, server diagnostics nodes, and OpenTelemetry observability hooks. Capability dispatch (OPC-012), per-host Polly isolation (OPC-013), idempotence-aware write retry (OPC-006 + OPC-012), the alarm surface (OPC-008), the history surface (OPC-009), and the transport-security / server-certificate profile matrix (OPC-010) are folded into the renumbered body above. Galaxy-specific behavior has been moved out to `GalaxyRepositoryReqs.md` and `MxAccessClientReqs.md`.
>
> **Reserved** — OPC-020, OPC-021, and OPC-022 are intentionally unallocated and held for future use. An earlier draft of this revision header listed them; no matching requirement bodies were ever pinned down because the scope they were meant to hold is already covered by OPC-006/008/009/010/012/013. Do not recycle these IDs for unrelated requirements without a deliberate renumbering pass.
Parent: [HLR-001](HighLevelReqs.md#hlr-001-opc-ua-server), [HLR-003](HighLevelReqs.md#hlr-003-address-space-composition-per-namespace), [HLR-009](HighLevelReqs.md#hlr-009-transport-security-and-authentication), [HLR-010](HighLevelReqs.md#hlr-010-per-driver-instance-resilience), [HLR-013](HighLevelReqs.md#hlr-013-cluster-redundancy) Parent: [HLR-001](HighLevelReqs.md#hlr-001-opc-ua-server), [HLR-003](HighLevelReqs.md#hlr-003-address-space-composition-per-namespace), [HLR-009](HighLevelReqs.md#hlr-009-transport-security-and-authentication), [HLR-010](HighLevelReqs.md#hlr-010-per-driver-instance-resilience), [HLR-013](HighLevelReqs.md#hlr-013-cluster-redundancy)

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,79 @@
# Phase 7 Exit Gate — Scripting, Virtual Tags, Scripted Alarms, Historian Sink
> **Status**: Open. Closed when every compliance check passes + every deferred item either ships or is filed as a post-v2-release follow-up.
>
> **Compliance script**: `scripts/compliance/phase-7-compliance.ps1`
> **Plan doc**: `docs/v2/implementation/phase-7-scripting-and-alarming.md`
## What shipped
| Stream | PR | Summary |
|--------|-----|---------|
| A | #177#179 | `Core.Scripting` — Roslyn sandbox + `DependencyExtractor` + `ForbiddenTypeAnalyzer` + per-script Serilog sink + 63 tests |
| B | #180 | `Core.VirtualTags` — dep graph (iterative Tarjan) + engine + timer scheduler + `VirtualTagSource` + 36 tests |
| C | #181 | `Core.ScriptedAlarms` — Part 9 state machine + predicate engine + message template + `ScriptedAlarmSource` + 47 tests |
| D | #182 | `Core.AlarmHistorian` — SQLite store-and-forward + backoff ladder + dead-letter retention + Galaxy.Host IPC contracts + 14 tests |
| E | #183 | Config DB schema — `Script` / `VirtualTag` / `ScriptedAlarm` / `ScriptedAlarmState` entities + migration + 12 tests |
| F | #185 | Admin UI — `ScriptService` / `VirtualTagService` / `ScriptedAlarmService` / `ScriptTestHarnessService` / `HistorianDiagnosticsService` + Monaco editor + `/alarms/historian` page + 13 tests |
| G | #184 | Walker emits Virtual + ScriptedAlarm variables with `NodeSourceKind` discriminator + 5 tests |
| G follow-up | #186 | `DriverNodeManager` dispatch routes by `NodeSourceKind` + writes rejected for non-Driver sources + 7 tests |
**Phase 7 totals**: ~197 new tests across 7 projects. Plan decisions #1#22 all realised in code.
## Compliance Checks (run at exit gate)
Covered by `scripts/compliance/phase-7-compliance.ps1`:
- [x] Roslyn sandbox anchored on `ScriptContext` assembly with `ForbiddenTypeAnalyzer` defense-in-depth (plan #6)
- [x] `DependencyExtractor` rejects non-literal tag paths with source spans (plan #7)
- [x] Per-script rolling Serilog sink + companion-forwarding Error+ to main log (plan #12)
- [x] VirtualTag dep graph uses iterative SCC — no stack overflow on 10 000-deep chains
- [x] `VirtualTagSource` implements `IReadable` + `ISubscribable` per ADR-002
- [x] Part 9 state machine covers every transition (Apply/Ack/Confirm/Shelve/Unshelve/Enable/Disable/Comment/ShelvingCheck)
- [x] `AlarmPredicateContext` rejects `SetVirtualTag` at runtime (predicates must be pure)
- [x] `MessageTemplate` substitutes `{TagPath}` tokens at event emission (plan #13); missing/bad → `{?}`
- [x] SQLite sink backoff ladder 1s → 2s → 5s → 15s → 60s cap (plan #16)
- [x] Default 1M-row capacity + 30-day dead-letter retention (plan #21)
- [x] Per-event outcomes Ack/RetryPlease/PermanentFail on the wire
- [x] Galaxy.Host IPC contracts (`HistorianAlarmEventRequest` / `Response` / `ConnectivityStatusNotification`)
- [x] Config DB check constraints: trigger-required, timer-min, severity-range, alarm-type-enum, JSON comments
- [x] `ScriptedAlarmState` keyed on `ScriptedAlarmId` (not generation-scoped) per plan #14
- [x] Admin services: SourceHash preserves compile-cache hit on rename; Update recomputes on source change
- [x] `ScriptTestHarnessService` enforces declared-inputs-only contract (plan #22)
- [x] Monaco editor via CDN + textarea fallback (plan #18)
- [x] `/alarms/historian` page with Retry-dead-lettered operator action
- [x] Walker emits `NodeSourceKind.Virtual` + `NodeSourceKind.ScriptedAlarm` variables
- [x] `DriverNodeManager` dispatch routes Reads by source; Writes to non-Driver rejected with `BadUserAccessDenied` (plan #6)
## Deferred to Post-Gate Follow-ups
Kept out of the capstone so the gate can close cleanly while the less-critical wiring lands in targeted PRs:
- [ ] **SealedBootstrap composition root** (task #239) — instantiate `VirtualTagEngine` + `ScriptedAlarmEngine` + `SqliteStoreAndForwardSink` in `Program.cs`; pass `VirtualTagSource` + `ScriptedAlarmSource` as the new `IReadable` parameters on `DriverNodeManager`. Without this, the engines are dormant in production even though every piece is tested.
- [ ] **Live OPC UA end-to-end smoke** (task #240) — Client.CLI browse + read a virtual tag computed by Roslyn; Client.CLI acknowledge a scripted alarm via the Part 9 method node; historian-disabled deployment returns `BadNotFound` for virtual nodes rather than silent failure.
- [ ] **sp_ComputeGenerationDiff extension** (task #241) — emit Script / VirtualTag / ScriptedAlarm sections alongside the existing Namespace/DriverInstance/Equipment/Tag/NodeAcl rows so the Admin DiffViewer shows Phase 7 changes between generations.
## Completion Checklist
- [x] Stream A shipped + merged
- [x] Stream B shipped + merged
- [x] Stream C shipped + merged
- [x] Stream D shipped + merged
- [x] Stream E shipped + merged
- [x] Stream F shipped + merged
- [x] Stream G shipped + merged
- [x] Stream G follow-up (dispatch) shipped + merged
- [x] `phase-7-compliance.ps1` present and passes
- [x] Full solution `dotnet test` passes (no new failures beyond pre-existing tolerated CLI flake)
- [x] Exit-gate doc checked in
- [ ] `SealedBootstrap` composition follow-up filed + tracked
- [ ] Live end-to-end smoke follow-up filed + tracked
- [ ] `sp_ComputeGenerationDiff` extension follow-up filed + tracked
## How to run
```powershell
pwsh ./scripts/compliance/phase-7-compliance.ps1
```
Exit code 0 = all pass; non-zero = failures listed in the preceding `[FAIL]` lines.

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

@@ -0,0 +1,157 @@
# Phase 7 Live OPC UA E2E Smoke (task #240)
End-to-end validation that the Phase 7 production wiring chain (#243 / #244 / #245 / #246 / #247) actually serves virtual tags + scripted alarms over OPC UA against a real Galaxy + Aveva Historian.
> **Scope.** Per-stream + per-follow-up unit tests already prove every piece in isolation (197 + 41 + 32 = 270 green tests as of #247). What's missing is a single demonstration that all the pieces wire together against a live deployment. This runbook is that demonstration.
## Prerequisites
| Component | How to verify |
|-----------|---------------|
| AVEVA Galaxy + MXAccess installed | `Get-Service ArchestrA*` returns at least one running service |
| `OtOpcUaGalaxyHost` Windows service running | `sc query OtOpcUaGalaxyHost``STATE: 4 RUNNING` |
| Galaxy.Host shared secret matches `.local/galaxy-host-secret.txt` | Set during NSSM install — see `docs/ServiceHosting.md` |
| SQL Server reachable, `OtOpcUaConfig` DB exists with all migrations applied | `sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "..." -Q "SELECT COUNT(*) FROM dbo.__EFMigrationsHistory"` returns ≥ 11 |
| Server's `appsettings.json` `Node:ConfigDbConnectionString` matches your SQL Server | `cat src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json` |
> **Galaxy.Host pipe ACL.** Per `docs/ServiceHosting.md`, the pipe ACL deliberately denies `BUILTIN\Administrators`. **Run the Server in a non-elevated shell** so its principal matches `OTOPCUA_ALLOWED_SID` (typically the same user that runs `OtOpcUaGalaxyHost` — `dohertj2` on the dev box).
## Setup
### 1. Migrate the Config DB
```powershell
cd src/ZB.MOM.WW.OtOpcUa.Configuration
dotnet ef database update --connection "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
```
Expect every migration through `20260420232000_ExtendComputeGenerationDiffWithPhase7` to report `Applying migration...`. Re-running is a no-op.
### 2. Seed the smoke fixture
```powershell
sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" `
-I -i scripts/smoke/seed-phase-7-smoke.sql
```
Expected output ends with `Phase 7 smoke seed complete.` plus a Cluster / Node / Generation summary. Idempotent — re-running wipes the prior smoke state and starts clean.
The seed creates one each of: `ServerCluster`, `ClusterNode`, `ConfigGeneration` (Published), `Namespace`, `UnsArea`, `UnsLine`, `Equipment`, `DriverInstance` (Galaxy proxy), `Tag`, two `Script` rows, one `VirtualTag` (`Doubled` = `Source × 2`), one `ScriptedAlarm` (`OverTemp` when `Source > 50`).
### 3. Replace the Galaxy attribute placeholder
`scripts/smoke/seed-phase-7-smoke.sql` inserts a `dbo.Tag.TagConfig` JSON with `FullName = "REPLACE_WITH_REAL_GALAXY_ATTRIBUTE"`. Edit the SQL + re-run, or `UPDATE dbo.Tag SET TagConfig = N'{"FullName":"YourReal.GalaxyAttr","DataType":"Float64"}' WHERE TagId='p7-smoke-tag-source'`. Pick an attribute that exists on the running Galaxy + has a numeric value the script can multiply.
### 4. Point Server.appsettings at the smoke node
```json
{
"Node": {
"NodeId": "p7-smoke-node",
"ClusterId": "p7-smoke",
"ConfigDbConnectionString": "Server=localhost,14330;..."
}
}
```
## Run
### 5. Start the Server (non-elevated shell)
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server
```
Expected log markers (in order):
```
Bootstrap complete: source=db generation=1
Equipment namespace snapshots loaded for 1/1 driver(s) at generation 1
Phase 7 historian sink: driver p7-smoke-galaxy provides IAlarmHistorianWriter — wiring SqliteStoreAndForwardSink
Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)
Phase 7 bridge subscribed N attribute(s) from driver GalaxyProxyDriver
OPC UA server started — endpoint=opc.tcp://0.0.0.0:4840/OtOpcUa driverCount=1
Address space populated for driver p7-smoke-galaxy
```
Any line missing = follow up the failure surface (each step has its own log signature so the broken piece is identifiable).
### 6. Validate via Client.CLI
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840/OtOpcUa -r -d 5
```
Expect to see under the namespace root: `lab-floor → galaxy-line → reactor-1` with three child variables: `Source` (driver-sourced), `Doubled` (virtual tag, value should track Source×2), and `OverTemp` (scripted alarm, boolean reflecting whether Source > 50).
#### Read the virtual tag
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-vt-derived"
```
Expected: a `Float64` value approximately equal to `2 × Source`. Push a value change in Galaxy + re-read — the virtual tag should follow within the bridge's publishing interval (1 second by default).
#### Read the scripted alarm
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-al-overtemp"
```
Expected: `Boolean``false` when Source ≤ 50, `true` when Source > 50.
#### Drive the alarm + verify historian queue
In Galaxy, push a Source value above 50. Within ~1 second, `OverTemp.Read` flips to `true`. The alarm engine emits a transition to `Phase7EngineComposer.RouteToHistorianAsync``SqliteStoreAndForwardSink.EnqueueAsync` → drain worker (every 2s) → `GalaxyHistorianWriter.WriteBatchAsync` → Galaxy.Host pipe → Aveva Historian alarm schema.
Verify the queue absorbed the event:
```powershell
sqlite3 "$env:ProgramData\OtOpcUa\alarm-historian-queue.db" "SELECT COUNT(*) FROM Queue;"
```
Should return 0 once the drain worker successfully forwards (or a small positive number while in-flight). A persistently-non-zero queue + log warnings about `RetryPlease` indicate the Galaxy.Host historian write path is failing — check the Host's log file.
#### Verify in Aveva Historian
Open the Historian Client (or InTouch alarm summary) — the `OverTemp` activation should appear with `EquipmentPath = /lab-floor/galaxy-line/reactor-1` + the rendered message `Reactor source value 75.3 exceeded 50` (or whatever value tripped it).
## Acceptance Checklist
- [ ] EF migrations applied through `20260420232000_ExtendComputeGenerationDiffWithPhase7`
- [ ] Smoke seed completes without errors + creates exactly 1 Published generation
- [ ] Server starts in non-elevated shell + logs the Phase 7 composition lines
- [ ] Client.CLI browse shows the UNS tree with Source / Doubled / OverTemp under reactor-1
- [ ] Read on `Doubled` returns `2 × Source` value
- [ ] Read on `OverTemp` returns the live boolean truth of `Source > 50`
- [ ] Pushing Source past 50 in Galaxy flips `OverTemp` to `true` within 1 s
- [ ] SQLite queue drains (`COUNT(*)` returns to 0 within 2 s of an alarm transition)
- [ ] Historian shows the `OverTemp` activation event with the rendered message
## First-run evidence (2026-04-20 dev box)
Ran the smoke against the live dev environment. Captured log signatures prove the Phase 7 wiring chain executes in production:
```
[INF] Bootstrapped from central DB: generation 1
[INF] Bootstrap complete: source=CentralDb generation=1
[INF] Phase 7 historian sink: no driver provides IAlarmHistorianWriter — using NullAlarmHistorianSink
[INF] VirtualTagEngine loaded 1 tag(s), 1 upstream subscription(s)
[INF] ScriptedAlarmEngine loaded 1 alarm(s)
[INF] Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)
```
Each line corresponds to a piece shipped in #243 / #244 / #245 / #246 / #247 — the composer ran, engines loaded, historian-sink decision fired, scripts compiled.
**Two gaps surfaced** (filed as new tasks below, NOT Phase 7 regressions):
1. **No driver-instance bootstrap pipeline.** The seeded `DriverInstance` row never materialised an actual `IDriver` instance in `DriverHost``Equipment namespace snapshots loaded for 0/0 driver(s)`. The DriverHost requires explicit registration which no current code path performs. Without a driver, scripts read `BadNodeIdUnknown` from `CachedTagUpstreamSource``NullReferenceException` on the `(double)ctx.GetTag(...).Value` cast. The engine isolated the error to the alarm + kept the rest running, exactly per plan decision #11.
2. **OPC UA endpoint port collision.** `Failed to establish tcp listener sockets` because port 4840 was already in use by another OPC UA server on the dev box.
Both are pre-Phase-7 deployment-wiring gaps. Phase 7 itself ships green — every line of new wiring executed exactly as designed.
## Known limitations + follow-ups
- Subscribing to virtual tags via OPC UA monitored items (instead of polled reads) needs `VirtualTagSource.SubscribeAsync` wiring through `DriverNodeManager.OnCreateMonitoredItem` — covered as part of release-readiness.
- Scripted alarm Acknowledge via the OPC UA Part 9 `Acknowledge` method node is not yet wired through `DriverNodeManager.MethodCall` dispatch — operators acknowledge through Admin UI today; the OPC UA-method path is a separate task.
- Phase 7 compliance script (`scripts/compliance/phase-7-compliance.ps1`) does not exercise the live engine path — it stays at the per-piece presence-check level. End-to-end runtime check belongs in this runbook, not the static analyzer.

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

@@ -0,0 +1,151 @@
<#
.SYNOPSIS
Phase 7 exit-gate compliance check. Each check either passes or records a failure;
non-zero exit = fail.
.DESCRIPTION
Validates Phase 7 (scripting runtime + virtual tags + scripted alarms + historian
alarm sink + Admin UI + address-space integration) per
`docs/v2/implementation/phase-7-scripting-and-alarming.md`.
.NOTES
Usage: pwsh ./scripts/compliance/phase-7-compliance.ps1
Exit: 0 = all checks passed; non-zero = one or more FAILs
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$script:failures = 0
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Assert-Pass { param([string]$C) Write-Host " [PASS] $C" -ForegroundColor Green }
function Assert-Fail { param([string]$C, [string]$R) Write-Host " [FAIL] $C - $R" -ForegroundColor Red; $script:failures++ }
function Assert-Deferred { param([string]$C, [string]$P) Write-Host " [DEFERRED] $C (follow-up: $P)" -ForegroundColor Yellow }
function Assert-FileExists {
param([string]$C, [string]$P)
if (Test-Path (Join-Path $repoRoot $P)) { Assert-Pass "$C ($P)" }
else { Assert-Fail $C "missing file: $P" }
}
function Assert-TextFound {
param([string]$C, [string]$Pat, [string[]]$Paths)
foreach ($p in $Paths) {
$full = Join-Path $repoRoot $p
if (-not (Test-Path $full)) { continue }
if (Select-String -Path $full -Pattern $Pat -Quiet) {
Assert-Pass "$C (matched in $p)"
return
}
}
Assert-Fail $C "pattern '$Pat' not found in any of: $($Paths -join ', ')"
}
Write-Host ""
Write-Host "=== Phase 7 compliance - scripting + virtual tags + scripted alarms + historian ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "Stream A - Core.Scripting (Roslyn + sandbox + AST inference + logger)"
Assert-FileExists "Core.Scripting project" "src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"
Assert-TextFound "ScriptSandbox allow-list anchored on ScriptContext assembly" "contextType\.Assembly" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs")
Assert-TextFound "ForbiddenTypeAnalyzer defense-in-depth (plan decision #6)" "class ForbiddenTypeAnalyzer" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs")
Assert-TextFound "DependencyExtractor rejects non-literal paths (plan decision #7)" "class DependencyExtractor" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs")
Assert-TextFound "Per-script log companion sink forwards Error+ to main log (plan decision #12)" "class ScriptLogCompanionSink" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogCompanionSink.cs")
Assert-TextFound "ScriptContext static Deadband helper" "static bool Deadband" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs")
Write-Host ""
Write-Host "Stream B - Core.VirtualTags (dependency graph + change/timer + source)"
Assert-FileExists "Core.VirtualTags project" "src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"
Assert-TextFound "DependencyGraph iterative Tarjan SCC (no stack overflow on 10k chains)" "class DependencyGraph" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs")
Assert-TextFound "VirtualTagEngine SemaphoreSlim async-safe cascade" "SemaphoreSlim" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs")
Assert-TextFound "VirtualTagSource IReadable + ISubscribable per ADR-002" "class VirtualTagSource" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs")
Assert-TextFound "TimerTriggerScheduler groups by interval" "class TimerTriggerScheduler" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs")
Write-Host ""
Write-Host "Stream C - Core.ScriptedAlarms (Part 9 state machine + predicate engine + IAlarmSource)"
Assert-FileExists "Core.ScriptedAlarms project" "src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"
Assert-TextFound "Part9StateMachine pure functions" "class Part9StateMachine" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs")
Assert-TextFound "Alarm condition state with GxP audit Comments list" "Comments" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs")
Assert-TextFound "MessageTemplate {TagPath} substitution (plan decision #13)" "class MessageTemplate" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs")
Assert-TextFound "AlarmPredicateContext rejects SetVirtualTag (predicates must be pure)" "class AlarmPredicateContext" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs")
Assert-TextFound "ScriptedAlarmSource implements IAlarmSource" "class ScriptedAlarmSource" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs")
Assert-TextFound "IAlarmStateStore abstraction + in-memory default" "class InMemoryAlarmStateStore" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs")
Write-Host ""
Write-Host "Stream D - Core.AlarmHistorian (SQLite store-and-forward + Galaxy.Host IPC contracts)"
Assert-FileExists "Core.AlarmHistorian project" "src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"
Assert-TextFound "SqliteStoreAndForwardSink backoff ladder (1s..60s cap)" "BackoffLadder" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
Assert-TextFound "Default 1M row capacity + 30-day dead-letter retention (plan decision #21)" "DefaultDeadLetterRetention" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
Assert-TextFound "Per-event outcomes (Ack/RetryPlease/PermanentFail)" "HistorianWriteOutcome" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs")
Assert-TextFound "Galaxy.Host IPC contract HistorianAlarmEventRequest" "class HistorianAlarmEventRequest" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
Assert-TextFound "Historian connectivity status notification" "HistorianConnectivityStatusNotification" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
Write-Host ""
Write-Host "Stream E - Config DB schema"
Assert-FileExists "Script entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs"
Assert-FileExists "VirtualTag entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs"
Assert-FileExists "ScriptedAlarm entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarm.cs"
Assert-FileExists "ScriptedAlarmState entity (logical-id keyed per plan decision #14)" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarmState.cs"
Assert-TextFound "VirtualTag trigger check constraint (change OR timer)" "CK_VirtualTag_Trigger_AtLeastOne" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-TextFound "ScriptedAlarm severity range check" "CK_ScriptedAlarm_Severity_Range" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-TextFound "ScriptedAlarm type enum check" "CK_ScriptedAlarm_AlarmType" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-TextFound "ScriptedAlarmState.CommentsJson is ISJSON (GxP audit)" "CK_ScriptedAlarmState_CommentsJson_IsJson" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-FileExists "Phase 7 migration present" "src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.cs"
Write-Host ""
Write-Host "Stream F - Admin UI (services + Monaco editor + test harness + historian diagnostics)"
Assert-FileExists "ScriptService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs"
Assert-FileExists "VirtualTagService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs"
Assert-FileExists "ScriptedAlarmService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs"
Assert-FileExists "ScriptTestHarnessService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs"
Assert-FileExists "HistorianDiagnosticsService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs"
Assert-FileExists "ScriptEditor Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptEditor.razor"
Assert-FileExists "ScriptsTab Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor"
Assert-FileExists "AlarmsHistorian diagnostics page" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor"
Assert-FileExists "Monaco loader (CDN progressive enhancement)" "src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js"
Assert-TextFound "Scripts tab wired into DraftEditor" "ScriptsTab " @("src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor")
Assert-TextFound "Harness enforces declared-inputs-only contract (plan decision #22)" "UnknownInputs" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs")
Write-Host ""
Write-Host "Stream G - Address-space integration"
Assert-TextFound "NodeSourceKind discriminator in DriverAttributeInfo" "enum NodeSourceKind" @("src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs")
Assert-TextFound "Walker emits VirtualTag variables with Source=Virtual" "AddVirtualTagVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
Assert-TextFound "Walker emits ScriptedAlarm variables with Source=ScriptedAlarm + IsAlarm" "AddScriptedAlarmVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
Assert-TextFound "EquipmentNamespaceContent carries VirtualTags + ScriptedAlarms" "VirtualTags" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
Assert-TextFound "DriverNodeManager selects IReadable by source kind" "SelectReadable" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
Assert-TextFound "Virtual/ScriptedAlarm writes rejected (plan decision #6)" "IsWriteAllowedBySource" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
Write-Host ""
Write-Host "Deferred surfaces"
Assert-Deferred "SealedBootstrap composition root wiring (VirtualTagEngine + ScriptedAlarmEngine + SqliteStoreAndForwardSink)" "task #239"
Assert-Deferred "Live OPC UA end-to-end test (virtual-tag Read + scripted-alarm Ack via method node)" "task #240"
Assert-Deferred "sp_ComputeGenerationDiff extension for Script/VirtualTag/ScriptedAlarm sections" "task #241"
Write-Host ""
Write-Host "Cross-cutting"
Write-Host " Running full solution test suite..." -ForegroundColor DarkGray
$prevPref = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1
$ErrorActionPreference = $prevPref
$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches
$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches
$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value }
$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value }
# Phase 6.4 exit-gate baseline was 1137; Phase 7 adds ~197 across 7 streams.
$baseline = 1300
if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-7-exit baseline)" }
else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" }
if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" }
else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" }
Write-Host ""
if ($script:failures -eq 0) {
Write-Host "Phase 7 compliance: PASS" -ForegroundColor Green
exit 0
}
Write-Host "Phase 7 compliance: $script:failures FAIL(s)" -ForegroundColor Red
exit 1

179
scripts/e2e/README.md Normal file
View File

@@ -0,0 +1,179 @@
# E2E CLI test scripts
End-to-end black-box tests that drive each protocol through its driver CLI
and verify the resulting OPC UA address-space state through
`otopcua-cli`. They answer one question per driver:
> **If I poke the real PLC through the driver, does the running OtOpcUa
> server see the change?**
This is the acceptance gate v1 was missing — the driver-level integration
tests (`tests/.../IntegrationTests/`) confirm the driver sees the PLC, and
the OPC UA `Client.CLI.Tests` confirm the client sees the server — but
nothing glued them end-to-end. These scripts close that loop.
## Five-stage test per driver
Every per-driver script runs the same five tests. The goal is to prove
**both directions** across the bridge plus subscription delivery —
forward-only coverage would miss writable-flag drops, `IWritable`
dispatch bugs, and broken data-change notification paths where a fresh
read still returns the right value.
1. **`probe`** — driver CLI opens a session + reads a sentinel. Confirms
the simulator / PLC is reachable and speaking the protocol.
2. **Driver loopback** — write a random value via the driver CLI, read
it back via the same CLI. Confirms the driver round-trips without
involving the OPC UA server. A failure here is a driver bug, not a
server-bridge bug.
3. **Forward bridge (driver → server → client)** — write a different
random value via the driver CLI, wait `--ServerPollDelaySec` (default
3s), read the OPC UA NodeId the server publishes that tag at via
`otopcua-cli read`. Confirms reads propagate from PLC to OPC UA
client.
4. **Reverse bridge (client → server → driver)** — write a fresh random
value via `otopcua-cli write` against the same NodeId, wait
`--DriverPollDelaySec` (default 3s), read the PLC-side via the
driver CLI. Confirms writes propagate the other way — catches
writable-flag drops, ACL misconfiguration, and `IWritable` dispatch
bugs the forward test can't see.
5. **Subscribe-sees-change** — start `otopcua-cli subscribe --duration N`
in the background, give it `--SettleSec` (default 2s) to attach,
write a random value via the driver CLI, wait for the subscription
window to close, and assert the captured output mentions the new
value. Confirms the server's monitored-item + data-change path
actually fires — not just that a fresh read returns the new value.
The OtOpcUa server must already be running with a config that
(a) binds a driver instance to the same PLC the script points at, and
(b) publishes the address the script writes under a NodeId the script
knows. Those NodeIds live in `e2e-config.json` (see below). The
published tag must be **writable** — stages 4 + 5 will fail against a
read-only tag.
## Status
Stages 1 + 2 (driver-side probe + loopback) are verified end-to-end
against the pymodbus / ab_server / python-snap7 fixtures. Stages 3-5
(anything crossing the OtOpcUa server) are **blocked** on server-side
driver factory wiring:
- `src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` only registers Galaxy +
FOCAS factories. `DriverInstanceBootstrapper` skips any `DriverType`
without a registered factory — so Modbus / AB CIP / AB Legacy / S7 /
TwinCAT rows in the Config DB are silently no-op'd even when the seed
is perfect.
- No Config DB seed script exists for non-Galaxy drivers; Admin UI is
currently the only path to author one.
Tracking: **#209** (umbrella) → #210 (Modbus), #211 (AB CIP), #212 (S7),
#213 (AB Legacy, also hardware-gated — #222). Each child issue lists
the factory class to write + the seed SQL shape + the verification
command.
Until those ship, stages 3-5 will fail with "read failed" (nothing
published at that NodeId) and `[FAIL]` the suite even on a running
server.
## Prereqs
1. **OtOpcUa server** running on `opc.tcp://localhost:4840` (or pass
`-OpcUaUrl` to override). The server's Config DB must define a
driver instance per protocol you want to test, bound to the matching
simulator endpoint.
2. **Per-driver simulators** running. See `docs/v2/test-data-sources.md`
for the simulator matrix — pymodbus / ab_server / python-snap7 /
opc-plc cover Modbus / AB / S7 / OPC UA Client. FOCAS and TwinCAT
have no public simulator; they are gated with env-var skip flags
below.
3. **PowerShell 7+**. The runner uses null-coalescing + `Set-StrictMode`;
the Windows-PowerShell-5.1 shell will not parse `test-all.ps1`.
4. **.NET 10 SDK**. Each script either runs `dotnet run --project
src/ZB.MOM.WW.OtOpcUa.Driver.<Name>.Cli` directly, or if
`$env:OTOPCUA_CLI_BIN` points at a publish folder, runs the pre-built
`otopcua-*.exe` from there (faster for repeat loops).
## Running
### One protocol at a time
```powershell
./scripts/e2e/test-modbus.ps1 `
-ModbusHost 127.0.0.1:5502 `
-BridgeNodeId "ns=2;s=Modbus/HR100"
```
Every per-protocol script takes the driver endpoint, the address to
write, and the OPC UA NodeId the server exposes it at.
### Full matrix
```powershell
./scripts/e2e/test-all.ps1 `
-ConfigFile ./scripts/e2e/e2e-config.json
```
The runner reads the sidecar JSON, invokes each driver's script with the
parameters from that section, and prints a `FINAL MATRIX` showing
PASS / FAIL / SKIP per driver. Any driver absent from the sidecar is
SKIP-ed rather than failing hard — useful on dev boxes that only have
one simulator up.
### Sidecar format
Copy `e2e-config.sample.json` → `e2e-config.json` and fill in the
NodeIds from **your** server's Config DB. The file is `.gitignore`-d
(each dev's NodeIds are specific to their local seed). Omit a driver
section to skip it.
## Expected pass/fail matrix (default config)
| Driver | Gate | Default state on a clean dev box |
|---|---|---|
| Modbus | — | **PASS** (pymodbus fixture) |
| AB CIP | — | **PASS** (ab_server fixture) |
| AB Legacy | — | **PASS** (ab_server SLC500/MicroLogix/PLC-5 profiles; `/1,0` cip-path required for the Docker fixture) |
| Galaxy | — | **PASS** (requires OtOpcUaGalaxyHost + a live Galaxy; 7 stages including alarms + history) |
| S7 | — | **PASS** (python-snap7 fixture) |
| FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) |
| TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** (needs XAR or standalone Router — task #221) |
| Phase 7 | — | **PASS** if the Modbus instance seeds a `VT_DoubledHR100` virtual tag + `AlarmHigh` scripted alarm |
Set the `*_TRUST_WIRE` env vars to `1` when you've pointed the script at
real hardware or a properly-configured simulator.
## Output
Each step prints one of:
- `[PASS] ...` — step succeeded
- `[FAIL] ...` — step failed, stdout of the failing CLI is echoed below
for diagnosis
- `[SKIP] ...` — step short-circuited (env-var gate)
- `[INFO] ...` — progress note (e.g., "waiting 3s for server-side poll")
The runner ends with a coloured summary per driver:
```
==================== FINAL MATRIX ====================
modbus PASS
abcip PASS
ablegacy SKIP (no config entry)
s7 PASS
focas SKIP (no config entry)
twincat SKIP (no config entry)
phase7 PASS
All present suites passed.
```
Non-zero exit if any present suite failed. SKIPs do not fail the run.
## Why this is separate from `dotnet test`
`dotnet test` covers driver-layer + server-layer correctness in
isolation — mocks + in-process test hosts. These e2e scripts cover the
integration seam that unit tests *can't* cover by design: a live OPC UA
server process, a live simulator, and the wire between them. Run them
before a v2 release-readiness sign-off, after a driver-layer change
that could plausibly affect the NodeManager contract, and before any
"it works on my box" handoff to QA.

430
scripts/e2e/_common.ps1 Normal file
View File

@@ -0,0 +1,430 @@
# Shared PowerShell helpers for the OtOpcUa end-to-end CLI test scripts.
#
# Every per-protocol script dot-sources this file and calls the Test-* functions
# below. Keeps the per-script code down to ~50 lines of parameterisation +
# bridging-tag identifiers.
#
# Conventions:
# - All test helpers return a hashtable: @{ Passed=<bool>; Reason=<string> }
# - Helpers never throw unless the test setup is itself broken (a crashed
# CLI is a test failure, not an exception).
# - Output is plain text with [PASS] / [FAIL] / [SKIP] / [INFO] prefixes so
# grep/log-scraping works.
Set-StrictMode -Version 3.0
# ---------------------------------------------------------------------------
# Colouring + prefixes.
# ---------------------------------------------------------------------------
function Write-Header {
param([string]$Title)
Write-Host ""
Write-Host "=== $Title ===" -ForegroundColor Cyan
}
function Write-Pass {
param([string]$Message)
Write-Host "[PASS] $Message" -ForegroundColor Green
}
function Write-Fail {
param([string]$Message)
Write-Host "[FAIL] $Message" -ForegroundColor Red
}
function Write-Skip {
param([string]$Message)
Write-Host "[SKIP] $Message" -ForegroundColor Yellow
}
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Gray
}
# ---------------------------------------------------------------------------
# CLI invocation helpers.
# ---------------------------------------------------------------------------
# Resolve a CLI path from either a published binary OR a `dotnet run` fallback.
# Preferred order:
# 1. $env:OTOPCUA_CLI_BIN points at a publish/ folder → use <exe> there
# 2. Fall back to `dotnet run --project src/<ProjectFolder> --`
#
# $ProjectFolder = relative path from repo root
# $ExeName = expected AssemblyName (no .exe)
function Get-CliInvocation {
param(
[Parameter(Mandatory)] [string]$ProjectFolder,
[Parameter(Mandatory)] [string]$ExeName
)
if ($env:OTOPCUA_CLI_BIN) {
$binPath = Join-Path $env:OTOPCUA_CLI_BIN "$ExeName.exe"
if (Test-Path $binPath) {
return @{ File = $binPath; PrefixArgs = @() }
}
}
# Dotnet-run fallback. --no-build would be faster but not every CI step
# has rebuilt; default to a full run so the script is forgiving.
return @{
File = "dotnet"
PrefixArgs = @("run", "--project", $ProjectFolder, "--")
}
}
# Run a CLI and capture stdout+stderr+exitcode. Never throws.
function Invoke-Cli {
param(
[Parameter(Mandatory)] $Cli, # output of Get-CliInvocation
[Parameter(Mandatory)] [string[]]$Args, # CLI arguments (after `-- `)
[int]$TimeoutSec = 30
)
$allArgs = @($Cli.PrefixArgs) + $Args
$output = $null
$exitCode = -1
try {
$output = & $Cli.File @allArgs 2>&1 | Out-String
$exitCode = $LASTEXITCODE
}
catch {
return @{
Output = $_.Exception.Message
ExitCode = -1
}
}
return @{
Output = $output
ExitCode = $exitCode
}
}
# ---------------------------------------------------------------------------
# Test helpers — reusable building blocks every per-protocol script calls.
# ---------------------------------------------------------------------------
# Test 1 — the driver CLI's probe command exits 0. Confirms the PLC / simulator
# is reachable and speaks the protocol. Prerequisite for everything else.
function Test-Probe {
param(
[Parameter(Mandatory)] $Cli,
[Parameter(Mandatory)] [string[]]$ProbeArgs
)
Write-Header "Probe"
$r = Invoke-Cli -Cli $Cli -Args $ProbeArgs
if ($r.ExitCode -eq 0) {
Write-Pass "driver CLI probe succeeded"
return @{ Passed = $true }
}
Write-Fail "driver CLI probe exit=$($r.ExitCode)"
Write-Host $r.Output
return @{ Passed = $false; Reason = "probe exit $($r.ExitCode)" }
}
# Test 2 — driver-loopback. Write a value via the driver CLI, read it back via
# the same CLI, assert round-trip equality. Confirms the driver itself is
# functional without pulling the OtOpcUa server into the loop.
function Test-DriverLoopback {
param(
[Parameter(Mandatory)] $Cli,
[Parameter(Mandatory)] [string[]]$WriteArgs,
[Parameter(Mandatory)] [string[]]$ReadArgs,
[Parameter(Mandatory)] [string]$ExpectedValue
)
Write-Header "Driver loopback"
$w = Invoke-Cli -Cli $Cli -Args $WriteArgs
if ($w.ExitCode -ne 0) {
Write-Fail "write failed (exit=$($w.ExitCode))"
Write-Host $w.Output
return @{ Passed = $false; Reason = "write failed" }
}
Write-Info "write ok"
$r = Invoke-Cli -Cli $Cli -Args $ReadArgs
if ($r.ExitCode -ne 0) {
Write-Fail "read failed (exit=$($r.ExitCode))"
Write-Host $r.Output
return @{ Passed = $false; Reason = "read failed" }
}
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
Write-Pass "round-trip equals $ExpectedValue"
return @{ Passed = $true }
}
Write-Fail "round-trip value mismatch — expected $ExpectedValue"
Write-Host $r.Output
return @{ Passed = $false; Reason = "value mismatch" }
}
# Test 3 — server bridge. Write via the driver CLI, read the corresponding
# OPC UA NodeId via the OPC UA client CLI. Confirms the full path:
# driver CLI → PLC → OtOpcUa server (polling/subscription) → OPC UA client.
function Test-ServerBridge {
param(
[Parameter(Mandatory)] $DriverCli,
[Parameter(Mandatory)] [string[]]$DriverWriteArgs,
[Parameter(Mandatory)] $OpcUaCli,
[Parameter(Mandatory)] [string]$OpcUaUrl,
[Parameter(Mandatory)] [string]$OpcUaNodeId,
[Parameter(Mandatory)] [string]$ExpectedValue,
[int]$ServerPollDelaySec = 3
)
Write-Header "Server bridge"
$w = Invoke-Cli -Cli $DriverCli -Args $DriverWriteArgs
if ($w.ExitCode -ne 0) {
Write-Fail "driver-side write failed (exit=$($w.ExitCode))"
Write-Host $w.Output
return @{ Passed = $false; Reason = "driver write failed" }
}
Write-Info "driver write ok, waiting ${ServerPollDelaySec}s for server-side poll"
Start-Sleep -Seconds $ServerPollDelaySec
$r = Invoke-Cli -Cli $OpcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $OpcUaNodeId)
if ($r.ExitCode -ne 0) {
Write-Fail "OPC UA client read failed (exit=$($r.ExitCode))"
Write-Host $r.Output
return @{ Passed = $false; Reason = "opc-ua read failed" }
}
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
Write-Pass "server-side read equals $ExpectedValue"
return @{ Passed = $true }
}
Write-Fail "server-side value mismatch — expected $ExpectedValue"
Write-Host $r.Output
return @{ Passed = $false; Reason = "bridge value mismatch" }
}
# Test 4 — reverse bridge. Write via the OPC UA client CLI, then read the PLC
# side via the driver CLI. Confirms the write path: OPC UA client → server →
# driver → PLC. This is the direction Test-ServerBridge does NOT cover — a
# clean Test-ServerBridge only proves reads flow server-ward.
function Test-OpcUaWriteBridge {
param(
[Parameter(Mandatory)] $OpcUaCli,
[Parameter(Mandatory)] [string]$OpcUaUrl,
[Parameter(Mandatory)] [string]$OpcUaNodeId,
[Parameter(Mandatory)] $DriverCli,
[Parameter(Mandatory)] [string[]]$DriverReadArgs,
[Parameter(Mandatory)] [string]$ExpectedValue,
[int]$DriverPollDelaySec = 3
)
Write-Header "OPC UA write bridge"
$w = Invoke-Cli -Cli $OpcUaCli -Args @(
"write", "-u", $OpcUaUrl, "-n", $OpcUaNodeId, "-v", $ExpectedValue)
if ($w.ExitCode -ne 0 -or $w.Output -notmatch "Write successful") {
Write-Fail "OPC UA client write failed (exit=$($w.ExitCode))"
Write-Host $w.Output
return @{ Passed = $false; Reason = "opc-ua write failed" }
}
Write-Info "opc-ua write ok, waiting ${DriverPollDelaySec}s for driver-side apply"
Start-Sleep -Seconds $DriverPollDelaySec
$r = Invoke-Cli -Cli $DriverCli -Args $DriverReadArgs
if ($r.ExitCode -ne 0) {
Write-Fail "driver-side read failed (exit=$($r.ExitCode))"
Write-Host $r.Output
return @{ Passed = $false; Reason = "driver read failed" }
}
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
Write-Pass "PLC-side value equals $ExpectedValue"
return @{ Passed = $true }
}
Write-Fail "PLC-side value mismatch — expected $ExpectedValue"
Write-Host $r.Output
return @{ Passed = $false; Reason = "reverse-bridge value mismatch" }
}
# Test 5 — subscribe-sees-change. Start `otopcua-cli subscribe --duration N`
# in the background, give it ~2s to attach, then write a known value via the
# driver CLI. After the subscription window closes, assert its captured
# output mentions the new value. Confirms the OPC UA server is actually
# pushing data-change notifications for driver-originated changes — not just
# that a fresh read returns the new value.
function Test-SubscribeSeesChange {
param(
[Parameter(Mandatory)] $OpcUaCli,
[Parameter(Mandatory)] [string]$OpcUaUrl,
[Parameter(Mandatory)] [string]$OpcUaNodeId,
[Parameter(Mandatory)] $DriverCli,
[Parameter(Mandatory)] [string[]]$DriverWriteArgs,
[Parameter(Mandatory)] [string]$ExpectedValue,
[int]$DurationSec = 8,
[int]$SettleSec = 2
)
Write-Header "Subscribe sees change"
# `Start-Job` would spin up a fresh PowerShell runtime and cost 2s+. Use
# Start-Process + a temp file instead — it's the same shape Invoke-Cli
# uses but non-blocking.
$stdout = New-TemporaryFile
$stderr = New-TemporaryFile
$allArgs = @($OpcUaCli.PrefixArgs) + @(
"subscribe", "-u", $OpcUaUrl, "-n", $OpcUaNodeId,
"-i", "200", "--duration", "$DurationSec")
$proc = Start-Process -FilePath $OpcUaCli.File `
-ArgumentList $allArgs `
-NoNewWindow -PassThru `
-RedirectStandardOutput $stdout.FullName `
-RedirectStandardError $stderr.FullName
Write-Info "subscription started (pid $($proc.Id)), waiting ${SettleSec}s to settle"
Start-Sleep -Seconds $SettleSec
$w = Invoke-Cli -Cli $DriverCli -Args $DriverWriteArgs
if ($w.ExitCode -ne 0) {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
Write-Fail "driver write during subscribe failed (exit=$($w.ExitCode))"
Write-Host $w.Output
return @{ Passed = $false; Reason = "driver write failed" }
}
Write-Info "driver write ok, waiting for subscription window to close"
# Wait for the subscribe process to exit its --duration timer. Grace
# margin on top of the duration in case the first data-change races the
# final flush.
$proc.WaitForExit(($DurationSec + 5) * 1000) | Out-Null
if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
$out = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
# The subscribe command prints `[timestamp] displayName = value (status)`
# per data-change event. We only care that one of those events carried
# the new value.
if ($out -match "=\s*$([Regex]::Escape($ExpectedValue))\b") {
Write-Pass "subscribe saw $ExpectedValue"
return @{ Passed = $true }
}
Write-Fail "subscribe did not observe $ExpectedValue in ${DurationSec}s"
Write-Host $out
return @{ Passed = $false; Reason = "change not observed on subscription" }
}
# Test — alarm fires on threshold. Start `otopcua-cli alarms --refresh` on the
# alarm Condition NodeId in the background; drive the underlying data change via
# `otopcua-cli write` on the input NodeId; wait for the subscription window to
# close; assert the captured stdout contains a matching ALARM line (`SourceName`
# of the Condition + an Active state). Covers Part 9 alarm propagation through
# the server → driver → Condition node path.
function Test-AlarmFiresOnThreshold {
param(
[Parameter(Mandatory)] $OpcUaCli,
[Parameter(Mandatory)] [string]$OpcUaUrl,
[Parameter(Mandatory)] [string]$AlarmNodeId,
[Parameter(Mandatory)] [string]$InputNodeId,
[Parameter(Mandatory)] [string]$TriggerValue,
[int]$DurationSec = 10,
[int]$SettleSec = 2
)
Write-Header "Alarm fires on threshold"
$stdout = New-TemporaryFile
$stderr = New-TemporaryFile
$allArgs = @($OpcUaCli.PrefixArgs) + @(
"alarms", "-u", $OpcUaUrl, "-n", $AlarmNodeId, "-i", "500", "--refresh")
$proc = Start-Process -FilePath $OpcUaCli.File `
-ArgumentList $allArgs `
-NoNewWindow -PassThru `
-RedirectStandardOutput $stdout.FullName `
-RedirectStandardError $stderr.FullName
Write-Info "alarm subscription started (pid $($proc.Id)), waiting ${SettleSec}s to settle"
Start-Sleep -Seconds $SettleSec
$w = Invoke-Cli -Cli $OpcUaCli -Args @(
"write", "-u", $OpcUaUrl, "-n", $InputNodeId, "-v", $TriggerValue)
if ($w.ExitCode -ne 0) {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
Write-Fail "input write failed (exit=$($w.ExitCode))"
Write-Host $w.Output
return @{ Passed = $false; Reason = "input write failed" }
}
Write-Info "input write ok, waiting up to ${DurationSec}s for the alarm to surface"
# otopcua-cli alarms runs until Ctrl+C; terminate it ourselves after the
# duration window (no built-in --duration flag on the alarms command).
Start-Sleep -Seconds $DurationSec
if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
$out = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
# AlarmsCommand emits `[ts] ALARM <SourceName>` per event + lines for
# State: Active,Unacknowledged | Severity | Message. Match on `ALARM` +
# `Active` — both need to appear for the alarm to count as fired.
if ($out -match "ALARM\b" -and $out -match "Active\b") {
Write-Pass "alarm condition fired with Active state"
return @{ Passed = $true }
}
Write-Fail "no Active alarm event observed in ${DurationSec}s"
Write-Host $out
return @{ Passed = $false; Reason = "no alarm event" }
}
# Test — history-read returns samples. Calls `otopcua-cli historyread` on the
# target NodeId for a time window (default 1h back) and asserts the CLI reports
# at least one value returned. Works against any historized tag — driver-sourced,
# virtual, or scripted-alarm historizing to the Aveva / SQLite sink.
function Test-HistoryHasSamples {
param(
[Parameter(Mandatory)] $OpcUaCli,
[Parameter(Mandatory)] [string]$OpcUaUrl,
[Parameter(Mandatory)] [string]$NodeId,
[int]$LookbackSec = 3600,
[int]$MinSamples = 1
)
Write-Header "History read"
$end = (Get-Date).ToUniversalTime().ToString("o")
$start = (Get-Date).ToUniversalTime().AddSeconds(-$LookbackSec).ToString("o")
$r = Invoke-Cli -Cli $OpcUaCli -Args @(
"historyread", "-u", $OpcUaUrl, "-n", $NodeId,
"--start", $start, "--end", $end, "--max", "1000")
if ($r.ExitCode -ne 0) {
Write-Fail "historyread exit=$($r.ExitCode)"
Write-Host $r.Output
return @{ Passed = $false; Reason = "historyread failed" }
}
# HistoryReadCommand ends with `N values returned.` — parse and check >= MinSamples.
if ($r.Output -match '(\d+)\s+values?\s+returned') {
$count = [int]$Matches[1]
if ($count -ge $MinSamples) {
Write-Pass "$count samples returned (>= $MinSamples)"
return @{ Passed = $true }
}
Write-Fail "only $count samples returned, expected >= $MinSamples — tag may not be historized, or lookback window misses samples"
Write-Host $r.Output
return @{ Passed = $false; Reason = "insufficient samples" }
}
Write-Fail "could not parse 'N values returned.' marker from historyread output"
Write-Host $r.Output
return @{ Passed = $false; Reason = "parse failure" }
}
# ---------------------------------------------------------------------------
# Summary helper — caller passes an array of test results.
# ---------------------------------------------------------------------------
function Write-Summary {
param(
[Parameter(Mandatory)] [string]$Title,
[Parameter(Mandatory)] [array]$Results
)
$passed = ($Results | Where-Object { $_.Passed }).Count
$failed = ($Results | Where-Object { -not $_.Passed }).Count
Write-Host ""
Write-Host "=== $Title summary: $passed/$($Results.Count) passed ===" `
-ForegroundColor $(if ($failed -eq 0) { "Green" } else { "Red" })
}

View File

@@ -0,0 +1,70 @@
{
"$comment": "Copy this file to e2e-config.json and replace the NodeIds with the ones your Config DB publishes. Fields named `opcUaUrl` override the -OpcUaUrl parameter on test-all.ps1 per-driver. Omit a top-level key to skip that driver.",
"modbus": {
"$comment": "Port 5020 matches tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml — `docker compose --profile standard up -d`.",
"endpoint": "127.0.0.1:5020",
"bridgeNodeId": "ns=2;s=Modbus/HR200",
"opcUaUrl": "opc.tcp://localhost:4840"
},
"abcip": {
"$comment": "ab_server listens on port 44818 (default CIP/EIP). `docker compose --profile controllogix up -d`.",
"gateway": "ab://127.0.0.1:44818/1,0",
"family": "ControlLogix",
"tagPath": "TestDINT",
"bridgeNodeId": "ns=2;s=AbCip/TestDINT"
},
"ablegacy": {
"$comment": "Works against ab_server --profile slc500 (Docker fixture) or real SLC/MicroLogix/PLC-5 hardware. `/1,0` cip-path is required for the Docker fixture; real hardware accepts an empty path — e.g. `ab://10.0.1.50:44818/`.",
"gateway": "ab://127.0.0.1/1,0",
"plcType": "Slc500",
"address": "N7:5",
"bridgeNodeId": "ns=2;s=AbLegacy/N7_5"
},
"s7": {
"$comment": "Port 1102 matches tests/.../S7.IntegrationTests/Docker/docker-compose.yml (python-snap7 needs non-priv port). `docker compose --profile s7_1500 up -d`. Real S7 PLCs listen on 102.",
"endpoint": "127.0.0.1:1102",
"cpu": "S71500",
"slot": 0,
"address": "DB1.DBW0",
"bridgeNodeId": "ns=2;s=S7/DB1_DBW0"
},
"focas": {
"$comment": "Gated behind FOCAS_TRUST_WIRE=1 — no public simulator. Point at a real CNC + ensure Fwlib32.dll is on PATH.",
"host": "192.168.1.20",
"port": 8193,
"address": "R100",
"bridgeNodeId": "ns=2;s=Focas/R100"
},
"twincat": {
"$comment": "Gated behind TWINCAT_TRUST_WIRE=1 — needs XAR or standalone TwinCAT Router NuGet reachable at -AmsNetId.",
"amsNetId": "127.0.0.1.1.1",
"amsPort": 851,
"symbolPath": "MAIN.iCounter",
"bridgeNodeId": "ns=2;s=TwinCAT/MAIN_iCounter"
},
"galaxy": {
"$comment": "Galaxy (MXAccess) driver. Has no per-driver CLI — all stages go through otopcua-cli against the published NodeIds. Seven stages: probe / source read / virtual-tag bridge / subscribe-sees-change / reverse write / alarm fires / history read. Requires OtOpcUaGalaxyHost running + seed-phase-7-smoke.sql applied with a real Galaxy attribute substituted into dbo.Tag.TagConfig.",
"sourceNodeId": "ns=2;s=p7-smoke-tag-source",
"virtualNodeId": "ns=2;s=p7-smoke-vt-derived",
"alarmNodeId": "ns=2;s=p7-smoke-al-overtemp",
"alarmTriggerValue": "75",
"changeWaitSec": 10,
"alarmWaitSec": 10,
"historyLookbackSec": 3600
},
"phase7": {
"$comment": "Virtual tags + scripted alarms. The VirtualNodeId must resolve to a server-side virtual tag whose script reads the modbus InputNodeId and writes VT = input * 2. The AlarmNodeId is the ConditionId of a scripted alarm that fires when VT > 100.",
"modbusEndpoint": "127.0.0.1:5502",
"inputNodeId": "ns=2;s=Modbus/HR100",
"virtualNodeId": "ns=2;s=Virtual/VT_DoubledHR100",
"alarmNodeId": "ns=2;s=Alarm/HR100_High"
}
}

123
scripts/e2e/test-abcip.ps1 Normal file
View File

@@ -0,0 +1,123 @@
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the AB CIP driver (ControlLogix / CompactLogix /
Micro800 / GuardLogix) bridged through the OtOpcUa server.
.DESCRIPTION
Mirrors test-modbus.ps1 but against libplctag's ab_server (or a real Logix
controller). Five assertions: probe / driver-loopback / forward-bridge /
reverse-bridge / subscribe-sees-change.
Prereqs:
- ab_server container up (tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml,
--profile controllogix) OR a real PLC on the network.
- OtOpcUa server running with an AB CIP DriverInstance pointing at the
same gateway + a Tag published at the -BridgeNodeId you pass.
.PARAMETER Gateway
ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (ab_server ControlLogix).
.PARAMETER Family
ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).
.PARAMETER TagPath
Logix symbolic path to exercise. Default 'TestDINT' — matches the ab_server
--tag=TestDINT:DINT[1] seed.
.PARAMETER OpcUaUrl
OtOpcUa server endpoint.
.PARAMETER BridgeNodeId
NodeId at which the server publishes the TagPath.
#>
param(
[string]$Gateway = "ab://127.0.0.1/1,0",
[string]$Family = "ControlLogix",
[string]$TagPath = "TestDINT",
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$abcipCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli" `
-ExeName "otopcua-abcip-cli"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$commonAbCip = @("-g", $Gateway, "-f", $Family)
$results = @()
# The AbCip driver's TagPath parser rejects CIP attribute syntax like
# `@raw_cpu_type` ("malformed TagPath"), so probe uses the real TagPath for
# every family. Works against ab_server + real controllers alike.
$results += Test-Probe `
-Cli $abcipCli `
-ProbeArgs (@("probe") + $commonAbCip + @("-t", $TagPath, "--type", "DInt"))
$writeValue = Get-Random -Minimum 1 -Maximum 9999
$results += Test-DriverLoopback `
-Cli $abcipCli `
-WriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $writeValue)) `
-ReadArgs (@("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) `
-ExpectedValue "$writeValue"
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
$results += Test-ServerBridge `
-DriverCli $abcipCli `
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $bridgeValue)) `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-ExpectedValue "$bridgeValue"
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
$results += Test-OpcUaWriteBridge `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $abcipCli `
-DriverReadArgs (@("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) `
-ExpectedValue "$reverseValue"
$subValue = Get-Random -Minimum 30000 -Maximum 39999
$results += Test-SubscribeSeesChange `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $abcipCli `
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $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
if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -0,0 +1,154 @@
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the AB Legacy (PCCC) driver.
.DESCRIPTION
Runs against libplctag's ab_server PCCC Docker fixture (one of the
slc500 / micrologix / plc5 compose profiles) or real SLC / MicroLogix /
PLC-5 hardware. Five assertions: probe / driver-loopback / forward-
bridge / reverse-bridge / subscribe-sees-change.
ab_server enforces a non-empty CIP routing path (`/1,0`) before the
PCCC dispatcher runs; real hardware accepts an empty path. The default
$Gateway uses `/1,0` for the Docker fixture — pass `-Gateway
"ab://host:44818/"` when pointing at a real SLC 5/05 / MicroLogix /
PLC-5.
.PARAMETER Gateway
ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (Docker fixture).
.PARAMETER PlcType
Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).
.PARAMETER Address
PCCC address to exercise. Default N7:5.
.PARAMETER OpcUaUrl
OtOpcUa server endpoint.
.PARAMETER BridgeNodeId
NodeId at which the server publishes the Address.
#>
param(
[string]$Gateway = "ab://127.0.0.1/1,0",
[string]$PlcType = "Slc500",
[string]$Address = "N7:5",
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
# ab_server PCCC works; the earlier "upstream-broken" gate is gone. The only
# caveat: libplctag's ab_server rejects empty CIP paths, so $Gateway must
# carry a non-empty path segment (default /1,0). Real SLC/PLC-5 hardware
# accepts an empty path — use `ab://host:44818/` when pointing at real PLCs.
$abLegacyCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli" `
-ExeName "otopcua-ablegacy-cli"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$commonAbLegacy = @("-g", $Gateway, "-P", $PlcType)
$results = @()
$results += Test-Probe `
-Cli $abLegacyCli `
-ProbeArgs (@("probe") + $commonAbLegacy + @("-a", "N7:0"))
$writeValue = Get-Random -Minimum 1 -Maximum 9999
$results += Test-DriverLoopback `
-Cli $abLegacyCli `
-WriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $writeValue)) `
-ReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) `
-ExpectedValue "$writeValue"
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
$results += Test-ServerBridge `
-DriverCli $abLegacyCli `
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $bridgeValue)) `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-ExpectedValue "$bridgeValue"
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
$results += Test-OpcUaWriteBridge `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $abLegacyCli `
-DriverReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) `
-ExpectedValue "$reverseValue"
$subValue = Get-Random -Minimum 30000 -Maximum 32766
$results += Test-SubscribeSeesChange `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $abLegacyCli `
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $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
if ($results | Where-Object { -not $_.Passed }) { exit 1 }

228
scripts/e2e/test-all.ps1 Normal file
View File

@@ -0,0 +1,228 @@
#Requires -Version 7.0
<#
.SYNOPSIS
Runs every scripts/e2e/test-*.ps1 and tallies PASS / FAIL / SKIP.
.DESCRIPTION
The per-protocol scripts require protocol-specific NodeIds that depend on
your server's config DB seed. This runner expects a JSON sidecar at
scripts/e2e/e2e-config.json (not checked in — see README) with one entry
per driver giving the NodeIds + endpoints to pass through. Any driver
missing from the sidecar is skipped with a clear message rather than
failing hard.
.PARAMETER ConfigFile
Path to the sidecar JSON. Default: scripts/e2e/e2e-config.json.
.PARAMETER OpcUaUrl
Default OPC UA endpoint passed to each per-driver script. Default
opc.tcp://localhost:4840. Individual entries in the config file can override.
#>
param(
[string]$ConfigFile = "$PSScriptRoot/e2e-config.json",
[string]$OpcUaUrl = "opc.tcp://localhost:4840"
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
if (-not (Test-Path $ConfigFile)) {
Write-Fail "no config at $ConfigFile — copy e2e-config.sample.json + fill in your NodeIds first (see README)"
exit 2
}
# -AsHashtable + Get-Or below keeps access tolerant of missing keys even under
# Set-StrictMode -Version 3.0 (inherited from _common.ps1). Without this a
# missing "$config.ablegacy" throws "property cannot be found on this object".
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json -AsHashtable
$summary = [ordered]@{}
# Return $Table[$Key] if present, else $Default. Nested tables are themselves
# hashtables so this composes: (Get-Or $config modbus)['opcUaUrl'].
function Get-Or {
param($Table, [string]$Key, $Default = $null)
if ($Table -and $Table.ContainsKey($Key)) { return $Table[$Key] }
return $Default
}
function Run-Suite {
param(
[string]$Name,
[scriptblock]$Action
)
try {
& $Action
$summary[$Name] = if ($LASTEXITCODE -eq 0) { "PASS" } else { "FAIL" }
}
catch {
Write-Fail "$Name runner crashed: $_"
$summary[$Name] = "FAIL"
}
}
# ---------------------------------------------------------------------------
# Modbus
# ---------------------------------------------------------------------------
$modbus = Get-Or $config "modbus"
if ($modbus) {
Write-Header "== MODBUS =="
Run-Suite "modbus" {
& "$PSScriptRoot/test-modbus.ps1" `
-ModbusHost $modbus["endpoint"] `
-OpcUaUrl (Get-Or $modbus "opcUaUrl" $OpcUaUrl) `
-BridgeNodeId $modbus["bridgeNodeId"]
}
}
else { $summary["modbus"] = "SKIP (no config entry)" }
# ---------------------------------------------------------------------------
# AB CIP
# ---------------------------------------------------------------------------
$abcip = Get-Or $config "abcip"
if ($abcip) {
Write-Header "== AB CIP =="
Run-Suite "abcip" {
& "$PSScriptRoot/test-abcip.ps1" `
-Gateway $abcip["gateway"] `
-Family (Get-Or $abcip "family" "ControlLogix") `
-TagPath (Get-Or $abcip "tagPath" "TestDINT") `
-OpcUaUrl (Get-Or $abcip "opcUaUrl" $OpcUaUrl) `
-BridgeNodeId $abcip["bridgeNodeId"]
}
}
else { $summary["abcip"] = "SKIP (no config entry)" }
# ---------------------------------------------------------------------------
# AB Legacy
# ---------------------------------------------------------------------------
$ablegacy = Get-Or $config "ablegacy"
if ($ablegacy) {
Write-Header "== AB LEGACY =="
Run-Suite "ablegacy" {
& "$PSScriptRoot/test-ablegacy.ps1" `
-Gateway $ablegacy["gateway"] `
-PlcType (Get-Or $ablegacy "plcType" "Slc500") `
-Address (Get-Or $ablegacy "address" "N7:5") `
-OpcUaUrl (Get-Or $ablegacy "opcUaUrl" $OpcUaUrl) `
-BridgeNodeId $ablegacy["bridgeNodeId"]
}
}
else { $summary["ablegacy"] = "SKIP (no config entry)" }
# ---------------------------------------------------------------------------
# S7
# ---------------------------------------------------------------------------
$s7 = Get-Or $config "s7"
if ($s7) {
Write-Header "== S7 =="
Run-Suite "s7" {
& "$PSScriptRoot/test-s7.ps1" `
-S7Host $s7["endpoint"] `
-Cpu (Get-Or $s7 "cpu" "S71500") `
-Slot (Get-Or $s7 "slot" 0) `
-Address (Get-Or $s7 "address" "DB1.DBW0") `
-OpcUaUrl (Get-Or $s7 "opcUaUrl" $OpcUaUrl) `
-BridgeNodeId $s7["bridgeNodeId"]
}
}
else { $summary["s7"] = "SKIP (no config entry)" }
# ---------------------------------------------------------------------------
# FOCAS
# ---------------------------------------------------------------------------
$focas = Get-Or $config "focas"
if ($focas) {
Write-Header "== FOCAS =="
Run-Suite "focas" {
& "$PSScriptRoot/test-focas.ps1" `
-CncHost $focas["host"] `
-CncPort (Get-Or $focas "port" 8193) `
-Address (Get-Or $focas "address" "R100") `
-OpcUaUrl (Get-Or $focas "opcUaUrl" $OpcUaUrl) `
-BridgeNodeId $focas["bridgeNodeId"]
}
}
else { $summary["focas"] = "SKIP (no config entry)" }
# ---------------------------------------------------------------------------
# TwinCAT
# ---------------------------------------------------------------------------
$twincat = Get-Or $config "twincat"
if ($twincat) {
Write-Header "== TWINCAT =="
Run-Suite "twincat" {
& "$PSScriptRoot/test-twincat.ps1" `
-AmsNetId $twincat["amsNetId"] `
-AmsPort (Get-Or $twincat "amsPort" 851) `
-SymbolPath (Get-Or $twincat "symbolPath" "MAIN.iCounter") `
-OpcUaUrl (Get-Or $twincat "opcUaUrl" $OpcUaUrl) `
-BridgeNodeId $twincat["bridgeNodeId"]
}
}
else { $summary["twincat"] = "SKIP (no config entry)" }
# ---------------------------------------------------------------------------
# Phase 7 virtual tags + scripted alarms
# ---------------------------------------------------------------------------
$galaxy = Get-Or $config "galaxy"
if ($galaxy) {
Write-Header "== GALAXY =="
Run-Suite "galaxy" {
& "$PSScriptRoot/test-galaxy.ps1" `
-OpcUaUrl (Get-Or $galaxy "opcUaUrl" $OpcUaUrl) `
-SourceNodeId $galaxy["sourceNodeId"] `
-VirtualNodeId (Get-Or $galaxy "virtualNodeId" "") `
-AlarmNodeId (Get-Or $galaxy "alarmNodeId" "") `
-AlarmTriggerValue (Get-Or $galaxy "alarmTriggerValue" "75") `
-ChangeWaitSec (Get-Or $galaxy "changeWaitSec" 10) `
-AlarmWaitSec (Get-Or $galaxy "alarmWaitSec" 10) `
-HistoryLookbackSec (Get-Or $galaxy "historyLookbackSec" 3600)
}
}
else { $summary["galaxy"] = "SKIP (no config entry)" }
$phase7 = Get-Or $config "phase7"
if ($phase7) {
Write-Header "== PHASE 7 virtual tags + scripted alarms =="
Run-Suite "phase7" {
$defaultModbus = if ($modbus) { $modbus["endpoint"] } else { $null }
& "$PSScriptRoot/test-phase7-virtualtags.ps1" `
-ModbusHost (Get-Or $phase7 "modbusEndpoint" $defaultModbus) `
-OpcUaUrl (Get-Or $phase7 "opcUaUrl" $OpcUaUrl) `
-InputNodeId $phase7["inputNodeId"] `
-VirtualNodeId $phase7["virtualNodeId"] `
-AlarmNodeId (Get-Or $phase7 "alarmNodeId" $null)
}
}
else { $summary["phase7"] = "SKIP (no config entry)" }
# ---------------------------------------------------------------------------
# Final matrix
# ---------------------------------------------------------------------------
Write-Host ""
Write-Host "==================== FINAL MATRIX ====================" -ForegroundColor Cyan
$summary.GetEnumerator() | ForEach-Object {
$color = switch -Wildcard ($_.Value) {
"PASS" { "Green" }
"FAIL" { "Red" }
"SKIP*" { "Yellow" }
default { "Gray" }
}
Write-Host (" {0,-10} {1}" -f $_.Key, $_.Value) -ForegroundColor $color
}
$failed = ($summary.Values | Where-Object { $_ -eq "FAIL" }).Count
if ($failed -gt 0) {
Write-Host "$failed suite(s) failed." -ForegroundColor Red
exit 1
}
Write-Host "All present suites passed." -ForegroundColor Green

View File

@@ -0,0 +1,96 @@
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the FOCAS (Fanuc CNC) driver.
.DESCRIPTION
**Hardware-gated.** There is no public FOCAS simulator; the driver's
FwlibFocasClient P/Invokes Fanuc's licensed Fwlib32.dll. Against a dev
box without the DLL on PATH the test will skip with a clear message.
Against a real CNC with the DLL present it runs probe / driver-loopback /
server-bridge the same way the other scripts do.
Set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC to un-gate.
.PARAMETER CncHost
IP or hostname of the CNC. Default 127.0.0.1 — override for real runs.
.PARAMETER CncPort
FOCAS TCP port. Default 8193.
.PARAMETER Address
FOCAS address to exercise. Default R100 (PMC R-file register).
.PARAMETER OpcUaUrl
OtOpcUa server endpoint.
.PARAMETER BridgeNodeId
NodeId at which the server publishes the Address.
#>
param(
[string]$CncHost = "127.0.0.1",
[int]$CncPort = 8193,
[string]$Address = "R100",
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
if (-not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) {
Write-Skip "FOCAS_TRUST_WIRE not set — no public simulator exists (task #222 tracks the lab rig). Set =1 when -CncHost points at a real CNC with Fwlib32.dll on PATH."
exit 0
}
$focasCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli" `
-ExeName "otopcua-focas-cli"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
$results = @()
$results += Test-Probe `
-Cli $focasCli `
-ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16"))
$writeValue = Get-Random -Minimum 1 -Maximum 9999
$results += Test-DriverLoopback `
-Cli $focasCli `
-WriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) `
-ReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
-ExpectedValue "$writeValue"
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
$results += Test-ServerBridge `
-DriverCli $focasCli `
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-ExpectedValue "$bridgeValue"
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
$results += Test-OpcUaWriteBridge `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $focasCli `
-DriverReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
-ExpectedValue "$reverseValue"
$subValue = Get-Random -Minimum 30000 -Maximum 32766
$results += Test-SubscribeSeesChange `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $focasCli `
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
-ExpectedValue "$subValue"
Write-Summary -Title "FOCAS e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }

278
scripts/e2e/test-galaxy.ps1 Normal file
View File

@@ -0,0 +1,278 @@
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the Galaxy (MXAccess) driver — read, write, subscribe,
alarms, and history through a running OtOpcUa server.
.DESCRIPTION
Unlike the other e2e scripts there is no `otopcua-galaxy-cli` — the Galaxy
driver proxy lives in-process with the server + talks to `OtOpcUaGalaxyHost`
over a named pipe (MXAccess is 32-bit COM, can't ship in the .NET 10 process).
Every stage therefore goes through `otopcua-cli` against the published OPC UA
address space.
Seven stages:
1. Probe — otopcua-cli connect + read the source NodeId; confirms
the whole Galaxy.Host → Proxy → server → client chain is
up
2. Source read — otopcua-cli read returns a Good value for the source
attribute; proves IReadable.ReadAsync is dispatching
through the IPC bridge
3. Virtual-tag bridge — `otopcua-cli read` on the VirtualTag NodeId; confirms
the Phase 7 CachedTagUpstreamSource is bridging the
driver-sourced input into the scripting engine
4. Subscribe-sees-change — subscribe to the source NodeId in the background;
Galaxy pushes a data-change event within N seconds
(Galaxy's underlying attribute must be actively
changing — production Galaxies typically have
scan-driven updates; for idle galaxies, widen
-ChangeWaitSec or drive the write stage below first)
5. Reverse bridge — `otopcua-cli write` to a writable Galaxy attribute;
read it back. Gracefully becomes INFO-only if the
attribute's Galaxy-side AccessLevel forbids writes
(BadUserAccessDenied / BadNotWritable)
6. Alarm fires — subscribe to the scripted-alarm Condition NodeId,
drive the source tag above its threshold, confirm an
Active alarm event surfaces. Exercises the Part 9
alarm-condition propagation path
7. History read — historyread on the source tag over the last hour;
confirms Aveva Historian → IHistoryProvider dispatch
returns samples
The Phase 7 seed (`scripts/smoke/seed-phase-7-smoke.sql`) already plants the
right shape — one Galaxy DriverInstance, one source Tag, one VirtualTag
(source × 2), one ScriptedAlarm (source > 50). Substitute the real Galaxy
attribute FullName into `dbo.Tag.TagConfig` before running.
.PARAMETER OpcUaUrl
OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
.PARAMETER SourceNodeId
NodeId of the driver-sourced Galaxy tag (numeric, writable preferred).
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-tag-source`.
.PARAMETER VirtualNodeId
NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting).
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-vt-derived`.
.PARAMETER AlarmNodeId
NodeId of the scripted-alarm Condition (fires when Source > 50).
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-al-overtemp`.
.PARAMETER AlarmTriggerValue
Value written to -SourceNodeId to push it over the alarm threshold.
Default 75 (well above the seeded 50-threshold).
.PARAMETER ChangeWaitSec
Seconds the subscribe-sees-change stage waits for a natural data change.
Default 10. Idle galaxies may need this extended or the stage will fail
with "subscribe did not observe...".
.PARAMETER AlarmWaitSec
Seconds the alarm-fires stage waits after triggering the write. Default 10.
.PARAMETER HistoryLookbackSec
Seconds back from now to query history. Default 3600 (1 h).
.EXAMPLE
# Against the default Phase-7 smoke seed + live Galaxy + OtOpcUa server
./scripts/e2e/test-galaxy.ps1
.EXAMPLE
# Custom NodeIds from a non-smoke cluster
./scripts/e2e/test-galaxy.ps1 `
-SourceNodeId "ns=2;s=Reactor1.Temperature" `
-VirtualNodeId "ns=2;s=Reactor1.TempDoubled" `
-AlarmNodeId "ns=2;s=Reactor1.OverTemp" `
-AlarmTriggerValue 120
#>
param(
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[string]$SourceNodeId = "ns=2;s=p7-smoke-tag-source",
[string]$VirtualNodeId = "ns=2;s=p7-smoke-vt-derived",
[string]$AlarmNodeId = "ns=2;s=p7-smoke-al-overtemp",
[string]$AlarmTriggerValue = "75",
[int]$ChangeWaitSec = 10,
[int]$AlarmWaitSec = 10,
[int]$HistoryLookbackSec = 3600
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$results = @()
# ---------------------------------------------------------------------------
# Stage 1 — Probe. The probe is an otopcua-cli read against the source NodeId;
# success implies Galaxy.Host is up + the pipe ACL lets the server connect +
# the Proxy is tracking the tag + the server published it.
# ---------------------------------------------------------------------------
Write-Header "Probe"
$probe = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") {
Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)"
$results += @{ Passed = $true }
} else {
Write-Fail "probe read failed (exit=$($probe.ExitCode))"
Write-Host $probe.Output
$results += @{ Passed = $false; Reason = "probe failed" }
}
# ---------------------------------------------------------------------------
# Stage 2 — Source read. Captures the current value for the later virtual-tag
# comparison + confirms read dispatch works end-to-end. Failure here without a
# stage-1 failure would be unusual — probe already reads.
# ---------------------------------------------------------------------------
Write-Header "Source read"
$sourceRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
$sourceValue = $null
if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") {
$sourceValue = $Matches[1].Trim()
Write-Pass "source value = $sourceValue"
$results += @{ Passed = $true }
} else {
Write-Fail "source read failed"
Write-Host $sourceRead.Output
$results += @{ Passed = $false; Reason = "source read failed" }
}
# ---------------------------------------------------------------------------
# Stage 3 — Virtual-tag bridge. Reads the Phase 7 VirtualTag (source × 2). Not
# strictly driver-specific, but exercises the CachedTagUpstreamSource bridge
# (the seam most likely to silently stop working after a Galaxy-side change).
# Skip if the VirtualNodeId param is empty (non-Phase-7 clusters).
# ---------------------------------------------------------------------------
if ([string]::IsNullOrEmpty($VirtualNodeId)) {
Write-Header "Virtual-tag bridge"
Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check"
} else {
Write-Header "Virtual-tag bridge"
$vtRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") {
$vtValue = $Matches[1].Trim()
Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)"
$results += @{ Passed = $true }
} else {
Write-Fail "virtual-tag read failed"
Write-Host $vtRead.Output
$results += @{ Passed = $false; Reason = "virtual-tag read failed" }
}
}
# ---------------------------------------------------------------------------
# Stage 4 — Subscribe-sees-change. otopcua-cli subscribe in the background;
# wait N seconds for Galaxy to push any data-change event on the source node.
# This is optimistic — if the Galaxy attribute is idle, widen -ChangeWaitSec.
# ---------------------------------------------------------------------------
Write-Header "Subscribe sees change"
$stdout = New-TemporaryFile
$stderr = New-TemporaryFile
$subArgs = @($opcUaCli.PrefixArgs) + @(
"subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId,
"-i", "500", "--duration", "$ChangeWaitSec")
$subProc = Start-Process -FilePath $opcUaCli.File `
-ArgumentList $subArgs -NoNewWindow -PassThru `
-RedirectStandardOutput $stdout.FullName `
-RedirectStandardError $stderr.FullName
Write-Info "subscription started (pid $($subProc.Id)) for ${ChangeWaitSec}s"
$subProc.WaitForExit(($ChangeWaitSec + 5) * 1000) | Out-Null
if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force }
$subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
# Any `=` followed by `(Good)` line after the initial subscribe-confirmation
# indicates at least one data-change tick arrived.
$changeLines = ($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" }
if ($changeLines.Count -gt 0) {
Write-Pass "$($changeLines.Count) data-change events observed"
$results += @{ Passed = $true }
} else {
Write-Fail "no data-change events in ${ChangeWaitSec}s — Galaxy attribute may be idle; rerun with -ChangeWaitSec larger, or trigger a change first"
Write-Host $subOut
$results += @{ Passed = $false; Reason = "no data-change" }
}
# ---------------------------------------------------------------------------
# Stage 5 — Reverse bridge (OPC UA write → Galaxy). Galaxy attributes with
# AccessLevel > FreeAccess often reject anonymous writes; record as INFO when
# that's the case rather than failing the whole script.
# ---------------------------------------------------------------------------
Write-Header "Reverse bridge (OPC UA write)"
$writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write
$w = Invoke-Cli -Cli $opcUaCli -Args @(
"write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue")
if ($w.ExitCode -ne 0) {
# Connection/protocol failure — still a test failure.
Write-Fail "write CLI exit=$($w.ExitCode)"
Write-Host $w.Output
$results += @{ Passed = $false; Reason = "write failed" }
} elseif ($w.Output -match "Write failed:\s*0x801F0000") {
Write-Info "BadUserAccessDenied — attribute's Galaxy-side ACL blocks writes for this session. Not a bug; grant WriteOperate or run against a writable attribute."
$results += @{ Passed = $true; Reason = "acl-expected" }
} elseif ($w.Output -match "Write failed:\s*0x80390000|BadNotWritable") {
Write-Info "BadNotWritable — attribute is read-only at the Galaxy layer (status attributes, @-prefixed meta, etc)."
$results += @{ Passed = $true; Reason = "readonly-expected" }
} elseif ($w.Output -match "Write successful") {
# Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle.
Start-Sleep -Seconds 2
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
Write-Pass "write propagated — source reads back $writeValue"
$results += @{ Passed = $true }
} else {
Write-Fail "write reported success but read-back did not reflect $writeValue"
Write-Host $r.Output
$results += @{ Passed = $false; Reason = "write-readback mismatch" }
}
} else {
Write-Fail "unexpected write response"
Write-Host $w.Output
$results += @{ Passed = $false; Reason = "unexpected write response" }
}
# ---------------------------------------------------------------------------
# Stage 6 — Alarm fires. Uses the helper from _common.ps1. If stage 5 already
# wrote the trigger value the alarm may already be active; that's fine — the
# Part 9 ConditionRefresh in the alarms CLI replays the current state so the
# subscribe window still captures the Active event.
# ---------------------------------------------------------------------------
if ([string]::IsNullOrEmpty($AlarmNodeId)) {
Write-Header "Alarm fires on threshold"
Write-Skip "AlarmNodeId not supplied — skipping alarm check"
} else {
$results += Test-AlarmFiresOnThreshold `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-AlarmNodeId $AlarmNodeId `
-InputNodeId $SourceNodeId `
-TriggerValue $AlarmTriggerValue `
-DurationSec $AlarmWaitSec
}
# ---------------------------------------------------------------------------
# Stage 7 — History read. historyread against the source tag over the last N
# seconds. Failure modes the skip pattern catches: tag not historized in the
# Galaxy attribute's historization profile, or the lookback window misses the
# sample cadence.
# ---------------------------------------------------------------------------
$results += Test-HistoryHasSamples `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-NodeId $SourceNodeId `
-LookbackSec $HistoryLookbackSec
Write-Summary -Title "Galaxy e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -0,0 +1,99 @@
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the Modbus-TCP driver bridged through the OtOpcUa server.
.DESCRIPTION
Five assertions:
1. `otopcua-modbus-cli probe` hits the simulator
2. Driver-loopback write + read-back via modbus-cli
3. Forward bridge: modbus-cli writes HR[200], OPC UA client reads the bridged NodeId
4. Reverse bridge: OPC UA client writes the NodeId, modbus-cli reads HR[200]
5. Subscribe-sees-change: OPC UA subscription observes a modbus-cli write
Requires a running Modbus simulator on localhost:5020 (the pymodbus fixture
default — see tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml)
and a running OtOpcUa server whose config DB has a Modbus DriverInstance
bound to that simulator + a Tag at HR[200] UInt16 published under the
NodeId passed via -BridgeNodeId.
NOTE: HR[200] (not HR[100]) — pymodbus standard.json makes HR[100] an
auto-incrementing register that mutates every poll, so loopback writes
can't be verified there.
.PARAMETER ModbusHost
Host:port of the Modbus simulator. Default 127.0.0.1:5020.
.PARAMETER OpcUaUrl
Endpoint URL of the OtOpcUa server. Default opc.tcp://localhost:4840.
.PARAMETER BridgeNodeId
OPC UA NodeId the OtOpcUa server publishes the HR[100] tag at. Set per your
server config — e.g. 'ns=2;s=/warsaw/modbus-sim/HR_100'. Required.
.EXAMPLE
.\test-modbus.ps1 -BridgeNodeId "ns=2;s=/warsaw/modbus-sim/HR_100"
#>
param(
[string]$ModbusHost = "127.0.0.1:5020",
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$hostPart, $portPart = $ModbusHost.Split(":")
$port = [int]$portPart
$modbusCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli" `
-ExeName "otopcua-modbus-cli"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$commonModbus = @("-h", $hostPart, "-p", $port)
$results = @()
$results += Test-Probe `
-Cli $modbusCli `
-ProbeArgs (@("probe") + $commonModbus)
$writeValue = Get-Random -Minimum 1 -Maximum 9999
$results += Test-DriverLoopback `
-Cli $modbusCli `
-WriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16", "-v", $writeValue)) `
-ReadArgs (@("read") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16")) `
-ExpectedValue "$writeValue"
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
$results += Test-ServerBridge `
-DriverCli $modbusCli `
-DriverWriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16", "-v", $bridgeValue)) `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-ExpectedValue "$bridgeValue"
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
$results += Test-OpcUaWriteBridge `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $modbusCli `
-DriverReadArgs (@("read") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16")) `
-ExpectedValue "$reverseValue"
$subValue = Get-Random -Minimum 30000 -Maximum 39999
$results += Test-SubscribeSeesChange `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $modbusCli `
-DriverWriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16", "-v", $subValue)) `
-ExpectedValue "$subValue"
Write-Summary -Title "Modbus e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -0,0 +1,156 @@
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end test for Phase 7 virtual tags + scripted alarms, driven via the
Modbus CLI.
.DESCRIPTION
Assumes the OtOpcUa server's config DB has this Phase 7 scaffolding:
1. A Modbus DriverInstance bound to -ModbusHost, with a Tag at HR[100]
as UInt16 published under -InputNodeId.
2. A VirtualTag `VT_DoubledHR100` = `double(input)` where input is
HR[100], published under -VirtualNodeId.
3. A ScriptedAlarm `Alarm_HighHR100` that fires when VT_DoubledHR100 > 100,
published so the client can subscribe to AlarmConditionType events.
Three assertions:
1. Virtual-tag bridge — modbus-cli writes HR[100]=21, OPC UA client reads
VirtualNodeId + expects 42.
2. Alarm fire — modbus-cli writes HR[100]=60 (VT=120, above threshold),
OPC UA client alarms subscribe sees the condition go Active.
3. Alarm clear — modbus-cli writes HR[100]=10 (VT=20, below threshold),
OPC UA client sees the condition go back to Inactive.
See scripts/smoke/seed-phase-7-smoke.sql for the seed shape. This script
doesn't seed; it verifies the running state.
.PARAMETER ModbusHost
Modbus simulator endpoint. Default 127.0.0.1:5502.
.PARAMETER OpcUaUrl
OtOpcUa server endpoint.
.PARAMETER InputNodeId
NodeId at which the server publishes HR[100] (the input tag).
.PARAMETER VirtualNodeId
NodeId at which the server publishes VT_DoubledHR100.
.PARAMETER AlarmNodeId
NodeId of the AlarmConditionType (or its source) the server publishes for
Alarm_HighHR100. Alarms subscribe filters by SourceNode = this NodeId.
#>
param(
[string]$ModbusHost = "127.0.0.1:5502",
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$InputNodeId,
[Parameter(Mandatory)] [string]$VirtualNodeId,
[string]$AlarmNodeId
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$hostPart, $portPart = $ModbusHost.Split(":")
$port = [int]$portPart
$modbusCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli" `
-ExeName "otopcua-modbus-cli"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$commonModbus = @("-h", $hostPart, "-p", $port)
$results = @()
# --- Assertion 1: virtual-tag bridge ------------------------------------------
Write-Header "Virtual tag — VT_DoubledHR100 = HR[100] * 2"
$inputValue = 21
$expectedVirtual = $inputValue * 2
$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + `
@("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $inputValue))
if ($w.ExitCode -ne 0) {
Write-Fail "modbus write failed (exit=$($w.ExitCode))"
$results += @{ Passed = $false; Reason = "seed write failed" }
}
else {
Write-Info "wrote HR[100]=$inputValue, waiting 3s for virtual-tag engine to re-evaluate"
Start-Sleep -Seconds 3
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
if ($r.ExitCode -eq 0 -and $r.Output -match "Value:\s+$expectedVirtual\b") {
Write-Pass "virtual tag = $expectedVirtual (input * 2)"
$results += @{ Passed = $true }
}
else {
Write-Fail "expected VT = $expectedVirtual; got:"
Write-Host $r.Output
$results += @{ Passed = $false; Reason = "virtual tag mismatch" }
}
}
# --- Assertion 2: scripted alarm fires ---------------------------------------
if ([string]::IsNullOrWhiteSpace($AlarmNodeId)) {
Write-Skip "AlarmNodeId not provided — skipping alarm fire/clear assertions"
}
else {
Write-Header "Scripted alarm — fires when VT > 100"
$fireValue = 60 # VT = 120, above threshold
$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + `
@("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $fireValue))
if ($w.ExitCode -ne 0) {
Write-Fail "modbus write failed"
$results += @{ Passed = $false }
}
else {
Write-Info "wrote HR[100]=$fireValue (VT=$($fireValue*2)); subscribing alarms for 5s"
# otopcua-cli's `alarms` command subscribes + prints events until an
# interrupt or timeout. We capture ~5s worth then parse for ActiveState.
$job = Start-Job -ScriptBlock {
param($file, $prefix, $url, $source)
$cmdArgs = $prefix + @("alarms", "-u", $url, "-n", $source, "--duration-seconds", "5")
& $file @cmdArgs 2>&1
} -ArgumentList $opcUaCli.File, $opcUaCli.PrefixArgs, $OpcUaUrl, $AlarmNodeId
$alarmOutput = Receive-Job -Job $job -Wait -AutoRemoveJob
$alarmText = ($alarmOutput | Out-String)
if ($alarmText -match "Active" -or $alarmText -match "HighAlarm" -or $alarmText -match "Severity") {
Write-Pass "alarm subscription received an event"
$results += @{ Passed = $true }
}
else {
Write-Fail "expected alarm event in subscription output"
Write-Host $alarmText
$results += @{ Passed = $false; Reason = "alarm did not fire" }
}
}
# --- Assertion 3: alarm clears ---
Write-Header "Scripted alarm — clears when VT falls below threshold"
$clearValue = 10 # VT = 20, below threshold
$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + `
@("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $clearValue))
if ($w.ExitCode -eq 0) {
Write-Info "wrote HR[100]=$clearValue (VT=$($clearValue*2)); alarm should clear"
# We don't re-subscribe here — the clear is asserted via the virtual
# tag's current value (the Phase 7 engine's commitment is that state
# propagates on the next tick; the OPC UA alarm transition follows).
Start-Sleep -Seconds 3
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
if ($r.Output -match "Value:\s+$($clearValue*2)\b") {
Write-Pass "virtual tag returned to below-threshold ($($clearValue*2))"
$results += @{ Passed = $true }
}
else {
Write-Fail "virtual tag did not reflect cleared state"
Write-Host $r.Output
$results += @{ Passed = $false; Reason = "clear state mismatch" }
}
}
}
Write-Summary -Title "Phase 7 virtual tags + scripted alarms" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }

100
scripts/e2e/test-s7.ps1 Normal file
View File

@@ -0,0 +1,100 @@
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the Siemens S7 driver bridged through the OtOpcUa server.
.DESCRIPTION
Five assertions (probe / driver-loopback / forward-bridge / reverse-bridge /
subscribe-sees-change) against a Siemens S7-300/400/1200/1500 or compatible
soft-PLC. python-snap7 simulator (task #216) or real hardware both work.
Prereqs:
- S7 simulator / PLC on $S7Host:$S7Port
- On real S7-1200/1500: PUT/GET communication enabled in TIA Portal.
- OtOpcUa server running with an S7 DriverInstance bound to the same
endpoint + a Tag at DB1.DBW0 Int16 published under -BridgeNodeId.
.PARAMETER S7Host
Host:port of the S7 simulator / PLC. Default 127.0.0.1:102.
.PARAMETER Cpu
S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 (default S71500).
.PARAMETER Slot
CPU slot. Default 0 (S7-1200/1500). S7-300 uses 2.
.PARAMETER Address
S7 address to exercise. Default DB1.DBW0.
.PARAMETER OpcUaUrl
OtOpcUa server endpoint.
.PARAMETER BridgeNodeId
NodeId at which the server publishes the Address.
#>
param(
[string]$S7Host = "127.0.0.1:102",
[string]$Cpu = "S71500",
[int]$Slot = 0,
[string]$Address = "DB1.DBW0",
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$hostPart, $portPart = $S7Host.Split(":")
$port = [int]$portPart
$s7Cli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli" `
-ExeName "otopcua-s7-cli"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$commonS7 = @("-h", $hostPart, "-p", $port, "-c", $Cpu, "--slot", $Slot)
$results = @()
$results += Test-Probe `
-Cli $s7Cli `
-ProbeArgs (@("probe") + $commonS7)
$writeValue = Get-Random -Minimum 1 -Maximum 9999
$results += Test-DriverLoopback `
-Cli $s7Cli `
-WriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) `
-ReadArgs (@("read") + $commonS7 + @("-a", $Address, "-t", "Int16")) `
-ExpectedValue "$writeValue"
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
$results += Test-ServerBridge `
-DriverCli $s7Cli `
-DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-ExpectedValue "$bridgeValue"
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
$results += Test-OpcUaWriteBridge `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $s7Cli `
-DriverReadArgs (@("read") + $commonS7 + @("-a", $Address, "-t", "Int16")) `
-ExpectedValue "$reverseValue"
$subValue = Get-Random -Minimum 30000 -Maximum 32766
$results += Test-SubscribeSeesChange `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $s7Cli `
-DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
-ExpectedValue "$subValue"
Write-Summary -Title "S7 e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -0,0 +1,99 @@
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the TwinCAT (Beckhoff ADS) driver.
.DESCRIPTION
Requires a reachable AMS router (local TwinCAT XAR, Beckhoff.TwinCAT.Ads.
TcpRouter NuGet, or an authorised remote AMS route) + a live TwinCAT
runtime on -AmsNetId. Without one the driver surfaces a transport error
on InitializeAsync + the script's probe fails.
Set TWINCAT_TRUST_WIRE=1 to promise the endpoint is live. Without it the
script skips (task #221 tracks the 7-day-trial CI fixture — until that
lands, TwinCAT testing is a manual operator task).
.PARAMETER AmsNetId
AMS Net ID of the target (e.g. 127.0.0.1.1.1 for local XAR,
192.168.1.40.1.1 for a remote PLC).
.PARAMETER AmsPort
AMS port. Default 851 (TC3 PLC runtime). TC2 uses 801.
.PARAMETER SymbolPath
TwinCAT symbol to exercise. Default 'MAIN.iCounter' — substitute with
whatever your project actually declares.
.PARAMETER OpcUaUrl
OtOpcUa server endpoint.
.PARAMETER BridgeNodeId
NodeId at which the server publishes the Symbol.
#>
param(
[string]$AmsNetId = "127.0.0.1.1.1",
[int]$AmsPort = 851,
[string]$SymbolPath = "MAIN.iCounter",
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
if (-not ($env:TWINCAT_TRUST_WIRE -eq "1" -or $env:TWINCAT_TRUST_WIRE -eq "true")) {
Write-Skip "TWINCAT_TRUST_WIRE not set — requires reachable AMS router + live TC runtime (task #221 tracks the CI fixture). Set =1 once the router is up."
exit 0
}
$twinCatCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli" `
-ExeName "otopcua-twincat-cli"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$commonTc = @("-n", $AmsNetId, "-p", $AmsPort)
$results = @()
$results += Test-Probe `
-Cli $twinCatCli `
-ProbeArgs (@("probe") + $commonTc + @("-s", $SymbolPath, "--type", "DInt"))
$writeValue = Get-Random -Minimum 1 -Maximum 9999
$results += Test-DriverLoopback `
-Cli $twinCatCli `
-WriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $writeValue)) `
-ReadArgs (@("read") + $commonTc + @("-s", $SymbolPath, "-t", "DInt")) `
-ExpectedValue "$writeValue"
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
$results += Test-ServerBridge `
-DriverCli $twinCatCli `
-DriverWriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $bridgeValue)) `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-ExpectedValue "$bridgeValue"
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
$results += Test-OpcUaWriteBridge `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $twinCatCli `
-DriverReadArgs (@("read") + $commonTc + @("-s", $SymbolPath, "-t", "DInt")) `
-ExpectedValue "$reverseValue"
$subValue = Get-Random -Minimum 30000 -Maximum 39999
$results += Test-SubscribeSeesChange `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $twinCatCli `
-DriverWriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $subValue)) `
-ExpectedValue "$subValue"
Write-Summary -Title "TwinCAT e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -0,0 +1,141 @@
-- AB CIP e2e smoke seed — closes #211 (umbrella #209).
--
-- One-cluster seed pointing at the ab_server ControlLogix fixture
-- (`docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d`).
-- Publishes a single `TestDINT:DInt` tag under NodeId `ns=<N>;s=TestDINT`
-- (ab_server seeds this tag by default).
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-abcip-smoke.sql
--
-- After seeding, point appsettings at this cluster:
-- Node:NodeId = "abcip-smoke-node"
-- Node:ClusterId = "abcip-smoke"
-- Then start server + run `./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"`.
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 'abcip-smoke';
DECLARE @NodeId nvarchar(64) = 'abcip-smoke-node';
DECLARE @DrvId nvarchar(64) = 'abcip-smoke-drv';
DECLARE @NsId nvarchar(64) = 'abcip-smoke-ns';
DECLARE @AreaId nvarchar(64) = 'abcip-smoke-area';
DECLARE @LineId nvarchar(64) = 'abcip-smoke-line';
DECLARE @EqId nvarchar(64) = 'abcip-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '41BC12E0-41BC-412E-841B-C12E041BC12E';
DECLARE @TagId nvarchar(64) = 'abcip-smoke-tag-testdint';
BEGIN TRAN;
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'AB CIP Smoke', 'zb', 'lab', 1, 'None', 1, 'abcip-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:abcip-smoke-node', 200, 1, 'abcip-smoke');
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'abcip-smoke');
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'abcip-smoke');
SET @Gen = SCOPE_IDENTITY();
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:abcip-smoke:eq', 1);
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 'abcip-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'ab-sim', 'abcip-001', 1);
-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture
-- 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,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
"TimeoutMs": 2000,
"Devices": [
{
"HostAddress": "ab://127.0.0.1:44818/1,0",
"PlcFamily": "ControlLogix",
"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 },
"Tags": [
{
"Name": "TestDINT",
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
"TagPath": "TestDINT",
"DataType": "DInt",
"Writable": true,
"WriteIdempotent": true
}
]
}', 1);
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'TestDINT', 'Int32', 'ReadWrite',
N'{"FullName":"TestDINT","DataType":"DInt"}', 1);
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'AB CIP smoke — task #211';
COMMIT;
PRINT '';
PRINT 'AB CIP smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "abcip-smoke-node"';
PRINT ' Node:ClusterId = "abcip-smoke"';
PRINT ' 2. docker compose -f tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d';
PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. ./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"';

View File

@@ -0,0 +1,146 @@
-- AB Legacy e2e smoke seed — closes #213 (umbrella #209).
--
-- Works against the ab_server PCCC Docker fixture (one of the slc500 /
-- micrologix / plc5 compose profiles) or real SLC 500 / MicroLogix / PLC-5
-- hardware. Default HostAddress below points at the Docker fixture with a
-- `/1,0` cip-path; libplctag's ab_server rejects empty paths before routing
-- to the PCCC dispatcher. Real hardware uses an empty path — change the
-- HostAddress to `ab://<plc-ip>:44818/` (note the trailing slash with nothing
-- after) before running the seed for that setup.
--
-- Usage:
-- docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml --profile slc500 up -d
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-ablegacy-smoke.sql
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 'ablegacy-smoke';
DECLARE @NodeId nvarchar(64) = 'ablegacy-smoke-node';
DECLARE @DrvId nvarchar(64) = 'ablegacy-smoke-drv';
DECLARE @NsId nvarchar(64) = 'ablegacy-smoke-ns';
DECLARE @AreaId nvarchar(64) = 'ablegacy-smoke-area';
DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line';
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
DECLARE @ArrTagId nvarchar(64) = 'ablegacy-smoke-tag-n7_block';
BEGIN TRAN;
DELETE FROM dbo.Tag WHERE TagId IN (@TagId, @ArrTagId);
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'AB Legacy Smoke', 'zb', 'lab', 1, 'None', 1, 'ablegacy-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:ablegacy-smoke-node', 200, 1, 'ablegacy-smoke');
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'ablegacy-smoke');
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'ablegacy-smoke');
SET @Gen = SCOPE_IDENTITY();
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:ablegacy-smoke:eq', 1);
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 'ablegacy-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'slc-sim', 'ablegacy-001', 1);
-- AB Legacy DriverInstance — SLC 500 target. Replace the placeholder gateway
-- `192.168.1.10` with the real PLC / RSEmulate host before running.
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
"TimeoutMs": 2000,
"Devices": [
{
"HostAddress": "ab://127.0.0.1:44818/1,0",
"PlcFamily": "Slc500",
"DeviceName": "slc-500"
}
],
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" },
"Tags": [
{
"Name": "N7_5",
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
"Address": "N7:5",
"DataType": "Int",
"Writable": 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);
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
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,
@Notes = N'AB Legacy smoke — task #213';
COMMIT;
PRINT '';
PRINT 'AB Legacy smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'NOTE: default points at the ab_server slc500 Docker fixture with a /1,0';
PRINT ' cip-path (required by ab_server). For real SLC/MicroLogix/PLC-5';
PRINT ' hardware, edit the DriverConfig HostAddress to end with /<empty>';
PRINT ' e.g. "ab://<plc-ip>:44818/" and re-run this seed.';

View File

@@ -0,0 +1,156 @@
-- Modbus e2e smoke seed — closes #210 (umbrella #209).
--
-- Idempotent — DROP-and-recreate of one cluster's worth of Modbus test config:
-- * 1 ServerCluster ('modbus-smoke') + ClusterNode ('modbus-smoke-node')
-- * 1 ConfigGeneration (Draft → Published at the end)
-- * 1 Namespace + UnsArea + UnsLine + Equipment
-- * 1 Modbus DriverInstance pointing at the pymodbus standard fixture
-- (127.0.0.1:5020 per tests/.../Modbus.IntegrationTests/Docker)
-- * 1 Tag at HR[200]:UInt16 (HR[100] is auto-increment in standard.json,
-- unusable as a write target — the e2e script uses HR[200] for that reason)
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-modbus-smoke.sql
--
-- After seeding, update src/.../Server/appsettings.json:
-- Node:NodeId = "modbus-smoke-node"
-- Node:ClusterId = "modbus-smoke"
--
-- Then start the simulator + server + run the e2e script:
-- docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d
-- dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server
-- ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 'modbus-smoke';
DECLARE @NodeId nvarchar(64) = 'modbus-smoke-node';
DECLARE @DrvId nvarchar(64) = 'modbus-smoke-drv';
DECLARE @NsId nvarchar(64) = 'modbus-smoke-ns';
DECLARE @AreaId nvarchar(64) = 'modbus-smoke-area';
DECLARE @LineId nvarchar(64) = 'modbus-smoke-line';
DECLARE @EqId nvarchar(64) = 'modbus-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '72BD5A10-72BD-45A1-B72B-D5A1072BD5A1';
DECLARE @TagHr200 nvarchar(64) = 'modbus-smoke-tag-hr200';
BEGIN TRAN;
-- Clean prior smoke state (child rows first).
DELETE FROM dbo.Tag WHERE TagId IN (@TagHr200);
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
-- `UX_ClusterNodeCredential_Value` is a unique index on (Kind, Value) WHERE
-- Enabled=1, so a `sa` login can only bind to one node at a time. Drop any
-- prior smoke cluster's binding before we claim the login for this one.
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
-- 1. Cluster + Node.
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'Modbus Smoke', 'zb', 'lab', 1, 'None', 1, 'modbus-smoke');
-- DashboardPort 15050 rather than 5000 — HttpListener on :5000 requires
-- URL-ACL reservation or admin rights on Windows (HttpListenerException 32).
-- 15000+ ports are unreserved by default. Safe to change back when deploying
-- with a netsh urlacl grant or reverse-proxy fronting :5000.
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:modbus-smoke-node', 200, 1, 'modbus-smoke');
-- Bind the SQL login this smoke test connects as to the node identity. The
-- sp_GetCurrentGenerationForCluster + sp_UpdateClusterNodeGenerationState
-- sprocs raise RAISERROR('Unauthorized: caller %s is not bound to NodeId %s')
-- when this row is missing. `Kind='SqlLogin'` / `Value='sa'` matches the
-- container's SA user; rotate Value for real deployments using a non-SA login.
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'modbus-smoke');
-- 2. Draft generation.
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'modbus-smoke');
SET @Gen = SCOPE_IDENTITY();
-- 3. Namespace.
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:modbus-smoke:eq', 1);
-- 4. UNS hierarchy.
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 'modbus-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'modbus-sim', 'modbus-001', 1);
-- 5. Modbus DriverInstance. DriverConfig mirrors ModbusDriverConfigDto
-- (mapped to ModbusDriverOptions by ModbusDriverFactoryExtensions).
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'pymodbus-smoke', 'Modbus', N'{
"Host": "127.0.0.1",
"Port": 5020,
"UnitId": 1,
"TimeoutMs": 2000,
"AutoReconnect": true,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": 0 },
"Tags": [
{
"Name": "HR200",
"Region": "HoldingRegisters",
"Address": 200,
"DataType": "UInt16",
"Writable": true,
"WriteIdempotent": true
}
]
}', 1);
-- 6. Tag row bound to the Equipment. Driver reports the same tag via
-- DiscoverAsync + the walker maps the UnsArea/Line/Equipment/Tag path to the
-- driver's folder/variable (NodeId ends up ns=<driver-ns>;s=HR200 per
-- ModbusDriver.DiscoverAsync using FullName = tag.Name).
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagHr200, @DrvId, @EqId, 'HR200', 'UInt16', 'ReadWrite',
N'{"FullName":"HR200","DataType":"UInt16"}', 1);
-- 7. Publish the generation — flips Status Draft → Published, merges
-- ExternalIdReservation, claims cluster write lock.
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'Modbus smoke — task #210';
COMMIT;
PRINT '';
PRINT 'Modbus smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "modbus-smoke-node"';
PRINT ' Node:ClusterId = "modbus-smoke"';
PRINT ' 2. docker compose -f tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d';
PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"';

View File

@@ -0,0 +1,166 @@
-- Phase 7 live OPC UA E2E smoke seed (task #240).
--
-- Idempotent — DROP-and-recreate of one cluster's worth of test config:
-- * 1 ServerCluster ('p7-smoke')
-- * 1 ClusterNode ('p7-smoke-node')
-- * 1 ConfigGeneration (created Draft, then flipped to Published at the end)
-- * 1 Namespace (Equipment kind)
-- * 1 UnsArea / UnsLine / Equipment / Tag — Tag bound to a real Galaxy attribute
-- * 1 DriverInstance (Galaxy)
-- * 1 Script + 1 VirtualTag using it
-- * 1 Script + 1 ScriptedAlarm using it
--
-- Drop & re-create deletes ALL rows scoped to the cluster (in dependency order)
-- so re-running this script after a code change starts from a clean state.
-- Table-level CHECK constraints are validated on insert; if a constraint is
-- violated this script aborts with the offending row's column.
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-phase-7-smoke.sql
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 'p7-smoke';
DECLARE @NodeId nvarchar(64) = 'p7-smoke-node';
DECLARE @DrvId nvarchar(64) = 'p7-smoke-galaxy';
DECLARE @NsId nvarchar(64) = 'p7-smoke-ns';
DECLARE @AreaId nvarchar(64) = 'p7-smoke-area';
DECLARE @LineId nvarchar(64) = 'p7-smoke-line';
DECLARE @EqId nvarchar(64) = 'p7-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '5B2CF10D-5B2C-4F10-B5B2-CF10D5B2CF10';
DECLARE @TagId nvarchar(64) = 'p7-smoke-tag-source';
DECLARE @VtScript nvarchar(64) = 'p7-smoke-script-vt';
DECLARE @AlScript nvarchar(64) = 'p7-smoke-script-al';
DECLARE @VtId nvarchar(64) = 'p7-smoke-vt-derived';
DECLARE @AlId nvarchar(64) = 'p7-smoke-al-overtemp';
BEGIN TRAN;
-- Wipe any prior smoke state. Order matters: child rows first.
DELETE s FROM dbo.ScriptedAlarmState s
WHERE s.ScriptedAlarmId = @AlId;
DELETE FROM dbo.ScriptedAlarm WHERE ScriptedAlarmId = @AlId;
DELETE FROM dbo.VirtualTag WHERE VirtualTagId = @VtId;
DELETE FROM dbo.Script WHERE ScriptId IN (@VtScript, @AlScript);
DELETE FROM dbo.Tag WHERE TagId = @TagId;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
-- 1. Cluster + Node
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'P7 Smoke', 'zb', 'lab', 1, 'None', 1, 'p7-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000,
'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke');
-- 2. Generation (created Draft, flipped to Published at the end so insert order
-- constraints (one Draft per cluster, etc.) don't fight us).
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'p7-smoke');
SET @Gen = SCOPE_IDENTITY();
-- 3. Namespace
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:p7-smoke:eq', 1);
-- 4. UNS hierarchy
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 'galaxy-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'reactor-1', 'p7-rx-001', 1);
-- 5. Driver — Galaxy proxy. DriverConfig JSON tells the proxy how to reach the
-- already-running OtOpcUaGalaxyHost. Secret + pipe name match
-- .local/galaxy-host-secret.txt + the OtOpcUaGalaxyHost service env.
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'galaxy-smoke', 'Galaxy', N'{
"DriverInstanceId": "p7-smoke-galaxy",
"PipeName": "OtOpcUaGalaxy",
"SharedSecret": "4hgDJ4jLcKXmOmD1Ara8xtE8N3R47Q2y1Xf/Eama/Fk=",
"ConnectTimeoutMs": 10000
}', 1);
-- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy
-- fullRef ("DelmiaReceiver_001.DownloadPath" style); replace with a real
-- attribute on this Galaxy. The script paths below use
-- /lab-floor/galaxy-line/reactor-1/Source which the EquipmentNodeWalker
-- emits + the DriverSubscriptionBridge maps to this driver fullRef.
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Float64', 'Read',
N'{"FullName":"REPLACE_WITH_REAL_GALAXY_ATTRIBUTE","DataType":"Float64"}', 0);
-- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using
-- a placeholder here; the engine recomputes on first use anyway).
INSERT dbo.Script(GenerationId, ScriptId, Name, SourceCode, SourceHash, Language)
VALUES
(@Gen, @VtScript, 'doubled-source',
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) * 2.0;',
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'),
(@Gen, @AlScript, 'overtemp-predicate',
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50.0;',
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp');
-- 8. VirtualTag — derived value computed by Roslyn each time Source changes.
INSERT dbo.VirtualTag(GenerationId, VirtualTagId, EquipmentId, Name, DataType,
ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled)
VALUES (@Gen, @VtId, @EqId, 'Doubled', 'Float64', @VtScript, 1, NULL, 0, 1);
-- 9. ScriptedAlarm — Active when Source > 50.
INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType,
Severity, MessageTemplate, PredicateScriptId,
HistorizeToAveva, Retain, Enabled)
VALUES (@Gen, @AlId, @EqId, 'OverTemp', 'LimitAlarm', 800,
N'Reactor source value {/lab-floor/galaxy-line/reactor-1/Source} exceeded 50',
@AlScript, 1, 1, 1);
-- 10. Publish — flip the generation Status. sp_PublishGeneration takes
-- concurrency locks + does ExternalIdReservation merging; we drive it via
-- EXEC rather than UPDATE so the rest of the publish workflow runs.
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'Phase 7 live smoke — task #240';
COMMIT;
PRINT '';
PRINT 'Phase 7 smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId + ' (set Node:NodeId in appsettings.json)';
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Edit src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json:';
PRINT ' Node:NodeId = "p7-smoke-node"';
PRINT ' Node:ClusterId = "p7-smoke"';
PRINT ' 2. Edit the placeholder Galaxy attribute in dbo.Tag.TagConfig above';
PRINT ' so it points at a real attribute on this Galaxy — replace';
PRINT ' REPLACE_WITH_REAL_GALAXY_ATTRIBUTE with e.g. "Plant1.Reactor1.Temp".';
PRINT ' 3. Start the Server in a non-elevated shell so the Galaxy.Host pipe ACL';
PRINT ' accepts the connection:';
PRINT ' dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. Validate via Client.CLI per docs/v2/implementation/phase-7-e2e-smoke.md';

View File

@@ -0,0 +1,127 @@
-- S7 e2e smoke seed — closes #212 (umbrella #209).
--
-- One-cluster seed pointing at the python-snap7 fixture
-- (`docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d`).
-- python-snap7 listens on port 1102 (non-priv); real S7 CPUs listen on 102.
-- Publishes one Int16 tag at DB1.DBW0 under `ns=<N>;s=DB1_DBW0` (driver
-- sanitises the dot for browse names — see S7Driver.DiscoverAsync).
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-s7-smoke.sql
--
-- After seeding:
-- Node:NodeId = "s7-smoke-node"
-- Node:ClusterId = "s7-smoke"
-- Then start server + run `./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"`.
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 's7-smoke';
DECLARE @NodeId nvarchar(64) = 's7-smoke-node';
DECLARE @DrvId nvarchar(64) = 's7-smoke-drv';
DECLARE @NsId nvarchar(64) = 's7-smoke-ns';
DECLARE @AreaId nvarchar(64) = 's7-smoke-area';
DECLARE @LineId nvarchar(64) = 's7-smoke-line';
DECLARE @EqId nvarchar(64) = 's7-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '17BD5A10-17BD-417B-917B-D5A1017BD5A1';
DECLARE @TagId nvarchar(64) = 's7-smoke-tag-db1dbw0';
BEGIN TRAN;
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'S7 Smoke', 'zb', 'lab', 1, 'None', 1, 's7-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:s7-smoke-node', 200, 1, 's7-smoke');
-- Dashboard moved off :5000 (Windows URL-ACL).
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 's7-smoke');
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 's7-smoke');
SET @Gen = SCOPE_IDENTITY();
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:s7-smoke:eq', 1);
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 's7-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 's7-sim', 's7-001', 1);
-- S7 DriverInstance — python-snap7 S7-1500 profile, slot 0, port 1102.
-- DriverConfig shape mirrors S7DriverConfigDto.
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'snap7-smoke', 'S7', N'{
"Host": "127.0.0.1",
"Port": 1102,
"CpuType": "S71500",
"Rack": 0,
"Slot": 0,
"TimeoutMs": 5000,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "MW0" },
"Tags": [
{
"Name": "DB1_DBW0",
"Address": "DB1.DBW0",
"DataType": "Int16",
"Writable": true,
"WriteIdempotent": true
}
]
}', 1);
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'DB1_DBW0', 'Int16', 'ReadWrite',
N'{"FullName":"DB1_DBW0","Address":"DB1.DBW0","DataType":"Int16"}', 1);
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'S7 smoke — task #212';
COMMIT;
PRINT '';
PRINT 'S7 smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "s7-smoke-node"';
PRINT ' Node:ClusterId = "s7-smoke"';
PRINT ' 2. docker compose -f tests/.../S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d';
PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. ./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"';

View File

@@ -0,0 +1,79 @@
@page "/alarms/historian"
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
@inject HistorianDiagnosticsService Diag
<h1>Alarm historian</h1>
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
<div class="card mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-3">
<small class="text-muted">Drain state</small>
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
</div>
<div class="col-md-3">
<small class="text-muted">Queue depth</small>
<h4>@_status.QueueDepth.ToString("N0")</h4>
</div>
<div class="col-md-3">
<small class="text-muted">Dead-letter depth</small>
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4>
</div>
<div class="col-md-3">
<small class="text-muted">Last success</small>
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
</div>
</div>
@if (!string.IsNullOrEmpty(_status.LastError))
{
<div class="alert alert-warning mt-3 mb-0">
<strong>Last error:</strong> @_status.LastError
</div>
}
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
Retry dead-lettered (@_status.DeadLetterDepth)
</button>
</div>
@if (_retryResult is not null)
{
<div class="alert alert-success mt-3">Requeued @_retryResult row(s) for retry.</div>
}
@code {
private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled);
private int? _retryResult;
protected override void OnInitialized() => _status = Diag.GetStatus();
private Task RefreshAsync()
{
_status = Diag.GetStatus();
_retryResult = null;
return Task.CompletedTask;
}
private Task RetryDeadLetteredAsync()
{
_retryResult = Diag.TryRetryDeadLettered();
_status = Diag.GetStatus();
return Task.CompletedTask;
}
private static string BadgeFor(HistorianDrainState s) => s switch
{
HistorianDrainState.Idle => "bg-success",
HistorianDrainState.Draining => "bg-info",
HistorianDrainState.BackingOff => "bg-warning text-dark",
HistorianDrainState.Disabled => "bg-secondary",
_ => "bg-secondary",
};
}

View File

@@ -23,6 +23,7 @@
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li> <li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li> <li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li> <li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
</ul> </ul>
<div class="row"> <div class="row">
@@ -32,6 +33,7 @@
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> } else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> } else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> } else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card sticky-top"> <div class="card sticky-top">

View File

@@ -0,0 +1,41 @@
@using Microsoft.AspNetCore.Components.Web
@inject IJSRuntime JS
@*
Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement:
textarea renders immediately, Monaco mounts via JS interop after first render.
Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js
pulls the CDN bundle).
Stream F keeps the interop surface small — bind `Source` two-way, and the parent
tab re-renders on change for the dependency preview. The test-harness button
lives in the parent so one editor can drive multiple script types.
*@
<div class="script-editor">
<textarea class="form-control font-monospace" rows="14" spellcheck="false"
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
</div>
@code {
[Parameter] public string Source { get; set; } = string.Empty;
[Parameter] public EventCallback<string> SourceChanged { get; set; }
private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId);
}
catch (JSException)
{
// Monaco bundle not yet loaded on this page — textarea fallback is
// still functional.
}
}
}
}

View File

@@ -0,0 +1,224 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Core.Scripting
@inject ScriptService ScriptSvc
@inject ScriptTestHarnessService Harness
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">Scripts</h4>
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
</div>
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
</div>
<script src="/js/monaco-loader.js"></script>
@if (_loading) { <p class="text-muted">Loading…</p> }
else if (_scripts.Count == 0 && _editing is null)
{
<div class="alert alert-info">No scripts yet in this draft.</div>
}
else
{
<div class="row">
<div class="col-md-4">
<div class="list-group">
@foreach (var s in _scripts)
{
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
@onclick="() => Open(s)">
<strong>@s.Name</strong>
<div class="small text-muted font-monospace">@s.ScriptId</div>
</button>
}
</div>
</div>
<div class="col-md-8">
@if (_editing is not null)
{
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
<div>
@if (!_isNew)
{
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
}
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
</div>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label">Name</label>
<input class="form-control" @bind="_editing.Name"/>
</div>
<label class="form-label">Source</label>
<ScriptEditor @bind-Source="_editing.SourceCode"/>
<div class="mt-3">
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
</div>
@if (_dependencies is not null)
{
<div class="mt-3">
<strong>Inferred reads</strong>
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
else
{
<ul class="mb-1">
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
</ul>
}
<strong>Inferred writes</strong>
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
else
{
<ul class="mb-1">
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
</ul>
}
@if (_dependencies.Rejections.Count > 0)
{
<div class="alert alert-danger mt-2">
<strong>Non-literal paths rejected:</strong>
<ul class="mb-0">
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
</ul>
</div>
}
</div>
}
@if (_testResult is not null)
{
<div class="mt-3 border-top pt-3">
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
@if (_testResult.Outcome == ScriptTestOutcome.Success)
{
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
@if (_testResult.Writes.Count > 0)
{
<div class="mt-1"><strong>Writes:</strong>
<ul class="mb-0">
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
</ul>
</div>
}
}
@if (_testResult.Errors.Count > 0)
{
<div class="alert alert-warning mt-2 mb-0">
@foreach (var e in _testResult.Errors) { <div>@e</div> }
</div>
}
@if (_testResult.LogEvents.Count > 0)
{
<div class="mt-2"><strong>Script log output:</strong>
<ul class="small mb-0">
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
</ul>
</div>
}
</div>
}
</div>
</div>
}
</div>
</div>
}
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
private bool _loading = true;
private bool _busy;
private bool _harnessBusy;
private bool _isNew;
private List<Script> _scripts = [];
private Script? _editing;
private DependencyExtractionResult? _dependencies;
private ScriptTestResult? _testResult;
protected override async Task OnParametersSetAsync()
{
_loading = true;
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
_loading = false;
}
private void Open(Script s)
{
_editing = new Script
{
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
SourceHash = s.SourceHash, Language = s.Language,
};
_isNew = false;
_dependencies = null;
_testResult = null;
}
private void StartNew()
{
_editing = new Script
{
GenerationId = GenerationId, ScriptId = "",
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
};
_isNew = true;
_dependencies = null;
_testResult = null;
}
private async Task SaveAsync()
{
if (_editing is null) return;
_busy = true;
try
{
if (_isNew)
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
else
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
_isNew = false;
}
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (_editing is null || _isNew) return;
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
_editing = null;
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
}
private void PreviewDependencies()
{
if (_editing is null) return;
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
}
private async Task RunHarnessAsync()
{
if (_editing is null) return;
_harnessBusy = true;
try
{
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
var inputs = new Dictionary<string, DataValueSnapshot>();
foreach (var read in _dependencies.Reads)
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
}
finally { _harnessBusy = false; }
}
}

View File

@@ -57,6 +57,18 @@ builder.Services.AddScoped<EquipmentImportBatchService>();
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService, builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>(); ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
// harness, and historian diagnostics. The historian sink is the Null variant here —
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
// from whichever sink is provided at composition time.
builder.Services.AddScoped<ScriptService>();
builder.Services.AddScoped<VirtualTagService>();
builder.Services.AddScoped<ScriptedAlarmService>();
builder.Services.AddScoped<ScriptTestHarnessService>();
builder.Services.AddScoped<HistorianDiagnosticsService>();
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs // Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just // can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
// filesystem operations. // filesystem operations.

View File

@@ -0,0 +1,32 @@
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Surfaces the local-node historian queue health on the Admin UI's
/// <c>/alarms/historian</c> diagnostics page (Phase 7 plan decisions #16/#21).
/// Exposes queue depth / drain state / last-error, and lets the operator retry
/// dead-lettered rows without restarting the node.
/// </summary>
/// <remarks>
/// The sink injected here is the server-process <see cref="IAlarmHistorianSink"/>.
/// When <see cref="NullAlarmHistorianSink"/> is bound (historian disabled for this
/// deployment), <see cref="TryRetryDeadLettered"/> silently returns 0 and
/// <see cref="GetStatus"/> reports <see cref="HistorianDrainState.Disabled"/>.
/// </remarks>
public sealed class HistorianDiagnosticsService(IAlarmHistorianSink sink)
{
public HistorianSinkStatus GetStatus() => sink.GetStatus();
/// <summary>
/// Operator action from the UI's "Retry dead-lettered" button. Returns the number
/// of rows revived so the UI can flash a confirmation. When the live sink doesn't
/// implement retry (test doubles, Null sink), returns 0.
/// </summary>
public int TryRetryDeadLettered()
{
if (sink is SqliteStoreAndForwardSink concrete)
return concrete.RetryDeadLettered();
return 0;
}
}

View File

@@ -0,0 +1,66 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Draft-generation CRUD for <see cref="Script"/> rows — the C# source code referenced
/// by Phase 7 virtual tags and scripted alarms. <see cref="Script.SourceHash"/> is
/// recomputed on every save so Core.Scripting's compile cache sees a fresh key when
/// source changes and reuses the compile when it doesn't.
/// </summary>
public sealed class ScriptService(OtOpcUaConfigDbContext db)
{
public Task<List<Script>> ListAsync(long generationId, CancellationToken ct) =>
db.Scripts.AsNoTracking()
.Where(s => s.GenerationId == generationId)
.OrderBy(s => s.Name)
.ToListAsync(ct);
public Task<Script?> GetAsync(long generationId, string scriptId, CancellationToken ct) =>
db.Scripts.AsNoTracking()
.FirstOrDefaultAsync(s => s.GenerationId == generationId && s.ScriptId == scriptId, ct);
public async Task<Script> AddAsync(long generationId, string name, string sourceCode, CancellationToken ct)
{
var s = new Script
{
GenerationId = generationId,
ScriptId = $"scr-{Guid.NewGuid():N}"[..20],
Name = name,
SourceCode = sourceCode,
SourceHash = ComputeHash(sourceCode),
};
db.Scripts.Add(s);
await db.SaveChangesAsync(ct);
return s;
}
public async Task<Script> UpdateAsync(long generationId, string scriptId, string name, string sourceCode, CancellationToken ct)
{
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct)
?? throw new InvalidOperationException($"Script '{scriptId}' not found in generation {generationId}");
s.Name = name;
s.SourceCode = sourceCode;
s.SourceHash = ComputeHash(sourceCode);
await db.SaveChangesAsync(ct);
return s;
}
public async Task DeleteAsync(long generationId, string scriptId, CancellationToken ct)
{
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct);
if (s is null) return;
db.Scripts.Remove(s);
await db.SaveChangesAsync(ct);
}
internal static string ComputeHash(string source)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source ?? string.Empty));
return Convert.ToHexString(bytes);
}
}

View File

@@ -0,0 +1,121 @@
using Serilog; // resolves Serilog.ILogger explicitly in signatures
using Serilog.Core;
using Serilog.Events;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
/// map + evaluates once, returns the output (or rejection / exception) plus any
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
/// the harness can't prove statically surfaces as a harness error, not a runtime
/// surprise later.
/// </summary>
public sealed class ScriptTestHarnessService
{
/// <summary>
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
/// tag's new value). <paramref name="inputs"/> supplies synthetic
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
/// </summary>
public async Task<ScriptTestResult> RunVirtualTagAsync(
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
{
var deps = DependencyExtractor.Extract(source);
if (!deps.IsValid)
return ScriptTestResult.DependencyRejections(deps.Rejections);
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
if (missing.Length > 0)
return ScriptTestResult.MissingInputs(missing);
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
if (extra.Length > 0)
return ScriptTestResult.UnknownInputs(extra);
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
try
{
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
}
catch (Exception compileEx)
{
return ScriptTestResult.Threw(compileEx.Message, []);
}
var capturing = new CapturingSink();
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
var ctx = new HarnessVirtualTagContext(inputs, logger);
try
{
var result = await evaluator.RunAsync(ctx, ct);
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
}
catch (Exception ex)
{
return ScriptTestResult.Threw(ex.Message, capturing.Events);
}
}
// Public so Roslyn's script compilation can reference the context type through the
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
public sealed class HarnessVirtualTagContext(
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
{
public Dictionary<string, object?> Writes { get; } = [];
public override DataValueSnapshot GetTag(string path) =>
inputs.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
public override DateTime Now => DateTime.UtcNow;
public override Serilog.ILogger Logger => logger;
}
private sealed class CapturingSink : ILogEventSink
{
public List<LogEvent> Events { get; } = [];
public void Emit(LogEvent e) => Events.Add(e);
}
}
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
public sealed record ScriptTestResult(
ScriptTestOutcome Outcome,
object? Output,
IReadOnlyDictionary<string, object?> Writes,
IReadOnlyList<LogEvent> LogEvents,
IReadOnlyList<string> Errors)
{
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
new(ScriptTestOutcome.Success, output, writes, logs, []);
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
rejs.Select(r => r.Message).ToArray());
public static ScriptTestResult MissingInputs(string[] paths) =>
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
public static ScriptTestResult UnknownInputs(string[] paths) =>
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
}
public enum ScriptTestOutcome
{
Success,
Threw,
DependencyRejected,
MissingInputs,
UnknownInputs,
}
file static class Ua
{
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
}

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
{
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
db.ScriptedAlarms.AsNoTracking()
.Where(a => a.GenerationId == generationId)
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
.ToListAsync(ct);
public async Task<ScriptedAlarm> AddAsync(
long generationId, string equipmentId, string name, string alarmType,
int severity, string messageTemplate, string predicateScriptId,
bool historizeToAveva, bool retain, CancellationToken ct)
{
var a = new ScriptedAlarm
{
GenerationId = generationId,
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
EquipmentId = equipmentId,
Name = name,
AlarmType = alarmType,
Severity = severity,
MessageTemplate = messageTemplate,
PredicateScriptId = predicateScriptId,
HistorizeToAveva = historizeToAveva,
Retain = retain,
};
db.ScriptedAlarms.Add(a);
await db.SaveChangesAsync(ct);
return a;
}
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
{
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
if (a is null) return;
db.ScriptedAlarms.Remove(a);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
/// </summary>
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
db.ScriptedAlarmStates.AsNoTracking()
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
}

View File

@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>Draft-generation CRUD for <see cref="VirtualTag"/> rows.</summary>
public sealed class VirtualTagService(OtOpcUaConfigDbContext db)
{
public Task<List<VirtualTag>> ListAsync(long generationId, CancellationToken ct) =>
db.VirtualTags.AsNoTracking()
.Where(v => v.GenerationId == generationId)
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
.ToListAsync(ct);
public async Task<VirtualTag> AddAsync(
long generationId, string equipmentId, string name, string dataType, string scriptId,
bool changeTriggered, int? timerIntervalMs, bool historize, CancellationToken ct)
{
var v = new VirtualTag
{
GenerationId = generationId,
VirtualTagId = $"vt-{Guid.NewGuid():N}"[..20],
EquipmentId = equipmentId,
Name = name,
DataType = dataType,
ScriptId = scriptId,
ChangeTriggered = changeTriggered,
TimerIntervalMs = timerIntervalMs,
Historize = historize,
};
db.VirtualTags.Add(v);
await db.SaveChangesAsync(ct);
return v;
}
public async Task DeleteAsync(long generationId, string virtualTagId, CancellationToken ct)
{
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct);
if (v is null) return;
db.VirtualTags.Remove(v);
await db.SaveChangesAsync(ct);
}
public async Task<VirtualTag> UpdateEnabledAsync(long generationId, string virtualTagId, bool enabled, CancellationToken ct)
{
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct)
?? throw new InvalidOperationException($"VirtualTag '{virtualTagId}' not found in generation {generationId}");
v.Enabled = enabled;
await db.SaveChangesAsync(ct);
return v;
}
}

View File

@@ -23,6 +23,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,59 @@
// Phase 7 Stream F — Monaco editor loader for ScriptEditor.razor.
// Progressive enhancement: the textarea is authoritative until Monaco attaches;
// after attach, Monaco syncs back into the textarea on every change so Blazor's
// @bind still sees the latest value.
(function () {
if (window.otOpcUaScriptEditor) return;
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
let loaderPromise = null;
function ensureLoader() {
if (loaderPromise) return loaderPromise;
loaderPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `${MONACO_CDN}/loader.js`;
script.onload = () => {
window.require.config({ paths: { vs: MONACO_CDN } });
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
};
script.onerror = () => reject(new Error('Monaco CDN unreachable'));
document.head.appendChild(script);
});
return loaderPromise;
}
window.otOpcUaScriptEditor = {
attach: async function (textareaId) {
const ta = document.getElementById(textareaId);
if (!ta) return;
const monaco = await ensureLoader();
// Mount Monaco over the textarea. The textarea stays in the DOM as the
// source of truth for Blazor's @bind — Monaco mirrors into it on every
// keystroke so server-side state stays in sync.
const host = document.createElement('div');
host.style.height = '340px';
host.style.border = '1px solid #ced4da';
host.style.borderRadius = '0.25rem';
ta.style.display = 'none';
ta.parentNode.insertBefore(host, ta);
const editor = monaco.editor.create(host, {
value: ta.value,
language: 'csharp',
theme: 'vs',
automaticLayout: true,
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
});
editor.onDidChangeModelContent(() => {
ta.value = editor.getValue();
ta.dispatchEvent(new Event('input', { bubbles: true }));
});
},
};
})();

View File

@@ -0,0 +1,232 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Phase 7 follow-up (task #241) — extends <c>dbo.sp_ComputeGenerationDiff</c> to emit
/// Script / VirtualTag / ScriptedAlarm rows alongside the existing Namespace /
/// DriverInstance / Equipment / Tag / NodeAcl output. Admin DiffViewer now shows
/// Phase 7 changes between generations.
/// </summary>
/// <remarks>
/// Logical ids: ScriptId, VirtualTagId, ScriptedAlarmId — stable across generations
/// so a Script whose source changes surfaces as Modified (CHECKSUM picks up the
/// SourceHash delta) while a renamed script surfaces as Modified on Name alone.
/// ScriptedAlarmState is deliberately excluded — it's not generation-scoped, so
/// diffing it between generations is meaningless.
/// </remarks>
/// <inheritdoc />
public partial class ExtendComputeGenerationDiffWithPhase7 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV3);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
}
private static class Procs
{
/// <summary>V3 — adds Script / VirtualTag / ScriptedAlarm sections.</summary>
public const string ComputeGenerationDiffV3 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 — Script section. CHECKSUM picks up source changes via SourceHash + rename
-- via Name; Language future-proofs for non-C# engines. Same Name + same Source =
-- Unchanged (identical hash).
WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Script', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 — VirtualTag section.
WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId),
t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'VirtualTag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 — ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is
-- logical-id keyed outside the generation scope + intentionally excluded here —
-- diffing ack state between generations is semantically meaningless.
WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
/// <summary>V2 — restores the pre-Phase-7 proc on Down().</summary>
public const string ComputeGenerationDiffV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
}
}
}

View File

@@ -33,6 +33,25 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// (holding registers with level-set values, set-point writes to analog tags) — the /// (holding registers with level-set values, set-point writes to analog tags) — the
/// capability invoker respects this flag when deciding whether to apply Polly retry. /// capability invoker respects this flag when deciding whether to apply Polly retry.
/// </param> /// </param>
/// <param name="Source">
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
/// </param>
/// <param name="VirtualTagId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
/// logical id the VirtualTagEngine addresses by. Null otherwise.
/// </param>
/// <param name="ScriptedAlarmId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
/// </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,
@@ -41,4 +60,22 @@ public sealed record DriverAttributeInfo(
SecurityClassification SecurityClass, SecurityClassification SecurityClass,
bool IsHistorized, bool IsHistorized,
bool IsAlarm = false, bool IsAlarm = false,
bool WriteIdempotent = false); bool WriteIdempotent = false,
NodeSourceKind Source = NodeSourceKind.Driver,
string? VirtualTagId = null,
string? ScriptedAlarmId = null,
string? Description = null);
/// <summary>
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
/// materialized by the ScriptedAlarmEngine.
/// </summary>
public enum NodeSourceKind
{
Driver = 0,
Virtual = 1,
ScriptedAlarm = 2,
}

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

@@ -0,0 +1,64 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
/// <summary>
/// Process-singleton registry of <see cref="IDriver"/> factories keyed by
/// <c>DriverInstance.DriverType</c> string. Each driver project ships a DI
/// extension (e.g. <c>services.AddGalaxyProxyDriverFactory()</c>) that registers
/// its factory at startup; the bootstrapper looks up the factory by
/// <c>DriverInstance.DriverType</c> + invokes it with the row's
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON.
/// </summary>
/// <remarks>
/// Closes the gap surfaced by task #240 live smoke — DriverInstance rows in
/// the central config DB had no path to materialise as registered <see cref="IDriver"/>
/// instances. The factory registry is the seam.
/// </remarks>
public sealed class DriverFactoryRegistry
{
private readonly Dictionary<string, Func<string, string, IDriver>> _factories
= new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
/// <summary>
/// Register a factory for <paramref name="driverType"/>. Throws if a factory is
/// already registered for that type — drivers are singletons by type-name in
/// this process.
/// </summary>
/// <param name="driverType">Matches <c>DriverInstance.DriverType</c>.</param>
/// <param name="factory">
/// Receives <c>(driverInstanceId, driverConfigJson)</c>; returns a new
/// <see cref="IDriver"/>. Must NOT call <see cref="IDriver.InitializeAsync"/>
/// itself — the bootstrapper calls it via <see cref="DriverHost.RegisterAsync"/>
/// so the host's per-driver retry semantics apply uniformly.
/// </param>
public void Register(string driverType, Func<string, string, IDriver> factory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
ArgumentNullException.ThrowIfNull(factory);
lock (_lock)
{
if (_factories.ContainsKey(driverType))
throw new InvalidOperationException(
$"DriverType '{driverType}' factory already registered for this process");
_factories[driverType] = factory;
}
}
/// <summary>
/// Try to look up the factory for <paramref name="driverType"/>. Returns null
/// if no driver assembly registered one — bootstrapper logs + skips so a
/// missing-assembly deployment doesn't take down the whole server.
/// </summary>
public Func<string, string, IDriver>? TryGet(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
lock (_lock) return _factories.GetValueOrDefault(driverType);
}
public IReadOnlyCollection<string> RegisteredTypes
{
get { lock (_lock) return [.. _factories.Keys]; }
}
}

View File

@@ -87,6 +87,16 @@ public static class EquipmentNodeWalker
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase) .GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var virtualTagsByEquipment = (content.VirtualTags ?? [])
.Where(v => v.Enabled)
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
.Where(a => a.Enabled)
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal)) foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
{ {
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name); var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
@@ -103,9 +113,17 @@ public static class EquipmentNodeWalker
AddIdentifierProperties(equipmentBuilder, equipment); AddIdentifierProperties(equipmentBuilder, equipment);
IdentificationFolderBuilder.Build(equipmentBuilder, equipment); IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue; if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
foreach (var tag in equipmentTags) foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag); AddTagVariable(equipmentBuilder, tag);
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
foreach (var vtag in vTags)
AddVirtualTagVariable(equipmentBuilder, vtag);
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
foreach (var alarm in alarms)
AddScriptedAlarmVariable(equipmentBuilder, alarm);
} }
} }
} }
@@ -157,6 +175,55 @@ public static class EquipmentNodeWalker
/// </summary> /// </summary>
private static DriverDataType ParseDriverDataType(string raw) => private static DriverDataType ParseDriverDataType(string raw) =>
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String; Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
/// <summary>
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
/// driver.
/// </summary>
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
{
var attr = new DriverAttributeInfo(
FullName: vtag.VirtualTagId,
DriverDataType: ParseDriverDataType(vtag.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: vtag.Historize,
IsAlarm: false,
WriteIdempotent: false,
Source: NodeSourceKind.Virtual,
VirtualTagId: vtag.VirtualTagId,
ScriptedAlarmId: null);
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
}
/// <summary>
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
/// materialization path.
/// </summary>
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
{
var attr = new DriverAttributeInfo(
FullName: alarm.ScriptedAlarmId,
DriverDataType: DriverDataType.Boolean,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: false,
IsAlarm: true,
WriteIdempotent: false,
Source: NodeSourceKind.ScriptedAlarm,
VirtualTagId: null,
ScriptedAlarmId: alarm.ScriptedAlarmId);
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
}
} }
/// <summary> /// <summary>
@@ -170,4 +237,6 @@ public sealed record EquipmentNamespaceContent(
IReadOnlyList<UnsArea> Areas, IReadOnlyList<UnsArea> Areas,
IReadOnlyList<UnsLine> Lines, IReadOnlyList<UnsLine> Lines,
IReadOnlyList<Equipment> Equipment, IReadOnlyList<Equipment> Equipment,
IReadOnlyList<Tag> Tags); IReadOnlyList<Tag> Tags,
IReadOnlyList<VirtualTag>? VirtualTags = null,
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);

View File

@@ -0,0 +1,74 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
/// <summary>
/// Base for every AB CIP CLI command. Carries the libplctag endpoint options
/// (<c>--gateway</c> + <c>--family</c>) and exposes <see cref="BuildOptions"/> so each
/// command can synthesise an <see cref="AbCipDriverOptions"/> from CLI flags + its own
/// tag list.
/// </summary>
public abstract class AbCipCommandBase : DriverCommandBase
{
[CommandOption("gateway", 'g', Description =
"Canonical AB CIP gateway: ab://host[:port]/cip-path. Port defaults to 44818 " +
"(EtherNet/IP). cip-path is family-specific: ControlLogix / CompactLogix need " +
"'1,0' to reach slot 0 of the CPU chassis; Micro800 takes an empty path; " +
"GuardLogix typically '1,0' same as ControlLogix.",
IsRequired = true)]
public string Gateway { get; init; } = default!;
[CommandOption("family", 'f', Description =
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 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 />
public override TimeSpan Timeout
{
get => TimeSpan.FromMilliseconds(TimeoutMs);
init { /* driven by TimeoutMs */ }
}
/// <summary>
/// Build an <see cref="AbCipDriverOptions"/> with the device + tag list a subclass
/// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the
/// probe loop would race the operator's own reads.
/// </summary>
protected AbCipDriverOptions BuildOptions(IReadOnlyList<AbCipTagDefinition> tags) => new()
{
Devices = [new AbCipDeviceOptions(
HostAddress: Gateway,
PlcFamily: Family,
DeviceName: $"cli-{Family}",
AddressingMode: AddressingMode)],
Tags = tags,
Timeout = Timeout,
Probe = new AbCipProbeOptions { Enabled = false },
EnableControllerBrowse = false,
EnableAlarmProjection = false,
};
/// <summary>
/// Short instance id used in Serilog output so operators running the CLI against
/// multiple gateways in parallel can distinguish the logs.
/// </summary>
protected string DriverInstanceId => $"abcip-cli-{Gateway}";
}

View File

@@ -0,0 +1,58 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
/// <summary>
/// Probes an AB CIP gateway: initialises the driver (connects via libplctag), reads a
/// single tag, and prints health + the read result. Fastest way to answer "is the PLC
/// up + reachable + speaking CIP via this path?".
/// </summary>
[Command("probe", Description = "Verify the AB CIP gateway is reachable and a sample tag reads.")]
public sealed class ProbeCommand : AbCipCommandBase
{
[CommandOption("tag", 't', Description =
"Tag path to probe. ControlLogix default is '@raw_cpu_type' (the canonical libplctag " +
"system tag); Micro800 takes a user-supplied global (e.g. '_SYSVA_CLOCK_HOUR').",
IsRequired = true)]
public string TagPath { get; init; } = default!;
[CommandOption("type", Description =
"Logix atomic type of the probe tag (default DInt).")]
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var probeTag = new AbCipTagDefinition(
Name: "__probe",
DeviceHostAddress: Gateway,
TagPath: TagPath,
DataType: DataType,
Writable: false);
var options = BuildOptions([probeTag]);
await using var driver = new AbCipDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
await console.Output.WriteLineAsync($"Family: {Family}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
}

View File

@@ -0,0 +1,60 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
/// <summary>
/// Read one Logix tag by symbolic path. Operator specifies <c>--tag</c> + <c>--type</c>;
/// the CLI synthesises a one-tag driver config, reads once, prints the snapshot, shuts
/// down. UDT / Structure reads are out of scope here — those need the member layout
/// declared, which belongs in a real driver config.
/// </summary>
[Command("read", Description = "Read a single Logix tag by symbolic path.")]
public sealed class ReadCommand : AbCipCommandBase
{
[CommandOption("tag", 't', Description =
"Logix symbolic path. Controller scope: 'Motor01_Speed'. Program scope: " +
"'Program:Main.Motor01_Speed'. Array element: 'Recipe[3]'. UDT member: " +
"'Motor01.Speed'.", IsRequired = true)]
public string TagPath { get; init; } = default!;
[CommandOption("type", Description =
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
"String / Dt / Structure (default DInt).")]
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(TagPath, DataType);
var tag = new AbCipTagDefinition(
Name: tagName,
DeviceHostAddress: Gateway,
TagPath: TagPath,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new AbCipDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>
/// Tag-name key the driver uses internally. The path + type pair is already unique
/// so we use them verbatim — keeps tag-level diagnostics readable without mangling.
/// </summary>
internal static string SynthesiseTagName(string tagPath, AbCipDataType type)
=> $"{tagPath}:{type}";
}

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,81 @@
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>
/// Watch a Logix tag via polled subscription until Ctrl+C. Uses the driver's
/// <c>ISubscribable</c> surface (PollGroupEngine under the hood). Prints each change
/// event with an HH:mm:ss.fff timestamp.
/// </summary>
[Command("subscribe", Description = "Watch a Logix tag via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : AbCipCommandBase
{
[CommandOption("tag", 't', Description =
"Logix symbolic path — same format as `read`.", IsRequired = true)]
public string TagPath { get; init; } = default!;
[CommandOption("type", Description =
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
"String / Dt (default DInt).")]
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
[CommandOption("interval-ms", 'i', Description =
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
"sub-250ms values.")]
public int IntervalMs { get; init; } = 1000;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
var tag = new AbCipTagDefinition(
Name: tagName,
DeviceHostAddress: Gateway,
TagPath: TagPath,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new AbCipDriver(options, DriverInstanceId);
ISubscriptionHandle? handle = null;
try
{
await driver.InitializeAsync("{}", ct);
driver.OnDataChange += (_, e) =>
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
console.Output.WriteLine(line);
};
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
await console.Output.WriteLineAsync(
$"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
try
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
}
catch (OperationCanceledException)
{
// Expected on Ctrl+C.
}
}
finally
{
if (handle is not null)
{
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
catch { /* teardown best-effort */ }
}
await driver.ShutdownAsync(CancellationToken.None);
}
}
}

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 @@
using System.Globalization;
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>
/// Write one value to a Logix tag by symbolic path. Mirrors <see cref="ReadCommand"/>'s
/// flag shape + adds <c>--value</c>. Value parsing respects <c>--type</c> so you can
/// write <c>--value 3.14 --type Real</c> without hex-encoding. GuardLogix safety tags
/// are refused at the driver level (they're forced to ViewOnly by PR 12).
/// </summary>
[Command("write", Description = "Write a single Logix tag by symbolic path.")]
public sealed class WriteCommand : AbCipCommandBase
{
[CommandOption("tag", 't', Description =
"Logix symbolic path — same format as `read`.", IsRequired = true)]
public string TagPath { get; init; } = default!;
[CommandOption("type", Description =
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
"String / Dt (default DInt).")]
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
IsRequired = true)]
public string Value { get; init; } = default!;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
if (DataType == AbCipDataType.Structure)
throw new CliFx.Exceptions.CommandException(
"Structure (UDT) writes need an explicit member layout — drop to the driver's " +
"config JSON for those. The CLI covers atomic types only.");
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
var tag = new AbCipTagDefinition(
Name: tagName,
DeviceHostAddress: Gateway,
TagPath: TagPath,
DataType: DataType,
Writable: true);
var options = BuildOptions([tag]);
var parsed = ParseValue(Value, DataType);
await using var driver = new AbCipDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(TagPath, results[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
/// for the declared <see cref="AbCipDataType"/>. Invariant culture everywhere.
/// </summary>
internal static object ParseValue(string raw, AbCipDataType type) => type switch
{
AbCipDataType.Bool => ParseBool(raw),
AbCipDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.DInt or AbCipDataType.Dt => int.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
AbCipDataType.String => raw,
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
};
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
{
"1" or "true" or "on" or "yes" => true,
"0" or "false" or "off" or "no" => false,
_ => throw new CliFx.Exceptions.CommandException(
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
};
}

View File

@@ -0,0 +1,11 @@
using CliFx;
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.SetExecutableName("otopcua-abcip-cli")
.SetDescription(
"OtOpcUa AB CIP test-client — ad-hoc probe + Logix symbolic reads/writes + polled " +
"subscriptions against ControlLogix / CompactLogix / Micro800 / GuardLogix families " +
"via libplctag. Second of four driver CLIs; mirrors otopcua-modbus-cli's shape.")
.Build()
.RunAsync(args);

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli</RootNamespace>
<AssemblyName>otopcua-abcip-cli</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.3.6"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests"/>
</ItemGroup>
</Project>

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

@@ -0,0 +1,189 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Static factory registration helper for <see cref="AbCipDriver"/>. Server's Program.cs
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
/// materialises AB CIP DriverInstance rows from the central config DB into live driver
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
/// </summary>
public static class AbCipDriverFactoryExtensions
{
public const string DriverTypeName = "AbCip";
public static void Register(DriverFactoryRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, CreateInstance);
}
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = JsonSerializer.Deserialize<AbCipDriverConfigDto>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"AB CIP driver config for '{driverInstanceId}' deserialised to null");
var options = new AbCipDriverOptions
{
Devices = dto.Devices is { Count: > 0 }
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
fallback: AbCipPlcFamily.ControlLogix),
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 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
: [],
Probe = new AbCipProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
ProbeTagPath = dto.Probe?.ProbeTagPath,
},
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
};
return new AbCipDriver(options, driverInstanceId);
}
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
new(
Name: t.Name ?? throw new InvalidOperationException(
$"AB CIP config for '{driverInstanceId}' has a tag missing Name"),
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
TagPath: t.TagPath ?? throw new InvalidOperationException(
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing TagPath"),
DataType: ParseEnum<AbCipDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false,
Members: t.Members is { Count: > 0 }
? [.. t.Members.Select(m => new AbCipStructureMember(
Name: m.Name ?? throw new InvalidOperationException(
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' has a member missing Name"),
DataType: ParseEnum<AbCipDataType>(m.DataType, t.Name, driverInstanceId,
$"Members[{m.Name}].DataType"),
Writable: m.Writable ?? true,
WriteIdempotent: m.WriteIdempotent ?? false))]
: null,
SafetyTag: t.SafetyTag ?? false);
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
T? fallback = null) where T : struct, Enum
{
if (string.IsNullOrWhiteSpace(raw))
{
if (fallback.HasValue) return fallback.Value;
throw new InvalidOperationException(
$"AB CIP tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
? v
: throw new InvalidOperationException(
$"AB CIP tag '{tagName}' has unknown {field} '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
internal sealed class AbCipDriverConfigDto
{
public int? TimeoutMs { get; init; }
public bool? EnableControllerBrowse { get; init; }
public bool? EnableAlarmProjection { get; init; }
public int? AlarmPollIntervalMs { get; init; }
public List<AbCipDeviceDto>? Devices { get; init; }
public List<AbCipTagDto>? Tags { get; init; }
public AbCipProbeDto? Probe { get; init; }
}
internal sealed class AbCipDeviceDto
{
public string? HostAddress { get; init; }
public string? PlcFamily { 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
{
public string? Name { get; init; }
public string? DeviceHostAddress { get; init; }
public string? TagPath { get; init; }
public string? DataType { get; init; }
public bool? Writable { get; init; }
public bool? WriteIdempotent { get; init; }
public List<AbCipMemberDto>? Members { get; init; }
public bool? SafetyTag { get; init; }
}
internal sealed class AbCipMemberDto
{
public string? Name { get; init; }
public string? DataType { get; init; }
public bool? Writable { get; init; }
public bool? WriteIdempotent { get; init; }
}
internal sealed class AbCipProbeDto
{
public bool? Enabled { get; init; }
public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; }
public string? ProbeTagPath { get; init; }
}
}

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.");

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