3531834cce
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
232 lines
12 KiB
C#
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));
|
|
}
|
|
}
|