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 // DiscoverAsync threads into ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the
// Template Object off the controller and cache it in the id-keyed _templateCache. No seed // Template Object off the controller and cache it in the id-keyed _templateCache. No seed
// needed. // needed.
// * NESTED struct members — SEAM-ONLY / a documented deferral. A decoded Template Object member // * NESTED struct members — FUNCTIONAL in production. A decoded Template Object member block carries
// block carries NO nested-template id, so a nested struct's shape can't be re-fetched in // the nested UDT's template instance id (low 12 bits of the member info, captured by
// production. Tests link nested sub-shapes by member name via SeedDiscoveredUdtShapeForTest; // CipTemplateObjectDecoder onto AbCipUdtMember.NestedTemplateId), which the fan-out threads into
// without a seed a nested struct member is dropped (the top-level UDT still expands). // 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 = private readonly Dictionary<string, AbCipUdtShape> _discoveredUdtShapes =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
@@ -65,12 +67,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary> /// <summary>
/// Test seam — seed a discovered-UDT shape under <paramref name="structName"/> for /// Test seam — seed a discovered-UDT shape under <paramref name="structName"/> for
/// <paramref name="deviceHostAddress"/>. Needed only for NESTED-struct sub-shapes (linked by /// <paramref name="deviceHostAddress"/>, consulted by name before any id-keyed fetch. Lets a
/// member name): a decoded Template Object member block carries no nested-template id, so a /// fan-out test link a nested sub-shape by member name without a live Template Object read.
/// nested struct's shape genuinely can't be re-fetched in production — seeding is the only way /// Neither the TOP-LEVEL discovered UDT nor a NESTED-struct member needs a seed in production:
/// a fan-out test can recurse into it. The TOP-LEVEL discovered UDT does NOT need a seed; the /// both carry a template instance id (the top level on
/// production path fetches its shape via <see cref="FetchUdtShapeAsync"/> from the discovered /// <see cref="AbCipDiscoveredTag.TemplateInstanceId"/>, a nested member on
/// tag's template instance id. /// <see cref="AbCipUdtMember.NestedTemplateId"/>) that the fan-out fetches via
/// <see cref="FetchUdtShapeAsync"/>.
/// </summary> /// </summary>
/// <param name="deviceHostAddress">The device the struct lives on.</param> /// <param name="deviceHostAddress">The device the struct lives on.</param>
/// <param name="structName">The discovered struct type or nested-member name.</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. // members live a level deeper, so the cap is checked against depth + 1.
if (depth + 1 > MaxUdtDepth) continue; if (depth + 1 > MaxUdtDepth) continue;
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here 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( var nested = await ResolveDiscoveredUdtShapeAsync(
deviceHostAddress, member.Name, templateInstanceId: null, cancellationToken) deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (nested is not null) if (nested is not null)
{ {
@@ -54,8 +54,16 @@ public sealed record AbCipUdtShape(
IReadOnlyList<AbCipUdtMember> Members); IReadOnlyList<AbCipUdtMember> Members);
/// <summary>One member of a Logix UDT.</summary> /// <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( public sealed record AbCipUdtMember(
string Name, string Name,
int Offset, int Offset,
AbCipDataType DataType, AbCipDataType DataType,
int ArrayLength); int ArrayLength,
uint? NestedTemplateId = null);
@@ -88,12 +88,18 @@ public static class CipTemplateObjectDecoder
? AbCipDataType.Structure ? AbCipDataType.Structure
: (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? 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}>"; var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
members.Add(new AbCipUdtMember( members.Add(new AbCipUdtMember(
Name: memberName, Name: memberName,
Offset: offset, Offset: offset,
DataType: dataType, DataType: dataType,
ArrayLength: arraySize == 0 ? 1 : arraySize)); ArrayLength: arraySize == 0 ? 1 : arraySize,
NestedTemplateId: nestedTemplateId));
} }
return new AbCipUdtShape( return new AbCipUdtShape(
@@ -227,6 +227,66 @@ public sealed class AbCipDriverDiscoveryTests
byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean); byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean);
} }
/// <summary>
/// PRODUCTION PATH (no test seam): a controller-discovered UDT whose Template Object carries
/// a NESTED struct member fans the nested member out into its own atomic leaves WITHOUT any
/// <c>SeedDiscoveredUdtShapeForTest</c>. The nested member block carries the nested UDT's
/// template instance id (low 12 bits, struct flag set); the decoder captures it as
/// <c>NestedTemplateId</c> and DiscoverAsync threads it into a real <c>FetchUdtShapeAsync</c>
/// for the nested id. Proves the nested fetch actually happened (reader consulted for the
/// nested id) and that the nested struct's leaf member is addressable.
/// </summary>
[Fact]
public async Task Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam()
{
const string device = "ab://10.0.0.5/1,0";
const uint motorTemplateId = 0x2A; // parent UDT template id
const uint statusTemplateId = 0x123; // nested UDT template id — exceeds a byte (12-bit proof)
var builder = new RecordingBuilder();
var enumeratorFactory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false,
TemplateInstanceId: motorTemplateId));
// The parent template carries an atomic member (Speed/Real) + a NESTED struct member (Status)
// whose member-info encodes the struct flag (0x8000) | the nested template id (0x123). The
// nested template id is read off @udt/0x123 and yields one atomic leaf (Code/DInt).
var templateReaderFactory = new IdKeyedTemplateReaderFactory
{
Templates =
{
[motorTemplateId] = BuildSimpleTemplate("MotorUdt", 12,
("Speed", 0xCA, 0, 0), // Real
("Status", (ushort)(0x8000 | statusTemplateId), 0, 8)), // struct + nested id 0x123
[statusTemplateId] = BuildSimpleTemplate("StatusUdt", 4,
("Code", 0xC4, 0, 0)), // DInt
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device)],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templateReaderFactory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
// The nested template id actually drove a fetch — the reader was consulted for it (the seam
// would have left LastTemplateId at the parent id and never read the nested shape).
templateReaderFactory.RequestedIds.ShouldContain(statusTemplateId);
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
// The parent atomic leaf is emitted.
byName.ShouldContainKey("Motor1.Speed");
byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32);
// The NESTED struct's atomic leaf is addressable — proving the nested fetch + fan-out ran.
byName.ShouldContainKey("Motor1.Status.Code");
byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
// A Motor1.Status sub-folder was materialised for the nested struct.
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1.Status");
}
/// <summary> /// <summary>
/// A discovered Structure whose shape cannot be resolved degrades to the prior single /// A discovered Structure whose shape cannot be resolved degrades to the prior single
/// Variable behavior (no regression): one Variable under the Discovered folder keyed by /// Variable behavior (no regression): one Variable under the Discovered folder keyed by
@@ -619,6 +679,8 @@ public sealed class AbCipDriverDiscoveryTests
public Dictionary<uint, byte[]> Templates { get; } = new(); public Dictionary<uint, byte[]> Templates { get; } = new();
/// <summary>Gets the last template id any reader was asked for.</summary> /// <summary>Gets the last template id any reader was asked for.</summary>
public uint? LastTemplateId { get; private set; } public uint? LastTemplateId { get; private set; }
/// <summary>Gets every template id any reader was asked for, in request order.</summary>
public List<uint> RequestedIds { get; } = new();
/// <summary>Creates a reader over the shared template map.</summary> /// <summary>Creates a reader over the shared template map.</summary>
public IAbCipTemplateReader Create() => new Reader(this); public IAbCipTemplateReader Create() => new Reader(this);
@@ -629,6 +691,7 @@ public sealed class AbCipDriverDiscoveryTests
AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken) AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
{ {
outer.LastTemplateId = templateInstanceId; outer.LastTemplateId = templateInstanceId;
outer.RequestedIds.Add(templateInstanceId);
return Task.FromResult( return Task.FromResult(
outer.Templates.TryGetValue(templateInstanceId, out var blob) ? blob : []); outer.Templates.TryGetValue(templateInstanceId, out var blob) ? blob : []);
} }
@@ -86,6 +86,31 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure); shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
} }
/// <summary>
/// A struct member's lower 12 bits carry the nested UDT's template instance id (same
/// encoding as the Symbol Object); the decoder captures it as <c>NestedTemplateId</c> so the
/// nested shape can be fetched. A scalar member carries no nested id. Uses the FULL 12-bit
/// mask (0x123 here exceeds a byte) to prove the id is not byte-truncated.
/// </summary>
[Fact]
public void Struct_member_captures_nested_template_id_scalar_member_does_not()
{
var bytes = BuildTemplate("ContainerUdt", instanceSize: 8,
("Inner", info: (ushort)(0x8000 | 0x123), arraySize: 0, offset: 0), // struct + nested id 0x123
("Count", info: 0xC4, arraySize: 0, offset: 4)); // scalar DINT, no nested id
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.Members.Count.ShouldBe(2);
shape.Members[0].Name.ShouldBe("Inner");
shape.Members[0].DataType.ShouldBe(AbCipDataType.Structure);
shape.Members[0].NestedTemplateId.ShouldBe(0x123u); // full 12-bit id, not byte-truncated
shape.Members[1].Name.ShouldBe("Count");
shape.Members[1].DataType.ShouldBe(AbCipDataType.DInt);
shape.Members[1].NestedTemplateId.ShouldBeNull();
}
/// <summary>Verifies that array members carry correct non-one array lengths.</summary> /// <summary>Verifies that array members carry correct non-one array lengths.</summary>
[Fact] [Fact]
public void Array_member_carries_non_one_ArrayLength() public void Array_member_carries_non_one_ArrayLength()