Compare commits

...

22 Commits

Author SHA1 Message Date
Joseph Doherty
d06cc01a48 Admin /hosts red-badge + resilience columns + Polly telemetry observer. Closes task #164 (the remaining slice of Phase 6.1 Stream E.3 after the earlier publisher + hub PR). Three cooperating pieces wired together so the operator-facing /hosts table actually reflects the live Polly counters that the pipeline builder is producing. DriverResiliencePipelineBuilder gains an optional DriverResilienceStatusTracker ctor param — when non-null, every built pipeline wires Polly's OnRetry/OnOpened/OnClosed strategy-options callbacks into the tracker. OnRetry → tracker.RecordFailure (so ConsecutiveFailures climbs per retry), OnOpened → tracker.RecordBreakerOpen (stamps LastCircuitBreakerOpenUtc), OnClosed → tracker.RecordSuccess (resets the failure counter once the target recovers). Absent tracker = silent, preserving the unit-test constructor path + any deployment that doesn't care about resilience observability. Cancellation stays excluded from the failure count via the existing ShouldHandle predicate. HostStatusService.HostStatusRow extends with four new fields — ConsecutiveFailures, LastCircuitBreakerOpenUtc, CurrentBulkheadDepth, LastRecycleUtc — populated via a second LEFT JOIN onto DriverInstanceResilienceStatuses keyed on (DriverInstanceId, HostName). LEFT JOIN because brand-new hosts haven't been sampled yet; a missing row means zero failures + never-opened breaker, which is the correct default. New FailureFlagThreshold constant (=3, matches plan decision #143's conservative half-of-breaker convention) + IsFlagged predicate so the UI can pre-warn before the breaker actually trips. Hosts.razor paints three new columns between State and Last-transition — Fail# (bold red when flagged), In-flight (bulkhead-depth proxy), Breaker-opened (relative age). Per-row "Flagged" red badge alongside State when IsFlagged is true. Above the first cluster table, a red alert banner summarises the flagged-host count when ≥1 host is flagged, so operators see the problem before scanning rows. Three new tests in DriverResiliencePipelineBuilderTests — Tracker_RecordsFailure_OnEveryRetry verifies ConsecutiveFailures reaches RetryCount after a transient-forever operation, Tracker_StampsBreakerOpen_WhenBreakerTrips verifies LastBreakerOpenUtc is set after threshold failures on a Write pipeline, Tracker_IsolatesCounters_PerHost verifies one dead host does not leak failure counts into a healthy sibling. Full suite — Core.Tests 14/14 resilience-builder tests passing (11 existing + 3 new), Admin.Tests 72/72 passing, Admin project builds 0 errors. SignalR live push of status changes + browser visual review are deliberately left to a follow-up — this PR keeps the structural change minimal (polling refresh already exists in the page's 10s timer; SignalR would be a structural add that touches hub registration + client subscription).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:35:54 -04:00
5536e96b46 Merge pull request (#131) - AbCip UDT Template reader 2026-04-19 21:23:34 -04:00
Joseph Doherty
ece530d133 AB CIP UDT Template Object shape reader. Closes the shape-reader half of task #179. CipTemplateObjectDecoder (pure-managed) parses the Read Template blob per Rockwell CIP Vol 1 + libplctag ab/cip.c handle_read_template_reply — 12-byte header (u16 member_count + u16 struct_handle + u32 instance_size + u32 member_def_size) followed by memberCount × 8-byte member blocks (u16 info with bit-15 struct flag + lower-12-bit type code matching the Symbol Object encoding, u16 array_size, u32 struct_offset) followed by semicolon-terminated strings (UDT name first, then one per member). ParseSemicolonTerminatedStrings handles the observed firmware variations — name;\0 vs name; delimiters, optional null/space padding after the semicolon, trailing-name-without-semicolon corner case. Struct-flag members decode as AbCipDataType.Structure; unknown atomic codes fall back to Structure so the shape remains valid even with unrecognised members. Zero member count + short buffer both return null; missing member names yield <member_N> placeholders. IAbCipTemplateReader + IAbCipTemplateReaderFactory abstraction — one call per template instance id returning the raw blob. LibplctagTemplateReader is the production implementation creating a libplctag Tag with name @udt/{templateId} + handing the buffer to the decoder. AbCipDriver ctor gains optional templateReaderFactory parameter (defaults to LibplctagTemplateReaderFactory) + new internal FetchUdtShapeAsync that — checks AbCipTemplateCache first, misses call the reader + decode + cache, template-read exceptions + decode failures return null so callers can fall back to declaration-driven fan-out without the whole discovery blowing up. OperationCanceledException rethrows for shutdown propagation. Unknown device host returns null without attempting a fetch. FlushOptionalCachesAsync empties the cache so a subsequent fetch re-reads. 16 new decoder tests — simple two-member UDT, struct-member flag → Structure, array member ArrayLength, 6-member mixed-type with correct offsets, unknown type code → Structure, zero member count → null, short buffer → null, missing member name → placeholder, ParseSemicolonTerminatedStrings theory across 5 shapes. 6 new AbCipFetchUdtShapeTests exercising the driver integration via reflection (method is internal) — happy-path decode + cache, different template ids get separate fetches, unknown device → null without reader creation, decode failure returns null + doesn't cache (next call retries), reader exception returns null, FlushOptionalCachesAsync clears the cache. Total AbCip unit tests now 211/211 passing (+19 from the @tags merge's 192); full solution builds 0 errors; other drivers untouched. Whole-UDT read optimization (single libplctag call returning the packed buffer + client-side member decode using the template offsets) is left as a follow-up — requires rethinking the per-tag read path + careful hardware validation; current per-member fan-out still works correctly, just with N round-trips instead of 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:21:42 -04:00
b55cef5f8b Merge pull request (#130) - AbCip @tags walker 2026-04-19 21:15:16 -04:00
Joseph Doherty
088c4817fe AB CIP @tags walker — CIP Symbol Object decoder + LibplctagTagEnumerator. Closes task #178. CipSymbolObjectDecoder (pure-managed, no libplctag dep) parses the raw Symbol Object (class 0x6B) blob returned by reading the @tags pseudo-tag into an enumerable sequence of AbCipDiscoveredTag records. Entry layout per Rockwell CIP Vol 1 + Logix 5000 CIP Programming Manual 1756-PM019, cross-checked against libplctag's ab/cip.c handle_listed_tags_reply — u32 instance-id + u16 symbol-type + u16 element-length + 3×u32 array-dims + u16 name-length + name[len] + even-pad. Symbol-type lower 12 bits carry the CIP type code (0xC1 BOOL, 0xC2 SINT, …, 0xD0 STRING), bit 12 is the system-tag flag, bit 15 is the struct flag (when set lower 12 bits become the template instance id). Truncated tails stop decoding gracefully — caller keeps whatever parsed cleanly rather than getting an exception mid-walk. Program:-scope names (Program:MainProgram.StepIndex) are split via SplitProgramScope so the enumerator surfaces scope + simple name separately. 12 atomic type codes mapped (BOOL/SINT/INT/DINT/LINT/USINT/UINT/UDINT/ULINT/REAL/LREAL/STRING + DT/DATE_AND_TIME under Dt); unknown codes return null so the caller treats them as opaque Structure. LibplctagTagEnumerator is the real production walker — creates a libplctag Tag with name=@tags against the device's gateway/port/path, InitializeAsync + ReadAsync + GetBuffer, hands bytes to the decoder. Factory LibplctagTagEnumeratorFactory replaces EmptyAbCipTagEnumeratorFactory as the AbCipDriver default. AbCipDriverOptions gains EnableControllerBrowse (default false) matching the TwinCAT pattern — keeps the strict-config path for deployments where only declared tags should appear. When true, DiscoverAsync walks each device's @tags + emits surviving symbols under Discovered/ sub-folder. System-tag filter (AbCipSystemTagFilter shipped in PR 5) runs alongside the wire-layer system-flag hint. Tests — 18 new CipSymbolObjectDecoderTests with crafted byte arrays matching the documented layout — single-entry DInt, theory across 12 atomic type codes, unknown→null, struct flag override, system flag surface, Program:-scope split, multi-entry wire-order with even-pad, truncated-buffer graceful stop, empty buffer, SplitProgramScope theory across 6 shapes. 4 pre-existing AbCipDriverDiscoveryTests that tested controller-enumeration behavior updated with EnableControllerBrowse=true so they continue exercising the walker path (behavior unchanged from their perspective). Total AbCip unit tests now 192/192 passing (+26 from the RMW merge's 166); full solution builds 0 errors; other drivers untouched. Field validation note — the decoder layout matches published Rockwell docs + libplctag C source, but actual @tags responses vary slightly by controller firmware (some ship an older entry format with u16 array dims instead of u32). Any layout drift surfaces as gibberish names in the Discovered/ folder; field testing will flag that for a decoder patch if it occurs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:13:20 -04:00
91e6153b5d Merge pull request (#129) - Bit RMW pass 2 (AbCip+AbLegacy) 2026-04-19 20:36:21 -04:00
Joseph Doherty
00a428c444 RMW pass 2 — AbCip BOOL-within-DINT + AbLegacy bit-within-word. Closes task #181. AbCip — AbCipDriver.WriteAsync now detects BOOL writes with a bit index + routes them through WriteBitInDIntAsync: strip the .N suffix to form the parent DINT tag path (via AbCipTagPath with BitIndex=null + ToLibplctagName), get/create a cached parent IAbCipTagRuntime via EnsureParentRuntimeAsync (distinct from the bit-selector tag runtime so read + write target the DINT directly), acquire a per-parent-name SemaphoreSlim, Read → Convert.ToInt32 the current DINT → (current | 1<<bit) or (current & ~(1<<bit)) → Write via EncodeValue(DInt, updated). Per-parent lock prevents concurrent writers to the same DINT from losing updates — parallels Modbus + FOCAS pass 1. DeviceState gains ParentRuntimes dict + GetRmwLock helper + _rmwLocks ConcurrentDictionary. DisposeHandles now walks ParentRuntimes too. LibplctagTagRuntime.EncodeValue's BOOL-with-bitIndex branch stays as a defensive throw (message updated to point at the new driver-level dispatch) so an accidental bypass fails loudly rather than silently clobbering the whole DINT. AbLegacy — identical pattern for PCCC N-file bit writes. AbLegacyDriver.WriteAsync detects Bit with bitIndex + PMC letter not in {B, I, O} (B-file + I/O use their own bit-addressable semantics so don't RMW at N-file word level), routes through WriteBitInWordAsync which uses Int16 for the parent word, creates + caches a parent runtime with the suffix-stripped N7:0 address, acquires per-parent lock, RMW. DeviceState extended the same way as AbCip (ParentRuntimes + GetRmwLock). LibplctagLegacyTagRuntime.EncodeValue Bit-with-bitIndex branch points at the driver dispatch. Tests — 5 new AbCipBoolInDIntRmwTests (bit set ORs + preserves, bit clear ANDs + preserves, 8-way concurrent writes to same parent compose to 0xFF, different-parent writes get separate runtimes, repeat bit writes reuse the parent runtime init-count 1 + write-count 2), 4 new AbLegacyBitRmwTests (bit set preserves, bit clear preserves 0xFFF7, 8-way concurrent 0xFF, repeat writes reuse parent). Two pre-existing tests flipped — AbCipDriverWriteTests.Bit_in_dint_write_returns_BadNotSupported + AbLegacyReadWriteTests.Bit_within_word_write_rejected_as_BadNotSupported both now assert Good instead of BadNotSupported, renamed to _now_succeeds_via_RMW. Total tests — AbCip 166/166, AbLegacy 96/96, full solution builds 0 errors; Modbus + FOCAS + TwinCAT + other drivers untouched. Task #181 done across all four libplctag-backed + non-libplctag drivers (Modbus BitInRegister + AbCip BOOL-in-DINT + AbLegacy N-file bit + FOCAS PMC Bit — all with per-parent-word serialisation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:34:29 -04:00
07fd105ffc Merge pull request (#128) - Bit RMW pass 1 (Modbus+FOCAS) 2026-04-19 20:27:17 -04:00
Joseph Doherty
8c309aebf3 RMW pass 1 — Modbus BitInRegister + FOCAS PMC Bit write paths. First half of task #181 — the two drivers where read-modify-write is a clean protocol-level insertion (Modbus FC03/FC06 round-trip + FOCAS pmc_rdpmcrng / pmc_wrpmcrng round-trip). Per-driver SemaphoreSlim registry keyed on the parent word address serialises concurrent bit writes so two writers targeting different bits in the same word don't lose one another's update. Modbus — ModbusDriver gains WriteBitInRegisterAsync + _rmwLocks ConcurrentDictionary. WriteOneAsync routes BitInRegister (HoldingRegisters region only) through RMW ahead of the normal encode path. Read uses FC03 Read Holding Registers for 1 register at tag.Address, bit-op on the returned ushort via (current | 1<<bit) for set / (current & ~(1<<bit)) for clear, write back via FC06 Write Single Register. Per-address lock prevents concurrent bit writes to the same register from racing. Rejects out-of-range bits (0-15) with InvalidOperationException. EncodeRegister's BitInRegister branch repurposed as a defensive guard — if a non-RMW caller ever reaches it, throw so an unintended bypass stays loud rather than silently clobbering. FOCAS — FwlibFocasClient gains WritePmcBitAsync + _rmwLocks keyed on {addrType}:{byteAddr}. Driver-layer WriteAsync routes Bit writes with a bitIndex through the new path; other Pmc writes still hit the direct pmc_wrpmcrng path. RMW uses cnc_rdpmcrng + Byte dataType to grab the parent byte, bit-op with (current | 1<<bit) or (current & ~(1<<bit)), cnc_wrpmcrng to write back. Rejects out-of-range bits (0-7, FOCAS PMC bytes are 8-bit) with InvalidOperationException. EncodePmcValue's Bit branch now treats a no-bitIndex case as whole-byte boolean (non-zero / zero); bitIndex-present writes never hit this path because they dispatch to WritePmcBitAsync upstream. Tests — 5 new ModbusBitRmwTests + 4 new FocasPmcBitRmwTests + 1 renamed pre-existing test each covering — bit set preserves other bits, bit clear preserves other bits, concurrent bit writes to same word/byte compose correctly (8-parallel stress), bit writes on different parent words proceed without contention (4-parallel), sequential bit sets compose into 0xFF after all 8. Fake PmcRmwFake in FOCAS tests simulates the PMC byte storage + surfaces it through the IFocasClient contract so the test asserts driver-level behavior without needing Fwlib32.dll. FwlibNativeHelperTests.EncodePmcValue_Bit_throws_NotSupported_for_RMW_gap replaced with EncodePmcValue_Bit_without_bit_index_writes_byte_boolean reflecting the new behavior. ModbusDataTypeTests.BitInRegister_write_is_not_supported_in_PR24 renamed to BitInRegister_EncodeRegister_still_rejects_direct_calls; the message assertion updated to match the new defensive message. Modbus tests now 182/182, FOCAS tests now 119/119; full solution builds 0 errors; AbCip/AbLegacy/TwinCAT untouched (those get their RMW pass in a follow-up since libplctag bit access may need a parallel parent-word handle). Task #181 stays pending until that second pass lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:25:27 -04:00
d1ca0817e9 Merge pull request (#127) - TwinCAT symbol browser 2026-04-19 20:15:25 -04:00
Joseph Doherty
c95228391d TwinCAT follow-up — Symbol browser via AdsClient + SymbolLoaderFactory. Closes task #188. Adds ITwinCATClient.BrowseSymbolsAsync — IAsyncEnumerable yielding TwinCATDiscoveredSymbol (InstancePath + mapped TwinCATDataType + ReadOnly flag) from the target's flat symbol table. AdsTwinCATClient implementation uses SymbolLoaderFactory.Create(_client, new SymbolLoaderSettings(SymbolsLoadMode.Flat)) + iterates loader.Symbols, maps IEC 61131-3 type names (BOOL/SINT/INT/DINT/LINT/REAL/LREAL/STRING/WSTRING/TIME/DATE/DT/TOD + BYTE/WORD/DWORD/LWORD unsigned-word aliases) through MapSymbolTypeName, checks SymbolAccessRights.Write bit for writable vs read-only. Unsupported types (UDTs / function blocks / arrays / pointers) surface with DataType=null so callers can skip or recurse. TwinCATDriverOptions.EnableControllerBrowse — new bool, default false to preserve the strict-config path. When true, DiscoverAsync iterates each device's BrowseSymbolsAsync, filters via TwinCATSystemSymbolFilter (rejects TwinCAT_*, Constants.*, Mc_*, __*, Global_Version* prefixes + anything empty), skips null-DataType symbols, emits surviving symbols under a per-device Discovered/ sub-folder with InstancePath as both FullName + BrowseName + ReadOnly→ViewOnly/writable→Operate. Pre-declared tags from TwinCATDriverOptions.Tags always emit regardless. Browse failure is non-fatal — exception caught + swallowed, pre-declared tags stay in the address space, operators see the failure in driver health on next read. TwinCATSystemSymbolFilter static class mirrors AbCipSystemTagFilter's shape with TwinCAT-specific prefixes. Fake client updated — BrowseResults list for test setup + FireNotification-style single-invocation on each subscribe, ThrowOnBrowse flag for failure testing. 8 new unit tests — strict path emits only pre-declared when EnableControllerBrowse=false, browse enabled adds Discovered/ folder, filter rejects system prefixes, null-DataType symbols skipped, ReadOnly symbols surface ViewOnly, browse failure leaves pre-declared intact, SystemSymbolFilter theory (10 cases). Total TwinCAT unit tests now 110/110 passing (+17 from the native-notification merge's 93); full solution builds 0 errors; other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:13:33 -04:00
9ca80fd450 Merge pull request (#126) - FOCAS capabilities 2026-04-19 20:01:28 -04:00
Joseph Doherty
1d6015bc87 FOCAS PR 3 — ITagDiscovery + ISubscribable + IHostConnectivityProbe + IPerCallHostResolver. Completes the FOCAS driver — 7-interface capability set matching AbCip/AbLegacy/TwinCAT (minus IAlarmSource — Fanuc CNC alarms live in a different API surface, tracked as a future-phase concern). ITagDiscovery emits pre-declared tags under a FOCAS root + per-device sub-folder keyed on the canonical focas://host:port string with DeviceName fallback. Writable → Operate, non-writable → ViewOnly. No native FOCAS symbol browsing — CNCs don't expose a tag catalogue the way Logix or TwinCAT do; operators declare addresses explicitly. ISubscribable consumes the shared PollGroupEngine — 5th consumer of the engine after Modbus + AbCip + AbLegacy + TwinCAT-poll-mode. 100ms interval floor inherited. FOCAS has no native notification/subscription protocol (unlike TwinCAT ADS), so polling is the only option — every subscribed tag round-trips through cnc_rdpmcrng / cnc_rdparam / cnc_rdmacro on each tick. IHostConnectivityProbe uses the existing IFocasClient.ProbeAsync which in the real FwlibFocasClient calls cnc_statinfo (cheap handshake returning ODBST with tmmode/aut/run/motion/alarm state). Probe loop runs when Enabled=true, catches OperationCanceledException during shutdown, falls through to Stopped on exceptions, emits Running/Stopped transitions via OnHostStatusChanged with the canonical focas://host:port as the host-name key. Same-state spurious-event guard under per-device lock. IPerCallHostResolver maps tag full-ref to DeviceHostAddress for Phase 6.1 bulkhead/breaker keying per plan decision #144 — unknown refs fall back to first device, no devices → DriverInstanceId. ShutdownAsync now disposes PollGroupEngine + cancels/disposes per-device probe CTS + disposes cached clients. DeviceState gains ProbeLock / HostState / HostStateChangedUtc / ProbeCts matching the shape used by AbCip/AbLegacy/TwinCAT. 9 new unit tests in FocasCapabilityTests — discovery tag emission with correct SecurityClassification, subscription initial poll raises OnDataChange, shutdown cancels subscriptions, GetHostStatuses entry-per-device, probe Running / Stopped transitions, ResolveHost for known / unknown / no-devices paths. FocasScaffoldingTests updated with Probe.Enabled=false where the default factory would otherwise try to load Fwlib32.dll during the probe-loop spinup. Total FOCAS unit tests now 115/115 passing (+9 from PR 2's 106); full solution builds 0 errors; Modbus / AbCip / AbLegacy / TwinCAT / other drivers untouched. FOCAS driver is real-wire-capable end-to-end — read / write / discover / subscribe / probe / host-resolve for Fanuc FS 0i/16i/18i/21i/30i/31i/32i/Series 35i/Power Mate i controllers once deployment drops Fwlib32.dll beside the server. Closes task #120 subtask FOCAS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:59:37 -04:00
5cfb0fc6d0 Merge pull request (#125) - FOCAS R/W + real P/Invoke 2026-04-19 19:57:31 -04:00
Joseph Doherty
a2c7fda5f5 FOCAS PR 2 — IReadable + IWritable + real FwlibFocasClient P/Invoke. Closes task #193 early now that strangesast/fwlib provides the licensed DLL references. Skips shipping with the Unimplemented stub as the default — FwlibFocasClientFactory is now the production default, UnimplementedFocasClientFactory stays as an opt-in for tests/deployments without FWLIB access. FwlibNative — narrow P/Invoke surface for the 7 calls the driver actually makes: cnc_allclibhndl3 (open Ethernet handle), cnc_freelibhndl (close), pmc_rdpmcrng + pmc_wrpmcrng (PMC range I/O), cnc_rdparam + cnc_wrparam (CNC parameters), cnc_rdmacro + cnc_wrmacro (macro variables), cnc_statinfo (probe). DllImport targets Fwlib32.dll; deployment places it next to the executable or on PATH. IODBPMC/IODBPSD/ODBM/ODBST marshaled with LayoutKind.Sequential + Pack=1 + fixed byte-array unions (avoids LayoutKind.Explicit complexity; managed-side BitConverter extracts typed values from the byte buffer). Internal helpers FocasPmcAddrType.FromLetter (G=0/F=1/Y=2/X=3/A=4/R=5/T=6/K=7/C=8/D=9/E=10 per Fanuc FOCAS/2 spec) + FocasPmcDataType.FromFocasDataType (Byte=0 / Word=1 / Long=2 / Float=4 / Double=5) exposed for testing without the DLL loaded. FwlibFocasClient is the concrete IFocasClient backed by P/Invoke. Construction is licence-safe — .NET P/Invoke is lazy so instantiating the class does NOT load Fwlib32.dll; DLL loads on first wire call (Connect/Read/Write/Probe). When missing, calls throw DllNotFoundException which the driver surfaces as BadCommunicationError via the normal exception path. Session-scoped handle from cnc_allclibhndl3; Dispose calls cnc_freelibhndl. Dispatch on FocasAreaKind — Pmc reads use pmc_rdpmcrng with the right ADR_* + data-type codes + parses the union via BinaryPrimitives LittleEndian, Parameter reads use cnc_rdparam + IODBPSD, Macro reads use cnc_rdmacro + compute scaled double as McrVal / 10^DecVal. Write paths mirror reads. PMC Bit writes throw NotSupportedException pointing at task #181 (read-modify-write gap — same as Modbus / AbCip / AbLegacy / TwinCAT). Macro writes accept int + pass decimal-point count 0 (decimal precision writes are a future enhancement). Probe calls cnc_statinfo with ODBST result. Driver wiring — FocasDriver now IDriver + IReadable + IWritable. Per-device connection caching via EnsureConnectedAsync + DeviceState.Client. ReadAsync/WriteAsync dispatch through the injected IFocasClient — ordered snapshots preserve per-tag status, OperationCanceledException rethrows, FormatException/InvalidCastException → BadTypeMismatch, OverflowException → BadOutOfRange, NotSupportedException → BadNotSupported, anything else → BadCommunicationError + Degraded health. Connect-failure disposes the half-open client. ShutdownAsync disposes every cached client. Default factory switched — constructor now defaults to FwlibFocasClientFactory (backed by real Fwlib32.dll) rather than UnimplementedFocasClientFactory. UnimplementedFocasClientFactory stays as an opt-in. 41 new tests — 14 in FocasReadWriteTests (ordered unknown-ref handling, successful PMC/Parameter/Macro reads routing through correct FocasAreaKind, repeat-read reuses connection, FOCAS error mapping, exception paths, batched order across areas, non-writable rejection, successful write logging, status mapping, batch ordering, cancellation, shutdown disposes), 27 in FwlibNativeHelperTests (12 letter-mapping cases + 3 unknown rejections + 6 data-type mapping + 4 encode helpers + Bit-write NotSupported). Total FOCAS unit tests now 106/106 passing (+41 from PR 1's 65); full solution builds 0 errors; Modbus / AbCip / AbLegacy / TwinCAT / other drivers untouched. FOCAS driver is real-wire-capable from day one — deployment drops Fwlib32.dll beside the server + driver talks to live FS 0i/16i/18i/21i/30i/31i/32i controllers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:55:37 -04:00
c13fe8f587 Merge pull request (#124) - FOCAS scaffolding 2026-04-19 19:49:47 -04:00
Joseph Doherty
285799a954 FOCAS PR 1 — Scaffolding + Core (FocasDriver skeleton + address parser + stub client). New Driver.FOCAS project for Fanuc CNC controllers (FS 0i/16i/18i/21i/30i/31i/32i/Series 35i/Power Mate i) talking via the Fanuc FOCAS/2 protocol. No NuGet reference to a FOCAS library — FWLIB (Fwlib32.dll) is Fanuc-proprietary + per-customer licensed + cannot be legally redistributed, so the driver is designed from the start to accept an IFocasClient supplied by the deployment side. Default IFocasClientFactory is UnimplementedFocasClientFactory which throws with a clear deployment-docs pointer at Create time so misconfigured servers fail fast rather than mysteriously hanging. Matches the pattern other drivers use for swappable wire layers (Modbus IModbusTransport, AbCip IAbCipTagFactory, TwinCAT ITwinCATClientFactory) — but uniquely, FOCAS ships without a production factory because of licensing. FocasHostAddress parses focas://{host}[:{port}] canonical form with default port 8193 (Fanuc-reserved FOCAS Ethernet port). Default-port stripping on ToString for roundtrip stability. Case-insensitive scheme. Rejects wrong scheme, empty body, invalid port, non-numeric port. FocasAddress handles the three addressing spaces a FOCAS driver touches — PMC (letter + byte + optional bit, X/Y for IO, F/G for PMC-CNC signals, R for internal relay, D for data table, C for counter, K for keep relay, A for message display, E for extended relay, T for timer, with .N bit syntax 0-7), CNC parameters (PARAM:n for a parameter number, PARAM:n/N for bit 0-31 of a parameter), macro variables (MACRO:n). Rejects unknown PMC letters, negative numbers, out-of-range bits (PMC 0-7, parameter 0-31), non-numeric fragments. FocasDataType — Bit / Byte / Int16 / Int32 / Float32 / Float64 / String covering the atomic types PMC reads + CNC parameters + macro variables return. ToDriverDataType widens to the Int32/Float32/Float64/Boolean/String surface. FocasStatusMapper covers the FWLIB EW_* return-code family documented in the FOCAS/1 + FOCAS/2 references — EW_OK=0, EW_FUNC=1 → BadNotSupported, EW_OVRFLOW=2/EW_NUMBER=3/EW_LENGTH=4 → BadOutOfRange, EW_PROT=5/EW_PASSWD=11 → BadNotWritable, EW_NOOPT=6/EW_VERSION=-9 → BadNotSupported, EW_ATTRIB=7 → BadTypeMismatch, EW_DATA=8 → BadNodeIdUnknown, EW_PARITY=9 → BadCommunicationError, EW_BUSY=-1 → BadDeviceFailure, EW_HANDLE=-8 → BadInternalError, EW_UNEXP=-10/EW_SOCKET=-16 → BadCommunicationError. IFocasClient + IFocasClientFactory abstraction — ConnectAsync, IsConnected, ReadAsync returning (value, status) tuple, WriteAsync returning status, ProbeAsync for IHostConnectivityProbe. Deployment supplies the real factory; driver assembly stays licence-clean. FocasDriverOptions + FocasDeviceOptions + FocasTagDefinition + FocasProbeOptions — one instance supports N CNCs, tags cross-key by HostAddress + use canonical FocasAddress strings. FocasDriver implements IDriver only (PRs 2-3 add read/write/discover/subscribe/probe/resolver). InitializeAsync parses each device HostAddress + fails fast on malformed strings → Faulted health. 65 new unit tests in FocasScaffoldingTests covering — 5 valid host forms + 8 invalid + default-port-strip ToString, 12 valid PMC addresses across all 11 canonical letters + 3 parameter forms with + without bit + 2 macro forms, 10 invalid address shapes, canonical roundtrip theory, data-type mapping theory, FWLIB EW_* status mapping theory (9 codes + unknown → generic), DriverType, multi-device Initialize + address parsing, malformed-address fault, shutdown, default factory throws NotSupportedException with deployment pointer + Fwlib32.dll mention. Total project count 31 src + 20 tests; full solution builds 0 errors. Other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:47:52 -04:00
9da578d5a5 Merge pull request (#123) - TwinCAT native notifications 2026-04-19 18:51:39 -04:00
Joseph Doherty
6c5b202910 TwinCAT follow-up — Native ADS notifications for ISubscribable. Closes task #189 — upgrades TwinCATDriver's subscription path from polling (shared PollGroupEngine) to native AdsClient.AddDeviceNotificationExAsync so the PLC pushes changes on its own cycle rather than the driver polling. Strictly better for latency + CPU — TC2 and TC3 runtimes notify on value change with sub-millisecond latency from the PLC cycle. ITwinCATClient gains AddNotificationAsync — takes symbolPath + TwinCATDataType + optional bitIndex + cycleTime + onChange callback + CancellationToken; returns an ITwinCATNotificationHandle whose Dispose tears the notification down on the wire. Bit-within-word reads supported — the parent word value arrives via the notification, driver extracts the bit before invoking the callback (same ExtractBit path as the read surface from PR 2). AdsTwinCATClient — subscribes to AdsClient.AdsNotificationEx in the ctor, maintains a ConcurrentDictionary<uint, NotificationRegistration> keyed on the server-side notification handle. AddDeviceNotificationExAsync returns Task<ResultHandle> with Handle + ErrorCode; non-NoError throws InvalidOperationException so the driver can catch + retry. Notification event args carry Handle + Value + DataType; lookup in _notifications dict routes the value through any bit-extraction + calls the consumer callback. Consumer-side exceptions are swallowed so a misbehaving callback can't crash the ADS notification thread. Dispose unsubscribes from AdsNotificationEx + clears the dict + disposes AdsClient. NotificationRegistration is ITwinCATNotificationHandle — Dispose fires DeleteDeviceNotificationAsync as fire-and-forget with CancellationToken.None (caller has already committed to teardown; blocking would slow shutdown). TwinCATDriverOptions.UseNativeNotifications — new bool, default true. When true the driver uses native notifications; when false it falls through to the shared PollGroupEngine (same semantics as other libplctag-backed drivers, also a safety valve for targets with notification limits). TwinCATDriver.SubscribeAsync dual-path — if UseNativeNotifications false delegate into _poll.Subscribe (unchanged behavior from PR 3). If true, iterate fullReferences, resolve each to its device's client via EnsureConnectedAsync (reuses PR 2's per-device connection cache), parse the SymbolPath via TwinCATSymbolPath (preserves bit-in-word support), call ITwinCATClient.AddNotificationAsync with a closure over the FullReference (not the ADS symbol — OPC UA subscribers addressed the driver-side name). Per-registration callback bridges (_, value) → OnDataChange event with a fresh DataValueSnapshot (Good status, current UtcNow timestamps). Any mid-registration failure triggers a try/catch that disposes every already-registered handle before rethrowing, keeping the driver in a clean never-existed state rather than half-registered. UnsubscribeAsync dispatches on handle type — NativeSubscriptionHandle disposes all its cached ITwinCATNotificationHandles; anything else delegates to _poll.Unsubscribe for the poll fallback. ShutdownAsync tears down native subs first (so AdsClient-level cleanup happens before the client itself disposes), then PollGroupEngine, then per-device probe CTS + client. NativeSubscriptionHandle DiagnosticId prefixes with twincat-native-sub- so Admin UI + logs can distinguish the paths. 9 new unit tests in TwinCATNativeNotificationTests — native subscribe registers one notification per tag, pushed value via FireNotification fires OnDataChange with the right FullReference (driver-side, not ADS symbol), unsubscribe disposes all notifications, unsubscribe halts future notifications, partial-failure cleanup via FailAfterNAddsFake (first succeeds, second throws → first gets torn down + Notifications count returns to 0 + AddCallCount=2 proving the test actually exercised both calls), shutdown disposes subscriptions, poll fallback works when UseNativeNotifications=false (no native handles created + initial-data push still fires), handle DiagnosticId distinguishes native vs poll. Existing poll-mode ISubscribable tests in TwinCATCapabilityTests updated with UseNativeNotifications=false so they continue testing the poll path specifically — both poll + native paths have test coverage now. TwinCATDriverTests got Probe.Enabled=false added because the default factory creates a real AdsClient which was flakily affected by parallel test execution sharing AMS router state. Total TwinCAT unit tests now 93/93 passing (+8 from PR 3's 85 counting the new native tests + 2 existing tests that got options tweaks). Full solution builds 0 errors; Modbus / AbCip / AbLegacy / other drivers untouched. TwinCAT driver is now feature-complete end-to-end — read / write / discover / native-subscribe / probe / host-resolve, with poll-mode as a safety valve. Unblocks closing task #120 for TwinCAT; remaining sub-task: FOCAS + task #188 (symbol-browsing — lower priority than FOCAS since real config flows still use pre-declared tags).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:49:48 -04:00
a0112ddb43 Merge pull request (#122) - TwinCAT capabilities 2026-04-19 18:38:44 -04:00
Joseph Doherty
aeb28cc8e7 TwinCAT PR 3 — ITagDiscovery + ISubscribable + IHostConnectivityProbe + IPerCallHostResolver. Completes the TwinCAT driver — 7-interface capability set matching AbCip / AbLegacy (minus IAlarmSource, same deferral). ITagDiscovery emits pre-declared tags under TwinCAT/device-host folder with DeviceName fallback to HostAddress; Writable→Operate / non-writable→ViewOnly. Symbol-browsing via AdsClient.ReadSymbolsAsync / ReadSymbolInfoAsync deferred to a follow-up (same shape as the @tags deferral for AbCip — needs careful traversal of the TwinCAT symbol table + type graph which the ReadSymbolsAsync API does expose but adds enough scope to warrant its own PR). ISubscribable consumes the shared PollGroupEngine — 4th consumer after Modbus + AbCip + AbLegacy. TwinCAT supports native ADS notifications (AddDeviceNotification) which would be strictly superior to polling, but plumbing through OPC UA semantics + the PollGroupEngine abstraction would require a parallel sampling path; poll-first matches the cross-driver pattern + gets the driver shippable. Follow-up task for native-notification upgrade tracked after merge. IHostConnectivityProbe — per-device probe loop using ITwinCATClient.ProbeAsync which wraps AdsClient.ReadStateAsync (cheap handshake that returns the target's AdsState, succeeds when router + target both respond). Success transitions to Running, any exception or probe-false to Stopped. Same lazy-connect + dispose-on-failure pattern as the read/write path — device state reconnects cleanly after a transient. IPerCallHostResolver maps tag full-ref to DeviceHostAddress for Phase 6.1 (DriverInstanceId, ResolvedHostName) bulkhead/breaker keying per plan decision #144; unknown refs fall back to first device, no devices → DriverInstanceId. ShutdownAsync disposes PollGroupEngine + cancels/disposes every probe CTS + disposes every cached client. DeviceState extended with ProbeLock / HostState / HostStateChangedUtc / ProbeCts matching AbCip/AbLegacy shape. 10 new tests in TwinCATCapabilityTests — discovery tag emission with correct SecurityClassification, subscription initial poll raises OnDataChange, shutdown cancels subscriptions, GetHostStatuses entry-per-device, probe Running transition on ProbeResult=true, probe Stopped on ProbeResult=false, probe disabled when Enabled=false, ResolveHost for known/unknown/no-devices paths. Total TwinCAT unit tests now 85/85 passing (+10 from PR 2's 75); full solution builds 0 errors; other drivers untouched. TwinCAT driver complete end-to-end — any TC2/TC3 AMS target reachable through a router is now shippable with read/write/discover/subscribe/probe/host-resolve, feature-parity with AbCip/AbLegacy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:36:55 -04:00
2d5aaf1eda Merge pull request (#121) - TwinCAT R/W 2026-04-19 18:34:52 -04:00
53 changed files with 5412 additions and 44 deletions

View File

@@ -13,6 +13,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.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"/>
@@ -35,6 +36,7 @@
<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.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>

View File

@@ -56,6 +56,16 @@ else
</div></div></div>
</div>
@if (_rows.Any(HostStatusService.IsFlagged))
{
var flaggedCount = _rows.Count(HostStatusService.IsFlagged);
<div class="alert alert-danger small mb-3">
<strong>@flaggedCount host@(flaggedCount == 1 ? "" : "s")</strong>
reporting ≥ @HostStatusService.FailureFlagThreshold consecutive failures — circuit breaker
may trip soon. Inspect the resilience columns below to locate.
</div>
}
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
{
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
@@ -66,6 +76,9 @@ else
<th>Driver</th>
<th>Host</th>
<th>State</th>
<th class="text-end" title="Consecutive failures — resets when a call succeeds or the breaker closes">Fail#</th>
<th class="text-end" title="In-flight capability calls (bulkhead-depth proxy)">In-flight</th>
<th>Breaker opened</th>
<th>Last transition</th>
<th>Last seen</th>
<th>Detail</th>
@@ -84,10 +97,21 @@ else
{
<span class="badge bg-warning text-dark ms-1">Stale</span>
}
@if (HostStatusService.IsFlagged(r))
{
<span class="badge bg-danger ms-1" title="≥ @HostStatusService.FailureFlagThreshold consecutive failures">Flagged</span>
}
</td>
<td class="text-end small @(HostStatusService.IsFlagged(r) ? "text-danger fw-bold" : "")">
@r.ConsecutiveFailures
</td>
<td class="text-end small">@r.CurrentBulkheadDepth</td>
<td class="small">
@(r.LastCircuitBreakerOpenUtc is null ? "—" : FormatAge(r.LastCircuitBreakerOpenUtc.Value))
</td>
<td class="small">@FormatAge(r.StateChangedUtc)</td>
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
<td class="text-truncate small" style="max-width: 320px;" title="@r.Detail">@r.Detail</td>
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
</tr>
}
</tbody>

View File

@@ -7,8 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
/// <c>ClusterNode.ClusterId</c> when available (left-join). The Admin <c>/hosts</c> page
/// groups by cluster and renders a per-node → per-driver → per-host tree.
/// <c>ClusterNode.ClusterId</c> (left-join) + the per-<c>(DriverInstanceId, HostName)</c>
/// <see cref="DriverInstanceResilienceStatus"/> counters (also left-join) so the Admin
/// <c>/hosts</c> page renders the resilience surface inline with host state.
/// </summary>
public sealed record HostStatusRow(
string NodeId,
@@ -18,7 +19,11 @@ public sealed record HostStatusRow(
DriverHostState State,
DateTime StateChangedUtc,
DateTime LastSeenUtc,
string? Detail);
string? Detail,
int ConsecutiveFailures,
DateTime? LastCircuitBreakerOpenUtc,
int CurrentBulkheadDepth,
DateTime? LastRecycleUtc);
/// <summary>
/// Read-side service for the Admin UI's per-host drill-down. Loads
@@ -36,15 +41,26 @@ public sealed class HostStatusService(OtOpcUaConfigDbContext db)
{
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
/// <summary>Consecutive-failure threshold at which <see cref="IsFlagged"/> returns <c>true</c>
/// so the Admin UI can paint a red badge. Matches Phase 6.1 decision #143's conservative
/// half-of-breaker-threshold convention — flags before the breaker actually opens.</summary>
public const int FailureFlagThreshold = 3;
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
{
// LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
// been created yet (first-boot bootstrap case — keeps the UI from losing sight of
// the reporting server).
// Two LEFT JOINs:
// 1. ClusterNodes on NodeId — row persists even when its owning ClusterNode row
// hasn't been created yet (first-boot bootstrap case).
// 2. DriverInstanceResilienceStatuses on (DriverInstanceId, HostName) — resilience
// counters haven't been sampled yet for brand-new hosts, so a missing row means
// zero failures + never-opened breaker.
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
join n in db.ClusterNodes.AsNoTracking()
on s.NodeId equals n.NodeId into nodeJoin
from n in nodeJoin.DefaultIfEmpty()
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
on new { s.DriverInstanceId, s.HostName } equals new { r.DriverInstanceId, r.HostName } into resilJoin
from r in resilJoin.DefaultIfEmpty()
orderby s.NodeId, s.DriverInstanceId, s.HostName
select new HostStatusRow(
s.NodeId,
@@ -54,10 +70,21 @@ public sealed class HostStatusService(OtOpcUaConfigDbContext db)
s.State,
s.StateChangedUtc,
s.LastSeenUtc,
s.Detail)).ToListAsync(ct);
s.Detail,
r != null ? r.ConsecutiveFailures : 0,
r != null ? r.LastCircuitBreakerOpenUtc : null,
r != null ? r.CurrentBulkheadDepth : 0,
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
return rows;
}
public static bool IsStale(HostStatusRow row) =>
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
/// <summary>
/// Red-badge predicate — <c>true</c> when the host has accumulated enough consecutive
/// failures that an operator should take notice before the breaker trips.
/// </summary>
public static bool IsFlagged(HostStatusRow row) =>
row.ConsecutiveFailures >= FailureFlagThreshold;
}

View File

@@ -24,11 +24,21 @@ public sealed class DriverResiliencePipelineBuilder
{
private readonly ConcurrentDictionary<PipelineKey, ResiliencePipeline> _pipelines = new();
private readonly TimeProvider _timeProvider;
private readonly DriverResilienceStatusTracker? _statusTracker;
/// <summary>Construct with the ambient clock (use <see cref="TimeProvider.System"/> in prod).</summary>
public DriverResiliencePipelineBuilder(TimeProvider? timeProvider = null)
/// <param name="timeProvider">Clock source for pipeline timeouts + breaker sampling. Defaults to system.</param>
/// <param name="statusTracker">When non-null, every built pipeline wires Polly telemetry into
/// the tracker — retries increment <c>ConsecutiveFailures</c>, breaker-open stamps
/// <c>LastBreakerOpenUtc</c>, breaker-close resets failures. Feeds Admin <c>/hosts</c> +
/// the Polly bulkhead-depth column. Absent tracker means no telemetry (unit tests +
/// deployments that don't care about resilience observability).</param>
public DriverResiliencePipelineBuilder(
TimeProvider? timeProvider = null,
DriverResilienceStatusTracker? statusTracker = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_statusTracker = statusTracker;
}
/// <summary>
@@ -54,8 +64,9 @@ public sealed class DriverResiliencePipelineBuilder
ArgumentException.ThrowIfNullOrWhiteSpace(hostName);
var key = new PipelineKey(driverInstanceId, hostName, capability);
return _pipelines.GetOrAdd(key, static (_, state) => Build(state.capability, state.options, state.timeProvider),
(capability, options, timeProvider: _timeProvider));
return _pipelines.GetOrAdd(key, static (k, state) => Build(
k.DriverInstanceId, k.HostName, state.capability, state.options, state.timeProvider, state.tracker),
(capability, options, timeProvider: _timeProvider, tracker: _statusTracker));
}
/// <summary>Drop cached pipelines for one driver instance (e.g. on ResilienceConfig change). Test + Admin-reload use.</summary>
@@ -74,9 +85,12 @@ public sealed class DriverResiliencePipelineBuilder
public int CachedPipelineCount => _pipelines.Count;
private static ResiliencePipeline Build(
string driverInstanceId,
string hostName,
DriverCapability capability,
DriverResilienceOptions options,
TimeProvider timeProvider)
TimeProvider timeProvider,
DriverResilienceStatusTracker? tracker)
{
var policy = options.Resolve(capability);
var builder = new ResiliencePipelineBuilder { TimeProvider = timeProvider };
@@ -88,7 +102,7 @@ public sealed class DriverResiliencePipelineBuilder
if (policy.RetryCount > 0)
{
builder.AddRetry(new RetryStrategyOptions
var retryOptions = new RetryStrategyOptions
{
MaxRetryAttempts = policy.RetryCount,
BackoffType = DelayBackoffType.Exponential,
@@ -96,19 +110,44 @@ public sealed class DriverResiliencePipelineBuilder
Delay = TimeSpan.FromMilliseconds(100),
MaxDelay = TimeSpan.FromSeconds(5),
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
});
};
if (tracker is not null)
{
retryOptions.OnRetry = args =>
{
tracker.RecordFailure(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
return default;
};
}
builder.AddRetry(retryOptions);
}
if (policy.BreakerFailureThreshold > 0)
{
builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions
var breakerOptions = new CircuitBreakerStrategyOptions
{
FailureRatio = 1.0,
MinimumThroughput = policy.BreakerFailureThreshold,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(15),
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
});
};
if (tracker is not null)
{
breakerOptions.OnOpened = args =>
{
tracker.RecordBreakerOpen(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
return default;
};
breakerOptions.OnClosed = args =>
{
// Closing the breaker means the target recovered — reset the consecutive-
// failure counter so Admin UI stops flashing red for this host.
tracker.RecordSuccess(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
return default;
};
}
builder.AddCircuitBreaker(breakerOptions);
}
return builder.Build();

View File

@@ -27,6 +27,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly string _driverInstanceId;
private readonly IAbCipTagFactory _tagFactory;
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
private readonly AbCipTemplateCache _templateCache = new();
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
@@ -38,19 +39,63 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
IAbCipTagEnumeratorFactory? enumeratorFactory = null)
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
IAbCipTemplateReaderFactory? templateReaderFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagTagFactory();
_enumeratorFactory = enumeratorFactory ?? new EmptyAbCipTagEnumeratorFactory();
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
}
/// <summary>
/// Fetch + cache the shape of a Logix UDT by template instance id. First call reads
/// the Template Object off the controller; subsequent calls for the same
/// <c>(deviceHostAddress, templateInstanceId)</c> return the cached shape without
/// additional network traffic. <c>null</c> on template-not-found / decode failure so
/// callers can fall back to declaration-driven UDT fan-out.
/// </summary>
internal async Task<AbCipUdtShape?> FetchUdtShapeAsync(
string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken)
{
var cached = _templateCache.TryGet(deviceHostAddress, templateInstanceId);
if (cached is not null) return cached;
if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null;
var deviceParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: $"@udt/{templateInstanceId}",
Timeout: _options.Timeout);
try
{
using var reader = _templateReaderFactory.Create();
var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
var shape = CipTemplateObjectDecoder.Decode(buffer);
if (shape is not null)
_templateCache.Put(deviceHostAddress, templateInstanceId, shape);
return shape;
}
catch (OperationCanceledException) { throw; }
catch
{
// Template read failure — log via the driver's health surface so operators see it,
// but don't propagate since callers should fall back to declaration-driven UDT
// semantics rather than failing the whole discovery run.
return null;
}
}
/// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
internal AbCipTemplateCache TemplateCache => _templateCache;
@@ -329,9 +374,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try
{
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
// per-parent lock prevents two concurrent bit writes to the same DINT from
// losing one another's update.
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
{
results[i] = new WriteResult(
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
.ConfigureAwait(false));
if (results[i].StatusCode == AbCipStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
continue;
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
var tagPath = AbCipTagPath.TryParse(def.TagPath);
runtime.EncodeValue(def.DataType, tagPath?.BitIndex, w.Value);
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
var status = runtime.GetStatus();
@@ -374,6 +434,74 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return results;
}
/// <summary>
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
/// writers against the same parent via a per-parent <see cref="SemaphoreSlim"/>.
/// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern shipped in pass 1 of task #181.
/// </summary>
private async Task<uint> WriteBitInDIntAsync(
DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
{
var parentPath = bitPath with { BitIndex = null };
var parentName = parentPath.ToLibplctagName();
var rmwLock = device.GetRmwLock(parentName);
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
try
{
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
var updated = Convert.ToBoolean(value)
? current | (1 << bit)
: current & ~(1 << bit);
parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbCipStatusMapper.Good
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
}
finally
{
rmwLock.Release();
}
}
/// <summary>
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
/// so repeated bit writes against the same DINT share one handle.
/// </summary>
private async Task<IAbCipTagRuntime> EnsureParentRuntimeAsync(
DeviceState device, string parentTagName, CancellationToken ct)
{
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentTagName,
Timeout: _options.Timeout));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.ParentRuntimes[parentTagName] = runtime;
return runtime;
}
/// <summary>
/// Idempotently materialise the runtime handle for a tag definition. First call creates
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
@@ -476,9 +604,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
}
// Controller-discovered tags — optional. Default enumerator returns an empty sequence;
// tests + the follow-up real @tags walker plug in via the ctor parameter.
if (_devices.TryGetValue(device.HostAddress, out var state))
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
// walker (LibplctagTagEnumerator) is the factory default since task #178 shipped,
// so leaving the flag off keeps the strict-config path for deployments where only
// declared tags should appear.
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
{
using var enumerator = _enumeratorFactory.Create();
var deviceParams = new AbCipTagCreateParams(
@@ -572,12 +702,28 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Parent-DINT runtimes created on-demand by <see cref="AbCipDriver.EnsureParentRuntimeAsync"/>
/// for BOOL-within-DINT RMW writes. Separate from <see cref="Runtimes"/> because a
/// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
/// parent ("Motor.Flags") used to do the read + write.
/// </summary>
public Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
public SemaphoreSlim GetRmwLock(string parentTagName) =>
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
public void DisposeHandles()
{
foreach (var h in TagHandles.Values) h.Dispose();
TagHandles.Clear();
foreach (var r in Runtimes.Values) r.Dispose();
Runtimes.Clear();
foreach (var r in ParentRuntimes.Values) r.Dispose();
ParentRuntimes.Clear();
}
}
}

View File

@@ -29,6 +29,15 @@ public sealed class AbCipDriverOptions
/// not pass a more specific value. Matches the Modbus driver's 2-second default.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's Logix symbol table via
/// the <c>@tags</c> pseudo-tag + surfaces controller-resident globals under a
/// <c>Discovered/</c> sub-folder. Pre-declared tags always emit regardless. Default
/// <c>false</c> to keep the strict-config path for deployments where only declared tags
/// should appear in the address space.
/// </summary>
public bool EnableControllerBrowse { get; init; }
}
/// <summary>

View File

@@ -0,0 +1,128 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Decoder for the CIP Symbol Object (class 0x6B) response returned by Logix controllers
/// when a client reads the <c>@tags</c> pseudo-tag. Parses the concatenated tag-info
/// entries into a sequence of <see cref="AbCipDiscoveredTag"/>s that the driver can stream
/// into the address-space builder.
/// </summary>
/// <remarks>
/// <para>Entry layout (little-endian) per Rockwell CIP Vol 1 + Logix 5000 CIP Programming
/// Manual (1756-PM019 chapter "Symbol Object"), cross-checked against libplctag's
/// <c>ab/cip.c</c> <c>handle_listed_tags_reply</c>:</para>
/// <list type="table">
/// <item><term>u32</term><description>Symbol Instance ID — opaque identifier for the tag.</description></item>
/// <item><term>u16</term><description>Symbol Type — lower 12 bits = CIP type code (0xC1 BOOL,
/// 0xC2 SINT, …, 0xD0 STRING). Bit 12 = system-tag flag. Bit 13 = reserved.
/// Bit 15 = struct flag; when set, the lower 12 bits are the template instance id
/// (not a primitive type code).</description></item>
/// <item><term>u16</term><description>Element length — bytes per element (e.g. 4 for DINT).</description></item>
/// <item><term>u32 × 3</term><description>Array dimensions — zero for scalar tags.</description></item>
/// <item><term>u16</term><description>Symbol name length in bytes.</description></item>
/// <item><term>u8 × N</term><description>ASCII symbol name, padded to an even byte boundary.</description></item>
/// </list>
///
/// <para><c>Program:</c>-scope tags arrive with their scope prefix baked into the name
/// (<c>Program:MainProgram.StepIndex</c>); decoder strips the prefix + emits the scope
/// separately so the driver's IAddressSpaceBuilder can organise them.</para>
/// </remarks>
public static class CipSymbolObjectDecoder
{
// Fixed header size in bytes — instance-id(4) + symbol-type(2) + element-length(2)
// + array-dims(4×3) + name-length(2) = 22.
private const int FixedHeaderSize = 22;
private const ushort SymbolTypeSystemFlag = 0x1000;
private const ushort SymbolTypeStructFlag = 0x8000;
private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
/// <summary>
/// Decode the raw <c>@tags</c> blob into an enumerable sequence. Malformed entries at
/// the tail cause decoding to stop gracefully — the caller gets whatever it could parse
/// cleanly before the corruption.
/// </summary>
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
return DecodeImpl(buffer);
}
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer)
{
var pos = 0;
while (pos + FixedHeaderSize <= buffer.Length)
{
var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos));
var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4));
// element_length at pos+6 (u16) — useful for array sizing but not surfaced here
// array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros)
var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
pos += FixedHeaderSize;
if (pos + nameLength > buffer.Length) break;
var name = Encoding.ASCII.GetString(buffer, pos, nameLength);
pos += nameLength;
if ((pos & 1) != 0) pos++; // even-align for the next entry
if (string.IsNullOrWhiteSpace(name)) continue;
var isSystem = (symbolType & SymbolTypeSystemFlag) != 0;
var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
var typeCode = symbolType & SymbolTypeTypeCodeMask;
var (programScope, simpleName) = SplitProgramScope(name);
var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
yield return new AbCipDiscoveredTag(
Name: simpleName,
ProgramScope: programScope,
DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque
ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later
IsSystemTag: isSystem);
_ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
}
}
/// <summary>
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
/// </summary>
internal static (string? programScope, string simpleName) SplitProgramScope(string fullName)
{
const string prefix = "Program:";
if (!fullName.StartsWith(prefix, StringComparison.Ordinal)) return (null, fullName);
var afterPrefix = fullName[prefix.Length..];
var dot = afterPrefix.IndexOf('.');
if (dot <= 0) return (null, fullName); // malformed scope — surface the raw name
return (afterPrefix[..dot], afterPrefix[(dot + 1)..]);
}
/// <summary>
/// Map a CIP atomic type code (lower 12 bits of the symbol-type field) to our
/// <see cref="AbCipDataType"/> surface. Returns <c>null</c> for unrecognised codes —
/// caller treats those as <see cref="AbCipDataType.Structure"/> so the symbol is still
/// surfaced + downstream config can add a concrete type override.
/// </summary>
internal static AbCipDataType? MapTypeCode(byte typeCode) => typeCode switch
{
0xC1 => AbCipDataType.Bool,
0xC2 => AbCipDataType.SInt,
0xC3 => AbCipDataType.Int,
0xC4 => AbCipDataType.DInt,
0xC5 => AbCipDataType.LInt,
0xC6 => AbCipDataType.USInt,
0xC7 => AbCipDataType.UInt,
0xC8 => AbCipDataType.UDInt,
0xC9 => AbCipDataType.ULInt,
0xCA => AbCipDataType.Real,
0xCB => AbCipDataType.LReal,
0xCD => AbCipDataType.Dt, // DATE
0xCF => AbCipDataType.Dt, // DATE_AND_TIME
0xD0 => AbCipDataType.String,
_ => null,
};
}

