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>
This commit is contained in:
@@ -329,9 +329,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
|
|
||||||
try
|
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 runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||||
runtime.EncodeValue(def.DataType, tagPath?.BitIndex, w.Value);
|
|
||||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var status = runtime.GetStatus();
|
var status = runtime.GetStatus();
|
||||||
@@ -374,6 +389,74 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return results;
|
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>
|
/// <summary>
|
||||||
/// Idempotently materialise the runtime handle for a tag definition. First call creates
|
/// Idempotently materialise the runtime handle for a tag definition. First call creates
|
||||||
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
|
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
|
||||||
@@ -572,12 +655,28 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
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()
|
public void DisposeHandles()
|
||||||
{
|
{
|
||||||
foreach (var h in TagHandles.Values) h.Dispose();
|
foreach (var h in TagHandles.Values) h.Dispose();
|
||||||
TagHandles.Clear();
|
TagHandles.Clear();
|
||||||
foreach (var r in Runtimes.Values) r.Dispose();
|
foreach (var r in Runtimes.Values) r.Dispose();
|
||||||
Runtimes.Clear();
|
Runtimes.Clear();
|
||||||
|
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||||
|
ParentRuntimes.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,14 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
switch (type)
|
switch (type)
|
||||||
{
|
{
|
||||||
case AbCipDataType.Bool:
|
case AbCipDataType.Bool:
|
||||||
if (bitIndex is int bit)
|
if (bitIndex is int)
|
||||||
{
|
{
|
||||||
// BOOL-within-DINT writes require read-modify-write on the parent DINT.
|
// BOOL-within-DINT writes are routed at the driver level (AbCipDriver.
|
||||||
// Deferred to a follow-up PR — matches the Modbus BitInRegister pattern at
|
// WriteBitInDIntAsync) via a parallel parent-DINT runtime so the RMW stays
|
||||||
// ModbusDriver.cs:640.
|
// 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(
|
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);
|
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -186,8 +186,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
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);
|
runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
|
||||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -331,6 +344,70 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
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(
|
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
||||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -374,6 +451,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
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 object ProbeLock { get; } = new();
|
||||||
public HostState HostState { get; set; } = HostState.Unknown;
|
public HostState HostState { get; set; } = HostState.Unknown;
|
||||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
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();
|
foreach (var r in Runtimes.Values) r.Dispose();
|
||||||
Runtimes.Clear();
|
Runtimes.Clear();
|
||||||
|
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||||
|
ParentRuntimes.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
{
|
{
|
||||||
case AbLegacyDataType.Bit:
|
case AbLegacyDataType.Bit:
|
||||||
if (bitIndex is int)
|
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(
|
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);
|
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
|
||||||
break;
|
break;
|
||||||
case AbLegacyDataType.Int:
|
case AbLegacyDataType.Int:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,9 +60,12 @@ public sealed class AbCipDriverWriteTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
@@ -73,7 +76,7 @@ public sealed class AbCipDriverWriteTests
|
|||||||
var results = await drv.WriteAsync(
|
var results = await drv.WriteAsync(
|
||||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||||
|
|
||||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,9 +157,12 @@ public sealed class AbLegacyReadWriteTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
@@ -170,7 +173,7 @@ public sealed class AbLegacyReadWriteTests
|
|||||||
var results = await drv.WriteAsync(
|
var results = await drv.WriteAsync(
|
||||||
[new WriteRequest("Bit3", true)], CancellationToken.None);
|
[new WriteRequest("Bit3", true)], CancellationToken.None);
|
||||||
|
|
||||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user