Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
Joseph Doherty 780358c790 AbCip whole-UDT read optimization (#194) — declaration-driven member grouping collapses N per-member reads into one parent-UDT read + client-side decode. Closes task #194. On a batch that includes multiple members of the same hand-declared UDT tag, ReadAsync now issues one libplctag read on the parent + decodes each member from the runtime's buffer at its computed byte offset. A 6-member Motor UDT read goes from 6 libplctag round-trips to 1 — the Rockwell-suggested pattern for minimizing CIP request overhead on batch reads of UDT state (decision #11's follow-through on what the template decoder from task #179 was meant to enable). AbCipUdtMemberLayout is a pure-function helper that computes declared-member byte offsets under Logix natural-alignment rules (SInt 1-byte / Int 2-byte / DInt + Real + Dt 4-byte / LInt + ULInt + LReal 8-byte; alignment pad inserted before each member as needed). Opts out for BOOL / String / Structure members — BOOL storage in Logix UDTs packs into a hidden host byte whose position can't be computed from declaration-only info, and String members need length-prefix + STRING[82] fan-out which libplctag already handles via a per-tag DecodeValue path. The CIP Template Object shape from task #179 (when populated via FetchUdtShapeAsync) carries real offsets for those members — layering that richer path on top of the planner is a separate follow-up and does not change this PR's conservative behaviour. AbCipUdtReadPlanner is the scheduling function ReadAsync consults each batch — pure over (requests, tagsByName), emits Groups + Fallbacks. A group is formed when (a) the reference resolves to "parent.member"; (b) parent is a Structure tag with declared Members; (c) the layout helper succeeds on those members; (d) the specific member appears in the computed offset map; (e) at least two members of the same parent appear in the batch — single-member groups demote to the fallback path because one whole-UDT read vs one per-member read is equivalent cost but more client-side work. Original batch indices are preserved through the plan so out-of-order batches write decoded values back at the right output slot; the caller's result array order is invariant. IAbCipTagRuntime.DecodeValueAt(AbCipDataType, int offset, int? bitIndex) is the new hot-path method — LibplctagTagRuntime delegates to libplctag's offset-aware Get*(offset) calls (GetInt32, GetFloat32, etc.) that were always there; previously every call passed offset 0. DecodeValue(type, bitIndex) stays as the shorthand + forwards to DecodeValueAt with offset 0, preserving the existing single-tag read path + every test that exercises it. FakeAbCipTag gains a ValuesByOffset dictionary so tests can drive multi-member decoding by setting offset→value before the read fires; unmapped offsets fall back to the existing Value field so the 200+ existing tests that never set ValuesByOffset keep working unchanged. AbCipDriver.ReadAsync refactored: planner splits the batch, ReadGroupAsync handles each UDT group (one EnsureTagRuntimeAsync on the parent + one ReadAsync + N DecodeValueAt calls), ReadSingleAsync handles each fallback (the pre-#194 per-tag path, now extracted + threaded through). A per-group failure stamps the mapped libplctag status across every grouped member only — sibling groups + fallback refs are unaffected. Health-surface updates happen once per successful group rather than once per member to avoid ping-ponging the DriverState bookkeeping. Five AbCipUdtMemberLayoutTests: packed atomics get natural-alignment offsets including 8-byte pad before LInt; SInts pack without padding; BOOL/String/Structure opt out + return null; empty member list returns null. Six AbCipUdtReadPlannerTests: two members group; single-member demotes to fallback; unknown references fall back without poisoning groups; atomic top-level tags fall back untouched; UDTs containing BOOL don't group; original indices survive out-of-order batches. Five AbCipDriverWholeUdtReadTests (real driver + fake runtime): two grouped members trigger exactly one parent read + one fake runtime (proving the optimization engages); each member decodes at its own offset via ValuesByOffset; parent-read non-zero status stamps Bad across the group; mixed UDT-member + atomic top-level batch produces 2 runtimes + 2 reads (not 3); single-member-of-UDT still uses the member-level runtime (proving demotion works). Driver builds 0 errors; AbCip.Tests 227/227 (was 211, +16 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 04:17:57 -04:00

141 lines
5.7 KiB
C#

using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Default libplctag-backed <see cref="IAbCipTagRuntime"/>. Wraps a <see cref="Tag"/>
/// instance + translates our <see cref="AbCipDataType"/> enum into the
/// <c>GetInt32</c> / <c>GetFloat32</c> / <c>GetString</c> / <c>GetBit</c> calls libplctag
/// exposes. One runtime instance per <c>(device, tag path)</c>; lifetime is owned by the
/// driver's per-device state dict.
/// </summary>
internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
private readonly Tag _tag;
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
_tag = new Tag
{
Gateway = p.Gateway,
Path = p.CipPath,
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = p.TagName,
Timeout = p.Timeout,
};
}
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
public int GetStatus() => (int)_tag.GetStatus();
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
: _tag.GetInt8(offset) != 0,
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
AbCipDataType.Int => (int)_tag.GetInt16(offset),
AbCipDataType.UInt => (int)_tag.GetUInt16(offset),
AbCipDataType.DInt => _tag.GetInt32(offset),
AbCipDataType.UDInt => (int)_tag.GetUInt32(offset),
AbCipDataType.LInt => _tag.GetInt64(offset),
AbCipDataType.ULInt => (long)_tag.GetUInt64(offset),
AbCipDataType.Real => _tag.GetFloat32(offset),
AbCipDataType.LReal => _tag.GetFloat64(offset),
AbCipDataType.String => _tag.GetString(offset),
AbCipDataType.Dt => _tag.GetInt32(offset),
AbCipDataType.Structure => null,
_ => null,
};
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
switch (type)
{
case AbCipDataType.Bool:
if (bitIndex is int)
{
// BOOL-within-DINT writes are routed at the driver level (AbCipDriver.
// WriteBitInDIntAsync) via a parallel parent-DINT runtime so the RMW stays
// 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(
"BOOL-with-bitIndex writes must go through AbCipDriver.WriteBitInDIntAsync, not LibplctagTagRuntime.");
}
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
break;
case AbCipDataType.SInt:
_tag.SetInt8(0, Convert.ToSByte(value));
break;
case AbCipDataType.USInt:
_tag.SetUInt8(0, Convert.ToByte(value));
break;
case AbCipDataType.Int:
_tag.SetInt16(0, Convert.ToInt16(value));
break;
case AbCipDataType.UInt:
_tag.SetUInt16(0, Convert.ToUInt16(value));
break;
case AbCipDataType.DInt:
_tag.SetInt32(0, Convert.ToInt32(value));
break;
case AbCipDataType.UDInt:
_tag.SetUInt32(0, Convert.ToUInt32(value));
break;
case AbCipDataType.LInt:
_tag.SetInt64(0, Convert.ToInt64(value));
break;
case AbCipDataType.ULInt:
_tag.SetUInt64(0, Convert.ToUInt64(value));
break;
case AbCipDataType.Real:
_tag.SetFloat32(0, Convert.ToSingle(value));
break;
case AbCipDataType.LReal:
_tag.SetFloat64(0, Convert.ToDouble(value));
break;
case AbCipDataType.String:
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
break;
case AbCipDataType.Dt:
_tag.SetInt32(0, Convert.ToInt32(value));
break;
case AbCipDataType.Structure:
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
default:
throw new NotSupportedException($"AbCipDataType {type} not writable.");
}
}
public void Dispose() => _tag.Dispose();
private static PlcType MapPlcType(string attribute) => attribute switch
{
"controllogix" => PlcType.ControlLogix,
"compactlogix" => PlcType.ControlLogix, // libplctag treats CompactLogix under ControlLogix family
"micro800" => PlcType.Micro800,
"micrologix" => PlcType.MicroLogix,
"slc500" => PlcType.Slc500,
"plc5" => PlcType.Plc5,
"omron-njnx" => PlcType.Omron,
_ => PlcType.ControlLogix,
};
}
/// <summary>
/// Default <see cref="IAbCipTagFactory"/> — creates a fresh <see cref="LibplctagTagRuntime"/>
/// per call. Stateless; safe to share across devices.
/// </summary>
internal sealed class LibplctagTagFactory : IAbCipTagFactory
{
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
new LibplctagTagRuntime(createParams);
}