View File

@@ -0,0 +1,140 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Decoder for the CIP Template Object (class 0x6C) blob returned by a <c>Read Template</c>
/// service. Produces an <see cref="AbCipUdtShape"/> describing the UDT's name, total size,
/// + ordered member list with per-member offset + type + array length.
/// </summary>
/// <remarks>
/// <para>Wire format per Rockwell CIP Vol 1 §5A + Logix 5000 CIP Programming Manual
/// 1756-PM019 §"Template Object", cross-checked against libplctag's <c>ab/cip.c</c>
/// <c>handle_read_template_reply</c>:</para>
///
/// <para>Header (fixed-size, little-endian):</para>
/// <list type="table">
/// <item><term>u16</term><description>Member count.</description></item>
/// <item><term>u16</term><description>Struct handle (opaque id).</description></item>
/// <item><term>u32</term><description>Instance size — bytes per structure instance.</description></item>
/// <item><term>u32</term><description>Member-definition total size — not used here.</description></item>
/// </list>
///
/// <para>Then <c>member_count</c> member blocks (8 bytes each):</para>
/// <list type="table">
/// <item><term>u16</term><description>Member info — type code + flags (same encoding
/// as Symbol Object: bit 15 = struct, lower 12 = CIP type code).</description></item>
/// <item><term>u16</term><description>Array size — 0 for scalar members.</description></item>
/// <item><term>u32</term><description>Struct offset — byte offset from struct start.</description></item>
/// </list>
///
/// <para>Then strings: UDT name followed by each member name, each terminated by a
/// semicolon <c>;</c> followed by a null <c>\0</c>. The UDT name may itself contain the
/// sequence <c>UDTName;0\0</c> where <c>0</c> after the semicolon is an ASCII flag byte.
/// Decoder trims to the first semicolon.</para>
/// </remarks>
public static class CipTemplateObjectDecoder
{
private const int HeaderSize = 12; // u16 + u16 + u32 + u32
private const int MemberBlockSize = 8; // u16 + u16 + u32
private const ushort MemberInfoStructFlag = 0x8000;
private const ushort MemberInfoTypeCodeMask = 0x0FFF;
/// <summary>
/// Decode the raw Template Object blob. Returns <c>null</c> when the header indicates
/// zero members or the buffer is too short to hold the fixed header.
/// </summary>
public static AbCipUdtShape? Decode(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
if (buffer.Length < HeaderSize) return null;
var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(0));
// bytes 2-3: struct handle — opaque, not needed for the shape record
var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(4));
// bytes 8-11: member-definition total size — inferred from names list instead
if (memberCount == 0) return null;
var memberBlocksOffset = HeaderSize;
var namesOffset = memberBlocksOffset + MemberBlockSize * memberCount;
if (namesOffset > buffer.Length) return null;
var stringsSpan = buffer.AsSpan(namesOffset);
var names = ParseSemicolonTerminatedStrings(stringsSpan);
if (names.Count == 0) return null;
// Strings layout: UDT name first, then one per member (in the same order as the
// member-info blocks). Always consume the first entry as the UDT name; missing
// trailing member names get <member_N> placeholders below.
var udtName = names[0];
var memberNames = names.Skip(1).ToArray();
var members = new List<AbCipUdtMember>(memberCount);
for (var i = 0; i < memberCount; i++)
{
var blockOffset = memberBlocksOffset + (i * MemberBlockSize);
var info = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset));
var arraySize = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2));
var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(blockOffset + 4));
var isStruct = (info & MemberInfoStructFlag) != 0;
var typeCode = (byte)(info & MemberInfoTypeCodeMask);
var dataType = isStruct
? AbCipDataType.Structure
: (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure);
var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
members.Add(new AbCipUdtMember(
Name: memberName,
Offset: offset,
DataType: dataType,
ArrayLength: arraySize == 0 ? 1 : arraySize));
}
return new AbCipUdtShape(
TypeName: udtName,
TotalSize: (int)instanceSize,
Members: members);
}
/// <summary>
/// Walk a span of <c>NAME;\0NAME;\0…</c> byte sequences. Splits at each semicolon —
/// the null byte after each semicolon is optional padding per Rockwell's string
/// encoding convention. Stops at a trailing null / end of buffer.
/// </summary>
internal static List<string> ParseSemicolonTerminatedStrings(ReadOnlySpan<byte> span)
{
var result = new List<string>();
var start = 0;
for (var i = 0; i < span.Length; i++)
{
var b = span[i];
if (b == ';')
{
if (i > start)
result.Add(Encoding.ASCII.GetString(span[start..i]));
// Skip the optional null/space padding following the semicolon.
while (i + 1 < span.Length && (span[i + 1] == '\0' || span[i + 1] == ' '))
i++;
start = i + 1;
}
else if (b == 0 && start == i)
{
// Trailing null at a string boundary — done.
break;
}
}
// Trailing name without a semicolon (unlikely but observed on some firmwares).
if (start < span.Length)
{
var zeroAt = span[start..].IndexOf((byte)0);
var end = zeroAt < 0 ? span.Length : start + zeroAt;
if (end > start)
result.Add(Encoding.ASCII.GetString(span[start..end]));
}
return result;
}
}

