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; /// /// Review 07 U1: Family, MelsecSubFamily and DisableFC23 were authored in the /// AdminUI and consumed by nothing. A tag's addressString now resolves through /// with the driver's family; DisableFC23 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. /// [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); } /// /// 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.) /// [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}"); } } /// /// 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. /// [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); } /// /// 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). /// [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(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)); } }