From 20be5416b9a80322fc411d7df034088956b0daf2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:04:34 -0400 Subject: [PATCH] feat(modbus-rtu): bind Transport mode on options/DTO/factory (string-enum guard) Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ModbusDriverOptions.cs | 8 ++++ .../ModbusDriverFactoryExtensions.cs | 4 ++ .../ModbusTransportConfigRoundTripTests.cs | 47 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs index 642f1902..39a4104e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs @@ -130,6 +130,14 @@ public sealed class ModbusDriverOptions /// public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR; + /// + /// Wire transport selector. (default) is the + /// historical Modbus TCP/MBAP framing. + /// frames Modbus RTU PDUs (CRC16, no MBAP header) over the same TCP socket — for + /// serial-to-Ethernet gateways that bridge RS-485 without re-encapsulating to MBAP. + /// + public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp; + /// /// When true, the driver suppresses redundant writes: if the most recent /// successful write to a tag carried value V and a new write of V arrives, the second diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs index 5291f0cb..93fdb4a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs @@ -74,6 +74,8 @@ public static class ModbusDriverFactoryExtensions : ParseEnum(dto.Family, "", driverInstanceId, "Family"), MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR : ParseEnum(dto.MelsecSubFamily, "", driverInstanceId, "MelsecSubFamily"), + Transport = dto.Transport is null ? ModbusTransportMode.Tcp + : ParseEnum(dto.Transport, "", driverInstanceId, "Transport"), AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null, AutoReconnect = dto.AutoReconnect ?? true, // v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus @@ -159,6 +161,8 @@ public static class ModbusDriverFactoryExtensions public string? Family { get; init; } /// Gets or sets the Melsec subfamily. public string? MelsecSubFamily { get; init; } + /// Gets or sets the wire transport mode (Tcp / RtuOverTcp). Null defaults to Tcp. + public string? Transport { get; init; } /// Gets or sets the automatic prohibition reprobei interval in milliseconds. public int? AutoProhibitReprobeMs { get; init; } /// Gets or sets a value indicating whether automatic reconnection is enabled. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs new file mode 100644 index 00000000..343eb379 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +public sealed class ModbusTransportConfigRoundTripTests +{ + // Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums. + private static readonly JsonSerializerOptions _adminOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + }; + + [Fact] + public void Transport_serializes_as_a_string_not_a_number() + { + var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp }; + var json = JsonSerializer.Serialize(opts, _adminOpts); + json.ShouldContain("\"transport\":\"RtuOverTcp\""); + json.ShouldNotContain("\"transport\":1", Case.Sensitive); + } + + [Fact] + public void Factory_binds_RtuOverTcp_from_string_config() + { + const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }"""; + using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json); + var opts = (ModbusDriverOptions)typeof(ModbusDriver) + .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(driver)!; + opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp); + } + + [Fact] + public void Factory_defaults_Transport_to_Tcp_when_omitted() + { + const string json = """{ "host": "10.0.0.10" }"""; + using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json); + var opts = (ModbusDriverOptions)typeof(ModbusDriver) + .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(driver)!; + opts.Transport.ShouldBe(ModbusTransportMode.Tcp); + } +}