View File

@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Reads the raw Template Object (class 0x6C) blob for a given UDT template instance id
/// off a Logix controller. The default production implementation (see
/// <see cref="LibplctagTemplateReader"/>) uses libplctag's <c>@udt/{id}</c> pseudo-tag.
/// Tests swap in a fake via <see cref="IAbCipTemplateReaderFactory"/>.
/// </summary>
public interface IAbCipTemplateReader : IDisposable
{
/// <summary>
/// Read the raw template bytes for <paramref name="templateInstanceId"/>. Returns the
/// full blob the Read Template service produced — the managed <see cref="CipTemplateObjectDecoder"/>
/// parses it into an <see cref="AbCipUdtShape"/>.
/// </summary>
Task<byte[]> ReadAsync(
AbCipTagCreateParams deviceParams,
uint templateInstanceId,
CancellationToken cancellationToken);
}
/// <summary>Factory for <see cref="IAbCipTemplateReader"/>.</summary>
public interface IAbCipTemplateReaderFactory
{
IAbCipTemplateReader Create();
}

View File

@@ -0,0 +1,63 @@
using System.Runtime.CompilerServices;
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Real <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table by
/// reading the <c>@tags</c> pseudo-tag via libplctag + decoding the CIP Symbol Object
/// response with <see cref="CipSymbolObjectDecoder"/>.
/// </summary>
/// <remarks>
/// <para>libplctag's <c>Tag.GetBuffer()</c> returns the raw Symbol Object bytes when the
/// tag name is <c>@tags</c>. The decoder walks the concatenated entries + emits
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
///
/// <para>Task #178 closed the stub gap from PR 5 — <see cref="EmptyAbCipTagEnumerator"/>
/// is still available for tests that don't want to touch the native library, but the
/// production factory default now wires this implementation in.</para>
/// </remarks>
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
{
private Tag? _tag;
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// Build a tag specifically for the @tags pseudo — same gateway + path as the device,
// distinguished by the name alone.
_tag = new Tag
{
Gateway = deviceParams.Gateway,
Path = deviceParams.CipPath,
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = "@tags",
Timeout = deviceParams.Timeout,
};
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
var buffer = _tag.GetBuffer();
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
yield return tag;
}
public void Dispose() => _tag?.Dispose();
private static PlcType MapPlcType(string attribute) => attribute switch
{
"controllogix" => PlcType.ControlLogix,
"compactlogix" => PlcType.ControlLogix,
"micro800" => PlcType.Micro800,
_ => PlcType.ControlLogix,
};
}
/// <summary>Factory for <see cref="LibplctagTagEnumerator"/>.</summary>
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
{
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
}

View File

@@ -58,13 +58,14 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
switch (type)
{
case AbCipDataType.Bool:
if (bitIndex is int bit)
if (bitIndex is int)
{
// BOOL-within-DINT writes require read-modify-write on the parent DINT.
// Deferred to a follow-up PR — matches the Modbus BitInRegister pattern at
// ModbusDriver.cs:640.
// BOOL-within-DINT writes are routed at the driver level (AbCipDriver.
// WriteBitInDIntAsync) via a parallel parent-DINT runtime so the RMW stays
// serialised. If one reaches here it means the driver dispatch was bypassed —
// throw so the error surfaces loudly rather than clobbering the whole DINT.
throw new NotSupportedException(
"BOOL-within-DINT writes require read-modify-write; not implemented in PR 4.");
"BOOL-with-bitIndex writes must go through AbCipDriver.WriteBitInDIntAsync, not LibplctagTagRuntime.");
}
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
break;

