diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs index aad6a2b1..2bf7f5d0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs @@ -42,6 +42,10 @@ public static class CipSymbolObjectDecoder private const ushort SymbolTypeSystemFlag = 0x1000; private const ushort SymbolTypeStructFlag = 0x8000; private const ushort SymbolTypeTypeCodeMask = 0x0FFF; + private const int SymbolTypeDimsShift = 13; + private const int SymbolTypeDimsMask = 0x3; + + private const string ProgramPrefix = "Program:"; /// /// Decode the raw @tags blob into an enumerable sequence. Malformed entries at @@ -49,22 +53,44 @@ public static class CipSymbolObjectDecoder /// cleanly before the corruption. /// /// The raw byte buffer from the Symbol Object response. + /// The program this listing belongs to when it is a + /// Program:<name>.@tags read — whose entries carry bare names — or null + /// for the controller-scope @tags listing. /// An enumerable sequence of discovered CIP tags. - public static IEnumerable Decode(byte[] buffer) + public static IEnumerable Decode(byte[] buffer, string? programScope = null) { ArgumentNullException.ThrowIfNull(buffer); - return DecodeImpl(buffer); + return DecodeImpl(buffer, programScope); } - private static IEnumerable DecodeImpl(byte[] buffer) + /// + /// The program name when is a controller-scope program entry + /// (Program:UN01_Press, a system symbol) — the name to read Program:<name>.@tags + /// for, since a controller lists program-scope tags only under their own program. null + /// for anything else. + /// + /// A decoded controller-scope symbol. + /// The program name, or null. + public static string? ProgramNameOf(AbCipDiscoveredTag tag) + { + ArgumentNullException.ThrowIfNull(tag); + if (!tag.IsSystemTag || tag.ProgramScope is not null) return null; + if (!tag.Name.StartsWith(ProgramPrefix, StringComparison.Ordinal)) return null; + var name = tag.Name[ProgramPrefix.Length..]; + return name.Length == 0 || name.Contains('.') ? null : name; + } + + private static IEnumerable DecodeImpl(byte[] buffer, string? listingScope) { var pos = 0; while (pos + FixedHeaderSize <= buffer.Length) { var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos)); var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4)); - // element_length at pos+6 (u16) — useful for array sizing but not surfaced here - // array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros) + // element_length at pos+6 (u16) — not surfaced. + var dim0 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 8)); + var dim1 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 12)); + var dim2 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 16)); var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20)); pos += FixedHeaderSize; @@ -83,7 +109,10 @@ public static class CipSymbolObjectDecoder var isStruct = (symbolType & SymbolTypeStructFlag) != 0; var typeCode = symbolType & SymbolTypeTypeCodeMask; - var (programScope, simpleName) = SplitProgramScope(name); + // A program listing (Program:X.@tags) carries bare names; the scope is the program read. + var (programScope, simpleName) = listingScope is null + ? SplitProgramScope(name) + : (listingScope, name); var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode); // For a struct symbol the lower 12 bits (typeCode) are NOT a primitive CIP type code — @@ -92,18 +121,46 @@ public static class CipSymbolObjectDecoder // carry no template id. var templateInstanceId = isStruct ? (uint?)typeCode : null; + var (isArray, elementCount) = ArrayShape((symbolType >> SymbolTypeDimsShift) & SymbolTypeDimsMask, + dim0, dim1, dim2); + yield return new AbCipDiscoveredTag( Name: simpleName, ProgramScope: programScope, DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later IsSystemTag: isSystem, + ElementCount: elementCount, + IsArray: isArray, TemplateInstanceId: templateInstanceId); _ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today } } + /// + /// Array shape from the symbol type's dimension count (bits 13-14) and the three dimension + /// words. A real controller sets no dimension bits on a scalar; ab_server reports every + /// scalar as a one-dimensional array of one, so a 1-D single-element entry is taken as a scalar + /// too. A multi-dimensional array is surfaced flattened (element count = product of dims) — + /// the driver addresses 1-D arrays only. + /// + /// Dimension count from bits 13-14 of the symbol type. + /// First dimension. + /// Second dimension. + /// Third dimension. + /// Whether the symbol is an array, and its flattened element count. + internal static (bool IsArray, int ElementCount) ArrayShape(int dimCount, uint dim0, uint dim1, uint dim2) + { + if (dimCount == 0) return (false, 1); + long count = 1; + if (dimCount >= 1) count *= Math.Max(1u, dim0); + if (dimCount >= 2) count *= Math.Max(1u, dim1); + if (dimCount >= 3) count *= Math.Max(1u, dim2); + if (dimCount == 1 && count == 1) return (false, 1); + return (true, (int)Math.Min(count, int.MaxValue)); + } + /// /// Split a Program:MainProgram.StepIndex-style name into its scope + local /// parts. Names without the Program: prefix pass through unchanged. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs index b7ed45f9..e2858385 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs @@ -19,29 +19,73 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator { - private Tag? _tag; + private readonly Func> _readListing; + + /// Production enumerator: reads each listing through libplctag. + public LibplctagTagEnumerator() : this(ReadListingAsync) { } + + /// Enumerator over an injected listing reader (unit-test seam). + /// Reads one symbol listing (@tags or Program:X.@tags) and + /// returns its raw Symbol Object bytes. + internal LibplctagTagEnumerator(Func> readListing) + => _readListing = readListing; /// + /// + /// A controller's @tags listing holds controller-scope tags plus one system entry per + /// program (Program:UN01_Press); the program's own tags are listed only by + /// Program:<name>.@tags. So the controller listing is read first, then each program's + /// listing, decoded with that program as the scope. A program whose listing cannot be read is + /// skipped — browse is best-effort and one unreadable program must not cost the others. + /// public async IAsyncEnumerable EnumerateAsync( AbCipTagCreateParams deviceParams, [EnumeratorCancellation] CancellationToken cancellationToken) { - _tag = BuildSymbolListTag(deviceParams); + var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false); - await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false); - await _tag.ReadAsync(cancellationToken).ConfigureAwait(false); - - var buffer = _tag.GetBuffer(); // The decoder already surfaces each struct tag's CIP template instance id on // AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so // re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out. + var programs = new List(); foreach (var tag in CipSymbolObjectDecoder.Decode(buffer)) + { + if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program); yield return tag; + } + + foreach (var program in programs) + { + byte[] programBuffer; + try + { + programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (Exception) + { + continue; + } + + foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program)) + yield return tag; + } + } + + private static async Task ReadListingAsync( + AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken) + { + using var tag = BuildSymbolListTag(deviceParams, listingName); + await tag.InitializeAsync(cancellationToken).ConfigureAwait(false); + await tag.ReadAsync(cancellationToken).ConfigureAwait(false); + return tag.GetBuffer(); } /// - /// Builds the (uninitialized) libplctag tag for the @tags pseudo — same gateway + - /// path as the device, distinguished by the name alone. + /// Builds the (uninitialized) libplctag tag for a symbol listing pseudo-tag — @tags or + /// Program:<name>.@tags — same gateway + path as the device, distinguished by the + /// name alone. /// /// /// Gateway must be (host:port), never the @@ -49,18 +93,21 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator /// host, so a browse against a device authored on another port silently returns whatever /// answers on 44818 (Gitea #539, the discovery half of #534). /// - internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams) => new() + /// The device's create params. + /// The listing pseudo-tag name (default @tags). + /// The uninitialized tag. + internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams, string listingName = "@tags") => new() { Gateway = deviceParams.LibplctagGateway, Path = deviceParams.CipPath, PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute), Protocol = Protocol.ab_eip, - Name = "@tags", + Name = listingName, Timeout = deviceParams.Timeout, }; - /// Disposes the enumerator and releases the underlying libplctag tag. - public void Dispose() => _tag?.Dispose(); + /// Nothing to release — each listing's libplctag tag is disposed after its read. + public void Dispose() { } private static PlcType MapPlcType(string attribute) => attribute switch { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs new file mode 100644 index 00000000..53061245 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs @@ -0,0 +1,69 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// A controller's @tags listing names each program (Program:UN01_Press, a system +/// symbol) but none of its tags — those are listed only by Program:<name>.@tags. +/// Seen live on a production press: the PackML state (Program:UN01_Press.Out_eState) and +/// every alarm bit the Ignition project reads are program-scoped, and the browse showed none. +/// +[Trait("Category", "Unit")] +public sealed class AbCipProgramScopeEnumerationTests +{ + // Captured entries (CompactLogix 5069-L320ERS2 rev 34.11): WW_InCycle + the Program:UN01_Press entry + // from @tags, and Out_eState from Program:UN01_Press.@tags. + private const string ControllerListing = + "AF010000C10001000000000000000000000000000A0057575F496E4379636C65" + + "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373"; + + private const string Un01Listing = "1A000000C40004000000000000000000000000000A004F75745F655374617465"; + + private static readonly AbCipTagCreateParams Params = new( + Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix", + TagName: "@tags", Timeout: TimeSpan.FromSeconds(2)); + + /// The enumerator reads each program's own listing and yields its tags program-scoped. + [Fact] + public async Task Program_scoped_tags_are_enumerated_from_each_program_listing() + { + var reads = new List(); + using var enumerator = new LibplctagTagEnumerator((_, name, _) => + { + reads.Add(name); + return Task.FromResult(name switch + { + "@tags" => Convert.FromHexString(ControllerListing), + "Program:UN01_Press.@tags" => Convert.FromHexString(Un01Listing), + _ => throw new InvalidOperationException(name), + }); + }); + + var tags = new List(); + await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken)) + tags.Add(t); + + reads.ShouldBe(["@tags", "Program:UN01_Press.@tags"]); + var outState = tags.Single(t => t.Name == "Out_eState"); + outState.ProgramScope.ShouldBe("UN01_Press"); + outState.DataType.ShouldBe(AbCipDataType.DInt); + tags.ShouldContain(t => t.Name == "WW_InCycle" && t.ProgramScope == null); + } + + /// One unreadable program listing does not cost the controller tags or other programs. + [Fact] + public async Task A_failing_program_listing_is_skipped() + { + using var enumerator = new LibplctagTagEnumerator((_, name, _) => name == "@tags" + ? Task.FromResult(Convert.FromHexString(ControllerListing)) + : Task.FromException(new InvalidOperationException("ErrorNotFound"))); + + var tags = new List(); + await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken)) + tags.Add(t); + + tags.ShouldContain(t => t.Name == "WW_InCycle"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs new file mode 100644 index 00000000..934ddcbc --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs @@ -0,0 +1,85 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Symbol Object (@tags) entries captured byte for byte off two real CompactLogix +/// controllers on 2026-10-07 (read-only, one listing read each): a 5069-L320ERS2 rev 34.11 press +/// and a second CompactLogix. Unlike the ab_server capture in +/// — which reports every scalar as a 1-element +/// array — a real controller sets the dimension bits only on genuine arrays. +/// +[Trait("Category", "Unit")] +public sealed class CipSymbolObjectRealControllerCaptureTests +{ + // Controller scope, press: WW_InCycle (BOOL), Inf_LookupFwkB (UDT[23], template 3386), + // Program:UN01_Press (the program entry itself, type 0x1068). + private const string WwInCycle = "AF010000C10001000000000000000000000000000A0057575F496E4379636C65"; + private const string InfLookupFwkB = "6D0000003AAD60001700000000000000000000000E00496E665F4C6F6F6B757046776B42"; + private const string ProgramUn01 = "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373"; + + // Controller scope, second controller: arrSysTime (DINT[7]), arrMessages (STRING[50]). + private const string ArrSysTime = "11000000C42004000700000000000000000000000A0061727253797354696D65"; + private const string ArrMessages = "61000000CEAF58003200000000000000000000000B006172724D65737361676573"; + + // Program:UN01_Press.@tags: names arrive WITHOUT the Program: prefix. + private const string OutEState = "1A000000C40004000000000000000000000000000A004F75745F655374617465"; + private const string DisableStates = "4C000000C42004002000000000000000000000000E005F44697361626C65537461746573"; + + private static List Decode(params string[] entries) => + CipSymbolObjectDecoder.Decode(Convert.FromHexString(string.Concat(entries))).ToList(); + + /// Array dimensions in the entry surface as IsArray + ElementCount; scalars stay scalar. + [Fact] + public void Real_controller_arrays_surface_their_element_count() + { + var tags = Decode(WwInCycle, InfLookupFwkB, ArrSysTime, ArrMessages); + + var inCycle = tags.Single(t => t.Name == "WW_InCycle"); + inCycle.IsArray.ShouldBeFalse(); + inCycle.ElementCount.ShouldBe(1); + + var sysTime = tags.Single(t => t.Name == "arrSysTime"); + sysTime.DataType.ShouldBe(AbCipDataType.DInt); + sysTime.IsArray.ShouldBeTrue(); + sysTime.ElementCount.ShouldBe(7); + + var lookup = tags.Single(t => t.Name == "Inf_LookupFwkB"); + lookup.DataType.ShouldBe(AbCipDataType.Structure); + lookup.TemplateInstanceId.ShouldBe(3386u); + lookup.IsArray.ShouldBeTrue(); + lookup.ElementCount.ShouldBe(23); + + var messages = tags.Single(t => t.Name == "arrMessages"); + messages.TemplateInstanceId.ShouldBe(0x0FCEu); + messages.IsArray.ShouldBeTrue(); + messages.ElementCount.ShouldBe(50); + } + + /// A program entry is a system tag whose program name the enumerator can walk. + [Fact] + public void Program_entry_is_system_and_names_its_program() + { + var program = Decode(ProgramUn01).ShouldHaveSingleItem(); + + program.IsSystemTag.ShouldBeTrue(); + CipSymbolObjectDecoder.ProgramNameOf(program).ShouldBe("UN01_Press"); + CipSymbolObjectDecoder.ProgramNameOf(Decode(WwInCycle).Single()).ShouldBeNull(); + } + + /// A program listing decodes with the program as each tag's scope. + [Fact] + public void Program_listing_decodes_with_program_scope() + { + var tags = CipSymbolObjectDecoder.Decode( + Convert.FromHexString(OutEState + DisableStates), programScope: "UN01_Press").ToList(); + + tags.Select(t => t.Name).ShouldBe(["Out_eState", "_DisableStates"]); + tags.ShouldAllBe(t => t.ProgramScope == "UN01_Press"); + tags[0].DataType.ShouldBe(AbCipDataType.DInt); + tags[1].IsArray.ShouldBeTrue(); + tags[1].ElementCount.ShouldBe(32); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectWireCaptureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectWireCaptureTests.cs index b8fdea96..e536f7c4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectWireCaptureTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectWireCaptureTests.cs @@ -100,6 +100,24 @@ public sealed class CipSymbolObjectWireCaptureTests tags.Single(t => t.Name == "TestSINT").DataType.ShouldBe(AbCipDataType.SInt); } + /// + /// ab_server marks every symbol as a one-dimensional array (a scalar is + /// [1]); only TestArray has more than one element. A 1-element, 1-D entry is + /// treated as a scalar, so the fixture's scalars do not browse as 1-element arrays — a + /// real controller sets no dimension bits on a scalar at all + /// (). + /// + [Fact] + public void Only_the_multi_element_capture_entry_is_an_array() + { + var tags = CipSymbolObjectDecoder.Decode(Capture()).ToList(); + + var array = tags.Single(t => t.Name == "TestArray"); + array.IsArray.ShouldBeTrue(); + array.ElementCount.ShouldBe(16); + tags.Where(t => t.Name != "TestArray").ShouldAllBe(t => !t.IsArray && t.ElementCount == 1); + } + /// None of the captured symbols is misread as a system tag or a structure. /// /// The dimension bits sit next to the flags the decoder tests for, so a mask that is one