diff --git a/CLAUDE.md b/CLAUDE.md index aec3bf30..ceb70efd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -984,7 +984,7 @@ CLI share them) — except an AbLegacy driver with any PLC-5 / `LogixPccc` devic "Default" is encoded by omitting the key; the AdminUI must never re-emit a concrete default. Evidence: AbCip reads proven on a real CompactLogix (Z-35162, read-only); AbCip writes and all AbLegacy are `ab_server`-only. Fall back with `"transport": "Libplctag"`. ⚠️ AbLegacy native refuses status-bit sub-element -writes (`T4:0.DN`…, client sees `BadCommunicationError`) and its ST byte order is unverified. Client-visible: native reports `BadTypeMismatch` where libplctag returned +writes (`T4:0.DN`…, client sees `BadNotSupported`; refusals never evict or degrade) and its ST byte order is unverified. Client-visible: native reports `BadTypeMismatch` where libplctag returned Good garbage for a same-width mis-authored type (DINT authored, REAL returned). libplctag is confined to `Libplctag*.cs` adapters by `LibplctagConfinementGuardTests`; do not reference it elsewhere. See `docs/drivers/AbCip.md` §Transport, `docs/drivers/AbLegacy.md`, and diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index dd253d3f..837bce56 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -159,6 +159,9 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi alignment pad (84, not 82) (#570). - Status-bit sub-element writes (e.g. `T4:0.DN`) are Unsupported natively (they would clobber the control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. +- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g. + a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag + handle, and leaves driver health unchanged; only real transport failures evict and degrade. ## Known gaps diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index 45ea0438..71931876 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -134,10 +134,8 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected - ⚠️ **Status-bit writes are refused on the default.** An SLC 500 / MicroLogix driver on the native default **refuses writes to status-bit sub-elements** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, - `R6:0.EN`, and the rest. The client sees **`BadCommunicationError`** (not `BadNotSupported`: the driver's - write path maps the refusal like any transport failure, as for the PLC-5 refusal in #571), the tag's handle - is evicted, and driver health goes `Degraded` with "Writes to a status-bit sub-element … are not supported by - the native transport". **Nothing is written to the PLC.** libplctag performed a masked bit write here, so an + `R6:0.EN`, and the rest. The client sees **`BadNotSupported`**. It is not treated as a comms fault: the + tag's handle is kept and driver health is unchanged. **Nothing is written to the PLC.** libplctag performed a masked bit write here, so an authored writable status-bit tag that worked before the upgrade fails after it. This is deliberate: a native read-modify-write of the control word would race the PLC updating TT/DN and could clobber EN/TT/DN, and such writes are rare. **Drivers that need them must set `"transport": "Libplctag"`.** Reads of status bits are @@ -164,8 +162,8 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected - ⚠️ **Simulator-only.** PCCC reads and writes are verified against `ab_server` only. No real SLC/MicroLogix/ PLC-5 has been exercised; the real-press gate (Task 21) was read-only and AbCip-only. -- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused, currently - surfacing as `BadCommunicationError` rather than `BadNotSupported` (#571). The default excludes PLC-5 and +- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the + wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). The default excludes PLC-5 and `logixpccc` (they resolve to libplctag unless `Native` is set explicitly). - Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 5bf613ca..1e3ad79a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -854,6 +854,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { throw; } + catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported) + { + // A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no + // health signal (Task 22 review r1). + results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now); + return ReadOutcome.None; + } catch (Exception ex) { // Transport exception — evict so the next read creates a fresh handle. An AbTransportException @@ -928,6 +935,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { throw; } + catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported) + { + StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported); + return ReadOutcome.None; + } catch (Exception ex) { EvictRuntime(device, parent.Name); @@ -1064,6 +1076,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, "AbCip write value out of range for tag {Tag} on device {Device}", w.FullReference, def.DeviceHostAddress); } + catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported) + { + // A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms + // fault: BadNotSupported, keep the handle, leave health alone (Task 22 review r1). + results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported); + _logger.LogWarning(ate, + "AbCip write refused (not supported) for tag {Tag} on device {Device}", + w.FullReference, def.DeviceHostAddress); + } catch (Exception ex) { // Transport / wire error — evict so the next write creates a fresh handle. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 510e588a..aef9f6bd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; @@ -376,6 +377,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover _health = new DriverHealth(DriverState.Healthy, now, null); } catch (OperationCanceledException) { throw; } + catch (AbTransportException ex) when (IsLocalRefusal(ex)) + { + // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read, #571). + // Not a comms fault: BadNotSupported, keep the handle, leave health alone. + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); + } catch (Exception ex) { // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip). @@ -478,6 +485,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange); } + catch (AbTransportException ex) when (IsLocalRefusal(ex)) + { + // The runtime refused the write before the wire — a native status-bit sub-element write or a + // PLC-5 write (#571). Not a comms fault: BadNotSupported, keep the handle, leave health alone. + results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); + } catch (Exception ex) { // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip). @@ -744,16 +757,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parentName, Timeout: _options.Timeout)); - try - { - await runtime.InitializeAsync(ct).ConfigureAwait(false); - } - catch - { - runtime.Dispose(); - throw; - } - device.ParentRuntimes[parentName] = runtime; + await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct) + .ConfigureAwait(false); return runtime; } finally @@ -780,6 +785,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover private static int EffectiveArrayLength(AbLegacyTagDefinition def) => def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1; + /// + /// True for a deterministic refusal of the operation itself () — the + /// native runtime raises it BEFORE anything reaches the wire (status-bit writes, PLC-5 non-N reads and + /// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the + /// device's reachability, so it must not evict the handle or degrade driver health. + /// + internal static bool IsLocalRefusal(Exception ex) => + ex is AbTransportException { Status: AbStatus.Unsupported }; + + /// + /// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is + /// still KEPT: the refusal is deterministic and per-operation, so caching it avoids re-creating — and + /// re-leasing the pooled device connection — on every poll. Any other failure disposes it, as before. + /// + private static async Task InitializeOrKeepRefusedAsync(IAbLegacyTagRuntime runtime, Action keep, CancellationToken ct) + { + try + { + await runtime.InitializeAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (IsLocalRefusal(ex) && runtime is NativeAbLegacyTagRuntime) + { + keep(); + throw; + } + catch + { + runtime.Dispose(); + throw; + } + keep(); + } + private async Task EnsureTagRuntimeAsync( DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) { @@ -806,16 +844,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover TagName: parsed.ToLibplctagName(), Timeout: _options.Timeout, ElementCount: EffectiveArrayLength(def))); - try - { - await runtime.InitializeAsync(ct).ConfigureAwait(false); - } - catch - { - runtime.Dispose(); - throw; - } - device.Runtimes[def.Name] = runtime; + await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct) + .ConfigureAwait(false); return runtime; } finally diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs new file mode 100644 index 00000000..f51bfe18 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs @@ -0,0 +1,107 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Task 22 review r1: a runtime that refuses an operation with (the native +/// transport does this before the wire, e.g. a write it cannot type) reaches the client as BadNotSupported — +/// NOT as a comms fault. The handle is kept (one create, never disposed) and driver health is untouched. +/// +[Trait("Category", "Unit")] +public sealed class AbCipLocalRefusalTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + private sealed class RefusingTag(AbCipTagCreateParams p, bool refuseRead, bool refuseWrite) : FakeAbCipTag(p) + { + public override Task ReadAsync(CancellationToken cancellationToken) => + refuseRead + ? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused read")) + : base.ReadAsync(cancellationToken); + + public override Task WriteAsync(CancellationToken cancellationToken) => + refuseWrite + ? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused write")) + : base.WriteAsync(cancellationToken); + } + + private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory, Func Creates)> NewAsync( + bool refuseRead, bool refuseWrite, AbCipDataType type = AbCipDataType.DInt) + { + var creates = 0; + var factory = new FakeAbCipTagFactory + { + Customise = p => { Interlocked.Increment(ref creates); return new RefusingTag(p, refuseRead, refuseWrite) { Value = 1 }; }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", type)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + return (drv, factory, () => Volatile.Read(ref creates)); + } + +#pragma warning disable OTOPCUA0001 // the driver's own status mapping is under test, not resilience wrapping + [Fact] + public async Task A_refused_write_is_BadNotSupported_keeps_the_handle_and_leaves_health_alone() + { + var (drv, factory, creates) = await NewAsync(refuseRead: false, refuseWrite: true); + (await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); + var health = drv.GetHealth(); + + (await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbCipStatusMapper.BadNotSupported); + + drv.GetHealth().ShouldBe(health); + factory.Tags["T"].Disposed.ShouldBeFalse(); + (await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); + creates().ShouldBe(1); + } + + [Fact] + public async Task A_refused_read_is_BadNotSupported_keeps_the_handle_and_leaves_health_alone() + { + var (drv, factory, creates) = await NewAsync(refuseRead: true, refuseWrite: false); + var health = drv.GetHealth(); + + for (var n = 0; n < 2; n++) + (await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbCipStatusMapper.BadNotSupported); + + drv.GetHealth().ShouldBe(health); + factory.Tags["T"].Disposed.ShouldBeFalse(); + creates().ShouldBe(1); + } + + /// Control: any OTHER transport failure is still a comms fault (evicted, Degraded). + [Fact] + public async Task A_non_refusal_write_failure_still_evicts_and_degrades() + { + var factory = new FakeAbCipTagFactory(); + factory.Customise = p => new ThrowingWriteTag(p) { Value = 1 }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + (await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbCipStatusMapper.BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + factory.Tags["T"].Disposed.ShouldBeTrue(); + } +#pragma warning restore OTOPCUA0001 + + private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p) + { + public override Task WriteAsync(CancellationToken cancellationToken) => + Task.FromException(new AbTransportException(AbStatus.BadConnection, "socket closed")); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs index 84070fb9..40320025 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs @@ -382,61 +382,121 @@ public sealed class NativeAbLegacyTagRuntimeTests h.Channel.SentCount.ShouldBe(0); } - // These two tests call the driver's WriteAsync directly to pin its OWN status mapping; resilience wrapping - // (CapabilityInvoker) is out of scope and would retry/mask the very status under test. + // These tests call the driver's WriteAsync/ReadAsync directly to pin its OWN status mapping; resilience + // wrapping (CapabilityInvoker) is out of scope and would retry/mask the very status under test. #pragma warning disable OTOPCUA0001 + /// Native runtimes over the harness channel; counts creates so a test can prove a handle was KEPT. private sealed class HarnessFactory(Harness h) : IAbLegacyTagFactory { - public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => - new NativeAbLegacyTagRuntime(p, h.Scheduler, Vendor, Serial); + public int Creates; + + public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) + { + Interlocked.Increment(ref Creates); + return new NativeAbLegacyTagRuntime(p, h.Scheduler, Vendor, Serial); + } + } + + private const string Host = "ab://10.0.0.1:44818/1,0"; + + private static async Task DriverAsync( + HarnessFactory factory, AbLegacyPlcFamily family, AbLegacyTagDefinition tag) + { + var driver = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host, family)], + RawTags = AbLegacyRawTags.Entries([tag]), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-sb", factory); + await driver.InitializeAsync("{}", CancellationToken.None); + return driver; } /// - /// Pins the CLIENT-VISIBLE status of the native status-bit write refusal, through the driver (Task 22 - /// review r1 I2 — the release note states it). The runtime throws AbTransportException(Unsupported); - /// the driver's write path does not special-case it, so the client sees the generic transport - /// status, and nothing reaches the wire. + /// Task 22 review r1 / #571: a native status-bit sub-element write is refused locally and reaches the + /// client as BadNotSupported — NOT a comms fault: nothing on the wire, the tag handle is kept (no + /// re-create), and driver health is untouched. The tag is read first so its handle exists and the + /// wire count isolates the write. /// [Theory] [InlineData("T4:0.DN")] [InlineData("C5:0.CU")] [InlineData("R6:0.EN")] - public async Task A_status_bit_write_through_the_driver_reports_the_documented_status(string address) + public async Task A_status_bit_write_is_BadNotSupported_without_wire_eviction_or_degraded_health(string address) { await using var h = new Harness("slc500", "N7:0").Replies(Words(0)); - const string host = "ab://10.0.0.1:44818/1,0"; - var driver = new AbLegacyDriver(new AbLegacyDriverOptions - { - Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)], - RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Bit", host, address, AbLegacyDataType.Bit)]), - Probe = new AbLegacyProbeOptions { Enabled = false }, - }, "drv-sb", new HarnessFactory(h)); - await driver.InitializeAsync("{}", CancellationToken.None); + var factory = new HarnessFactory(h); + var driver = await DriverAsync(factory, AbLegacyPlcFamily.Slc500, + new AbLegacyTagDefinition("Bit", Host, address, AbLegacyDataType.Bit)); + (await driver.ReadAsync(["Bit"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var health = driver.GetHealth(); + var sent = h.Channel.SentCount; + var results = await driver.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); - // BadCommunicationError, NOT BadNotSupported: the driver's catch-all maps any runtime exception to - // it (same as the PLC-5 write refusal, #571), evicts the handle and marks health Degraded. - results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); - driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("status-bit sub-element"); - // Refused before the wire: the lazy runtime's init read may go out, but never a write (fnc 0xAA). - lock (h.Channel.Sent) h.Channel.Sent.ShouldNotContain(r => FncOf(r) == 0xAA); + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + h.Channel.SentCount.ShouldBe(sent); // zero wire requests for the refused write + driver.GetHealth().ShouldBe(health); // health unchanged (not Degraded) + (await driver.ReadAsync(["Bit"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + factory.Creates.ShouldBe(1); // handle kept: the read after reused it await driver.ShutdownAsync(CancellationToken.None); } - /// Positive control for the "no 0xAA on the wire" assertion above: the same driver path for a WORD - /// sub-element does send a typed write, so the detector can see one. + /// + /// #571, read side: a native PLC-5 non-N read is refused locally — BadNotSupported, nothing on the wire, + /// health unchanged, and the refused handle is KEPT (one create across repeated reads, so a poll does + /// not re-lease the device connection every cycle). + /// + [Fact] + public async Task A_PLC5_non_N_read_is_BadNotSupported_without_wire_eviction_or_degraded_health() + { + await using var h = new Harness("plc5", "N7:0").Replies(Words(0)); + var factory = new HarnessFactory(h); + var driver = await DriverAsync(factory, AbLegacyPlcFamily.Plc5, + new AbLegacyTagDefinition("F", Host, "F8:0", AbLegacyDataType.Float)); + var health = driver.GetHealth(); + + for (var n = 0; n < 3; n++) + (await driver.ReadAsync(["F"], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.BadNotSupported); + + h.Channel.SentCount.ShouldBe(0); + driver.GetHealth().ShouldBe(health); + factory.Creates.ShouldBe(1); + await driver.ShutdownAsync(CancellationToken.None); + } + + /// #571, write side: a native PLC-5 write is refused locally — BadNotSupported, no write on the wire, + /// handle kept, health unchanged. + [Fact] + public async Task A_PLC5_write_is_BadNotSupported_without_wire_eviction_or_degraded_health() + { + await using var h = new Harness("plc5", "N7:0").Replies(Words(0)); + var factory = new HarnessFactory(h); + var driver = await DriverAsync(factory, AbLegacyPlcFamily.Plc5, + new AbLegacyTagDefinition("N", Host, "N7:0", AbLegacyDataType.Int)); + (await driver.ReadAsync(["N"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var health = driver.GetHealth(); + var sent = h.Channel.SentCount; + + (await driver.WriteAsync([new WriteRequest("N", (short)5)], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.BadNotSupported); + + h.Channel.SentCount.ShouldBe(sent); + driver.GetHealth().ShouldBe(health); + (await driver.ReadAsync(["N"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + factory.Creates.ShouldBe(1); + await driver.ShutdownAsync(CancellationToken.None); + } + + /// Positive control for the wire-count assertions above: the same driver path for a WORD sub-element + /// does send a typed write (fnc 0xAA), so the harness can see one. [Fact] public async Task A_word_sub_element_write_through_the_driver_sends_a_typed_write() { await using var h = new Harness("slc500", "N7:0").Replies(Words(0)); - const string host = "ab://10.0.0.1:44818/1,0"; - var driver = new AbLegacyDriver(new AbLegacyDriverOptions - { - Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)], - RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Pre", host, "T4:0.PRE", AbLegacyDataType.Int)]), - Probe = new AbLegacyProbeOptions { Enabled = false }, - }, "drv-sb", new HarnessFactory(h)); - await driver.InitializeAsync("{}", CancellationToken.None); + var driver = await DriverAsync(new HarnessFactory(h), AbLegacyPlcFamily.Slc500, + new AbLegacyTagDefinition("Pre", Host, "T4:0.PRE", AbLegacyDataType.Int)); var results = await driver.WriteAsync([new WriteRequest("Pre", (short)5)], CancellationToken.None);