View File

@@ -0,0 +1,49 @@
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// libplctag-backed <see cref="IAbCipTemplateReader"/>. Opens the <c>@udt/{templateId}</c>
/// pseudo-tag libplctag exposes for Template Object reads, issues a <c>Read Template</c>
/// internally via a normal read call, + returns the raw byte buffer so
/// <see cref="CipTemplateObjectDecoder"/> can decode it.
/// </summary>
internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
{
private Tag? _tag;
public async Task<byte[]> ReadAsync(
AbCipTagCreateParams deviceParams,
uint templateInstanceId,
CancellationToken cancellationToken)
{
_tag?.Dispose();
_tag = new Tag
{
Gateway = deviceParams.Gateway,
Path = deviceParams.CipPath,
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = $"@udt/{templateInstanceId}",
Timeout = deviceParams.Timeout,
};
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return _tag.GetBuffer();
}
public void Dispose() => _tag?.Dispose();
private static PlcType MapPlcType(string attribute) => attribute switch
{
"controllogix" => PlcType.ControlLogix,
"compactlogix" => PlcType.ControlLogix,
"micro800" => PlcType.Micro800,
_ => PlcType.ControlLogix,
};
}
internal sealed class LibplctagTemplateReaderFactory : IAbCipTemplateReaderFactory
{
public IAbCipTemplateReader Create() => new LibplctagTemplateReader();
}

View File

@@ -186,8 +186,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
var parsed = AbLegacyAddress.TryParse(def.Address);
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
// concurrent bit writers. Applies to N-file bit-in-word (N7:0/3) + B-file bits
// (B3:0/0). T/C/R sub-elements don't hit this path because they're not Bit typed.
if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit
&& parsed.FileLetter is not "B" and not "I" and not "O")
{
results[i] = new WriteResult(
await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
continue;
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
@@ -331,6 +344,70 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
/// <summary>
/// Read-modify-write one bit within a PCCC N-file word. Strips the /N bit suffix to
/// form the parent-word address (N7:0/3 → N7:0), creates / reuses a parent-word runtime
/// typed as Int16, serialises concurrent bit writers against the same parent via a
/// per-parent <see cref="SemaphoreSlim"/>.
/// </summary>
private async Task<uint> WriteBitInWordAsync(
AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
{
var parentAddress = bitAddress with { BitIndex = null };
var parentName = parentAddress.ToLibplctagName();
var rmwLock = device.GetRmwLock(parentName);
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
try
{
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
if (readStatus != 0) return AbLegacyStatusMapper.MapLibplctagStatus(readStatus);
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbLegacyDataType.Int, bitIndex: null) ?? 0);
var updated = Convert.ToBoolean(value)
? current | (1 << bit)
: current & ~(1 << bit);
parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbLegacyStatusMapper.Good
: AbLegacyStatusMapper.MapLibplctagStatus(writeStatus);
}
finally
{
rmwLock.Release();
}
}
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
{
if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentName,
Timeout: _options.Timeout));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.ParentRuntimes[parentName] = runtime;
return runtime;
}
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
@@ -374,6 +451,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Parent-word runtimes for bit-within-word RMW writes (task #181). Keyed by the
/// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
/// single parent runtime for N7:0.
/// </summary>
public Dictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
public SemaphoreSlim GetRmwLock(string parentName) =>
_rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(1, 1));
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
@@ -384,6 +474,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
foreach (var r in Runtimes.Values) r.Dispose();
Runtimes.Clear();
foreach (var r in ParentRuntimes.Values) r.Dispose();
ParentRuntimes.Clear();
}
}
}

View File

@@ -51,8 +51,12 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
case AbLegacyDataType.Bit:
if (bitIndex is int)
// Bit-within-word writes are routed at the driver level
// (AbLegacyDriver.WriteBitInWordAsync) via a parallel parent-word runtime —
// this branch only fires if dispatch was bypassed. Throw loudly rather than
// silently clobbering the whole word.
throw new NotSupportedException(
"Bit-within-word writes require read-modify-write; tracked in task #181.");
"Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
break;
case AbLegacyDataType.Int:

View File

@@ -0,0 +1,95 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
/// </summary>
/// <remarks>
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
/// bit index when present is 07 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
/// </remarks>
public sealed record FocasAddress(
FocasAreaKind Kind,
string? PmcLetter,
int Number,
int? BitIndex)
{
public string Canonical => Kind switch
{
FocasAreaKind.Pmc => BitIndex is null
? $"{PmcLetter}{Number}"
: $"{PmcLetter}{Number}.{BitIndex}",
FocasAreaKind.Parameter => BitIndex is null
? $"PARAM:{Number}"
: $"PARAM:{Number}/{BitIndex}",
FocasAreaKind.Macro => $"MACRO:{Number}",
_ => $"?{Number}",
};
public static FocasAddress? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim();
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
// PMC path: letter + digits + optional .bit
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
var letter = src[0..1].ToUpperInvariant();
if (!IsValidPmcLetter(letter)) return null;
var remainder = src[1..];
int? bit = null;
var dotIdx = remainder.IndexOf('.');
if (dotIdx >= 0)
{
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
return null;
bit = bitValue;
remainder = remainder[..dotIdx];
}
if (!int.TryParse(remainder, out var number) || number < 0) return null;
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
}
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
{
int? bit = null;
if (bitSeparator is char sep)
{
var slashIdx = body.IndexOf(sep);
if (slashIdx >= 0)
{
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
return null;
bit = bitValue;
body = body[..slashIdx];
}
}
if (!int.TryParse(body, out var number) || number < 0) return null;
return new FocasAddress(kind, PmcLetter: null, number, bit);
}
private static bool IsValidPmcLetter(string letter) => letter switch
{
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
_ => false,
};
}
/// <summary>Addressing-space kinds the driver understands.</summary>
public enum FocasAreaKind
{
Pmc,
Parameter,
Macro,
}

View File

@@ -0,0 +1,39 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS atomic data types. Narrower than Logix/IEC — FANUC CNCs expose mostly integer +
/// floating-point data with no UDT concept; macro variables are double-precision floats
/// and PMC reads return byte / signed word / signed dword.
/// </summary>
public enum FocasDataType
{
/// <summary>Single bit (PMC bit, or bit within a CNC parameter).</summary>
Bit,
/// <summary>8-bit signed byte (PMC 1-byte read).</summary>
Byte,
/// <summary>16-bit signed word (PMC 2-byte read, or CNC parameter as short).</summary>
Int16,
/// <summary>32-bit signed int (PMC 4-byte read, or CNC parameter as int).</summary>
Int32,
/// <summary>32-bit IEEE-754 float (rare; some CNC macro variables).</summary>
Float32,
/// <summary>64-bit IEEE-754 double (most macro variables are double-precision).</summary>
Float64,
/// <summary>ASCII string (alarm text, parameter names, some PMC string areas).</summary>
String,
}
public static class FocasDataTypeExtensions
{
public static DriverDataType ToDriverDataType(this FocasDataType t) => t switch
{
FocasDataType.Bit => DriverDataType.Boolean,
FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 => DriverDataType.Int32,
FocasDataType.Float32 => DriverDataType.Float32,
FocasDataType.Float64 => DriverDataType.Float64,
FocasDataType.String => DriverDataType.String,
_ => DriverDataType.Int32,
};
}

View File

@@ -0,0 +1,344 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS driver for Fanuc CNC controllers (FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / Series
/// 35i / Power Mate i). Talks to the CNC via the Fanuc FOCAS/2 FWLIB protocol through an
/// <see cref="IFocasClient"/> the deployment supplies — FWLIB itself is Fanuc-proprietary
/// and cannot be redistributed.
/// </summary>
/// <remarks>
/// PR 1 ships <see cref="IDriver"/> only; read / write / discover / subscribe / probe / host-
/// resolver capabilities land in PRs 2 and 3. The <see cref="IFocasClient"/> abstraction
/// shipped here lets PR 2 onward stay license-clean — all tests run against a fake client
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
/// fail fast.
/// </remarks>
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
{
private readonly FocasDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IFocasClientFactory _clientFactory;
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new FwlibFocasClientFactory();
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
}
public string DriverInstanceId => _driverInstanceId;
public string DriverType => "FOCAS";
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
foreach (var device in _options.Devices)
{
var addr = FocasHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
_devices[device.HostAddress] = new DeviceState(addr, device);
}
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
if (_options.Probe.Enabled)
{
foreach (var state in _devices.Values)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
return Task.CompletedTask;
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _poll.DisposeAsync().ConfigureAwait(false);
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.DisposeClient();
}
_devices.Clear();
_tagsByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
public DriverHealth GetHealth() => _health;
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
internal int DeviceCount => _devices.Count;
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == FocasStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
else
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"FOCAS status 0x{status:X8} reading {reference}");
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
// ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
continue;
}
if (!def.Writable)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
continue;
}
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(status);
}
catch (OperationCanceledException) { throw; }
catch (NotSupportedException nse)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
results[i] = new WriteResult(FocasStatusMapper.BadTypeMismatch);
}
catch (OverflowException)
{
results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange);
}
catch (Exception ex)
{
results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
// ---- ITagDiscovery ----
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var root = builder.Folder("FOCAS", "FOCAS");
foreach (var device in _options.Devices)
{
var label = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, label);
var tagsForDevice = _options.Tags.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent));
}
}
return Task.CompletedTask;
}
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
success = await client.ProbeAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* connect-failure path already disposed + cleared the client */ }
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
lock (state.ProbeLock)
{
old = state.HostState;
if (old == newState) return;
state.HostState = newState;
state.HostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this,
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
}
// ---- IPerCallHostResolver ----
public string ResolveHost(string fullReference)
{
if (_tagsByName.TryGetValue(fullReference, out var def))
return def.DeviceHostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
{
if (device.Client is { IsConnected: true } c) return c;
device.Client ??= _clientFactory.Create();
try
{
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false);
}
catch
{
device.Client.Dispose();
device.Client = null;
throw;
}
return device.Client;
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
{
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
public FocasDeviceOptions Options { get; } = options;
public IFocasClient? Client { get; set; }
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
public CancellationTokenSource? ProbeCts { get; set; }
public void DisposeClient()
{
Client?.Dispose();
Client = null;
}
}
}

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS driver configuration. One instance supports N CNC devices. Per plan decision #144
/// each device gets its own <c>(DriverInstanceId, HostAddress)</c> bulkhead key at the
/// Phase 6.1 resilience layer.
/// </summary>
public sealed class FocasDriverOptions
{
public IReadOnlyList<FocasDeviceOptions> Devices { get; init; } = [];
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
public FocasProbeOptions Probe { get; init; } = new();
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}
public sealed record FocasDeviceOptions(
string HostAddress,
string? DeviceName = null);
/// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
/// </summary>
public sealed record FocasTagDefinition(
string Name,
string DeviceHostAddress,
string Address,
FocasDataType DataType,
bool Writable = true,
bool WriteIdempotent = false);
public sealed class FocasProbeOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}

View File

@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Parsed FOCAS target address — IP + TCP port. Canonical <c>focas://{ip}[:{port}]</c>.
/// Default port 8193 (Fanuc-reserved FOCAS Ethernet port).
/// </summary>
public sealed record FocasHostAddress(string Host, int Port)
{
/// <summary>Fanuc-reserved TCP port for FOCAS Ethernet.</summary>
public const int DefaultPort = 8193;
public override string ToString() => Port == DefaultPort
? $"focas://{Host}"
: $"focas://{Host}:{Port}";
public static FocasHostAddress? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
const string prefix = "focas://";
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
var body = value[prefix.Length..];
if (string.IsNullOrEmpty(body)) return null;
var colonIdx = body.LastIndexOf(':');
string host;
var port = DefaultPort;
if (colonIdx >= 0)
{
host = body[..colonIdx];
if (!int.TryParse(body[(colonIdx + 1)..], out port) || port is <= 0 or > 65535)
return null;
}
else
{
host = body;
}
if (string.IsNullOrEmpty(host)) return null;
return new FocasHostAddress(host, port);
}
}

View File

@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Maps FOCAS / FWLIB return codes to OPC UA StatusCodes. The FWLIB C API uses an
/// <c>EW_*</c> constant family per the Fanuc FOCAS/1 and FOCAS/2 documentation
/// (<c>EW_OK = 0</c>, <c>EW_NUMBER</c>, <c>EW_SOCKET</c>, etc.). Mirrors the shape of the
/// AbCip / TwinCAT mappers so Admin UI status displays stay uniform across drivers.
/// </summary>
public static class FocasStatusMapper
{
public const uint Good = 0u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u;
public const uint BadOutOfRange = 0x803C0000u;
public const uint BadNotSupported = 0x803D0000u;
public const uint BadDeviceFailure = 0x80550000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
/// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
/// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7,
/// EW_DATA=8, EW_NOOPT=6, EW_PROT=5, EW_OVRFLOW=2, EW_PARITY=9, EW_PASSWD=11,
/// EW_BUSY=-1, EW_HANDLE=-8, EW_VERSION=-9, EW_UNEXP=-10, EW_SOCKET=-16).
/// </summary>
public static uint MapFocasReturn(int ret) => ret switch
{
0 => Good,
1 => BadNotSupported, // EW_FUNC — CNC does not support this function
2 => BadOutOfRange, // EW_OVRFLOW
3 => BadOutOfRange, // EW_NUMBER
4 => BadOutOfRange, // EW_LENGTH
5 => BadNotWritable, // EW_PROT
6 => BadNotSupported, // EW_NOOPT — optional CNC feature missing
7 => BadTypeMismatch, // EW_ATTRIB
8 => BadNodeIdUnknown, // EW_DATA — invalid data address
9 => BadCommunicationError, // EW_PARITY
11 => BadNotWritable, // EW_PASSWD
-1 => BadDeviceFailure, // EW_BUSY
-8 => BadInternalError, // EW_HANDLE — CNC handle not available
-9 => BadNotSupported, // EW_VERSION — FWLIB vs CNC version mismatch
-10 => BadCommunicationError, // EW_UNEXP
-16 => BadCommunicationError, // EW_SOCKET
_ => BadCommunicationError,
};
}

View File

@@ -0,0 +1,328 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// <see cref="IFocasClient"/> implementation backed by Fanuc's licensed
/// <c>Fwlib32.dll</c> via <see cref="FwlibNative"/> P/Invoke. The DLL is NOT shipped with
/// OtOpcUa; the deployment places it next to the server executable or on <c>PATH</c>
/// (per Fanuc licensing — see <c>docs/v2/focas-deployment.md</c>).
/// </summary>
/// <remarks>
/// <para>Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class
/// does NOT load <c>Fwlib32.dll</c>. The DLL only loads on the first wire call (Connect /
/// Read / Write / Probe). When missing, those calls throw <see cref="DllNotFoundException"/>
/// which the driver surfaces as <c>BadCommunicationError</c> through the normal exception
/// mapping.</para>
///
/// <para>Session-scoped handle — <c>cnc_allclibhndl3</c> opens one FWLIB handle per CNC;
/// all PMC / parameter / macro reads on that device go through the same handle. Dispose
/// calls <c>cnc_freelibhndl</c>.</para>
/// </remarks>
internal sealed class FwlibFocasClient : IFocasClient
{
private ushort _handle;
private bool _connected;
// Per-PMC-byte RMW lock registry. Bit writes to the same byte get serialised so two
// concurrent bit updates don't lose one another's modification. Key = "{addrType}:{byteAddr}".
private readonly ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
private SemaphoreSlim GetRmwLock(short addrType, int byteAddr) =>
_rmwLocks.GetOrAdd($"{addrType}:{byteAddr}", _ => new SemaphoreSlim(1, 1));
public bool IsConnected => _connected;
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (_connected) return Task.CompletedTask;
var timeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds);
var ret = FwlibNative.AllcLibHndl3(address.Host, (ushort)address.Port, timeoutMs, out var handle);
if (ret != 0)
throw new InvalidOperationException(
$"FWLIB cnc_allclibhndl3 failed with EW_{ret} connecting to {address}.");
_handle = handle;
_connected = true;
return Task.CompletedTask;
}
public Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
cancellationToken.ThrowIfCancellationRequested();
return address.Kind switch
{
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
};
}
public async Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
{
if (!_connected) return FocasStatusMapper.BadCommunicationError;
cancellationToken.ThrowIfCancellationRequested();
return address.Kind switch
{
FocasAreaKind.Pmc when type == FocasDataType.Bit && address.BitIndex is int =>
await WritePmcBitAsync(address, Convert.ToBoolean(value), cancellationToken).ConfigureAwait(false),
FocasAreaKind.Pmc => WritePmc(address, type, value),
FocasAreaKind.Parameter => WriteParameter(address, type, value),
FocasAreaKind.Macro => WriteMacro(address, value),
_ => FocasStatusMapper.BadNotSupported,
};
}
/// <summary>
/// Read-modify-write one bit within a PMC byte. Acquires a per-byte semaphore so
/// concurrent bit writes against the same byte serialise and neither loses its update.
/// </summary>
private async Task<uint> WritePmcBitAsync(
FocasAddress address, bool newValue, CancellationToken cancellationToken)
{
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
var bit = address.BitIndex ?? 0;
if (bit is < 0 or > 7)
throw new InvalidOperationException(
$"PMC bit index {bit} out of range (0-7) for {address.Canonical}.");
var rmwLock = GetRmwLock(addrType, address.Number);
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Read the parent byte.
var readBuf = new FwlibNative.IODBPMC { Data = new byte[40] };
var readRet = FwlibNative.PmcRdPmcRng(
_handle, addrType, FocasPmcDataType.Byte,
(ushort)address.Number, (ushort)address.Number, 8 + 1, ref readBuf);
if (readRet != 0) return FocasStatusMapper.MapFocasReturn(readRet);
var current = readBuf.Data[0];
var updated = newValue
? (byte)(current | (1 << bit))
: (byte)(current & ~(1 << bit));
// Write the updated byte.
var writeBuf = new FwlibNative.IODBPMC
{
TypeA = addrType,
TypeD = FocasPmcDataType.Byte,
DatanoS = (ushort)address.Number,
DatanoE = (ushort)address.Number,
Data = new byte[40],
};
writeBuf.Data[0] = updated;
var writeRet = FwlibNative.PmcWrPmcRng(_handle, 8 + 1, ref writeBuf);
return writeRet == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(writeRet);
}
finally
{
rmwLock.Release();
}
}
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult(false);
var buf = new FwlibNative.ODBST();
var ret = FwlibNative.StatInfo(_handle, ref buf);
return Task.FromResult(ret == 0);
}
// ---- PMC ----
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
{
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "")
?? throw new InvalidOperationException($"Unknown PMC letter '{address.PmcLetter}'.");
var dataType = FocasPmcDataType.FromFocasDataType(type);
var length = PmcReadLength(type);
var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
var ret = FwlibNative.PmcRdPmcRng(
_handle, addrType, dataType,
(ushort)address.Number, (ushort)address.Number, (ushort)length, ref buf);
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
var value = type switch
{
FocasDataType.Bit => ExtractBit(buf.Data[0], address.BitIndex ?? 0),
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
_ => (object)buf.Data[0],
};
return (value, FocasStatusMapper.Good);
}
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
{
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
var dataType = FocasPmcDataType.FromFocasDataType(type);
var length = PmcWriteLength(type);
var buf = new FwlibNative.IODBPMC
{
TypeA = addrType,
TypeD = dataType,
DatanoS = (ushort)address.Number,
DatanoE = (ushort)address.Number,
Data = new byte[40],
};
EncodePmcValue(buf.Data, type, value, address.BitIndex);
var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)length, ref buf);
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
}
private (object? value, uint status) ReadParameter(FocasAddress address, FocasDataType type)
{
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
var length = ParamReadLength(type);
var ret = FwlibNative.RdParam(_handle, (ushort)address.Number, axis: 0, (short)length, ref buf);
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
var value = type switch
{
FocasDataType.Bit when address.BitIndex is int bit => ExtractBit(buf.Data[0], bit),
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
};
return (value, FocasStatusMapper.Good);
}
private uint WriteParameter(FocasAddress address, FocasDataType type, object? value)
{
var buf = new FwlibNative.IODBPSD
{
Datano = (short)address.Number,
Type = 0,
Data = new byte[32],
};
var length = ParamReadLength(type);
EncodeParamValue(buf.Data, type, value);
var ret = FwlibNative.WrParam(_handle, (short)length, ref buf);
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
}
private (object? value, uint status) ReadMacro(FocasAddress address)
{
var buf = new FwlibNative.ODBM();
var ret = FwlibNative.RdMacro(_handle, (short)address.Number, length: 8, ref buf);
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
// Macro value = mcr_val / 10^dec_val. Convert to double so callers get the correct
// scaled value regardless of the decimal-point count the CNC reports.
var scaled = buf.McrVal / Math.Pow(10.0, buf.DecVal);
return (scaled, FocasStatusMapper.Good);
}
private uint WriteMacro(FocasAddress address, object? value)
{
// Write as integer + 0 decimal places — callers that need decimal precision can extend
// this via a future WriteMacroScaled overload. Consistent with what most HMIs do today.
var intValue = Convert.ToInt32(value);
var ret = FwlibNative.WrMacro(_handle, (short)address.Number, length: 8, intValue, decimalPointCount: 0);
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
}
public void Dispose()
{
if (_connected)
{
try { FwlibNative.FreeLibHndl(_handle); } catch { }
_connected = false;
}
}
// ---- helpers ----
private static int PmcReadLength(FocasDataType type) => type switch
{
FocasDataType.Bit or FocasDataType.Byte => 8 + 1, // 8-byte header + 1 byte payload
FocasDataType.Int16 => 8 + 2,
FocasDataType.Int32 => 8 + 4,
FocasDataType.Float32 => 8 + 4,
FocasDataType.Float64 => 8 + 8,
_ => 8 + 1,
};
private static int PmcWriteLength(FocasDataType type) => PmcReadLength(type);
private static int ParamReadLength(FocasDataType type) => type switch
{
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
FocasDataType.Int16 => 4 + 2,
FocasDataType.Int32 => 4 + 4,
_ => 4 + 4,
};
private static bool ExtractBit(byte word, int bit) => (word & (1 << bit)) != 0;
internal static void EncodePmcValue(byte[] data, FocasDataType type, object? value, int? bitIndex)
{
switch (type)
{
case FocasDataType.Bit:
// PMC Bit writes with a non-null bitIndex go through WritePmcBitAsync's RMW path
// upstream. This branch only fires when a caller passes Bit with no bitIndex —
// treat the value as a whole-byte boolean (non-zero / zero).
data[0] = Convert.ToBoolean(value) ? (byte)1 : (byte)0;
break;
case FocasDataType.Byte:
data[0] = (byte)(sbyte)Convert.ToSByte(value);
break;
case FocasDataType.Int16:
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
break;
case FocasDataType.Int32:
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
break;
case FocasDataType.Float32:
BinaryPrimitives.WriteSingleLittleEndian(data, Convert.ToSingle(value));
break;
case FocasDataType.Float64:
BinaryPrimitives.WriteDoubleLittleEndian(data, Convert.ToDouble(value));
break;
default:
throw new NotSupportedException($"FocasDataType {type} not writable via PMC.");
}
_ = bitIndex; // bit-in-byte handled above
}
internal static void EncodeParamValue(byte[] data, FocasDataType type, object? value)
{
switch (type)
{
case FocasDataType.Byte:
data[0] = (byte)(sbyte)Convert.ToSByte(value);
break;
case FocasDataType.Int16:
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
break;
case FocasDataType.Int32:
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
break;
default:
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
break;
}
}
}
/// <summary>Default <see cref="IFocasClientFactory"/> — produces a fresh <see cref="FwlibFocasClient"/> per device.</summary>
public sealed class FwlibFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => new FwlibFocasClient();
}

