fix(abcip): bit-in-word writes take the parent width from its read

H17: the .n read-modify-write hard-coded a DINT parent. On an INT or SINT host
(both legal in Logix, and reads of Flags.3 there already worked) the DINT
decode of a 2-byte buffer threw OutOfBounds, the generic catch evicted the BIT
tag's (non-existent) runtime and reported BadCommunicationError + Degraded.

The parent width now comes from the parent read's size (1 SINT / 2 INT /
4 DINT / 8 LINT; unknown keeps the DINT assumption). Narrow hosts use unsigned
arithmetic so a set top bit encodes without overflow; a bit past the host's
width is BadOutOfRange and a non-integer width BadTypeMismatch, both refused
before anything is written, warned once, no health write. A transport failure
in the RMW now evicts the parent runtime it actually used. Native end-to-end:
an INT host is written as ONE INT (type 0xC3, 2 bytes).

Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:21:43 -04:00
parent 4e9bd9d966
commit cb16ebc75e
2 changed files with 189 additions and 11 deletions
@@ -1374,6 +1374,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue;
}
string? rmwParent = null;
try
{
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
@@ -1384,6 +1385,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// losing one another's update.
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
{
rmwParent = (parsedPath with { BitIndex = null }).ToLibplctagName();
results[i] = new WriteResult(
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
.ConfigureAwait(false));
@@ -1542,8 +1544,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
catch (Exception ex)
{
// Transport / wire error — evict so the next write creates a fresh handle.
EvictRuntime(device, def.Name);
// Transport / wire error — evict so the next write creates a fresh handle (for a bit write, the
// parent runtime the read-modify-write used; the bit tag itself holds none).
if (rmwParent is not null) EvictParentRuntime(device, rmwParent);
else EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
_logger.LogWarning(ex,
@@ -1556,10 +1560,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
/// <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.
/// Read-modify-write one bit within an integer parent (SINT / INT / DINT / LINT — Logix allows <c>.n</c> on
/// each). Creates / reuses a parallel parent runtime (distinct from the bit-selector handle) + serialises
/// concurrent writers against the same parent via a per-parent <see cref="SemaphoreSlim"/>. The parent's
/// width comes from the size its read returned (H17 — a hard-coded DINT decode threw OutOfBounds on an INT
/// host and was reported as a comms fault). Matches the Modbus BitInRegister + FOCAS PMC Bit pattern.
/// </summary>
private async Task<uint> WriteBitInDIntAsync(
DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
@@ -1576,12 +1581,49 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var readStatus = parentRuntime.GetStatus();
if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus);
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
var updated = Convert.ToBoolean(value)
? current | (1 << bit)
: current & ~(1 << bit);
// Unsigned at the narrow widths so a set top bit (SINT bit 7, INT bit 15) encodes without overflowing the
// signed type; DINT keeps its signed int arithmetic (1 << 31 is int.MinValue, which a DINT encodes as is).
var size = parentRuntime.GetSize();
AbCipDataType? hostType = size switch
{
1 => AbCipDataType.USInt,
2 => AbCipDataType.UInt,
8 => AbCipDataType.LInt,
4 or <= 0 => AbCipDataType.DInt, // <= 0: the runtime cannot say — the historical DINT assumption
_ => null,
};
if (hostType is not { } type)
{
WarnTypeMismatchOnce(parentName,
"AbCip bit write to {Parent}.{Bit} on device {Device} refused: the parent is {Size} byte(s), not an integer host (logged once per tag)",
parentName, bit, device.Options.HostAddress, size);
return AbCipStatusMapper.BadTypeMismatch;
}
var widthBits = type switch { AbCipDataType.USInt => 8, AbCipDataType.UInt => 16, AbCipDataType.LInt => 64, _ => 32 };
if (bit >= widthBits)
{
WarnTypeMismatchOnce(parentName,
"AbCip bit write to {Parent}.{Bit} on device {Device} refused: the parent holds only {Bits} bits (logged once per tag)",
parentName, bit, device.Options.HostAddress, widthBits);
return AbCipStatusMapper.BadOutOfRange;
}
parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
var set = Convert.ToBoolean(value);
var decoded = parentRuntime.DecodeValue(type, bitIndex: null) ?? 0;
object updated;
if (type == AbCipDataType.LInt)
{
var current = Convert.ToInt64(decoded);
updated = set ? current | (1L << bit) : current & ~(1L << bit);
}
else
{
var mask = type switch { AbCipDataType.USInt => 0xFF, AbCipDataType.UInt => 0xFFFF, _ => -1 };
var current = Convert.ToInt32(decoded) & mask;
updated = (set ? current | (1 << bit) : current & ~(1 << bit)) & mask;
}
parentRuntime.EncodeValue(type, bitIndex: null, updated);
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
@@ -1763,6 +1805,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>Evicts + disposes a bit-write parent runtime so the next read-modify-write re-creates it.</summary>
private static void EvictParentRuntime(DeviceState device, string parentName)
{
if (device.ParentRuntimes.TryRemove(parentName, out var stale))
{
try { stale.Dispose(); } catch { }
}
}
/// <inheritdoc />
public DriverHealth GetHealth() => _health;