Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusFamilyWiringTests.cs
T
Joseph Doherty 3531834cce fix(modbus): review r1 - resolved address keys win only when the editor pinned them
N1: the m2 precedence trusted region/address whenever the keys were present,
but the AdminUI model stamps defaulted HoldingRegisters/0 on every save, so a
string-only tag (CSV import, or any editor toggle) silently became HR[0] -
reads AND writes. The resolved keys are now authoritative only when the editor
marked them ("addressResolved": true, written by Resolve); an unmarked
addressString is always resolved through the parser with the driver's family.
The model no longer stamps address fields for a string-only tag, CSV export
leaves its address cells blank, and the editor hint distinguishes string-only
from pinned tags. Tests: CSV import and editor save of a string-only tag, a
stamped-default blob, and the pinned positive control across families.
n1: docs/v2/modbus-addressing.md updated (FC23 opt-in, maxReadGap semantics,
coalescing scope, addressString/family rules, coil coercion, refusals).
n2: form round-trip tests for UseFC23ForMultiRegisterWrites /
DisableReadCoalescing. n4: the editor keeps unresolved address text when the
parent echoes back its own emit. n3: noted, no change.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:06:02 -04:00

232 lines
12 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>
[Fact]
public void Defaulted_region_and_address_beside_an_unmarked_string_do_not_win()
{
const string json = """{"addressString":"D100","region":"HoldingRegisters","address":0,"dataType":"Int16"}""";
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)100);
// Under a family that cannot parse it the tag is refused (BadNodeIdUnknown), never served as HR[0].
ModbusTagDefinitionFactory.FromTagConfig(json, "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));
}
}