Compare commits

...

16 Commits

Author SHA1 Message Date
Joseph Doherty
2acea08ced Admin Equipment editor — IdentificationFields component + edit mode + three missing OPC 40010 fields. Closes the UI-editor slice of task #159 (Phase 6.4 Stream D remaining); the DriverNodeManager wire-in + ACL integration test are split into a new follow-up task #195 because they're blocked on a prerequisite that hasn't shipped — the DriverNodeManager does not currently materialize Equipment nodes at all (NodeScopeResolver has an explicit "A future resolver will..." TODO in its decomposition docstring). Shipping the IdentificationFolderBuilder call before the parent walker exists would wire a call that no code path hits, so the wire-in is deferred until the Equipment node walker lands first. New IdentificationFields.razor reusable component renders the 9-field decision #139 grid in a Bootstrap 3-column layout — Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction (InputNumber), AssetLocation, ManufacturerUri (placeholder https://…), DeviceManualUri (placeholder https://…). Takes a required Equipment parameter + 2-way binds every field; no state of its own. Three fields that were missing from the old inline form — AssetLocation, ManufacturerUri, DeviceManualUri — now present, matching IdentificationFolderBuilder.FieldNames exactly. EquipmentTab.razor refactored to consume the component in both create + edit flows. Each table row gains an Edit button next to Remove. StartEdit clones the row into _draft so Cancel doesn't mutate the displayed list row with in-flight edits; on Save, UpdateAsync persists through EquipmentService's existing update path which already handles all 9 Identification fields. SaveAsync branches on _editMode — create still derives EquipmentId from a fresh Uuid via DraftValidator per decision #125, edit keeps the original EquipmentId + EquipmentUuid (immutable once set). FormName renamed equipment-form (was new-equipment) to work for both flows. Admin project builds 0 errors; Admin.Tests 72/72 passing. No new tests shipped — this PR is strictly a Razor-component refactor + two new bound fields + an Edit branch; the existing EquipmentService tests cover both the create + update paths. Task #195 created to track the blocked server-side work: call IdentificationFolderBuilder.Build from DriverNodeManager once the Equipment walker exists, plus an integration test browsing Equipment/Identification as an unauthorized user asserting BadUserAccessDenied per the builder's cross-reference note in docs/v2/acl-design.md §Identification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:41:13 -04:00
49f6c9484e Merge pull request (#132) - Admin /hosts red-badge + Polly telemetry observer 2026-04-19 21:38:11 -04:00
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
40 changed files with 2968 additions and 79 deletions

View File

@@ -36,7 +36,10 @@ else if (_equipment.Count > 0)
<td>@e.SAPID</td>
<td>@e.Manufacturer / @e.Model</td>
<td>@e.SerialNumber</td>
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button></td>
<td>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(e)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button>
</td>
</tr>
}
</tbody>
@@ -47,8 +50,8 @@ else if (_equipment.Count > 0)
{
<div class="card mt-3">
<div class="card-body">
<h5>New equipment</h5>
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="new-equipment">
<h5>@(_editMode ? "Edit equipment" : "New equipment")</h5>
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form">
<DataAnnotationsValidator/>
<div class="row g-3">
<div class="col-md-4">
@@ -78,24 +81,13 @@ else if (_equipment.Count > 0)
</div>
</div>
<h6 class="mt-4">OPC 40010 Identification</h6>
<div class="row g-3">
<div class="col-md-4"><label class="form-label">Manufacturer</label><InputText @bind-Value="_draft.Manufacturer" class="form-control"/></div>
<div class="col-md-4"><label class="form-label">Model</label><InputText @bind-Value="_draft.Model" class="form-control"/></div>
<div class="col-md-4"><label class="form-label">Serial number</label><InputText @bind-Value="_draft.SerialNumber" class="form-control"/></div>
<div class="col-md-4"><label class="form-label">Hardware rev</label><InputText @bind-Value="_draft.HardwareRevision" class="form-control"/></div>
<div class="col-md-4"><label class="form-label">Software rev</label><InputText @bind-Value="_draft.SoftwareRevision" class="form-control"/></div>
<div class="col-md-4">
<label class="form-label">Year of construction</label>
<InputNumber @bind-Value="_draft.YearOfConstruction" class="form-control"/>
</div>
</div>
<IdentificationFields Equipment="_draft"/>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="() => _showForm = false">Cancel</button>
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="Cancel">Cancel</button>
</div>
</EditForm>
</div>
@@ -106,6 +98,7 @@ else if (_equipment.Count > 0)
[Parameter] public long GenerationId { get; set; }
private List<Equipment>? _equipment;
private bool _showForm;
private bool _editMode;
private Equipment _draft = NewBlankDraft();
private string? _error;
@@ -125,20 +118,68 @@ else if (_equipment.Count > 0)
private void StartAdd()
{
_draft = NewBlankDraft();
_editMode = false;
_error = null;
_showForm = true;
}
private void StartEdit(Equipment row)
{
// Shallow-clone so Cancel doesn't mutate the list-displayed row with in-flight form edits.
_draft = new Equipment
{
EquipmentRowId = row.EquipmentRowId,
GenerationId = row.GenerationId,
EquipmentId = row.EquipmentId,
EquipmentUuid = row.EquipmentUuid,
DriverInstanceId = row.DriverInstanceId,
DeviceId = row.DeviceId,
UnsLineId = row.UnsLineId,
Name = row.Name,
MachineCode = row.MachineCode,
ZTag = row.ZTag,
SAPID = row.SAPID,
Manufacturer = row.Manufacturer,
Model = row.Model,
SerialNumber = row.SerialNumber,
HardwareRevision = row.HardwareRevision,
SoftwareRevision = row.SoftwareRevision,
YearOfConstruction = row.YearOfConstruction,
AssetLocation = row.AssetLocation,
ManufacturerUri = row.ManufacturerUri,
DeviceManualUri = row.DeviceManualUri,
EquipmentClassRef = row.EquipmentClassRef,
Enabled = row.Enabled,
};
_editMode = true;
_error = null;
_showForm = true;
}
private void Cancel()
{
_showForm = false;
_editMode = false;
}
private async Task SaveAsync()
{
_error = null;
_draft.EquipmentUuid = Guid.NewGuid();
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
_draft.GenerationId = GenerationId;
try
{
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
if (_editMode)
{
await EquipmentSvc.UpdateAsync(_draft, CancellationToken.None);
}
else
{
_draft.EquipmentUuid = Guid.NewGuid();
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
_draft.GenerationId = GenerationId;
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
}
_showForm = false;
_editMode = false;
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }

View File

@@ -0,0 +1,49 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@* Reusable OPC 40010 Machinery Identification editor. Binds to an Equipment row and renders the
nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's
create + edit forms so the same UI renders regardless of which flow opened it. *@
<h6 class="mt-4">OPC 40010 Identification</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Manufacturer</label>
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Model</label>
<InputText @bind-Value="Equipment!.Model" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Serial number</label>
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Hardware rev</label>
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Software rev</label>
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Year of construction</label>
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Asset location</label>
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Manufacturer URI</label>
<InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control" placeholder="https://…"/>
</div>
<div class="col-md-4">
<label class="form-label">Device manual URI</label>
<InputText @bind-Value="Equipment!.DeviceManualUri" class="form-control" placeholder="https://…"/>
</div>
</div>
@code {
[Parameter, EditorRequired] public Equipment? Equipment { get; set; }
}

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

@@ -15,15 +15,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
/// fail fast.
/// </remarks>
public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
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)
{
@@ -31,6 +36,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
_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;
@@ -49,6 +58,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
_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)
@@ -65,13 +84,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
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();
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;
@@ -189,6 +214,96 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
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;
@@ -215,6 +330,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
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();

View File

@@ -1,4 +1,5 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -24,6 +25,13 @@ 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)
@@ -55,21 +63,72 @@ internal sealed class FwlibFocasClient : IFocasClient
};
}
public Task<uint> WriteAsync(
public async Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
if (!_connected) return FocasStatusMapper.BadCommunicationError;
cancellationToken.ThrowIfCancellationRequested();
return address.Kind switch
{
FocasAreaKind.Pmc => Task.FromResult(WritePmc(address, type, value)),
FocasAreaKind.Parameter => Task.FromResult(WriteParameter(address, type, value)),
FocasAreaKind.Macro => Task.FromResult(WriteMacro(address, value)),
_ => Task.FromResult(FocasStatusMapper.BadNotSupported),
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);
@@ -216,11 +275,11 @@ internal sealed class FwlibFocasClient : IFocasClient
switch (type)
{
case FocasDataType.Bit:
// Bit-in-byte write is a read-modify-write at the PMC level — the underlying
// pmc_wrpmcrng takes a byte payload, so caller must set the bit on a byte they
// just read. This path is flagged for the follow-up RMW work in task #181.
throw new NotSupportedException(
"FOCAS Bit writes require read-modify-write; tracked in task #181.");
// 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;

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,5 +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;
@@ -149,6 +153,56 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
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;

View File

@@ -66,11 +66,33 @@ public interface ITwinCATClient : IDisposable
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

@@ -217,7 +217,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// ---- ITagDiscovery ----
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var root = builder.Folder("TwinCAT", "TwinCAT");
@@ -225,6 +225,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
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)
@@ -241,8 +243,42 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
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.
}
}
}
return Task.CompletedTask;
}
// ---- ISubscribable (native ADS notifications with poll fallback) ----

View File

@@ -23,6 +23,15 @@ public sealed class TwinCATDriverOptions
/// 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,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

@@ -207,6 +207,7 @@ public sealed class FocasScaffoldingTests
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);

View File

@@ -80,11 +80,17 @@ public sealed class FwlibNativeHelperTests
}
[Fact]
public void EncodePmcValue_Bit_throws_NotSupported_for_RMW_gap()
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];
Should.Throw<NotSupportedException>(() =>
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: 3));
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]

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;
@@ -82,6 +83,23 @@ internal class FakeTwinCATClient : ITwinCATClient
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

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) { } }
}
}