View File

@@ -0,0 +1,190 @@
using System.Runtime.InteropServices;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// P/Invoke surface for Fanuc FWLIB (<c>Fwlib32.dll</c>). Declarations extracted from
/// <c>fwlib32.h</c> in the strangesast/fwlib repo; the licensed DLL itself is NOT shipped
/// with OtOpcUa — the deployment places <c>Fwlib32.dll</c> next to the server executable
/// or on <c>PATH</c>.
/// </summary>
/// <remarks>
/// Deliberately narrow — only the calls <see cref="FwlibFocasClient"/> actually makes.
/// FOCAS has 800+ functions in <c>fwlib32.h</c>; pulling in every one would bloat the
/// P/Invoke surface + signal more coverage than this driver provides. Expand as capabilities
/// are added.
/// </remarks>
internal static class FwlibNative
{
private const string Library = "Fwlib32.dll";
// ---- Handle lifetime ----
/// <summary>Open an Ethernet FWLIB handle. Returns EW_OK (0) on success; handle written out.</summary>
[DllImport(Library, EntryPoint = "cnc_allclibhndl3", CharSet = CharSet.Ansi, ExactSpelling = true)]
public static extern short AllcLibHndl3(
[MarshalAs(UnmanagedType.LPStr)] string ipaddr,
ushort port,
int timeout,
out ushort handle);
[DllImport(Library, EntryPoint = "cnc_freelibhndl", ExactSpelling = true)]
public static extern short FreeLibHndl(ushort handle);
// ---- PMC ----
/// <summary>PMC range read. <paramref name="addrType"/> is the ADR_* enum; <paramref name="dataType"/> is 0 byte / 1 word / 2 long.</summary>
[DllImport(Library, EntryPoint = "pmc_rdpmcrng", ExactSpelling = true)]
public static extern short PmcRdPmcRng(
ushort handle,
short addrType,
short dataType,
ushort startNumber,
ushort endNumber,
ushort length,
ref IODBPMC buffer);
[DllImport(Library, EntryPoint = "pmc_wrpmcrng", ExactSpelling = true)]
public static extern short PmcWrPmcRng(
ushort handle,
ushort length,
ref IODBPMC buffer);
// ---- Parameters ----
[DllImport(Library, EntryPoint = "cnc_rdparam", ExactSpelling = true)]
public static extern short RdParam(
ushort handle,
ushort number,
short axis,
short length,
ref IODBPSD buffer);
[DllImport(Library, EntryPoint = "cnc_wrparam", ExactSpelling = true)]
public static extern short WrParam(
ushort handle,
short length,
ref IODBPSD buffer);
// ---- Macro variables ----
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
public static extern short RdMacro(
ushort handle,
short number,
short length,
ref ODBM buffer);
[DllImport(Library, EntryPoint = "cnc_wrmacro", ExactSpelling = true)]
public static extern short WrMacro(
ushort handle,
short number,
short length,
int macroValue,
short decimalPointCount);
// ---- Status ----
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
public static extern short StatInfo(ushort handle, ref ODBST buffer);
// ---- Structs ----
/// <summary>
/// IODBPMC — PMC range I/O buffer. 8-byte header + 40-byte union. We marshal the union
/// as a fixed byte buffer + interpret per <see cref="FocasDataType"/> on the managed side.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBPMC
{
public short TypeA;
public short TypeD;
public ushort DatanoS;
public ushort DatanoE;
// 40-byte union: cdata[5] / idata[5] / ldata[5] / fdata[5] / dbdata[5] — dbdata is the widest.
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)]
public byte[] Data;
}
/// <summary>
/// IODBPSD — CNC parameter I/O buffer. Axis-aware; for non-axis parameters pass axis=0.
/// Union payload is bytes / shorts / longs — we marshal 32 bytes as the widest slot.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBPSD
{
public short Datano;
public short Type; // axis index (0 for non-axis)
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
public byte[] Data;
}
/// <summary>ODBM — macro variable read buffer. Value = <c>McrVal / 10^DecVal</c>.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBM
{
public short Datano;
public short Dummy;
public int McrVal; // long in C; 32-bit signed
public short DecVal; // decimal-point count
}
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBST
{
public short Dummy;
public short TmMode;
public short Aut;
public short Run;
public short Motion;
public short Mstb;
public short Emergency;
public short Alarm;
public short Edit;
}
}
/// <summary>
/// PMC address-letter → FOCAS <c>ADR_*</c> numeric code. Per Fanuc FOCAS/2 spec the codes
/// are: G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10. Exposed internally +
/// tested so the FwlibFocasClient translation is verifiable without the DLL loaded.
/// </summary>
internal static class FocasPmcAddrType
{
public static short? FromLetter(string letter) => letter.ToUpperInvariant() switch
{
"G" => 0,
"F" => 1,
"Y" => 2,
"X" => 3,
"A" => 4,
"R" => 5,
"T" => 6,
"K" => 7,
"C" => 8,
"D" => 9,
"E" => 10,
_ => null,
};
}
/// <summary>PMC data-type numeric codes per FOCAS/2: 0 = byte, 1 = word, 2 = long, 4 = float, 5 = double.</summary>
internal static class FocasPmcDataType
{
public const short Byte = 0;
public const short Word = 1;
public const short Long = 2;
public const short Float = 4;
public const short Double = 5;
public static short FromFocasDataType(FocasDataType t) => t switch
{
FocasDataType.Bit or FocasDataType.Byte => Byte,
FocasDataType.Int16 => Word,
FocasDataType.Int32 => Long,
FocasDataType.Float32 => Float,
FocasDataType.Float64 => Double,
_ => Byte,
};
}

View File

@@ -0,0 +1,70 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Wire-layer abstraction over one FOCAS session to a CNC. The driver holds one per
/// configured device; lifetime matches the device.
/// </summary>
/// <remarks>
/// <para><b>No default wire implementation ships with this assembly.</b> FWLIB
/// (<c>Fwlib32.dll</c>) is Fanuc-proprietary and requires a valid customer license — it
/// cannot legally be redistributed. The deployment team supplies an
/// <see cref="IFocasClientFactory"/> that wraps the licensed <c>Fwlib32.dll</c> via
/// P/Invoke and registers it at server startup.</para>
///
/// <para>The default <see cref="UnimplementedFocasClientFactory"/> throws with a pointer at
/// the deployment docs so misconfigured servers fail fast with a clear error rather than
/// mysteriously hanging.</para>
/// </remarks>
public interface IFocasClient : IDisposable
{
/// <summary>Open the FWLIB handle + TCP session. Idempotent.</summary>
Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
bool IsConnected { get; }
/// <summary>
/// Read the value at <paramref name="address"/> in the requested
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
/// through <see cref="FocasStatusMapper"/>.
/// </summary>
Task<(object? value, uint status)> ReadAsync(
FocasAddress address,
FocasDataType type,
CancellationToken cancellationToken);
/// <summary>
/// Write <paramref name="value"/> to <paramref name="address"/>. Returns the mapped
/// OPC UA status (0 = Good).
/// </summary>
Task<uint> WriteAsync(
FocasAddress address,
FocasDataType type,
object? value,
CancellationToken cancellationToken);
/// <summary>
/// Cheap health probe — e.g. <c>cnc_rdcncstat</c>. Returns <c>true</c> when the CNC
/// responds with any valid status.
/// </summary>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
}
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{
IFocasClient Create();
}
/// <summary>
/// Default factory that throws at construction time — the deployment must register a real
/// factory. Keeps the driver assembly licence-clean while still allowing the skeleton to
/// compile + the abstraction tests to run.
/// </summary>
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => throw new NotSupportedException(
"FOCAS driver has no wire client configured. Register a real IFocasClientFactory at " +
"server startup wrapping the licensed Fwlib32.dll — see docs/v2/focas-deployment.md. " +
"Fanuc licensing forbids shipping Fwlib32.dll in the OtOpcUa package.");
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<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.FOCAS</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.FOCAS</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<!--
No NuGet reference to a FOCAS library — FWLIB is Fanuc-proprietary and the licensed
Fwlib32.dll cannot be redistributed. The deployment side supplies an IFocasClient
implementation that P/Invokes against whatever Fwlib32.dll the customer has licensed.
Driver.FOCAS.IntegrationTests in a separate repo can wire in the real binary.
Follow-up task #193 tracks the real-client reference implementation that customers may
drop in privately.
-->
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests"/>
</ItemGroup>
</Project>

View File

@@ -264,8 +264,27 @@ public sealed class ModbusDriver
return results;
}
// BitInRegister writes need a read-modify-write against the full holding register. A
// per-register lock keeps concurrent bit-write callers from stomping on each other —
// Write bit 0 and Write bit 5 targeting the same register can arrive on separate
// subscriber threads, and without serialising the RMW the second-to-commit value wins
// + the first bit update is lost.
private readonly System.Collections.Concurrent.ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();
private SemaphoreSlim GetRmwLock(ushort address) =>
_rmwLocks.GetOrAdd(address, _ => new SemaphoreSlim(1, 1));
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
{
// BitInRegister → RMW dispatch ahead of the normal encode path so the lock + read-modify-
// write sequence doesn't hit EncodeRegister's defensive throw.
if (tag.DataType == ModbusDataType.BitInRegister &&
tag.Region is ModbusRegion.HoldingRegisters)
{
await WriteBitInRegisterAsync(transport, tag, value, ct).ConfigureAwait(false);
return;
}
switch (tag.Region)
{
case ModbusRegion.Coils:
@@ -309,6 +328,44 @@ public sealed class ModbusDriver
}
}
/// <summary>
/// Read-modify-write one bit in a holding register. FC03 → bit-swap → FC06. Serialised
/// against other bit writes targeting the same register via <see cref="GetRmwLock"/>.
/// </summary>
private async Task WriteBitInRegisterAsync(
IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
{
var bit = tag.BitIndex;
if (bit > 15)
throw new InvalidOperationException(
$"BitInRegister bit index {bit} out of range (0-15) for tag {tag.Name}.");
var on = Convert.ToBoolean(value);
var rmwLock = GetRmwLock(tag.Address);
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
try
{
// FC03 read 1 holding register at tag.Address.
var readPdu = new byte[] { 0x03, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
var readResp = await transport.SendAsync(_options.UnitId, readPdu, ct).ConfigureAwait(false);
// resp = [fc][byte-count=2][hi][lo]
var current = (ushort)((readResp[2] << 8) | readResp[3]);
var updated = on
? (ushort)(current | (1 << bit))
: (ushort)(current & ~(1 << bit));
// FC06 write single holding register.
var writePdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
(byte)(updated >> 8), (byte)(updated & 0xFF) };
await transport.SendAsync(_options.UnitId, writePdu, ct).ConfigureAwait(false);
}
finally
{
rmwLock.Release();
}
}
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
@@ -575,8 +632,11 @@ public sealed class ModbusDriver
return b;
}
case ModbusDataType.BitInRegister:
// Reached only if BitInRegister is somehow passed outside the HoldingRegisters
// path. Normal BitInRegister writes dispatch through WriteBitInRegisterAsync via
// the RMW shortcut in WriteOneAsync.
throw new InvalidOperationException(
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
"BitInRegister writes must go through WriteBitInRegisterAsync (HoldingRegisters region only).");
default:
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
}

View File

