fix(modbus): equipment tags carry unitId; ResolveHost keys per-unit via the resolver (CONV-4)
This commit is contained in:
@@ -169,7 +169,7 @@
|
|||||||
{
|
{
|
||||||
"id": "B5.3",
|
"id": "B5.3",
|
||||||
"subject": "B5: Modbus \u2014 optional unitId in ModbusEquipmentTagParser + ResolveHost via resolver (per-unit breaker key); scope-note vs R2-11",
|
"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": [
|
"blockedBy": [
|
||||||
"B5.1"
|
"B5.1"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -55,10 +55,14 @@ public static class ModbusEquipmentTagParser
|
|||||||
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
|
// "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.
|
// authorable (UNDER-6). Node-level authz remains the effective write gate.
|
||||||
var writable = TagConfigJson.ReadWritable(root);
|
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(
|
def = new ModbusTagDefinition(
|
||||||
Name: reference, Region: region, Address: (ushort)address, DataType: dataType,
|
Name: reference, Region: region, Address: (ushort)address, DataType: dataType,
|
||||||
Writable: writable, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
|
Writable: writable, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
|
||||||
ArrayCount: arrayCount);
|
ArrayCount: arrayCount, UnitId: unitId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (JsonException) { return false; }
|
catch (JsonException) { return false; }
|
||||||
@@ -109,6 +113,10 @@ public static class ModbusEquipmentTagParser
|
|||||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
|
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
|
||||||
&& e.TryGetInt32(out var v) ? v : 0;
|
&& 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)
|
private static bool ReadBool(JsonElement o, string name)
|
||||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True;
|
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,10 +140,19 @@ public sealed class ModbusDriver
|
|||||||
WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
|
WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Resolve a reference to the per-slave host key (<c>host:port/unitN</c>) that keys its
|
||||||
|
/// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through
|
||||||
|
/// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
|
||||||
|
/// JSON) keys its OWN authored unit — via the tag's optional <c>unitId</c> or the driver
|
||||||
|
/// default — rather than the single-slave fallback. The resolver caches parses, so this adds
|
||||||
|
/// no per-call cost after first use.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
|
||||||
|
/// <returns>The per-slave host key.</returns>
|
||||||
public string ResolveHost(string fullReference)
|
public string ResolveHost(string fullReference)
|
||||||
{
|
{
|
||||||
if (_tagsByName.TryGetValue(fullReference, out var tag))
|
if (_resolver.TryResolve(fullReference, out var tag))
|
||||||
return BuildSlaveHostName(ResolveUnitId(tag));
|
return BuildSlaveHostName(ResolveUnitId(tag));
|
||||||
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
|
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
|
||||||
return HostName;
|
return HostName;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CONV-4 (Modbus leg) — Modbus keys per-host resilience per SLAVE UNIT
|
||||||
|
/// (<c>host:port/unitN</c>). An equipment tag must carry its own <c>unitId</c> so multi-unit
|
||||||
|
/// equipment tags key their own breaker; <see cref="ModbusDriver.ResolveHost"/> must resolve
|
||||||
|
/// the equipment ref through the resolver rather than only matching authored names. Scope:
|
||||||
|
/// <c>unitId</c> ONLY — the parser's other strictness gaps are R2-11's.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ModbusUnitIdResolveHostTests
|
||||||
|
{
|
||||||
|
private sealed class NoopTransport : IModbusTransport
|
||||||
|
{
|
||||||
|
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task<byte[]> 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());
|
||||||
|
|
||||||
|
/// <summary>The parser reads an optional unitId into the transient definition.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An absent unitId leaves the definition on the driver-level default (null override).</summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An equipment ref carrying a unitId keys its own per-unit host name.</summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An equipment ref without a unitId keys the driver-level unit host name.</summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user