diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index 16f626da..773acc64 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// only at PR 1 time; read / write / discovery / subscribe / probe /
/// host-resolver capabilities ship in PRs 2 and 3.
///
-public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
+public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// Authored tags this driver could not build into its poll table.
@@ -64,6 +64,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
///
public event EventHandler? OnDataChange;
+ ///
+ /// One raise per poll cycle carrying every tag that changed in it (review 02 P1). Raised alongside the per-tag
+ /// ; a consumer subscribes to exactly one of the two.
+ ///
+ public event EventHandler? OnDataChangeBatch;
+
///
/// Occurs when host status changes.
///
@@ -99,6 +105,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap,
+ onBatch: (handle, items) =>
+ OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs
new file mode 100644
index 00000000..08c422b0
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs
@@ -0,0 +1,50 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 02 P1: AbLegacy ran but did not implement ,
+/// so every changed tag paid its own trip through the actor chain. It now raises one
+/// per poll cycle, alongside the per-tag event.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyBatchSubscribeTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ [Fact]
+ public async Task One_batch_per_poll_carries_every_changed_tag_and_per_tag_events_still_fire()
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? 1 : 2 } };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(
+ new AbLegacyTagDefinition("A", Host, "N7:0", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("B", Host, "N9:0", AbLegacyDataType.Int)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-batch", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ IBatchSubscribable batching = drv; // the capability the actor prefers
+
+ var batches = new System.Collections.Concurrent.ConcurrentQueue();
+ var singles = new System.Collections.Concurrent.ConcurrentQueue();
+ batching.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
+ drv.OnDataChange += (_, e) => singles.Enqueue(e);
+
+ var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (batches.IsEmpty && DateTime.UtcNow < deadline) await Task.Delay(10);
+
+ batches.TryPeek(out var first).ShouldBeTrue();
+ first!.SubscriptionHandle.ShouldBe(handle);
+ first.Items.Select(i => i.FullReference).ShouldBe(new[] { "A", "B" }, ignoreOrder: true);
+ first.Items.Single(i => i.FullReference == "A").Snapshot.Value.ShouldBe(1);
+ singles.Count.ShouldBeGreaterThanOrEqualTo(2); // positive control: the legacy per-tag event still fires
+
+ await drv.UnsubscribeAsync(handle, CancellationToken.None);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}