diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 311bca0..a45421c 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -44,7 +44,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagTagFactory();
- _enumeratorFactory = enumeratorFactory ?? new EmptyAbCipTagEnumeratorFactory();
+ _enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -559,9 +559,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
}
- // Controller-discovered tags — optional. Default enumerator returns an empty sequence;
- // tests + the follow-up real @tags walker plug in via the ctor parameter.
- if (_devices.TryGetValue(device.HostAddress, out var state))
+ // Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
+ // walker (LibplctagTagEnumerator) is the factory default since task #178 shipped,
+ // so leaving the flag off keeps the strict-config path for deployments where only
+ // declared tags should appear.
+ if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
{
using var enumerator = _enumeratorFactory.Create();
var deviceParams = new AbCipTagCreateParams(
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
index 735fca2..469a64b 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
@@ -29,6 +29,15 @@ public sealed class AbCipDriverOptions
/// not pass a more specific value. Matches the Modbus driver's 2-second default.
///
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
+
+ ///
+ /// When true, DiscoverAsync walks each device's Logix symbol table via
+ /// the @tags pseudo-tag + surfaces controller-resident globals under a
+ /// Discovered/ sub-folder. Pre-declared tags always emit regardless. Default
+ /// false to keep the strict-config path for deployments where only declared tags
+ /// should appear in the address space.
+ ///
+ public bool EnableControllerBrowse { get; init; }
}
///
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
new file mode 100644
index 0000000..e5248cb
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
@@ -0,0 +1,128 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Decoder for the CIP Symbol Object (class 0x6B) response returned by Logix controllers
+/// when a client reads the @tags pseudo-tag. Parses the concatenated tag-info
+/// entries into a sequence of s that the driver can stream
+/// into the address-space builder.
+///
+///
+/// Entry layout (little-endian) per Rockwell CIP Vol 1 + Logix 5000 CIP Programming
+/// Manual (1756-PM019 chapter "Symbol Object"), cross-checked against libplctag's
+/// ab/cip.c handle_listed_tags_reply:
+///
+/// - u32Symbol Instance ID — opaque identifier for the tag.
+/// - u16Symbol Type — lower 12 bits = CIP type code (0xC1 BOOL,
+/// 0xC2 SINT, …, 0xD0 STRING). Bit 12 = system-tag flag. Bit 13 = reserved.
+/// Bit 15 = struct flag; when set, the lower 12 bits are the template instance id
+/// (not a primitive type code).
+/// - u16Element length — bytes per element (e.g. 4 for DINT).
+/// - u32 × 3Array dimensions — zero for scalar tags.
+/// - u16Symbol name length in bytes.
+/// - u8 × NASCII symbol name, padded to an even byte boundary.
+///
+///
+/// Program:-scope tags arrive with their scope prefix baked into the name
+/// (Program:MainProgram.StepIndex); decoder strips the prefix + emits the scope
+/// separately so the driver's IAddressSpaceBuilder can organise them.
+///
+public static class CipSymbolObjectDecoder
+{
+ // Fixed header size in bytes — instance-id(4) + symbol-type(2) + element-length(2)
+ // + array-dims(4×3) + name-length(2) = 22.
+ private const int FixedHeaderSize = 22;
+
+ private const ushort SymbolTypeSystemFlag = 0x1000;
+ private const ushort SymbolTypeStructFlag = 0x8000;
+ private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
+
+ ///
+ /// Decode the raw @tags blob into an enumerable sequence. Malformed entries at
+ /// the tail cause decoding to stop gracefully — the caller gets whatever it could parse
+ /// cleanly before the corruption.
+ ///
+ public static IEnumerable Decode(byte[] buffer)
+ {
+ ArgumentNullException.ThrowIfNull(buffer);
+ return DecodeImpl(buffer);
+ }
+
+ private static IEnumerable DecodeImpl(byte[] buffer)
+ {
+ 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)
+ var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
+ pos += FixedHeaderSize;
+
+ if (pos + nameLength > buffer.Length) break;
+ var name = Encoding.ASCII.GetString(buffer, pos, nameLength);
+ pos += nameLength;
+ if ((pos & 1) != 0) pos++; // even-align for the next entry
+
+ if (string.IsNullOrWhiteSpace(name)) continue;
+
+ var isSystem = (symbolType & SymbolTypeSystemFlag) != 0;
+ var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
+ var typeCode = symbolType & SymbolTypeTypeCodeMask;
+
+ var (programScope, simpleName) = SplitProgramScope(name);
+ var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
+
+ 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);
+
+ _ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
+ }
+ }
+
+ ///
+ /// Split a Program:MainProgram.StepIndex-style name into its scope + local
+ /// parts. Names without the Program: prefix pass through unchanged.
+ ///
+ internal static (string? programScope, string simpleName) SplitProgramScope(string fullName)
+ {
+ const string prefix = "Program:";
+ if (!fullName.StartsWith(prefix, StringComparison.Ordinal)) return (null, fullName);
+ var afterPrefix = fullName[prefix.Length..];
+ var dot = afterPrefix.IndexOf('.');
+ if (dot <= 0) return (null, fullName); // malformed scope — surface the raw name
+ return (afterPrefix[..dot], afterPrefix[(dot + 1)..]);
+ }
+
+ ///
+ /// Map a CIP atomic type code (lower 12 bits of the symbol-type field) to our
+ /// surface. Returns null for unrecognised codes —
+ /// caller treats those as so the symbol is still
+ /// surfaced + downstream config can add a concrete type override.
+ ///
+ internal static AbCipDataType? MapTypeCode(byte typeCode) => typeCode switch
+ {
+ 0xC1 => AbCipDataType.Bool,
+ 0xC2 => AbCipDataType.SInt,
+ 0xC3 => AbCipDataType.Int,
+ 0xC4 => AbCipDataType.DInt,
+ 0xC5 => AbCipDataType.LInt,
+ 0xC6 => AbCipDataType.USInt,
+ 0xC7 => AbCipDataType.UInt,
+ 0xC8 => AbCipDataType.UDInt,
+ 0xC9 => AbCipDataType.ULInt,
+ 0xCA => AbCipDataType.Real,
+ 0xCB => AbCipDataType.LReal,
+ 0xCD => AbCipDataType.Dt, // DATE
+ 0xCF => AbCipDataType.Dt, // DATE_AND_TIME
+ 0xD0 => AbCipDataType.String,
+ _ => null,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
new file mode 100644
index 0000000..77476f6
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
@@ -0,0 +1,63 @@
+using System.Runtime.CompilerServices;
+using libplctag;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Real that walks a Logix controller's symbol table by
+/// reading the @tags pseudo-tag via libplctag + decoding the CIP Symbol Object
+/// response with .
+///
+///
+/// libplctag's Tag.GetBuffer() returns the raw Symbol Object bytes when the
+/// tag name is @tags. The decoder walks the concatenated entries + emits
+/// records matching our driver surface.
+///
+/// Task #178 closed the stub gap from PR 5 —
+/// is still available for tests that don't want to touch the native library, but the
+/// production factory default now wires this implementation in.
+///
+internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
+{
+ private Tag? _tag;
+
+ public async IAsyncEnumerable EnumerateAsync(
+ AbCipTagCreateParams deviceParams,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ // Build a tag specifically for the @tags pseudo — same gateway + path as the device,
+ // distinguished by the name alone.
+ _tag = new Tag
+ {
+ Gateway = deviceParams.Gateway,
+ Path = deviceParams.CipPath,
+ PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
+ Protocol = Protocol.ab_eip,
+ Name = "@tags",
+ Timeout = deviceParams.Timeout,
+ };
+
+ await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
+ await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
+
+ var buffer = _tag.GetBuffer();
+ foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
+ yield return tag;
+ }
+
+ public void Dispose() => _tag?.Dispose();
+
+ private static PlcType MapPlcType(string attribute) => attribute switch
+ {
+ "controllogix" => PlcType.ControlLogix,
+ "compactlogix" => PlcType.ControlLogix,
+ "micro800" => PlcType.Micro800,
+ _ => PlcType.ControlLogix,
+ };
+}
+
+/// Factory for .
+internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
+{
+ public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs
index bfee8c0..1a07857 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs
@@ -97,6 +97,7 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: enumeratorFactory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -119,6 +120,7 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -137,6 +139,7 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -153,6 +156,7 @@ public sealed class AbCipDriverDiscoveryTests
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
Timeout = TimeSpan.FromSeconds(7),
+ EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs
new file mode 100644
index 0000000..9efa4af
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs
@@ -0,0 +1,186 @@
+using System.Buffers.Binary;
+using System.Text;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class CipSymbolObjectDecoderTests
+{
+ ///
+ /// Build one Symbol Object entry in the byte layout
+ /// instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad.
+ ///
+ private static byte[] BuildEntry(
+ uint instanceId,
+ ushort symbolType,
+ ushort elementLength,
+ (uint, uint, uint) arrayDims,
+ string name)
+ {
+ var nameBytes = Encoding.ASCII.GetBytes(name);
+ var nameLen = nameBytes.Length;
+ var totalLen = 22 + nameLen;
+ if ((totalLen & 1) != 0) totalLen++; // pad to even
+
+ var buf = new byte[totalLen];
+ BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId);
+ BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType);
+ BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength);
+ BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1);
+ BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2);
+ BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3);
+ BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen);
+ Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen);
+ return buf;
+ }
+
+ private static byte[] Concat(params byte[][] chunks)
+ {
+ var total = chunks.Sum(c => c.Length);
+ var result = new byte[total];
+ var pos = 0;
+ foreach (var c in chunks)
+ {
+ Buffer.BlockCopy(c, 0, result, pos, c.Length);
+ pos += c.Length;
+ }
+ return result;
+ }
+
+ [Fact]
+ public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
+ {
+ var bytes = BuildEntry(
+ instanceId: 42,
+ symbolType: 0xC4,
+ elementLength: 4,
+ arrayDims: (0, 0, 0),
+ name: "Counter");
+
+ var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
+
+ tags.Count.ShouldBe(1);
+ tags[0].Name.ShouldBe("Counter");
+ tags[0].ProgramScope.ShouldBeNull();
+ tags[0].DataType.ShouldBe(AbCipDataType.DInt);
+ tags[0].IsSystemTag.ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData((byte)0xC1, AbCipDataType.Bool)]
+ [InlineData((byte)0xC2, AbCipDataType.SInt)]
+ [InlineData((byte)0xC3, AbCipDataType.Int)]
+ [InlineData((byte)0xC4, AbCipDataType.DInt)]
+ [InlineData((byte)0xC5, AbCipDataType.LInt)]
+ [InlineData((byte)0xC6, AbCipDataType.USInt)]
+ [InlineData((byte)0xC7, AbCipDataType.UInt)]
+ [InlineData((byte)0xC8, AbCipDataType.UDInt)]
+ [InlineData((byte)0xC9, AbCipDataType.ULInt)]
+ [InlineData((byte)0xCA, AbCipDataType.Real)]
+ [InlineData((byte)0xCB, AbCipDataType.LReal)]
+ [InlineData((byte)0xD0, AbCipDataType.String)]
+ public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected)
+ {
+ CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
+ }
+
+ [Fact]
+ public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
+ {
+ CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Struct_flag_overrides_type_code_and_yields_Structure()
+ {
+ // 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234)
+ var bytes = BuildEntry(
+ instanceId: 5,
+ symbolType: 0x8000 | 0x0234,
+ elementLength: 16,
+ arrayDims: (0, 0, 0),
+ name: "Motor1");
+
+ var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
+ tag.DataType.ShouldBe(AbCipDataType.Structure);
+ }
+
+ [Fact]
+ public void System_flag_surfaces_as_IsSystemTag_true()
+ {
+ var bytes = BuildEntry(
+ instanceId: 99,
+ symbolType: 0x1000 | 0xC4, // system flag + DINT
+ elementLength: 4,
+ arrayDims: (0, 0, 0),
+ name: "__Reserved_1");
+
+ var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
+ tag.IsSystemTag.ShouldBeTrue();
+ tag.DataType.ShouldBe(AbCipDataType.DInt);
+ }
+
+ [Fact]
+ public void Program_scope_name_splits_prefix_into_ProgramScope()
+ {
+ var bytes = BuildEntry(
+ instanceId: 1,
+ symbolType: 0xC4,
+ elementLength: 4,
+ arrayDims: (0, 0, 0),
+ name: "Program:MainProgram.StepIndex");
+
+ var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
+ tag.ProgramScope.ShouldBe("MainProgram");
+ tag.Name.ShouldBe("StepIndex");
+ }
+
+ [Fact]
+ public void Multiple_entries_decode_in_wire_order_with_even_padding()
+ {
+ // Name "Abc" is 3 bytes — triggers the even-pad branch between entries.
+ var bytes = Concat(
+ BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4)
+ BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi"
+
+ var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
+ tags.Count.ShouldBe(2);
+ tags[0].Name.ShouldBe("Abc");
+ tags[0].DataType.ShouldBe(AbCipDataType.DInt);
+ tags[1].Name.ShouldBe("Pi");
+ tags[1].DataType.ShouldBe(AbCipDataType.Real);
+ }
+
+ [Fact]
+ public void Truncated_buffer_stops_decoding_gracefully()
+ {
+ var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter");
+ // Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw.
+ var truncated = full.Take(full.Length - 5).ToArray();
+
+ CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
+ }
+
+ [Fact]
+ public void Empty_buffer_yields_no_tags()
+ {
+ CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
+ }
+
+ [Theory]
+ [InlineData("Counter", null, "Counter")]
+ [InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
+ [InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")]
+ [InlineData("Program:", null, "Program:")] // malformed — no dot
+ [InlineData("Program:OnlyProg", null, "Program:OnlyProg")]
+ [InlineData("Motor.Status.Running", null, "Motor.Status.Running")]
+ public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName)
+ {
+ var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input);
+ scope.ShouldBe(expectedScope);
+ name.ShouldBe(expectedName);
+ }
+}