feat(abcip): thread nested-struct template id so nested UDT members are addressable (#6)

This commit is contained in:
Joseph Doherty
2026-06-18 11:33:41 -04:00
parent 56c136b0fd
commit 3d8ce4e85f
5 changed files with 122 additions and 13 deletions
@@ -50,10 +50,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// DiscoverAsync threads into ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the
// Template Object off the controller and cache it in the id-keyed _templateCache. No seed
// needed.
// * NESTED struct members — SEAM-ONLY / a documented deferral. A decoded Template Object member
// block carries NO nested-template id, so a nested struct's shape can't be re-fetched in
// production. Tests link nested sub-shapes by member name via SeedDiscoveredUdtShapeForTest;
// without a seed a nested struct member is dropped (the top-level UDT still expands).
// * NESTED struct members — FUNCTIONAL in production. A decoded Template Object member block carries
// the nested UDT's template instance id (low 12 bits of the member info, captured by
// CipTemplateObjectDecoder onto AbCipUdtMember.NestedTemplateId), which the fan-out threads into
// ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the nested Template Object off the
// controller. The name-keyed seed (SeedDiscoveredUdtShapeForTest) still wins first for tests; a
// nested member whose id can't be resolved is dropped (the top-level UDT still expands).
private readonly Dictionary<string, AbCipUdtShape> _discoveredUdtShapes =
new(StringComparer.OrdinalIgnoreCase);
@@ -65,12 +67,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Test seam — seed a discovered-UDT shape under <paramref name="structName"/> for
/// <paramref name="deviceHostAddress"/>. Needed only for NESTED-struct sub-shapes (linked by
/// member name): a decoded Template Object member block carries no nested-template id, so a
/// nested struct's shape genuinely can't be re-fetched in production — seeding is the only way
/// a fan-out test can recurse into it. The TOP-LEVEL discovered UDT does NOT need a seed; the
/// production path fetches its shape via <see cref="FetchUdtShapeAsync"/> from the discovered
/// tag's template instance id.
/// <paramref name="deviceHostAddress"/>, consulted by name before any id-keyed fetch. Lets a
/// fan-out test link a nested sub-shape by member name without a live Template Object read.
/// Neither the TOP-LEVEL discovered UDT nor a NESTED-struct member needs a seed in production:
/// both carry a template instance id (the top level on
/// <see cref="AbCipDiscoveredTag.TemplateInstanceId"/>, a nested member on
/// <see cref="AbCipUdtMember.NestedTemplateId"/>) that the fan-out fetches via
/// <see cref="FetchUdtShapeAsync"/>.
/// </summary>
/// <param name="deviceHostAddress">The device the struct lives on.</param>
/// <param name="structName">The discovered struct type or nested-member name.</param>
@@ -1176,8 +1179,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// members live a level deeper, so the cap is checked against depth + 1.
if (depth + 1 > MaxUdtDepth) continue;
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
// The decoded parent shape carries the nested UDT's template instance id (low 12 bits of the
// member info, captured by CipTemplateObjectDecoder) — thread it so the nested shape is fetched
// off the controller via FetchUdtShapeAsync, making nested-struct members addressable in
// production (name-keyed seeded shapes still win first, for the test seam).
var nested = await ResolveDiscoveredUdtShapeAsync(
deviceHostAddress, member.Name, templateInstanceId: null, cancellationToken)
deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
.ConfigureAwait(false);
if (nested is not null)
{
@@ -54,8 +54,16 @@ public sealed record AbCipUdtShape(
IReadOnlyList<AbCipUdtMember> Members);
/// <summary>One member of a Logix UDT.</summary>
/// <param name="Name">Member name as reported by the Template Object.</param>
/// <param name="Offset">Byte offset of the member from the struct start.</param>
/// <param name="DataType">Decoded member data type (<see cref="AbCipDataType.Structure"/> for a nested UDT).</param>
/// <param name="ArrayLength">Element count (1 for a scalar member).</param>
/// <param name="NestedTemplateId">For a nested-struct member, the nested UDT's CIP template instance id
/// (low 12 bits of the member info, same encoding as the Symbol Object) so the nested shape can be
/// fetched via <see cref="AbCipDriver.FetchUdtShapeAsync"/>; <c>null</c> for a scalar member.</param>
public sealed record AbCipUdtMember(
string Name,
int Offset,
AbCipDataType DataType,
int ArrayLength);
int ArrayLength,
uint? NestedTemplateId = null);
@@ -88,12 +88,18 @@ public static class CipTemplateObjectDecoder
? AbCipDataType.Structure
: (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure);
// For a struct member the low 12 bits are the nested UDT's template instance id (same encoding as the
// Symbol Object), NOT a primitive type code — capture it so the nested shape can be fetched. Use the
// FULL 12-bit mask (not the byte-cast typeCode, which truncates a 12-bit id).
var nestedTemplateId = isStruct ? (uint?)(info & MemberInfoTypeCodeMask) : null;
var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
members.Add(new AbCipUdtMember(
Name: memberName,
Offset: offset,
DataType: dataType,
ArrayLength: arraySize == 0 ? 1 : arraySize));
ArrayLength: arraySize == 0 ? 1 : arraySize,
NestedTemplateId: nestedTemplateId));
}
return new AbCipUdtShape(