92749402b5
N3: a keyless blob carrying the addressResolved marker was served from its string by the driver but saved as HR[0] by the editor model / CSV path. The precedence rule now lives in ONE pure resolver in Driver.Modbus.Contracts (ModbusTagAddressResolution.Classify/Resolve): keys when region+address are both present, else a string-only addressString resolved with the driver's family (refused when it cannot parse), else refused. The marker never decides. Consumers that now ask it instead of restating it: the driver's tag reader (ModbusTagDefinitionFactory.FromTagConfig), the deploy gate (Inspect / DescribeRejection), the editor model (AddressShape / IsStringOnly / HasKeyAddress / ResolveWith, ToJson stamping, Validate), the CSV export cells and the editor hint. The model writes keys only when it loaded them or a field was authored - never defaults it did not resolve (also closes two divergences the new matrix found: malformed-string+marker and non-string addressString were saved as HR[0]). One table-driven test (ModbusAddressAuthorityAgreementTests) runs 15 persisted shapes through driver, deploy gate, model load/save/Validate and the real CSV export->import under all four families; breaking the CSV export or reverting the model to the marker rule fails it. Minors: m1 CSV address cells keep the string+marker while it still describes them (lossless unchanged round trip), drop it when contradicted, and the review grid reports the drop; m2 the pre-lane test uses the real export residual; m3 unmarked string-only + typed-cell case added; m4 stale docs/summary and the unused factory parameter removed. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
253 lines
13 KiB
C#
253 lines
13 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 07 U1: <c>Family</c>, <c>MelsecSubFamily</c> and <c>DisableFC23</c> were authored in the
|
|
/// AdminUI and consumed by nothing. A tag's <c>addressString</c> now resolves through
|
|
/// <see cref="ModbusAddressParser"/> with the driver's family; <c>DisableFC23</c> gates the FC23
|
|
/// write-and-read-back path for multi-register writes. Also pins that the AdminUI form's own
|
|
/// (TimeSpan-shaped) config keys reach the driver.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ModbusFamilyWiringTests
|
|
{
|
|
// ---------------- addressString through the family-aware parser ----------------
|
|
|
|
[Fact]
|
|
public void A_MELSEC_D_register_resolves_only_under_the_MELSEC_family()
|
|
{
|
|
const string json = """{"addressString":"D100"}""";
|
|
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
|
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
|
def.Address.ShouldBe((ushort)100);
|
|
// Positive control: the same text means nothing to a Generic (Modicon) driver.
|
|
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_DL205_V_memory_address_resolves_octal_under_DL205()
|
|
{
|
|
ModbusTagDefinitionFactory.FromTagConfig("""{"addressString":"V2000"}""", "T", ModbusFamily.DL205, MelsecFamily.Q_L_iQR, out var def)
|
|
.ShouldBeTrue();
|
|
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
|
def.Address.ShouldBe((ushort)0x400); // V2000 octal = 1024
|
|
}
|
|
|
|
[Fact]
|
|
public void A_string_only_blob_carries_type_order_and_count_and_keeps_the_other_keys()
|
|
{
|
|
ModbusTagDefinitionFactory.FromTagConfig(
|
|
"""{"addressString":"40011:F:CDAB:4","writable":false,"unitId":3}""",
|
|
"T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
|
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
|
def.Address.ShouldBe((ushort)10);
|
|
def.DataType.ShouldBe(ModbusDataType.Float32);
|
|
def.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
|
|
def.ArrayCount.ShouldBe(4);
|
|
def.Writable.ShouldBeFalse();
|
|
def.UnitId.ShouldBe((byte)3);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round 1 (review m2): persisted region/address are authoritative over an addressString, so a
|
|
/// later Family / MelsecSubFamily change can never silently remap a live tag the editor resolved. (Since
|
|
/// fix round 3 the keys win by PRESENCE; the marker in this blob is display state only.)
|
|
/// </summary>
|
|
[Fact]
|
|
public void Persisted_resolved_keys_win_over_the_address_string_so_a_family_change_cannot_remap()
|
|
{
|
|
// The editor resolved D100 under MELSEC, persisted HR 100 and MARKED it; the driver is later switched.
|
|
const string json = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""";
|
|
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
|
|
{
|
|
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
|
def.Address.ShouldBe((ushort)100, $"family {family}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round 3 (review r2 N2): the persisted-shape matrix. Every shape that carries region + address loads
|
|
/// from them under every family — including the pre-#741 "keys + addressString, no marker" row, which the
|
|
/// old driver served from its keys and must keep serving so. Only a string-only blob (no keys) resolves
|
|
/// the string.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("keys only", """{"region":"InputRegisters","address":5}""", ModbusRegion.InputRegisters, 5)]
|
|
[InlineData("pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", ModbusRegion.InputRegisters, 5)]
|
|
[InlineData("pre-lane stamped HR/0 + string", """{"region":"HoldingRegisters","address":0,"dataType":"Int16","addressString":"D100"}""", ModbusRegion.HoldingRegisters, 0)]
|
|
[InlineData("string only", """{"addressString":"40011:F"}""", ModbusRegion.HoldingRegisters, 10)]
|
|
[InlineData("string + marker + keys (editor-pinned)", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100}""", ModbusRegion.HoldingRegisters, 100)]
|
|
[InlineData("string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", ModbusRegion.HoldingRegisters, 10)]
|
|
public void Every_persisted_shape_resolves_to_its_address_under_every_family(
|
|
string shape, string json, ModbusRegion region, int address)
|
|
{
|
|
foreach (var (family, sub) in new[]
|
|
{
|
|
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR), (ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
|
|
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR), (ModbusFamily.MELSEC, MelsecFamily.F_iQF),
|
|
})
|
|
{
|
|
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def).ShouldBeTrue($"{shape} / {family}");
|
|
def.Region.ShouldBe(region, $"{shape} / {family}");
|
|
def.Address.ShouldBe((ushort)address, $"{shape} / {family}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void A_string_only_blob_no_family_parses_is_refused_not_served_as_HR0()
|
|
{
|
|
ModbusTagDefinitionFactory.FromTagConfig("""{"addressString":"D100"}""", "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _)
|
|
.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unparseable_address_string_rejects_the_tag_and_the_deploy_gate_says_why()
|
|
{
|
|
const string json = """{"addressString":"ZZ9"}""";
|
|
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out _).ShouldBeFalse();
|
|
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("addressString"));
|
|
// The family-unaware gate accepts what SOME family resolves (it must never be stricter than the driver).
|
|
ModbusTagDefinitionFactory.Inspect("""{"addressString":"D100"}""").ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_driver_resolves_address_strings_with_its_configured_family()
|
|
{
|
|
var fake = new ScriptedTransport();
|
|
fake.Bank.HoldingRegisters[100] = 1234;
|
|
var opts = new ModbusDriverOptions
|
|
{
|
|
Host = "f", Family = ModbusFamily.MELSEC, Probe = new ModbusProbeOptions { Enabled = false },
|
|
RawTags = [new RawTagEntry("D100", """{"addressString":"D100"}""", false)],
|
|
};
|
|
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
var r = await drv.ReadAsync(["D100"], TestContext.Current.CancellationToken);
|
|
r[0].StatusCode.ShouldBe(0u);
|
|
r[0].Value.ShouldBe((short)1234);
|
|
}
|
|
|
|
[Fact]
|
|
public void Family_and_MelsecSubFamily_parse_from_the_driver_config()
|
|
{
|
|
var o = ModbusDriverFactoryExtensions.ParseOptions("m1",
|
|
"""{"host":"h","family":"MELSEC","melsecSubFamily":"F_iQF","disableFC23":true,"useFC23ForMultiRegisterWrites":true}""");
|
|
o.Family.ShouldBe(ModbusFamily.MELSEC);
|
|
o.MelsecSubFamily.ShouldBe(MelsecFamily.F_iQF);
|
|
o.DisableFC23.ShouldBeTrue();
|
|
o.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
|
}
|
|
|
|
// ---------------- FC23 is OPT-IN; DisableFC23 is the kill-switch ----------------
|
|
|
|
private static async Task<(ModbusDriver, ScriptedTransport)> StartWriteAsync(string driverConfigJson)
|
|
{
|
|
var t = new ScriptedTransport();
|
|
var opts = ModbusDriverFactoryExtensions.ParseOptions("m1", driverConfigJson) is var parsed
|
|
? new ModbusDriverOptions
|
|
{
|
|
Host = "f", DisableFC23 = parsed.DisableFC23,
|
|
UseFC23ForMultiRegisterWrites = parsed.UseFC23ForMultiRegisterWrites,
|
|
Probe = new ModbusProbeOptions { Enabled = false },
|
|
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Float32)]),
|
|
}
|
|
: null!;
|
|
var drv = new ModbusDriver(opts, "m1", _ => t);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
return (drv, t);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round 1 (coordinator ruling 1): a config without the opt-in key keeps the FC16 wire — including the
|
|
/// legacy shape every form-saved config carries ("disableFC23": false).
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("""{"host":"h"}""")]
|
|
[InlineData("""{"host":"h","disableFC23":false,"maxReadGap":0,"useFC15ForSingleCoilWrites":false}""")]
|
|
public async Task Without_the_opt_in_multi_register_writes_stay_on_FC16(string legacyConfig)
|
|
{
|
|
var (drv, t) = await StartWriteAsync(legacyConfig);
|
|
await using var _ = drv;
|
|
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
|
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x10]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Opted_in_multi_register_writes_use_FC23_write_and_read_back()
|
|
{
|
|
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
|
|
await using var _ = drv;
|
|
var w = await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken);
|
|
w[0].StatusCode.ShouldBe(0u);
|
|
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17]);
|
|
(await drv.ReadAsync(["F"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(1.5f);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DisableFC23_overrides_the_opt_in()
|
|
{
|
|
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true,"disableFC23":true}""");
|
|
await using var _ = drv;
|
|
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
|
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x10]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_opted_in_device_without_FC23_falls_back_to_FC16_once_and_stays_on_it()
|
|
{
|
|
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
|
|
await using var _ = drv;
|
|
t.Handler = (_, pdu, _) => pdu[0] == 0x17
|
|
? Task.FromException<byte[]>(new ModbusException(0x17, 0x01, "illegal function"))
|
|
: null;
|
|
|
|
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
|
(await drv.WriteAsync([new WriteRequest("F", 2.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
|
|
|
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17, (byte)0x10, (byte)0x10]);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
// ---------------- the AdminUI form's own keys reach the driver ----------------
|
|
|
|
[Fact]
|
|
public void The_AdminUI_form_shape_of_the_timing_keys_is_honoured()
|
|
{
|
|
// The form serialises ModbusDriverOptions itself (camelCase, TimeSpan strings), not the *Ms DTO keys.
|
|
var formShape = JsonSerializer.Serialize(new ModbusDriverOptions
|
|
{
|
|
Host = "h",
|
|
Timeout = TimeSpan.FromSeconds(7),
|
|
IdleDisconnectTimeout = TimeSpan.FromSeconds(90),
|
|
AutoProhibitReprobeInterval = TimeSpan.FromSeconds(45),
|
|
Probe = new ModbusProbeOptions { Interval = TimeSpan.FromSeconds(11), Timeout = TimeSpan.FromSeconds(3) },
|
|
KeepAlive = new ModbusKeepAliveOptions { Time = TimeSpan.FromSeconds(60), Interval = TimeSpan.FromSeconds(20) },
|
|
Reconnect = new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(2), MaxDelay = TimeSpan.FromSeconds(40) },
|
|
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
|
|
|
|
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", formShape);
|
|
|
|
o.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
|
|
o.IdleDisconnectTimeout.ShouldBe(TimeSpan.FromSeconds(90));
|
|
o.AutoProhibitReprobeInterval.ShouldBe(TimeSpan.FromSeconds(45));
|
|
o.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(11));
|
|
o.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
|
o.KeepAlive.Time.ShouldBe(TimeSpan.FromSeconds(60));
|
|
o.KeepAlive.Interval.ShouldBe(TimeSpan.FromSeconds(20));
|
|
o.Reconnect.InitialDelay.ShouldBe(TimeSpan.FromSeconds(2));
|
|
o.Reconnect.MaxDelay.ShouldBe(TimeSpan.FromSeconds(40));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_millisecond_keys_still_win_when_both_are_present()
|
|
{
|
|
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h","timeoutMs":1500,"timeout":"00:00:07"}""");
|
|
o.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500));
|
|
}
|
|
}
|