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>
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user