Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
T
Joseph Doherty 4b14feb373 fix(drivers): serialize driver-config enums as strings in AdminUI pages + probes
AdminUI driver-instance pages serialized enum config fields (S7 CpuType,
Modbus DataType/Region, AbCip PlcFamily, ...) as JSON *numbers* because each
page's _jsonOpts lacked a JsonStringEnumConverter. The driver factories,
however, deserialize into string-typed DTOs (+ lenient ParseEnum) and throw
when binding a JSON number to a string? — so an AdminUI-authored config
containing any enum field produced a blob the driver could not parse,
faulting the driver on deploy. Proven end-to-end for S7 and Modbus; latent
for AbCip/AbLegacy/TwinCAT/FOCAS/Galaxy/Historian. Only OpcUaClient was safe
(its factory + probe already carried the converter).

Add JsonStringEnumConverter to all 9 driver-instance pages' _jsonOpts and the
8 missing driver probes' _opts (factories unchanged — already string-via-
ParseEnum; strictly more permissive, also lets pages load hand-seeded
string-enum configs back into the form).

Also fix DriverProbeHandshakeE2eTests.AbCip_Green_AgainstSim to probe a real
sim tag (TestDINT) — the no-tags @raw_cpu_type fallback is rejected by the
ab_server sim with ErrorBadParam (a real ControlLogix returns ErrorNotFound,
which the probe treats as reachable; hardware-gated follow-up).

Tests: reflection guard over all driver pages' _jsonOpts (AdminUI.Tests);
factory round-trip + numeric-form-throws guards for S7 and Modbus.

Found by running the never-before-run FB-9/FB-10 live verifies.
2026-06-19 04:52:47 -04:00

100 lines
4.0 KiB
C#

using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus FC03 probe for the <see cref="ModbusDriverOptions"/>-shaped driver config.
/// Opens a TCP connection to the configured endpoint and sends a one-shot FC03
/// (Read Holding Registers, qty 1 @ address 0) handshake. A normal FC03 response
/// or a Modbus exception PDU both confirm a live Modbus device (green + latency);
/// TCP failure surfaces the SocketError; a Modbus-level handshake failure after
/// TCP succeeds surfaces a targeted message; timeout surfaces "timed out after Ns."
/// </summary>
public sealed class ModbusDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
// FC03 Read Holding Registers: function=0x03, addr-hi=0, addr-lo=0, qty-hi=0, qty-lo=1
private static readonly byte[] Fc03Pdu = [0x03, 0x00, 0x00, 0x00, 0x01];
/// <inheritdoc />
public string DriverType => "Modbus";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
ModbusDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<ModbusDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var unitId = opts.UnitId;
var sw = Stopwatch.StartNew();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
// Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference).
// autoReconnect=false: this is a one-shot probe, no retry loops.
var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false);
await using (transport.ConfigureAwait(false))
{
try
{
await transport.ConnectAsync(cts.Token).ConfigureAwait(false);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
// Phase 2 — FC03 handshake. TCP is up; now prove a Modbus device is answering.
try
{
await transport.SendAsync(unitId, Fc03Pdu, cts.Token).ConfigureAwait(false);
sw.Stop();
return new(true, "Modbus FC03 OK", sw.Elapsed);
}
catch (ModbusException)
{
// Device replied with an exception PDU — it IS a real Modbus device.
sw.Stop();
return new(true, "Modbus FC03 OK (device returned exception PDU)", sw.Elapsed);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
sw.Stop();
return new(false, $"Reachable at {host}:{port} but Modbus FC03 handshake failed: {ex.Message}", null);
}
}
}
private static (string host, int port) ExtractTarget(ModbusDriverOptions opts)
=> (opts.Host, opts.Port);
}