@@ -1,4 +1,9 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using TwinCAT;
using TwinCAT.Ads;
using TwinCAT.Ads.TypeSystem;
using TwinCAT.TypeSystem;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -17,6 +22,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
internal sealed class AdsTwinCATClient : ITwinCATClient
{
private readonly AdsClient _client = new();
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
public AdsTwinCATClient()
{
_client.AdsNotificationEx += OnAdsNotificationEx;
}
public bool IsConnected => _client.IsConnected;
@@ -95,7 +106,129 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
public void Dispose() => _client.Dispose();
public async Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
TimeSpan cycleTime,
Action<string, object?> onChange,
CancellationToken cancellationToken)
{
var clrType = MapToClrType(type);
// NotificationSettings takes cycle + max-delay in 100ns units. AdsTransMode.OnChange
// fires when the value differs; OnCycle fires every cycle. OnChange is the right default
// for OPC UA data-change semantics — the PLC already has the best view of "has this
// changed" so we let it decide.
var cycleTicks = (uint)Math.Max(1, cycleTime.Ticks / TimeSpan.TicksPerMillisecond * 10_000);
var settings = new NotificationSettings(AdsTransMode.OnChange, (int)cycleTicks, 0);
// AddDeviceNotificationExAsync returns Task<ResultHandle>; AdsNotificationEx fires
// with the handle as part of the event args so we use the handle as the correlation
// key into _notifications.
var result = await _client.AddDeviceNotificationExAsync(
symbolPath, settings, userData: null, clrType, args: null, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
throw new InvalidOperationException(
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}");
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle);
_notifications[result.Handle] = reg;
return reg;
}
private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args)
{
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
var value = args.Value;
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit);
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
}
internal async Task DeleteNotificationAsync(uint handle, CancellationToken cancellationToken)
{
_notifications.TryRemove(handle, out _);
try { await _client.DeleteDeviceNotificationAsync(handle, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort tear-down; target may already be gone */ }
}
public async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the
// async surface on this interface is for our callers, not for the underlying call which
// is effectively sync on top of the already-open AdsClient.
var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat);
var loader = SymbolLoaderFactory.Create(_client, settings);
await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync
foreach (ISymbol symbol in loader.Symbols)
{
if (cancellationToken.IsCancellationRequested) yield break;
var mapped = MapSymbolTypeName(symbol.DataType?.Name);
var readOnly = !IsSymbolWritable(symbol);
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
}
}
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
{
"BOOL" or "BIT" => TwinCATDataType.Bool,
"SINT" or "BYTE" => TwinCATDataType.SInt,
"USINT" => TwinCATDataType.USInt,
"INT" or "WORD" => TwinCATDataType.Int,
"UINT" => TwinCATDataType.UInt,
"DINT" or "DWORD" => TwinCATDataType.DInt,
"UDINT" => TwinCATDataType.UDInt,
"LINT" or "LWORD" => TwinCATDataType.LInt,
"ULINT" => TwinCATDataType.ULInt,
"REAL" => TwinCATDataType.Real,
"LREAL" => TwinCATDataType.LReal,
"STRING" => TwinCATDataType.String,
"WSTRING" => TwinCATDataType.WString,
"TIME" => TwinCATDataType.Time,
"DATE" => TwinCATDataType.Date,
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
};
private static bool IsSymbolWritable(ISymbol symbol)
{
// SymbolAccessRights is a flags enum — the Write bit indicates a writable symbol.
// When the symbol implementation doesn't surface it, assume writable + let the PLC
// return AccessDenied at write time.
if (symbol is Symbol s) return (s.AccessRights & SymbolAccessRights.Write) != 0;
return true;
}
public void Dispose()
{
_client.AdsNotificationEx -= OnAdsNotificationEx;
_notifications.Clear();
_client.Dispose();
}
private sealed class NotificationRegistration(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
Action<string, object?> onChange,
AdsTwinCATClient owner,
uint handle) : ITwinCATNotificationHandle
{
public string SymbolPath { get; } = symbolPath;
public TwinCATDataType Type { get; } = type;
public int? BitIndex { get; } = bitIndex;
public Action<string, object?> OnChange { get; } = onChange;
public void Dispose()
{
// Fire-and-forget AMS call — caller has already committed to the tear-down.
_ = owner.DeleteNotificationAsync(handle, CancellationToken.None);
}
}
private static Type MapToClrType(TwinCATDataType type) => type switch
{

View File

@@ -46,8 +46,53 @@ public interface ITwinCATClient : IDisposable
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
/// </summary>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
/// Register a cyclic / on-change ADS notification for a symbol. Returns a handle whose
/// <see cref="IDisposable.Dispose"/> tears the notification down. Callback fires on the
/// thread libplctag / AdsClient uses for notifications — consumers should marshal to
/// their own scheduler before doing work of any size.
/// </summary>
/// <param name="symbolPath">ADS symbol path (e.g. <c>MAIN.bStart</c>).</param>
/// <param name="type">Declared type; drives the native layout + callback value boxing.</param>
/// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param>
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
TimeSpan cycleTime,
Action<string, object?> onChange,
CancellationToken cancellationToken);
/// <summary>
/// Walk the target's symbol table via the TwinCAT <c>SymbolLoaderFactory</c> (flat mode).
/// Yields each top-level symbol the PLC exposes — global variables, program-scope locals,
/// function-block instance fields. Filters for our atomic type surface; structured /
/// UDT / function-block typed symbols surface with <c>DataType = null</c> so callers can
/// decide whether to drill in via their own walker.
/// </summary>
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
}
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
public interface ITwinCATNotificationHandle : IDisposable { }
/// <summary>
/// One symbol yielded by <see cref="ITwinCATClient.BrowseSymbolsAsync"/> — full instance
/// path + detected <see cref="TwinCATDataType"/> + read-only flag.
/// </summary>
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>).</param>
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/>; <c>null</c> when the symbol's type
/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks).</param>
/// <param name="ReadOnly"><c>true</c> when the symbol's AccessRights flag forbids writes.</param>
public sealed record TwinCATDiscoveredSymbol(
string InstancePath,
TwinCATDataType? DataType,
bool ReadOnly);
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
public interface ITwinCATClientFactory
{

View File

@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -7,15 +8,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// the <see cref="IDriver"/> skeleton; read / write / discover / subscribe / probe / host-
/// resolver land in PRs 2 and 3.
/// </summary>
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
{
private readonly TwinCATDriverOptions _options;
private readonly string _driverInstanceId;
private readonly ITwinCATClientFactory _clientFactory;
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TwinCATTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
ITwinCATClientFactory? clientFactory = null)
{
@@ -23,6 +29,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable,
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
}
public string DriverInstanceId => _driverInstanceId;
@@ -41,6 +51,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable,
_devices[device.HostAddress] = new DeviceState(addr, device);
}
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
if (_options.Probe.Enabled)
{
foreach (var state in _devices.Values)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
@@ -57,13 +77,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable,
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public Task ShutdownAsync(CancellationToken cancellationToken)
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
foreach (var state in _devices.Values) state.DisposeClient();
// Native subs first — disposing the handles is cheap + lets the client close its
// notifications before the AdsClient itself goes away.
foreach (var sub in _nativeSubs.Values)
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
_nativeSubs.Clear();
await _poll.DisposeAsync().ConfigureAwait(false);
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.DisposeClient();
}
_devices.Clear();
_tagsByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
return Task.CompletedTask;
}
public DriverHealth GetHealth() => _health;
@@ -183,6 +215,201 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable,
return results;
}
// ---- ITagDiscovery ----
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var root = builder.Folder("TwinCAT", "TwinCAT");
foreach (var device in _options.Devices)
{
var label = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, label);
// Pre-declared tags — always emitted as the authoritative config path.
var tagsForDevice = _options.Tags.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent));
}
// Controller-side symbol browse — opt-in. Falls back to pre-declared-only on any
// client-side error so a flaky symbol-table download doesn't block discovery.
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
{
IAddressSpaceBuilder? discoveredFolder = null;
try
{
var client = await EnsureConnectedAsync(state, cancellationToken).ConfigureAwait(false);
await foreach (var sym in client.BrowseSymbolsAsync(cancellationToken).ConfigureAwait(false))
{
if (TwinCATSystemSymbolFilter.IsSystemSymbol(sym.InstancePath)) continue;
if (sym.DataType is not TwinCATDataType dt) continue; // unsupported type
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo(
FullName: sym.InstancePath,
DriverDataType: dt.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
SecurityClass: sym.ReadOnly
? SecurityClassification.ViewOnly
: SecurityClassification.Operate,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
catch (OperationCanceledException) { throw; }
catch
{
// Symbol-loader failure is non-fatal to discovery — pre-declared tags already
// shipped + operators see the failure in driver health on next read.
}
}
}
}
// ---- ISubscribable (native ADS notifications with poll fallback) ----
private readonly ConcurrentDictionary<long, NativeSubscription> _nativeSubs = new();
private long _nextNativeSubId;
/// <summary>
/// Subscribe via native ADS notifications when <see cref="TwinCATDriverOptions.UseNativeNotifications"/>
/// is <c>true</c>, otherwise fall through to the shared <see cref="PollGroupEngine"/>.
/// Native path registers one <see cref="ITwinCATNotificationHandle"/> per tag against the
/// target's PLC runtime — the PLC pushes changes on its own cycle so we skip the poll
/// loop entirely. Unsub path disposes the handles.
/// </summary>
public async Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
if (!_options.UseNativeNotifications)
return _poll.Subscribe(fullReferences, publishingInterval);
var id = Interlocked.Increment(ref _nextNativeSubId);
var handle = new NativeSubscriptionHandle(id);
var registrations = new List<ITwinCATNotificationHandle>(fullReferences.Count);
var now = DateTime.UtcNow;
try
{
foreach (var reference in fullReferences)
{
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
var bitIndex = parsed?.BitIndex;
var reg = await client.AddNotificationAsync(
symbolName, def.DataType, bitIndex, publishingInterval,
(_, value) => OnDataChange?.Invoke(this,
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
cancellationToken).ConfigureAwait(false);
registrations.Add(reg);
}
}
catch
{
// On any registration failure, tear down everything we got so far + rethrow. Leaves
// the subscription in a clean "never existed" state rather than a half-registered
// state the caller has to clean up.
foreach (var r in registrations) { try { r.Dispose(); } catch { } }
throw;
}
_nativeSubs[id] = new NativeSubscription(handle, registrations);
return handle;
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is NativeSubscriptionHandle native && _nativeSubs.TryRemove(native.Id, out var sub))
{
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
return Task.CompletedTask;
}
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
private sealed record NativeSubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"twincat-native-sub-{Id}";
}
private sealed record NativeSubscription(
NativeSubscriptionHandle Handle,
IReadOnlyList<ITwinCATNotificationHandle> Registrations);
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
success = await client.ProbeAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch
{
// Probe failure — EnsureConnectedAsync's connect-failure path already disposed
// + cleared the client, so next tick will reconnect.
}
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
lock (state.ProbeLock)
{
old = state.HostState;
if (old == newState) return;
state.HostState = newState;
state.HostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this,
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
}
// ---- IPerCallHostResolver ----
public string ResolveHost(string fullReference)
{
if (_tagsByName.TryGetValue(fullReference, out var def))
return def.DeviceHostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
private async Task<ITwinCATClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
{
if (device.Client is { IsConnected: true } c) return c;
@@ -210,6 +437,11 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable,
public TwinCATDeviceOptions Options { get; } = options;
public ITwinCATClient? Client { get; set; }
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
public CancellationTokenSource? ProbeCts { get; set; }
public void DisposeClient()
{
Client?.Dispose();

View File

@@ -11,6 +11,27 @@ public sealed class TwinCATDriverOptions
public IReadOnlyList<TwinCATTagDefinition> Tags { get; init; } = [];
public TwinCATProbeOptions Probe { get; init; } = new();
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// When <c>true</c> (default), <c>SubscribeAsync</c> registers native ADS notifications
/// via <c>AddDeviceNotificationExAsync</c> — the PLC pushes changes on its own cycle
/// rather than the driver polling. Strictly better for latency + CPU when the target
/// supports it (TC2 + TC3 PLC runtimes always do; some soft-PLC / third-party ADS
/// implementations may not). When <c>false</c>, the driver falls through to the shared
/// <see cref="Core.Abstractions.PollGroupEngine"/> — same semantics as the other
/// libplctag-backed drivers. Set <c>false</c> for deployments where the AMS router has
/// notification limits you can't raise.
/// </summary>
public bool UseNativeNotifications { get; init; } = true;
/// <summary>
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's symbol table via the
/// TwinCAT <c>SymbolLoaderFactory</c> (flat mode) + surfaces controller-resident
/// globals / program locals under a <c>Discovered/</c> sub-folder. Pre-declared tags
/// from <see cref="Tags"/> always emit regardless. Default <c>false</c> to preserve
/// the strict-config path for deployments where only declared tags should appear.
/// </summary>
public bool EnableControllerBrowse { get; init; }
}
/// <summary>

View File

@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Filter system / infrastructure symbols out of a TwinCAT symbol-loader walk. TC PLC
/// runtimes export plumbing symbols alongside user-declared ones — <c>TwinCAT_SystemInfoVarList</c>,
/// constants, IO task images, motion-layer internals — that clutter an OPC UA address space
/// if exposed.
/// </summary>
public static class TwinCATSystemSymbolFilter
{
/// <summary><c>true</c> when the symbol path matches a known system / infrastructure prefix.</summary>
public static bool IsSystemSymbol(string instancePath)
{
if (string.IsNullOrWhiteSpace(instancePath)) return true;
// Runtime-exported info lists.
if (instancePath.StartsWith("TwinCAT_SystemInfoVarList", StringComparison.OrdinalIgnoreCase)) return true;
if (instancePath.StartsWith("TwinCAT_", StringComparison.OrdinalIgnoreCase)) return true;
if (instancePath.StartsWith("Global_Version", StringComparison.OrdinalIgnoreCase)) return true;
// Constants pool — read-only, no operator value.
if (instancePath.StartsWith("Constants.", StringComparison.OrdinalIgnoreCase)) return true;
// Anonymous / compiler-generated.
if (instancePath.StartsWith("__", StringComparison.Ordinal)) return true;
// Motion / NC internals routinely surfaced by the symbol loader.
if (instancePath.StartsWith("Mc_", StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
}

View File

@@ -219,4 +219,67 @@ public sealed class DriverResiliencePipelineBuilderTests
attempts.ShouldBeLessThanOrEqualTo(1);
}
[Fact]
public async Task Tracker_RecordsFailure_OnEveryRetry()
{
var tracker = new DriverResilienceStatusTracker();
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
var pipeline = builder.GetOrCreate("drv-trk", "host-x", DriverCapability.Read, TierAOptions);
await Should.ThrowAsync<InvalidOperationException>(async () =>
await pipeline.ExecuteAsync(async _ =>
{
await Task.Yield();
throw new InvalidOperationException("always fails");
}));
var snap = tracker.TryGet("drv-trk", "host-x");
snap.ShouldNotBeNull();
var retryCount = TierAOptions.Resolve(DriverCapability.Read).RetryCount;
snap!.ConsecutiveFailures.ShouldBe(retryCount);
}
[Fact]
public async Task Tracker_StampsBreakerOpen_WhenBreakerTrips()
{
var tracker = new DriverResilienceStatusTracker();
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
var pipeline = builder.GetOrCreate("drv-trk", "host-b", DriverCapability.Write, TierAOptions);
var threshold = TierAOptions.Resolve(DriverCapability.Write).BreakerFailureThreshold;
for (var i = 0; i < threshold; i++)
{
await Should.ThrowAsync<InvalidOperationException>(async () =>
await pipeline.ExecuteAsync(async _ =>
{
await Task.Yield();
throw new InvalidOperationException("boom");
}));
}
var snap = tracker.TryGet("drv-trk", "host-b");
snap.ShouldNotBeNull();
snap!.LastBreakerOpenUtc.ShouldNotBeNull();
}
[Fact]
public async Task Tracker_IsolatesCounters_PerHost()
{
var tracker = new DriverResilienceStatusTracker();
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
var dead = builder.GetOrCreate("drv-trk", "dead", DriverCapability.Read, TierAOptions);
var live = builder.GetOrCreate("drv-trk", "live", DriverCapability.Read, TierAOptions);
await Should.ThrowAsync<InvalidOperationException>(async () =>
await dead.ExecuteAsync(async _ =>
{
await Task.Yield();
throw new InvalidOperationException("dead");
}));
await live.ExecuteAsync(async _ => await Task.Yield());
tracker.TryGet("drv-trk", "dead")!.ConsecutiveFailures.ShouldBeGreaterThan(0);
tracker.TryGet("drv-trk", "live").ShouldBeNull();
}
}

View File

@@ -0,0 +1,152 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipBoolInDIntRmwTests
{
/// <summary>
/// Fake tag runtime that stores a DINT value + exposes Read/Write/EncodeValue/DecodeValue
/// for DInt. RMW tests use one instance as the "parent" runtime (tag name "Motor.Flags")
/// which the driver's WriteBitInDIntAsync reads + writes.
/// </summary>
private sealed class ParentDintFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
// Uses the base FakeAbCipTag's Value + ReadCount + WriteCount.
}
[Fact]
public async Task Bit_set_reads_parent_ORs_bit_writes_back()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = 0b0001 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Flag3", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
// Parent runtime created under name "Motor.Flags" — distinct from the bit-selector tag.
factory.Tags.ShouldContainKey("Motor.Flags");
factory.Tags["Motor.Flags"].Value.ShouldBe(0b1001); // bit 3 set, bit 0 preserved
factory.Tags["Motor.Flags"].ReadCount.ShouldBe(1);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
}
[Fact]
public async Task Bit_clear_preserves_other_bits()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = unchecked((int)0xFFFFFFFF) },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
var updated = Convert.ToInt32(factory.Tags["Motor.Flags"].Value);
(updated & (1 << 3)).ShouldBe(0); // bit 3 cleared
(updated & ~(1 << 3)).ShouldBe(unchecked((int)0xFFFFFFF7)); // every other bit preserved
}
[Fact]
public async Task Concurrent_bit_writes_to_same_parent_compose_correctly()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = 0 },
};
var tags = Enumerable.Range(0, 8)
.Select(b => new AbCipTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"Flags.{b}", AbCipDataType.Bool))
.ToArray();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0xFF);
}
[Fact]
public async Task Bit_writes_to_different_parents_each_get_own_runtime()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = 0 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "Motor1.Flags.0", AbCipDataType.Bool),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("A", true)], CancellationToken.None);
await drv.WriteAsync([new WriteRequest("B", true)], CancellationToken.None);
factory.Tags.ShouldContainKey("Motor1.Flags");
factory.Tags.ShouldContainKey("Motor2.Flags");
}
[Fact]
public async Task Repeat_bit_writes_reuse_one_parent_runtime()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = 0 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Bit0", "ab://10.0.0.5/1,0", "Flags.0", AbCipDataType.Bool),
new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
// Three factory invocations: two bit-selector tags (never used for writes, but the
// driver may create them opportunistically) + one shared parent. Assert the parent was
// init'd exactly once + used for both writes.
factory.Tags["Flags"].InitializeCount.ShouldBe(1);
factory.Tags["Flags"].WriteCount.ShouldBe(2);
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0x21); // bits 0 + 5
}
}

View File

@@ -97,6 +97,7 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: enumeratorFactory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -119,6 +120,7 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -137,6 +139,7 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -153,6 +156,7 @@ public sealed class AbCipDriverDiscoveryTests
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
Timeout = TimeSpan.FromSeconds(7),
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);

View File

@@ -60,9 +60,12 @@ public sealed class AbCipDriverWriteTests
}
[Fact]
public async Task Bit_in_dint_write_returns_BadNotSupported()
public async Task Bit_in_dint_write_now_succeeds_via_RMW()
{
var factory = new FakeAbCipTagFactory { Customise = p => new ThrowingBoolBitFake(p) };
// Task #181 pass 2 lifted this gap — BOOL-within-DINT writes now go through
// WriteBitInDIntAsync + a parallel parent-DINT runtime, so the result is Good rather
// than BadNotSupported. Full RMW semantics covered by AbCipBoolInDIntRmwTests.
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
@@ -73,7 +76,7 @@ public sealed class AbCipDriverWriteTests
var results = await drv.WriteAsync(
[new WriteRequest("Flag3", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
[Fact]

View File

@@ -0,0 +1,221 @@
using System.Buffers.Binary;
using System.Reflection;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipFetchUdtShapeTests
{
private sealed class FakeTemplateReader : IAbCipTemplateReader
{
public byte[] Response { get; set; } = [];
public int ReadCount { get; private set; }
public bool Disposed { get; private set; }
public uint LastTemplateId { get; private set; }
public Task<byte[]> ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct)
{
ReadCount++;
LastTemplateId = templateInstanceId;
return Task.FromResult(Response);
}
public void Dispose() => Disposed = true;
}
private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory
{
public List<IAbCipTemplateReader> Readers { get; } = new();
public Func<IAbCipTemplateReader>? Customise { get; set; }
public IAbCipTemplateReader Create()
{
var r = Customise?.Invoke() ?? new FakeTemplateReader();
Readers.Add(r);
return r;
}
}
private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
{
var headerSize = 12;
var blockSize = 8;
var strings = new MemoryStream();
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
Add(name);
foreach (var m in members) Add(m.n);
var stringsArr = strings.ToArray();
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + i * blockSize;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
}
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
return buf;
}
private static Task<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
{
var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync",
BindingFlags.NonPublic | BindingFlags.Instance)!;
return (Task<AbCipUdtShape?>)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!;
}
[Fact]
public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new FakeTemplateReader
{
Response = BuildSimpleTemplate("MotorUdt", 8,
("Speed", 0xC4, 0, 0),
("Enabled", 0xC1, 0, 4)),
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
shape.ShouldNotBeNull();
shape.TypeName.ShouldBe("MotorUdt");
shape.Members.Count.ShouldBe(2);
// Second fetch must hit the cache — no second reader created.
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
factory.Readers.Count.ShouldBe(1);
}
[Fact]
public async Task FetchUdtShapeAsync_different_templateIds_each_fetch()
{
var callCount = 0;
var factory = new FakeTemplateReaderFactory
{
Customise = () =>
{
callCount++;
var name = callCount == 1 ? "UdtA" : "UdtB";
return new FakeTemplateReader
{
Response = BuildSimpleTemplate(name, 4, ("X", 0xC4, 0, 0)),
};
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var a = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
var b = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 2);
a!.TypeName.ShouldBe("UdtA");
b!.TypeName.ShouldBe("UdtB");
factory.Readers.Count.ShouldBe(2);
}
[Fact]
public async Task FetchUdtShapeAsync_unknown_device_returns_null()
{
var factory = new FakeTemplateReaderFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.99/1,0", 1);
shape.ShouldBeNull();
factory.Readers.ShouldBeEmpty();
}
[Fact]
public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new FakeTemplateReader { Response = [0x00, 0x00] }, // too short
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
shape.ShouldBeNull();
// Next call retries (not cached as a failure).
var shape2 = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
shape2.ShouldBeNull();
factory.Readers.Count.ShouldBe(2);
}
[Fact]
public async Task FetchUdtShapeAsync_reader_exception_returns_null()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new ThrowingTemplateReader(),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
shape.ShouldBeNull();
}
[Fact]
public async Task FlushOptionalCachesAsync_empties_template_cache()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new FakeTemplateReader
{
Response = BuildSimpleTemplate("U", 4, ("X", 0xC4, 0, 0)),
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
drv.TemplateCache.Count.ShouldBe(1);
await drv.FlushOptionalCachesAsync(CancellationToken.None);
drv.TemplateCache.Count.ShouldBe(0);
// Next fetch hits the network again.
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
factory.Readers.Count.ShouldBe(2);
}
private sealed class ThrowingTemplateReader : IAbCipTemplateReader
{
public Task<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
throw new InvalidOperationException("fake read failure");
public void Dispose() { }
}
}

View File

@@ -0,0 +1,186 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class CipSymbolObjectDecoderTests
{
/// <summary>
/// Build one Symbol Object entry in the byte layout
/// <c>instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad</c>.
/// </summary>
private static byte[] BuildEntry(
uint instanceId,
ushort symbolType,
ushort elementLength,
(uint, uint, uint) arrayDims,
string name)
{
var nameBytes = Encoding.ASCII.GetBytes(name);
var nameLen = nameBytes.Length;
var totalLen = 22 + nameLen;
if ((totalLen & 1) != 0) totalLen++; // pad to even
var buf = new byte[totalLen];
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen);
Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen);
return buf;
}
private static byte[] Concat(params byte[][] chunks)
{
var total = chunks.Sum(c => c.Length);
var result = new byte[total];
var pos = 0;
foreach (var c in chunks)
{
Buffer.BlockCopy(c, 0, result, pos, c.Length);
pos += c.Length;
}
return result;
}
[Fact]
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
{
var bytes = BuildEntry(
instanceId: 42,
symbolType: 0xC4,
elementLength: 4,
arrayDims: (0, 0, 0),
name: "Counter");
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
tags.Count.ShouldBe(1);
tags[0].Name.ShouldBe("Counter");
tags[0].ProgramScope.ShouldBeNull();
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
tags[0].IsSystemTag.ShouldBeFalse();
}
[Theory]
[InlineData((byte)0xC1, AbCipDataType.Bool)]
[InlineData((byte)0xC2, AbCipDataType.SInt)]
[InlineData((byte)0xC3, AbCipDataType.Int)]
[InlineData((byte)0xC4, AbCipDataType.DInt)]
[InlineData((byte)0xC5, AbCipDataType.LInt)]
[InlineData((byte)0xC6, AbCipDataType.USInt)]
[InlineData((byte)0xC7, AbCipDataType.UInt)]
[InlineData((byte)0xC8, AbCipDataType.UDInt)]
[InlineData((byte)0xC9, AbCipDataType.ULInt)]
[InlineData((byte)0xCA, AbCipDataType.Real)]
[InlineData((byte)0xCB, AbCipDataType.LReal)]
[InlineData((byte)0xD0, AbCipDataType.String)]
public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected)
{
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
}
[Fact]
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
{
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
}
[Fact]
public void Struct_flag_overrides_type_code_and_yields_Structure()
{
// 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234)
var bytes = BuildEntry(
instanceId: 5,
symbolType: 0x8000 | 0x0234,
elementLength: 16,
arrayDims: (0, 0, 0),
name: "Motor1");
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
tag.DataType.ShouldBe(AbCipDataType.Structure);
}
[Fact]
public void System_flag_surfaces_as_IsSystemTag_true()
{
var bytes = BuildEntry(
instanceId: 99,
symbolType: 0x1000 | 0xC4, // system flag + DINT
elementLength: 4,
arrayDims: (0, 0, 0),
name: "__Reserved_1");
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
tag.IsSystemTag.ShouldBeTrue();
tag.DataType.ShouldBe(AbCipDataType.DInt);
}
[Fact]
public void Program_scope_name_splits_prefix_into_ProgramScope()
{
var bytes = BuildEntry(
instanceId: 1,
symbolType: 0xC4,
elementLength: 4,
arrayDims: (0, 0, 0),
name: "Program:MainProgram.StepIndex");
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
tag.ProgramScope.ShouldBe("MainProgram");
tag.Name.ShouldBe("StepIndex");
}
[Fact]
public void Multiple_entries_decode_in_wire_order_with_even_padding()
{
// Name "Abc" is 3 bytes — triggers the even-pad branch between entries.
var bytes = Concat(
BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4)
BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi"
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
tags.Count.ShouldBe(2);
tags[0].Name.ShouldBe("Abc");
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
tags[1].Name.ShouldBe("Pi");
tags[1].DataType.ShouldBe(AbCipDataType.Real);
}
[Fact]
public void Truncated_buffer_stops_decoding_gracefully()
{
var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter");
// Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw.
var truncated = full.Take(full.Length - 5).ToArray();
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
}
[Fact]
public void Empty_buffer_yields_no_tags()
{
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
}
[Theory]
[InlineData("Counter", null, "Counter")]
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
[InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")]
[InlineData("Program:", null, "Program:")] // malformed — no dot
[InlineData("Program:OnlyProg", null, "Program:OnlyProg")]
[InlineData("Motor.Status.Running", null, "Motor.Status.Running")]
public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName)
{
var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input);
scope.ShouldBe(expectedScope);
name.ShouldBe(expectedName);
}
}

View File

