feat(abcip): #611 discovered UDT member writes + whole-UDT refusal by decision
Item 2 — controller-discovered UDT members are writable on both transports once browse-committed and authored ReadWrite (writability is the operator's: the commit lands AccessLevel Read with no writable key). The write names the member (one CIP symbolic segment per member), its own type, its own bytes. BIT members are supported: Z-35162 (read-only probe) exposes a UDT BIT member as a 1-byte 0xC1 BOOL, so the controller applies the bit and the hidden host's other bits are never in the request. - AbCipDriver: pre-write width check on BOTH transports (libplctag patched part of its read buffer and sent it all): BadTypeMismatch, Bool over a wider value BadNotSupported; nothing sent, handle kept, health untouched, warn once. - A runtime NotSupportedException is a refusal: no longer latches Degraded. Item 3 — whole-UDT writes refused BadNotSupported before any runtime exists, by decision (a structure write overwrites hidden hosts + padding), documented. ab_server patch 0003: serve member paths from dotted flat tags (UdtSeg.*), and accept libplctag's pad byte on 1-byte writes (stock ab_server refused every libplctag BOOL/SINT write with 0x03). New AbCipUdtMemberWriteTests (both transports), AbCipDiscoveredMemberWriteTests, native request-byte theory, and a browse-commit mapper pin. Docs: AbCip.md Member writes / Whole-UDT writes / Known gaps, fixture docs, CLAUDE.md AB section. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -218,6 +218,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
|
||||
// Tags whose write-refusal Warning has already been logged (Gitea #611) — a client may retry forever.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _writeRefusalWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Logs a write-refusal Warning the first time <paramref name="tagName"/> reports one on this instance.</summary>
|
||||
private void WarnWriteRefusedOnce(string tagName, string message, params object?[] args)
|
||||
{
|
||||
if (_writeRefusalWarned.TryAdd(tagName, 0))
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
|
||||
// Tags whose malformed-name Warning has already been logged (Gitea #570) — polled forever, warned once.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _configErrorWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -1137,6 +1148,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gitea #611 item 3 — whole-UDT writes are refused by DECISION, not deferred: a structure write
|
||||
// overwrites every member of the controller's instance, hidden BOOL hosts and padding included,
|
||||
// with whatever the client sent. Address the members instead (each is its own raw tag). Refused
|
||||
// before a runtime exists, so nothing is read or sent; a refusal is not a fault — no health change.
|
||||
if (def.DataType == AbCipDataType.Structure)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
WarnWriteRefusedOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused: whole-UDT writes are not supported — write its members (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
@@ -1162,18 +1186,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var opLock = device.GetRuntimeLock(def.Name);
|
||||
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
int status;
|
||||
uint? refusal;
|
||||
int size;
|
||||
try
|
||||
{
|
||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
// Gitea #611: the bytes the controller holds for this path must be what the authored type
|
||||
// encodes, BEFORE anything is sent — on BOTH transports. libplctag never checks: it encodes
|
||||
// into the buffer the last read sized and sends the whole buffer, so an INT authored over a
|
||||
// DINT patched two bytes and wrote four (stale high word), and a BOOL over a packed
|
||||
// BOOL-array DWORD wrote all 32 bits. Discovered UDT members are where a mis-authored type
|
||||
// is most likely (browse-commit fills dataType from the template, an operator may retype).
|
||||
size = runtime.GetSize();
|
||||
refusal = WriteWidthRefusal(def, parsedPath?.BitIndex, size);
|
||||
if (refusal is null)
|
||||
{
|
||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
}
|
||||
else
|
||||
{
|
||||
status = 0;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
opLock.Release();
|
||||
}
|
||||
|
||||
if (status == (int)AbStatus.TypeMismatch)
|
||||
if (refusal is { } refused)
|
||||
{
|
||||
// Deterministic, nothing sent: keep the handle, leave health alone, warn once per tag.
|
||||
results[i] = new WriteResult(refused);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused before sending: the controller holds {Size} byte(s) there but the authored {DataType} encodes {Width} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, size, def.DataType, ExpectedScalarWriteWidth(def.DataType));
|
||||
}
|
||||
else if (status == (int)AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
@@ -1202,12 +1251,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
// Type/protocol error — not a transport fault; don't evict the handle.
|
||||
// A deterministic local refusal (a runtime that cannot encode this write without clobbering
|
||||
// something — e.g. a BOOL-array element as a single BOOL) — not a transport fault: keep the
|
||||
// handle and leave driver health alone (Gitea #611; same rule as the Unsupported refusal
|
||||
// below). It used to latch Degraded, which a refusal the device never saw must not do.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
_logger.LogWarning(nse,
|
||||
"AbCip write not supported for tag {Tag} on device {Device}",
|
||||
w.FullReference, def.DeviceHostAddress);
|
||||
WarnWriteRefusedOnce(def.Name,
|
||||
"AbCip write not supported for tag {Tag} on device {Device}: {Reason} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, nse.Message);
|
||||
}
|
||||
catch (FormatException fe)
|
||||
{
|
||||
@@ -1642,6 +1693,37 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Bytes one scalar write of <paramref name="type"/> encodes: the read-side width, plus BOOL (one byte —
|
||||
/// a Logix BOOL tag or UDT BIT member reads back as one byte, type 0xC1). <c>null</c> where the encoded
|
||||
/// size is not fixed (STRING, DT, structures).
|
||||
/// </summary>
|
||||
/// <param name="type">The authored type.</param>
|
||||
/// <returns>The width in bytes, or <c>null</c>.</returns>
|
||||
internal static int? ExpectedScalarWriteWidth(AbCipDataType type) =>
|
||||
type == AbCipDataType.Bool ? 1 : ExpectedScalarWidth(type);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #611 — the refusal status for a scalar write whose authored type does not fit what the controller
|
||||
/// holds at that path (<paramref name="size"/> bytes, from the runtime's last read), or <c>null</c> to
|
||||
/// write. A BOOL over a wider value (a packed BOOL-array DWORD) is <c>BadNotSupported</c> — writing it
|
||||
/// would clobber the neighbouring bits, the same refusal the native runtime raises; any other width
|
||||
/// mismatch is <c>BadTypeMismatch</c>. Arrays, bit selectors (<c>Flags.3</c>, served by the
|
||||
/// read-modify-write path) and an unknown size (<c><= 0</c>) are not judged here.
|
||||
/// </summary>
|
||||
/// <param name="def">The tag being written.</param>
|
||||
/// <param name="bitIndex">The parsed bit selector, if any.</param>
|
||||
/// <param name="size">The runtime's read-buffer size in bytes.</param>
|
||||
/// <returns>The refusal status, or <c>null</c>.</returns>
|
||||
internal static uint? WriteWidthRefusal(AbCipTagDefinition def, int? bitIndex, int size)
|
||||
{
|
||||
if (IsArrayTag(def) || bitIndex is not null || size <= 0) return null;
|
||||
if (ExpectedScalarWriteWidth(def.DataType) is not { } width || size == width) return null;
|
||||
return def.DataType == AbCipDataType.Bool
|
||||
? AbCipStatusMapper.BadNotSupported
|
||||
: AbCipStatusMapper.BadTypeMismatch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
|
||||
/// ATOMIC leaf — under the folder produced by <paramref name="folder"/>. Mirrors the
|
||||
|
||||
@@ -277,9 +277,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
case AbCipDataType.Structure:
|
||||
// Whole-UDT writes are not implemented — operators address individual UDT
|
||||
// members via dotted tag paths, which route per-member through the atomic
|
||||
// encode cases above. A whole-UDT writer is a deferred follow-up.
|
||||
// Whole-UDT writes are refused by decision (Gitea #611), not deferred: a structure
|
||||
// write overwrites every member, hidden BOOL hosts and padding included. Members are
|
||||
// written via their own dotted tag paths through the atomic cases above. The driver
|
||||
// refuses a Structure tag before it ever reaches here; this is the backstop.
|
||||
throw new NotSupportedException(
|
||||
"Whole-UDT writes are not supported — address individual member paths instead.");
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user