0200619322
N2: pre-#741 rows carry region/address beside an addressString with no marker (the old driver never read the string, the old model preserved it as an unknown key). The r1 rule resolved those from the string - a silent remap or BadNodeIdUnknown on upgrade. The driver now loads from region+address whenever both are present (classic, editor-pinned, pre-lane); only a blob with NO keys is string-only and resolved through the parser. This is safe because the model no longer stamps keys onto a string-only tag. The model distinguishes string-only / legacy-string-beside-keys / pinned, and the editor hint says so. Test matrix over every persisted shape (keys only, pre-lane keys+string, pre-lane stamped HR/0+string, string only, marker+keys, marker no keys) under every family; the pre-lane row also through a model save and a CSV export->import. n5: a typed CSV address cell is an authored address - it drops addressString and the marker (new CsvTypedColumn.ClearsKeys) instead of being discarded. n6: Validate skips the region/dataType/byteOrder enum checks on a string-only tag (the driver ignores them). n7: docs/v2/modbus-addressing.md restates the key-presence rule. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
257 lines
13 KiB
C#
257 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.
|
|
/// </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 2 (review r1 N1): region/address that were merely STAMPED (an unmarked string beside them —
|
|
/// what a model round-trip of a string-only tag used to produce) never win: the string is resolved with
|
|
/// the driver's family, so the tag cannot collapse onto HoldingRegisters[0].
|
|
/// </summary>
|
|
/// <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));
|
|
}
|
|
}
|