@@ -0,0 +1,180 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class CipTemplateObjectDecoderTests
{
/// <summary>
/// Construct a Template Object blob — header + member blocks + semicolon-delimited
/// strings (UDT name first, then member names).
/// </summary>
private static byte[] BuildTemplate(
string udtName,
uint instanceSize,
params (string name, ushort info, ushort arraySize, uint offset)[] members)
{
var memberCount = (ushort)members.Length;
var headerSize = 12;
var memberBlockSize = 8;
var blocksSize = memberBlockSize * members.Length;
var stringsBuf = new MemoryStream();
void AppendString(string s)
{
var bytes = Encoding.ASCII.GetBytes(s + ";\0");
stringsBuf.Write(bytes, 0, bytes.Length);
}
AppendString(udtName);
foreach (var m in members) AppendString(m.name);
var strings = stringsBuf.ToArray();
var buf = new byte[headerSize + blocksSize + strings.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), 0);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + (i * memberBlockSize);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arraySize);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].offset);
}
Buffer.BlockCopy(strings, 0, buf, headerSize + blocksSize, strings.Length);
return buf;
}
[Fact]
public void Simple_two_member_UDT_decodes_correctly()
{
var bytes = BuildTemplate("MotorUdt", instanceSize: 8,
("Speed", info: 0xC4, arraySize: 0, offset: 0), // DINT at offset 0
("Enabled", info: 0xC1, arraySize: 0, offset: 4)); // BOOL at offset 4
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.TypeName.ShouldBe("MotorUdt");
shape.TotalSize.ShouldBe(8);
shape.Members.Count.ShouldBe(2);
shape.Members[0].Name.ShouldBe("Speed");
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
shape.Members[0].Offset.ShouldBe(0);
shape.Members[0].ArrayLength.ShouldBe(1);
shape.Members[1].Name.ShouldBe("Enabled");
shape.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
shape.Members[1].Offset.ShouldBe(4);
}
[Fact]
public void Struct_member_flag_surfaces_Structure_type()
{
var bytes = BuildTemplate("ContainerUdt", instanceSize: 32,
("InnerStruct", info: 0x8042, arraySize: 0, offset: 0)); // struct flag + template-id 0x42
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
[Fact]
public void Array_member_carries_non_one_ArrayLength()
{
var bytes = BuildTemplate("ArrayUdt", instanceSize: 40,
("Values", info: 0xC4, arraySize: 10, offset: 0));
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.Members.Single().ArrayLength.ShouldBe(10);
}
[Fact]
public void Multiple_atomic_types_preserve_offsets_and_types()
{
var bytes = BuildTemplate("MixedUdt", instanceSize: 24,
("A", 0xC1, 0, 0), // BOOL
("B", 0xC2, 0, 1), // SINT
("C", 0xC3, 0, 2), // INT
("D", 0xC4, 0, 4), // DINT
("E", 0xCA, 0, 8), // REAL
("F", 0xCB, 0, 16)); // LREAL
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.Members.Count.ShouldBe(6);
shape.Members.Select(m => m.DataType).ShouldBe(
[AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int,
AbCipDataType.DInt, AbCipDataType.Real, AbCipDataType.LReal]);
shape.Members.Select(m => m.Offset).ShouldBe([0, 1, 2, 4, 8, 16]);
}
[Fact]
public void Unknown_atomic_type_code_falls_back_to_Structure()
{
var bytes = BuildTemplate("WeirdUdt", instanceSize: 4,
("Unknown", info: 0xFF, 0, 0));
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
[Fact]
public void Zero_member_count_returns_null()
{
var buf = new byte[12];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 0);
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
}
[Fact]
public void Short_buffer_returns_null()
{
CipTemplateObjectDecoder.Decode([0x01, 0x00]).ShouldBeNull(); // only 2 bytes — less than header
}
[Fact]
public void Missing_member_name_surfaces_placeholder()
{
// Header says 3 members but strings list has only UDT name + 2 member names.
var memberCount = (ushort)3;
var buf = new byte[12 + 8 * 3];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), 12);
for (var i = 0; i < 3; i++)
{
var o = 12 + i * 8;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), 0xC4);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), (uint)(i * 4));
}
// strings: only UDT + 2 members, missing the third.
var strings = Encoding.ASCII.GetBytes("MyUdt;\0A;\0B;\0");
var combined = buf.Concat(strings).ToArray();
var shape = CipTemplateObjectDecoder.Decode(combined);
shape.ShouldNotBeNull();
shape.Members.Count.ShouldBe(3);
shape.Members[2].Name.ShouldBe("<member_2>");
}
[Theory]
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
[InlineData("Only;\0", new[] { "Only" })]
[InlineData(";\0", new string[] { })] // empty
[InlineData("", new string[] { })]
public void ParseSemicolonTerminatedStrings_handles_shapes(string input, string[] expected)
{
var bytes = Encoding.ASCII.GetBytes(input);
var result = CipTemplateObjectDecoder.ParseSemicolonTerminatedStrings(bytes);
result.ShouldBe(expected);
}
}

View File

@@ -0,0 +1,104 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
[Trait("Category", "Unit")]
public sealed class AbLegacyBitRmwTests
{
[Fact]
public async Task Bit_set_reads_parent_word_ORs_bit_writes_back()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0b0001 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("Flag3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Flag3", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.ShouldContainKey("N7:0"); // parent word runtime created
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0b1001);
}
[Fact]
public async Task Bit_clear_preserves_other_bits_in_N_file_word()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0xFFFF) },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(unchecked((short)0xFFF7));
}
[Fact]
public async Task Concurrent_bit_writes_to_same_word_compose_correctly()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
};
var tags = Enumerable.Range(0, 8)
.Select(b => new AbLegacyTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"N7:0/{b}", AbLegacyDataType.Bit))
.ToArray();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0xFF);
}
[Fact]
public async Task Repeat_bit_writes_reuse_parent_runtime()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbLegacyTagDefinition("Bit0", "ab://10.0.0.5/1,0", "N7:0/0", AbLegacyDataType.Bit),
new AbLegacyTagDefinition("Bit5", "ab://10.0.0.5/1,0", "N7:0/5", AbLegacyDataType.Bit),
],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
factory.Tags["N7:0"].WriteCount.ShouldBe(2);
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0x21); // bits 0 + 5
}
}

View File

@@ -157,9 +157,12 @@ public sealed class AbLegacyReadWriteTests
}
[Fact]
public async Task Bit_within_word_write_rejected_as_BadNotSupported()
public async Task Bit_within_word_write_now_succeeds_via_RMW()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new RmwThrowingFake(p) };
// Task #181 pass 2 lifted this gap — N-file bit writes now go through
// WriteBitInWordAsync + a parallel parent-word runtime, so the status is Good rather
// than BadNotSupported. Full RMW semantics covered by AbLegacyBitRmwTests.
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
@@ -170,7 +173,7 @@ public sealed class AbLegacyReadWriteTests
var results = await drv.WriteAsync(
[new WriteRequest("Bit3", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
}
[Fact]

View File

@@ -0,0 +1,69 @@
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
internal class FakeFocasClient : IFocasClient
{
public bool IsConnected { get; private set; }
public int ConnectCount { get; private set; }
public int DisposeCount { get; private set; }
public bool ThrowOnConnect { get; set; }
public bool ThrowOnRead { get; set; }
public bool ThrowOnWrite { get; set; }
public bool ProbeResult { get; set; } = true;
public Exception? Exception { get; set; }
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
ConnectCount++;
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
IsConnected = true;
return Task.CompletedTask;
}
public virtual Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
var key = address.Canonical;
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
var value = Values.TryGetValue(key, out var v) ? v : null;
return Task.FromResult((value, status));
}
public virtual Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
WriteLog.Add((address, type, value));
Values[address.Canonical] = value;
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
return Task.FromResult(status);
}
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
public virtual void Dispose()
{
DisposeCount++;
IsConnected = false;
}
}
internal sealed class FakeFocasClientFactory : IFocasClientFactory
{
public List<FakeFocasClient> Clients { get; } = new();
public Func<FakeFocasClient>? Customise { get; set; }
public IFocasClient Create()
{
var c = Customise?.Invoke() ?? new FakeFocasClient();
Clients.Add(c);
return c;
}
}

View File

@@ -0,0 +1,239 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasCapabilityTests
{
// ---- ITagDiscovery ----
[Fact]
public async Task DiscoverAsync_emits_pre_declared_tags()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
Tags =
[
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
// ---- ISubscribable ----
[Fact]
public async Task Subscribe_initial_poll_raises_OnDataChange()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)42 } },
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
events.First().Snapshot.Value.ShouldBe((sbyte)42);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
[Fact]
public async Task ShutdownAsync_cancels_active_subscriptions()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } },
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
await drv.ShutdownAsync(CancellationToken.None);
var afterShutdown = events.Count;
await Task.Delay(200);
events.Count.ShouldBe(afterShutdown);
}
// ---- IHostConnectivityProbe ----
[Fact]
public async Task GetHostStatuses_returns_entry_per_device()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions("focas://10.0.0.5:8193"),
new FocasDeviceOptions("focas://10.0.0.6:8193"),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetHostStatuses().Count.ShouldBe(2);
}
[Fact]
public async Task Probe_transitions_to_Running_on_success()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient { ProbeResult = true },
};
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_transitions_to_Stopped_on_failure()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient { ProbeResult = false },
};
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- IPerCallHostResolver ----
[Fact]
public async Task ResolveHost_returns_declared_device_for_known_tag()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions("focas://10.0.0.5:8193"),
new FocasDeviceOptions("focas://10.0.0.6:8193"),
],
Tags =
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("A").ShouldBe("focas://10.0.0.5:8193");
drv.ResolveHost("B").ShouldBe("focas://10.0.0.6:8193");
}
[Fact]
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("missing").ShouldBe("focas://10.0.0.5:8193");
}
[Fact]
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
{
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("anything").ShouldBe("drv-1");
}
// ---- helpers ----
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}

View File

@@ -0,0 +1,123 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasPmcBitRmwTests
{
/// <summary>
/// Fake client simulating PMC byte storage + exposing it as a sbyte so RMW callers can
/// observe the read-modify-write round-trip. ReadAsync for a Bit with bitIndex surfaces
/// the current bit; WriteAsync stores the full byte the driver issues.
/// </summary>
private sealed class PmcRmwFake : FakeFocasClient
{
public byte[] PmcBytes { get; } = new byte[1024];
public override Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
return Task.FromResult(((object?)(sbyte)PmcBytes[address.Number], FocasStatusMapper.Good));
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
return Task.FromResult(((object?)((PmcBytes[address.Number] & (1 << bit)) != 0), FocasStatusMapper.Good));
return base.ReadAsync(address, type, ct);
}
public override Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
// Driver writes the full byte after RMW (type==Byte with full byte value), OR a raw
// bit write (type==Bit, bitIndex non-null) — depending on how the driver routes it.
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
{
PmcBytes[address.Number] = (byte)Convert.ToSByte(value);
return Task.FromResult(FocasStatusMapper.Good);
}
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
{
var current = PmcBytes[address.Number];
PmcBytes[address.Number] = Convert.ToBoolean(value)
? (byte)(current | (1 << bit))
: (byte)(current & ~(1 << bit));
return Task.FromResult(FocasStatusMapper.Good);
}
return base.WriteAsync(address, type, value, ct);
}
}
private static (FocasDriver drv, PmcRmwFake fake) NewDriver(params FocasTagDefinition[] tags)
{
var fake = new PmcRmwFake();
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = tags,
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, fake);
}
[Fact]
public async Task Bit_set_surfaces_as_Good_status_and_flips_bit()
{
var (drv, fake) = NewDriver(
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.PmcBytes[100] = 0b0000_0001;
var results = await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
fake.PmcBytes[100].ShouldBe((byte)0b0000_1001);
}
[Fact]
public async Task Bit_clear_preserves_other_bits()
{
var (drv, fake) = NewDriver(
new FocasTagDefinition("Flag", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.PmcBytes[100] = 0xFF;
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
fake.PmcBytes[100].ShouldBe((byte)0b1111_0111);
}
[Fact]
public async Task Subsequent_bit_sets_in_same_byte_compose_correctly()
{
var tags = Enumerable.Range(0, 8)
.Select(b => new FocasTagDefinition($"Bit{b}", "focas://10.0.0.5:8193", $"R100.{b}", FocasDataType.Bit))
.ToArray();
var (drv, fake) = NewDriver(tags);
await drv.InitializeAsync("{}", CancellationToken.None);
fake.PmcBytes[100] = 0;
for (var b = 0; b < 8; b++)
await drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None);
fake.PmcBytes[100].ShouldBe((byte)0xFF);
}
[Fact]
public async Task Bit_write_to_different_bytes_does_not_contend()
{
var tags = Enumerable.Range(0, 4)
.Select(i => new FocasTagDefinition($"Bit{i}", "focas://10.0.0.5:8193", $"R{50 + i}.0", FocasDataType.Bit))
.ToArray();
var (drv, fake) = NewDriver(tags);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 4).Select(i =>
drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None)));
for (var i = 0; i < 4; i++)
fake.PmcBytes[50 + i].ShouldBe((byte)0x01);
}
}

View File

