diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
index ef0a502d..9198b710 100644
--- a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
+++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
@@ -169,7 +169,7 @@
{
"id": "B5.3",
"subject": "B5: Modbus \u2014 optional unitId in ModbusEquipmentTagParser + ResolveHost via resolver (per-unit breaker key); scope-note vs R2-11",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
"B5.1"
]
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
index a9ce79a7..65b16e29 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
@@ -55,10 +55,14 @@ public static class ModbusEquipmentTagParser
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable (UNDER-6). Node-level authz remains the effective write gate.
var writable = TagConfigJson.ReadWritable(root);
+ // CONV-4: optional per-tag unitId (0–255). Absent ⇒ null ⇒ the driver-level UnitId via
+ // ModbusDriver.ResolveUnitId, so multi-unit equipment tags key their own per-host breaker.
+ // Scope: unitId ONLY — the remaining parser-strictness gaps are R2-11's.
+ var unitId = ReadOptionalByte(root, "unitId");
def = new ModbusTagDefinition(
Name: reference, Region: region, Address: (ushort)address, DataType: dataType,
Writable: writable, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
- ArrayCount: arrayCount);
+ ArrayCount: arrayCount, UnitId: unitId);
return true;
}
catch (JsonException) { return false; }
@@ -109,6 +113,10 @@ public static class ModbusEquipmentTagParser
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt32(out var v) ? v : 0;
+ private static byte? ReadOptionalByte(JsonElement o, string name)
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
+ && e.TryGetInt32(out var v) && v is >= 0 and <= 255 ? (byte)v : null;
+
private static bool ReadBool(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
index 227b1abc..e522b3ef 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -140,10 +140,19 @@ public sealed class ModbusDriver
WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
}
- ///
+ ///
+ /// Resolve a reference to the per-slave host key (host:port/unitN) that keys its
+ /// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through
+ /// so an equipment-tag reference (raw TagConfig
+ /// JSON) keys its OWN authored unit — via the tag's optional unitId or the driver
+ /// default — rather than the single-slave fallback. The resolver caches parses, so this adds
+ /// no per-call cost after first use.
+ ///
+ /// The tag reference (authored name or equipment-tag JSON).
+ /// The per-slave host key.
public string ResolveHost(string fullReference)
{
- if (_tagsByName.TryGetValue(fullReference, out var tag))
+ if (_resolver.TryResolve(fullReference, out var tag))
return BuildSlaveHostName(ResolveUnitId(tag));
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
return HostName;
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusUnitIdResolveHostTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusUnitIdResolveHostTests.cs
new file mode 100644
index 00000000..933a201f
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusUnitIdResolveHostTests.cs
@@ -0,0 +1,63 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+///
+/// CONV-4 (Modbus leg) — Modbus keys per-host resilience per SLAVE UNIT
+/// (host:port/unitN). An equipment tag must carry its own unitId so multi-unit
+/// equipment tags key their own breaker; must resolve
+/// the equipment ref through the resolver rather than only matching authored names. Scope:
+/// unitId ONLY — the parser's other strictness gaps are R2-11's.
+///
+[Trait("Category", "Unit")]
+public sealed class ModbusUnitIdResolveHostTests
+{
+ private sealed class NoopTransport : IModbusTransport
+ {
+ public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
+ public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
+ => Task.FromResult(new byte[] { 0x03, 0x00 });
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+
+ private static ModbusDriver NewDriver() => new(
+ new ModbusDriverOptions { Host = "10.0.0.1", Port = 502, UnitId = 1, Probe = new ModbusProbeOptions { Enabled = false } },
+ "modbus-1", _ => new NoopTransport());
+
+ /// The parser reads an optional unitId into the transient definition.
+ [Fact]
+ public void Parser_reads_optional_unitId()
+ {
+ var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16","unitId":7}""";
+ ModbusEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def.UnitId.ShouldBe((byte)7);
+ }
+
+ /// An absent unitId leaves the definition on the driver-level default (null override).
+ [Fact]
+ public void Parser_absent_unitId_is_null()
+ {
+ var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16"}""";
+ ModbusEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def.UnitId.ShouldBeNull();
+ }
+
+ /// An equipment ref carrying a unitId keys its own per-unit host name.
+ [Fact]
+ public void EquipmentTag_WithUnitId_ResolvesPerUnitHostName()
+ {
+ var drv = NewDriver();
+ var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16","unitId":7}""";
+ drv.ResolveHost(json).ShouldBe("10.0.0.1:502/unit7");
+ }
+
+ /// An equipment ref without a unitId keys the driver-level unit host name.
+ [Fact]
+ public void EquipmentTag_WithoutUnitId_KeysDriverDefaultUnit()
+ {
+ var drv = NewDriver();
+ var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16"}""";
+ drv.ResolveHost(json).ShouldBe("10.0.0.1:502/unit1");
+ }
+}