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"); + } +}