@@ -0,0 +1,261 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasReadWriteTests
{
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(params FocasTagDefinition[] tags)
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = tags,
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, factory);
}
// ---- Read ----
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
var (drv, _) = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Successful_PMC_read_returns_Good_value()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)5 } };
var snapshots = await drv.ReadAsync(["Run"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
snapshots.Single().Value.ShouldBe((sbyte)5);
}
[Fact]
public async Task Parameter_read_routes_through_FocasAddress_Parameter_kind()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("Accel", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeFocasClient { Values = { ["PARAM:1820"] = 1500 } };
var snapshots = await drv.ReadAsync(["Accel"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
snapshots.Single().Value.ShouldBe(1500);
}
[Fact]
public async Task Macro_read_routes_through_FocasAddress_Macro_kind()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeFocasClient { Values = { ["MACRO:500"] = 3.14159 } };
var snapshots = await drv.ReadAsync(["CustomVar"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(3.14159);
}
[Fact]
public async Task Repeat_read_reuses_connection()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Clients.Count.ShouldBe(1);
factory.Clients[0].ConnectCount.ShouldBe(1);
}
[Fact]
public async Task FOCAS_error_status_maps_via_status_mapper()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("Ghost", "focas://10.0.0.5:8193", "R999", FocasDataType.Byte));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () =>
{
var c = new FakeFocasClient();
c.ReadStatuses["R999"] = FocasStatusMapper.BadNodeIdUnknown;
return c;
};
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Read_exception_surfaces_BadCommunicationError()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeFocasClient { ThrowOnRead = true };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
[Fact]
public async Task Connect_failure_disposes_client_and_surfaces_BadCommunicationError()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeFocasClient { ThrowOnConnect = true };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
factory.Clients[0].DisposeCount.ShouldBe(1);
}
[Fact]
public async Task Batched_reads_preserve_order_across_areas()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32),
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeFocasClient
{
Values =
{
["R100"] = (sbyte)5,
["PARAM:1820"] = 1500,
["MACRO:500"] = 2.718,
},
};
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
snapshots[0].Value.ShouldBe((sbyte)5);
snapshots[1].Value.ShouldBe(1500);
snapshots[2].Value.ShouldBe(2.718);
}
// ---- Write ----
[Fact]
public async Task Non_writable_tag_rejected_with_BadNotWritable()
{
var (drv, _) = NewDriver(
new FocasTagDefinition("RO", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("RO", 1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
[Fact]
public async Task Successful_write_logs_address_type_value()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Speed", (short)1800)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
var write = factory.Clients[0].WriteLog.Single();
write.addr.Canonical.ShouldBe("R100");
write.type.ShouldBe(FocasDataType.Int16);
write.value.ShouldBe((short)1800);
}
[Fact]
public async Task Write_status_code_maps_via_FocasStatusMapper()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () =>
{
var c = new FakeFocasClient();
c.WriteStatuses["R100"] = FocasStatusMapper.BadNotWritable;
return c;
};
var results = await drv.WriteAsync(
[new WriteRequest("Protected", (sbyte)1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
[Fact]
public async Task Batch_write_preserves_order_across_outcomes()
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[
new WriteRequest("A", (sbyte)1),
new WriteRequest("B", (sbyte)2),
new WriteRequest("Unknown", (sbyte)3),
], CancellationToken.None);
results[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
results[2].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Cancellation_propagates()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeFocasClient
{
ThrowOnRead = true,
Exception = new OperationCanceledException(),
};
await Should.ThrowAsync<OperationCanceledException>(
() => drv.ReadAsync(["X"], CancellationToken.None));
}
[Fact]
public async Task ShutdownAsync_disposes_client()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
factory.Clients[0].DisposeCount.ShouldBe(1);
}
}

View File

@@ -0,0 +1,229 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasScaffoldingTests
{
// ---- FocasHostAddress ----
[Theory]
[InlineData("focas://10.0.0.5:8193", "10.0.0.5", 8193)]
[InlineData("focas://10.0.0.5", "10.0.0.5", 8193)] // default port
[InlineData("focas://cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)]
[InlineData("focas://10.0.0.5:12345", "10.0.0.5", 12345)]
[InlineData("FOCAS://10.0.0.5:8193", "10.0.0.5", 8193)] // case-insensitive scheme
public void HostAddress_parses_valid(string input, string host, int port)
{
var parsed = FocasHostAddress.TryParse(input);
parsed.ShouldNotBeNull();
parsed.Host.ShouldBe(host);
parsed.Port.ShouldBe(port);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("http://10.0.0.5/")]
[InlineData("focas:10.0.0.5:8193")] // missing //
[InlineData("focas://")] // empty body
[InlineData("focas://10.0.0.5:0")] // port 0
[InlineData("focas://10.0.0.5:65536")] // port out of range
[InlineData("focas://10.0.0.5:abc")] // non-numeric port
public void HostAddress_rejects_invalid(string? input)
{
FocasHostAddress.TryParse(input).ShouldBeNull();
}
[Fact]
public void HostAddress_ToString_strips_default_port()
{
new FocasHostAddress("10.0.0.5", 8193).ToString().ShouldBe("focas://10.0.0.5");
new FocasHostAddress("10.0.0.5", 12345).ToString().ShouldBe("focas://10.0.0.5:12345");
}
// ---- FocasAddress ----
[Theory]
[InlineData("X0.0", FocasAreaKind.Pmc, "X", 0, 0)]
[InlineData("X0", FocasAreaKind.Pmc, "X", 0, null)]
[InlineData("Y10", FocasAreaKind.Pmc, "Y", 10, null)]
[InlineData("F20.3", FocasAreaKind.Pmc, "F", 20, 3)]
[InlineData("G54", FocasAreaKind.Pmc, "G", 54, null)]
[InlineData("R100", FocasAreaKind.Pmc, "R", 100, null)]
[InlineData("D200", FocasAreaKind.Pmc, "D", 200, null)]
[InlineData("C300", FocasAreaKind.Pmc, "C", 300, null)]
[InlineData("K400", FocasAreaKind.Pmc, "K", 400, null)]
[InlineData("A500", FocasAreaKind.Pmc, "A", 500, null)]
[InlineData("E600", FocasAreaKind.Pmc, "E", 600, null)]
[InlineData("T50.4", FocasAreaKind.Pmc, "T", 50, 4)]
public void Address_parses_PMC_forms(string input, FocasAreaKind kind, string letter, int num, int? bit)
{
var a = FocasAddress.TryParse(input);
a.ShouldNotBeNull();
a.Kind.ShouldBe(kind);
a.PmcLetter.ShouldBe(letter);
a.Number.ShouldBe(num);
a.BitIndex.ShouldBe(bit);
}
[Theory]
[InlineData("PARAM:1020", FocasAreaKind.Parameter, 1020, null)]
[InlineData("PARAM:1815/0", FocasAreaKind.Parameter, 1815, 0)]
[InlineData("PARAM:1815/31", FocasAreaKind.Parameter, 1815, 31)]
public void Address_parses_parameter_forms(string input, FocasAreaKind kind, int num, int? bit)
{
var a = FocasAddress.TryParse(input);
a.ShouldNotBeNull();
a.Kind.ShouldBe(kind);
a.PmcLetter.ShouldBeNull();
a.Number.ShouldBe(num);
a.BitIndex.ShouldBe(bit);
}
[Theory]
[InlineData("MACRO:100", FocasAreaKind.Macro, 100)]
[InlineData("MACRO:500", FocasAreaKind.Macro, 500)]
public void Address_parses_macro_forms(string input, FocasAreaKind kind, int num)
{
var a = FocasAddress.TryParse(input);
a.ShouldNotBeNull();
a.Kind.ShouldBe(kind);
a.Number.ShouldBe(num);
a.BitIndex.ShouldBeNull();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("Z0")] // unknown PMC letter
[InlineData("X")] // missing number
[InlineData("X-1")] // negative number
[InlineData("Xabc")] // non-numeric
[InlineData("X0.8")] // bit out of range (0-7)
[InlineData("X0.-1")] // negative bit
[InlineData("PARAM:")] // missing number
[InlineData("PARAM:1815/32")] // bit out of range (0-31)
[InlineData("MACRO:abc")] // non-numeric
public void Address_rejects_invalid_forms(string? input)
{
FocasAddress.TryParse(input).ShouldBeNull();
}
[Theory]
[InlineData("X0.0")]
[InlineData("R100")]
[InlineData("F20.3")]
[InlineData("PARAM:1020")]
[InlineData("PARAM:1815/0")]
[InlineData("MACRO:100")]
public void Address_Canonical_roundtrips(string input)
{
var parsed = FocasAddress.TryParse(input);
parsed.ShouldNotBeNull();
parsed.Canonical.ShouldBe(input);
}
// ---- FocasDataType ----
[Fact]
public void DataType_mapping_covers_atomic_focas_types()
{
FocasDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
FocasDataType.Int16.ToDriverDataType().ShouldBe(DriverDataType.Int32);
FocasDataType.Int32.ToDriverDataType().ShouldBe(DriverDataType.Int32);
FocasDataType.Float32.ToDriverDataType().ShouldBe(DriverDataType.Float32);
FocasDataType.Float64.ToDriverDataType().ShouldBe(DriverDataType.Float64);
FocasDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
}
// ---- FocasStatusMapper ----
[Theory]
[InlineData(0, FocasStatusMapper.Good)]
[InlineData(3, FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
[InlineData(4, FocasStatusMapper.BadOutOfRange)] // EW_LENGTH
[InlineData(5, FocasStatusMapper.BadNotWritable)] // EW_PROT
[InlineData(6, FocasStatusMapper.BadNotSupported)] // EW_NOOPT
[InlineData(8, FocasStatusMapper.BadNodeIdUnknown)] // EW_DATA
[InlineData(-1, FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
[InlineData(-8, FocasStatusMapper.BadInternalError)] // EW_HANDLE
[InlineData(-16, FocasStatusMapper.BadCommunicationError)] // EW_SOCKET
[InlineData(999, FocasStatusMapper.BadCommunicationError)] // unknown → generic
public void StatusMapper_covers_known_focas_returns(int ret, uint expected)
{
FocasStatusMapper.MapFocasReturn(ret).ShouldBe(expected);
}
// ---- FocasDriver ----
[Fact]
public void DriverType_is_FOCAS()
{
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
drv.DriverType.ShouldBe("FOCAS");
drv.DriverInstanceId.ShouldBe("drv-1");
}
[Fact]
public async Task InitializeAsync_parses_device_addresses()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions("focas://10.0.0.5:8193"),
new FocasDeviceOptions("focas://10.0.0.6:12345", DeviceName: "CNC-2"),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(2);
drv.GetDeviceState("focas://10.0.0.5:8193")!.ParsedAddress.Port.ShouldBe(8193);
drv.GetDeviceState("focas://10.0.0.6:12345")!.Options.DeviceName.ShouldBe("CNC-2");
}
[Fact]
public async Task InitializeAsync_malformed_address_faults()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("not-an-address")],
}, "drv-1");
await Should.ThrowAsync<InvalidOperationException>(
() => drv.InitializeAsync("{}", CancellationToken.None));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
[Fact]
public async Task ShutdownAsync_clears_devices()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
// ---- UnimplementedFocasClientFactory ----
[Fact]
public void Default_factory_throws_on_Create_with_deployment_pointer()
{
var factory = new UnimplementedFocasClientFactory();
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
ex.Message.ShouldContain("Fwlib32.dll");
ex.Message.ShouldContain("licensed");
}
}

View File

@@ -0,0 +1,106 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Tests for the managed helpers inside FwlibNative + FwlibFocasClient that don't require the
/// licensed Fwlib32.dll — letter→ADR_* mapping, FocasDataType→data-type mapping, byte encoding.
/// The actual P/Invoke calls can only run where the DLL is present; field testing covers those.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FwlibNativeHelperTests
{
[Theory]
[InlineData("G", 0)]
[InlineData("F", 1)]
[InlineData("Y", 2)]
[InlineData("X", 3)]
[InlineData("A", 4)]
[InlineData("R", 5)]
[InlineData("T", 6)]
[InlineData("K", 7)]
[InlineData("C", 8)]
[InlineData("D", 9)]
[InlineData("E", 10)]
[InlineData("g", 0)] // case-insensitive
public void PmcAddrType_maps_every_valid_letter(string letter, short expected)
{
FocasPmcAddrType.FromLetter(letter).ShouldBe(expected);
}
[Theory]
[InlineData("Z")]
[InlineData("")]
[InlineData("XX")]
public void PmcAddrType_rejects_unknown_letters(string letter)
{
FocasPmcAddrType.FromLetter(letter).ShouldBeNull();
}
[Theory]
[InlineData(FocasDataType.Bit, 0)] // byte
[InlineData(FocasDataType.Byte, 0)]
[InlineData(FocasDataType.Int16, 1)] // word
[InlineData(FocasDataType.Int32, 2)] // long
[InlineData(FocasDataType.Float32, 4)]
[InlineData(FocasDataType.Float64, 5)]
public void PmcDataType_maps_FocasDataType_to_FOCAS_code(FocasDataType input, short expected)
{
FocasPmcDataType.FromFocasDataType(input).ShouldBe(expected);
}
[Fact]
public void EncodePmcValue_Byte_writes_signed_byte_at_offset_0()
{
var buf = new byte[40];
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Byte, (sbyte)-5, bitIndex: null);
((sbyte)buf[0]).ShouldBe((sbyte)-5);
}
[Fact]
public void EncodePmcValue_Int16_writes_little_endian()
{
var buf = new byte[40];
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int16, (short)0x1234, bitIndex: null);
buf[0].ShouldBe((byte)0x34);
buf[1].ShouldBe((byte)0x12);
}
[Fact]
public void EncodePmcValue_Int32_writes_little_endian()
{
var buf = new byte[40];
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int32, 0x12345678, bitIndex: null);
buf[0].ShouldBe((byte)0x78);
buf[1].ShouldBe((byte)0x56);
buf[2].ShouldBe((byte)0x34);
buf[3].ShouldBe((byte)0x12);
}
[Fact]
public void EncodePmcValue_Bit_without_bit_index_writes_byte_boolean()
{
// Task #181 closed the Bit-write gap — PMC Bit with a bitIndex now routes through
// WritePmcBitAsync's RMW path upstream, and raw EncodePmcValue only gets the
// no-bit-index case (treated as a whole-byte boolean).
var buf = new byte[40];
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: null);
buf[0].ShouldBe((byte)1);
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, false, bitIndex: null);
buf[0].ShouldBe((byte)0);
}
[Fact]
public void EncodeParamValue_Int32_writes_little_endian()
{
var buf = new byte[32];
FwlibFocasClient.EncodeParamValue(buf, FocasDataType.Int32, 0x0A0B0C0D);
buf[0].ShouldBe((byte)0x0D);
buf[1].ShouldBe((byte)0x0C);
buf[2].ShouldBe((byte)0x0B);
buf[3].ShouldBe((byte)0x0A);
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,141 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class ModbusBitRmwTests
{
/// <summary>Fake transport capturing each PDU so tests can assert on the read + write sequence.</summary>
private sealed class RmwTransport : IModbusTransport
{
public readonly ushort[] HoldingRegisters = new ushort[256];
public readonly List<byte[]> Pdus = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
Pdus.Add(pdu);
if (pdu[0] == 0x03)
{
// FC03 Read Holding Registers.
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var resp = new byte[2 + qty * 2];
resp[0] = 0x03;
resp[1] = (byte)(qty * 2);
for (var i = 0; i < qty; i++)
{
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
}
return Task.FromResult(resp);
}
if (pdu[0] == 0x06)
{
// FC06 Write Single Register.
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var v = (ushort)((pdu[3] << 8) | pdu[4]);
HoldingRegisters[addr] = v;
return Task.FromResult(new byte[] { 0x06, pdu[1], pdu[2], pdu[3], pdu[4] });
}
return Task.FromException<byte[]>(new NotSupportedException($"FC 0x{pdu[0]:X2} not supported by fake"));
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static (ModbusDriver drv, RmwTransport fake) NewDriver(params ModbusTagDefinition[] tags)
{
var fake = new RmwTransport();
var opts = new ModbusDriverOptions
{
Host = "fake",
Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
};
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
}
[Fact]
public async Task Bit_set_reads_current_register_ORs_bit_writes_back()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("Flag3", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 3));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[10] = 0b0000_0001; // bit 0 already set
var results = await drv.WriteAsync([new WriteRequest("Flag3", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(0u);
fake.HoldingRegisters[10].ShouldBe((ushort)0b0000_1001); // bit 3 now set, bit 0 preserved
// Two PDUs: FC03 read then FC06 write.
fake.Pdus.Count.ShouldBe(2);
fake.Pdus[0][0].ShouldBe((byte)0x03);
fake.Pdus[1][0].ShouldBe((byte)0x06);
}
[Fact]
public async Task Bit_clear_reads_current_register_ANDs_bit_off_writes_back()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("Flag3", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 3));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[10] = 0xFFFF; // all bits set
await drv.WriteAsync([new WriteRequest("Flag3", false)], CancellationToken.None);
fake.HoldingRegisters[10].ShouldBe((ushort)0b1111_1111_1111_0111); // bit 3 cleared, rest preserved
}
[Fact]
public async Task Concurrent_bit_writes_to_same_register_preserve_all_updates()
{
// Serialization test — 8 writers target different bits in register 20. Without the RMW
// lock, concurrent reads interleave + last-to-commit wins so some bits get lost.
var tags = Enumerable.Range(0, 8)
.Select(b => new ModbusTagDefinition($"Bit{b}", ModbusRegion.HoldingRegisters, 20, ModbusDataType.BitInRegister, BitIndex: (byte)b))
.ToArray();
var (drv, fake) = NewDriver(tags);
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[20] = 0;
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
fake.HoldingRegisters[20].ShouldBe((ushort)0xFF); // all 8 bits set
}
[Fact]
public async Task Bit_write_on_different_registers_proceeds_in_parallel_without_contention()
{
var tags = Enumerable.Range(0, 4)
.Select(i => new ModbusTagDefinition($"Bit{i}", ModbusRegion.HoldingRegisters, (ushort)(50 + i), ModbusDataType.BitInRegister, BitIndex: 0))
.ToArray();
var (drv, fake) = NewDriver(tags);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 4).Select(i =>
drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None)));
for (var i = 0; i < 4; i++)
fake.HoldingRegisters[50 + i].ShouldBe((ushort)0x01);
}
[Fact]
public async Task Bit_write_preserves_other_bits_in_the_same_register()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("BitA", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 5),
new ModbusTagDefinition("BitB", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 10));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("BitA", true)], CancellationToken.None);
await drv.WriteAsync([new WriteRequest("BitB", true)], CancellationToken.None);
fake.HoldingRegisters[30].ShouldBe((ushort)((1 << 5) | (1 << 10)));
}
}

View File

@@ -132,12 +132,15 @@ public sealed class ModbusDataTypeTests
}
[Fact]
public void BitInRegister_write_is_not_supported_in_PR24()
public void BitInRegister_EncodeRegister_still_rejects_direct_calls()
{
// BitInRegister writes now go through WriteBitInRegisterAsync's RMW path (task #181).
// EncodeRegister should never be reached for this type — if it is, throwing keeps an
// unintended caller loud rather than silently clobbering the register.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
BitIndex: 5);
Should.Throw<InvalidOperationException>(() => ModbusDriver.EncodeRegister(true, tag))
.Message.ShouldContain("read-modify-write");
.Message.ShouldContain("WriteBitInRegisterAsync");
}
// --- String ---

View File

@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
@@ -56,6 +57,65 @@ internal class FakeTwinCATClient : ITwinCATClient
DisposeCount++;
IsConnected = false;
}
// ---- notification fake ----
public List<FakeNotification> Notifications { get; } = new();
public bool ThrowOnAddNotification { get; set; }
public virtual Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
Action<string, object?> onChange, CancellationToken cancellationToken)
{
if (ThrowOnAddNotification)
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
Notifications.Add(reg);
return Task.FromResult<ITwinCATNotificationHandle>(reg);
}
/// <summary>Fire a change event through the registered callback for <paramref name="symbolPath"/>.</summary>
public void FireNotification(string symbolPath, object? value)
{
foreach (var n in Notifications)
if (!n.Disposed && string.Equals(n.SymbolPath, symbolPath, StringComparison.OrdinalIgnoreCase))
n.OnChange(symbolPath, value);
}
// ---- symbol browser fake ----
public List<TwinCATDiscoveredSymbol> BrowseResults { get; } = new();
public bool ThrowOnBrowse { get; set; }
public virtual async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (ThrowOnBrowse) throw Exception ?? new InvalidOperationException("fake browse failure");
await Task.CompletedTask;
foreach (var sym in BrowseResults)
{
if (cancellationToken.IsCancellationRequested) yield break;
yield return sym;
}
}
public sealed class FakeNotification(
string symbolPath, TwinCATDataType type, int? bitIndex,
Action<string, object?> onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle
{
public string SymbolPath { get; } = symbolPath;
public TwinCATDataType Type { get; } = type;
public int? BitIndex { get; } = bitIndex;
public Action<string, object?> OnChange { get; } = onChange;
public bool Disposed { get; private set; }
public void Dispose()
{
Disposed = true;
owner.Notifications.Remove(this);
}
}
}
internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory

View File

@@ -0,0 +1,257 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATCapabilityTests
{
// ---- ITagDiscovery ----
[Fact]
public async Task DiscoverAsync_emits_pre_declared_tags()
{
var builder = new RecordingBuilder();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")],
Tags =
[
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt),
new TwinCATTagDefinition("Status", "ads://5.23.91.23.1.1:851", "GVL.Status", TwinCATDataType.Bool, Writable: false),
],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "TwinCAT");
builder.Folders.ShouldContain(f => f.BrowseName == "ads://5.23.91.23.1.1:851" && f.DisplayName == "Mach1");
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "Status").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
// ---- ISubscribable ----
[Fact]
public async Task Subscribe_initial_poll_raises_OnDataChange()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 42 } },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false, // poll-mode test
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
events.First().Snapshot.Value.ShouldBe(42);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
[Fact]
public async Task ShutdownAsync_cancels_active_subscriptions()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false, // poll-mode test
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
await drv.ShutdownAsync(CancellationToken.None);
var afterShutdown = events.Count;
await Task.Delay(200);
events.Count.ShouldBe(afterShutdown);
}
// ---- IHostConnectivityProbe ----
[Fact]
public async Task GetHostStatuses_returns_entry_per_device()
{
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices =
[
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetHostStatuses().Count.ShouldBe(2);
}
[Fact]
public async Task Probe_transitions_to_Running_on_successful_probe()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { ProbeResult = true },
};
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_transitions_to_Stopped_on_probe_failure()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { ProbeResult = false },
};
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_disabled_when_Enabled_is_false()
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(200);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- IPerCallHostResolver ----
[Fact]
public async Task ResolveHost_returns_declared_device_for_known_tag()
{
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices =
[
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
],
Tags =
[
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.24.1.1:851", "MAIN.B", TwinCATDataType.DInt),
],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("A").ShouldBe("ads://5.23.91.23.1.1:851");
drv.ResolveHost("B").ShouldBe("ads://5.23.91.24.1.1:851");
}
[Fact]
public async Task ResolveHost_falls_back_to_first_device_for_unknown_ref()
{
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("missing").ShouldBe("ads://5.23.91.23.1.1:851");
}
[Fact]
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
{
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("anything").ShouldBe("drv-1");
}
// ---- helpers ----
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}

View File

@@ -54,6 +54,7 @@ public sealed class TwinCATDriverTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -68,6 +69,7 @@ public sealed class TwinCATDriverTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReinitializeAsync("{}", CancellationToken.None);

View File

@@ -0,0 +1,221 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATNativeNotificationTests
{
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(params TwinCATTagDefinition[] tags)
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = tags,
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
return (drv, factory);
}
[Fact]
public async Task Native_subscribe_registers_one_notification_per_tag()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
handle.DiagnosticId.ShouldStartWith("twincat-native-sub-");
factory.Clients[0].Notifications.Count.ShouldBe(2);
factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.B"], ignoreOrder: true);
}
[Fact]
public async Task Native_notification_fires_OnDataChange_with_pushed_value()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].FireNotification("MAIN.Speed", 4200);
factory.Clients[0].FireNotification("MAIN.Speed", 4201);
events.Count.ShouldBe(2);
events.Last().Snapshot.Value.ShouldBe(4201);
events.Last().FullReference.ShouldBe("Speed"); // driver-side reference, not ADS symbol
}
[Fact]
public async Task Native_unsubscribe_disposes_all_notifications()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].Notifications.Count.ShouldBe(2);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
factory.Clients[0].Notifications.ShouldBeEmpty();
}
[Fact]
public async Task Native_unsubscribe_halts_future_notifications()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].FireNotification("MAIN.X", 1);
var snapshotFake = factory.Clients[0];
await drv.UnsubscribeAsync(handle, CancellationToken.None);
var afterUnsub = events.Count;
// After unsubscribe the fake's Notifications list is empty so FireNotification finds nothing
// to invoke. This mirrors the production contract — disposed handles no longer deliver.
snapshotFake.FireNotification("MAIN.X", 999);
events.Count.ShouldBe(afterUnsub);
}
[Fact]
public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state()
{
// Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws.
// Subscribe's catch block must tear the first one down before rethrowing so no zombie
// notification lingers.
var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1);
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags =
[
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt),
],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await Should.ThrowAsync<InvalidOperationException>(() =>
drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None));
// First registration succeeded then got torn down by the catch; second threw.
fake.AddCallCount.ShouldBe(2);
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
}
private sealed class AbTagParamsIrrelevant { }
private sealed class FailAfterNAddsFake : FakeTwinCATClient
{
private readonly int _succeedBefore;
public int AddCallCount { get; private set; }
public FailAfterNAddsFake(AbTagParamsIrrelevant _, int succeedBefore) : base()
{
_succeedBefore = succeedBefore;
}
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
Action<string, object?> onChange, CancellationToken cancellationToken)
{
AddCallCount++;
if (AddCallCount > _succeedBefore)
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, onChange, cancellationToken);
}
}
[Fact]
public async Task Native_shutdown_disposes_subscriptions()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].Notifications.Count.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
factory.Clients[0].Notifications.ShouldBeEmpty();
}
[Fact]
public async Task Poll_path_still_works_when_UseNativeNotifications_false()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 7 } },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(150), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
events.First().Snapshot.Value.ShouldBe(7);
factory.Clients[0].Notifications.ShouldBeEmpty(); // no native notifications on poll path
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
[Fact]
public async Task Subscribe_handle_DiagnosticId_indicates_native_vs_poll()
{
var (drvNative, _) = NewNativeDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drvNative.InitializeAsync("{}", CancellationToken.None);
var nativeHandle = await drvNative.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
nativeHandle.DiagnosticId.ShouldContain("native");
var factoryPoll = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } },
};
var drvPoll = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false,
}, "drv-1", factoryPoll);
await drvPoll.InitializeAsync("{}", CancellationToken.None);
var pollHandle = await drvPoll.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
pollHandle.DiagnosticId.ShouldNotContain("native");
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}

View File

@@ -0,0 +1,212 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATSymbolBrowserTests
{
[Fact]
public async Task Discovery_without_EnableControllerBrowse_emits_only_predeclared()
{
var builder = new RecordingBuilder();
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Hidden", TwinCATDataType.DInt, false));
return c;
},
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldBe(["Declared"]);
builder.Folders.ShouldNotContain(f => f.BrowseName == "Discovered");
}
[Fact]
public async Task Discovery_with_browse_enabled_adds_controller_symbols_under_Discovered_folder()
{
var builder = new RecordingBuilder();
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, ReadOnly: false));
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("GVL.Setpoint", TwinCATDataType.Real, ReadOnly: false));
return c;
},
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
builder.Variables.Select(v => v.Info.FullName).ShouldContain("MAIN.Counter");
builder.Variables.Select(v => v.Info.FullName).ShouldContain("GVL.Setpoint");
}
[Fact]
public async Task Browse_filters_system_symbols()
{
var builder = new RecordingBuilder();
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("TwinCAT_SystemInfoVarList._AppInfo", TwinCATDataType.DInt, false));
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Constants.PI", TwinCATDataType.LReal, true));
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Mc_InternalState", TwinCATDataType.DInt, true));
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("__CompilerGen", TwinCATDataType.DInt, true));
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Real", TwinCATDataType.DInt, false));
return c;
},
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Real"]);
}
[Fact]
public async Task Browse_skips_symbols_with_null_datatype()
{
var builder = new RecordingBuilder();
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Struct", DataType: null, ReadOnly: false));
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, false));
return c;
},
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Counter"]);
}
[Fact]
public async Task ReadOnly_symbol_surfaces_ViewOnly()
{
var builder = new RecordingBuilder();
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Status", TwinCATDataType.DInt, ReadOnly: true));
return c;
},
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task Browse_failure_is_non_fatal_predeclared_still_emits()
{
var builder = new RecordingBuilder();
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { ThrowOnBrowse = true },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldContain("Declared");
}
[Theory]
[InlineData("TwinCAT_SystemInfoVarList._AppInfo", true)]
[InlineData("TwinCAT_RuntimeInfo.Something", true)]
[InlineData("Constants.PI", true)]
[InlineData("Mc_AxisState", true)]
[InlineData("__hidden", true)]
[InlineData("Global_Version", true)]
[InlineData("MAIN.UserVar", false)]
[InlineData("GVL.Counter", false)]
[InlineData("MyFbInstance.State", false)]
[InlineData("", true)]
[InlineData(" ", true)]
public void SystemSymbolFilter_matches_expected_patterns(string path, bool expected)
{
TwinCATSystemSymbolFilter.IsSystemSymbol(path).ShouldBe(expected);
}
// ---- helpers ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}