d32d89c340
Five drivers ignored the config handed to them on reinitialize, serving the
options their constructor captured — and the deployment sealed green anyway.
Fixed on both halves, as chosen.
Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to
ToStop + ToSpawn instead of an in-place delta, making the factory the single
parse authority. This REVERSES the deliberate decision documented at
DriverSpawnPlan.cs:49-50 ("a pure DriverConfig change stays an in-place delta
— no reconnect"). That reasoning was correct about the resilience pipeline and
wrong about the driver. The accepted price is a reconnect per config edit.
Per-driver, and this split was not anticipated:
- Re-parse in place — Modbus, AbLegacy, OpcUaClient. ParseOptions extracted
from each factory, called from InitializeAsync behind a HasConfigBody guard
so "{}" still keeps the constructor options.
- Respawn-only, deliberately NOT re-parsed — Sql and FOCAS. Each builds more
than options from config (Sql's dialect + resolved connection string,
FOCAS's client-factory backend, both injected at construction), so adopting
new options alone would run a NEW tag set against an OLD connection. Half a
re-parse is worse than none. SqlDriver's doc-comment asserted the opposite
premise — "config parsing belongs to the factory, which builds a fresh
instance" — which was false when written and is true only now.
Two green seals removed from ApplyChildDelta: it overwrote the cached Spec
synchronously BEFORE the child dequeued the message, so the host believed the
new config was live and the next reconcile computed no delta, sealing the
drift permanently; and it Tell'd with no Receive<ApplyResult> registered, so a
failed reinit — including Galaxy's deliberate NotSupportedException —
dead-lettered.
Exposed (not created) by the change: a factory throw is a CONFIG error, and
SpawnChild catches it and silently substitutes a stub. Only a brand-new driver
could reach that before; an ordinary config edit can now. Raised Warning ->
Error with an actionable message. It still does not fail the deployment —
doing so would let one malformed driver block a fleet deploy, so that is a
follow-up rather than a drive-by.
Tests: every pre-existing reinit test in these suites passes "{}", the exact
input a guarded re-parser treats as "keep constructor options" — blind to this
defect by construction. New tests pass CHANGED json; the Modbus one was
verified falsifiable by deleting the re-parse (goes red). Two tests asserted
the old behaviour and were rewritten, including the positive control in
DriverHostActorUnreadableArtifactTests, whose observable moved from
UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the
child rather than emptying its desired set.
Remaining full-suite failures are pre-existing and environmental, verified
identical on the pre-change tree: Host.IntegrationTests (3) and
Driver.AbLegacy.IntegrationTests (4, docker fixture-gated).
232 lines
13 KiB
C#
232 lines
13 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
|
|
|
/// <summary>
|
|
/// Static factory registration helper for <see cref="ModbusDriver"/>. Server's Program.cs
|
|
/// calls <see cref="Register"/> once at startup; the bootstrapper then
|
|
/// materialises Modbus DriverInstance rows from the central config DB into live driver
|
|
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c> / <c>FocasDriverFactoryExtensions</c>.
|
|
/// </summary>
|
|
public static class ModbusDriverFactoryExtensions
|
|
{
|
|
public const string DriverTypeName = "Modbus";
|
|
|
|
/// <summary>
|
|
/// Register the Modbus factory with the driver registry. The optional
|
|
/// <paramref name="loggerFactory"/> is captured at registration time and used to
|
|
/// construct an <see cref="ILogger{ModbusDriver}"/> per driver instance — without it,
|
|
/// the driver runs with the null logger (existing tests and standalone callers stay
|
|
/// unchanged).
|
|
/// </summary>
|
|
/// <param name="registry">The driver factory registry to register with.</param>
|
|
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
|
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(registry);
|
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
|
}
|
|
|
|
/// <summary>Public for the Server-side bootstrapper + test consumers (Admin.Tests, etc.).</summary>
|
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
|
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
|
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
|
|
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
|
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
|
|
|
/// <summary>Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.</summary>
|
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
|
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
|
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
|
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
|
|
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
|
{
|
|
return new ModbusDriver(
|
|
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
|
|
transportFactory: null,
|
|
logger: loggerFactory?.CreateLogger<ModbusDriver>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a Modbus <c>DriverConfig</c> JSON document into typed options. Extracted from
|
|
/// <see cref="CreateInstance(string,string,ILoggerFactory?)"/> so <see cref="ModbusDriver.InitializeAsync"/>
|
|
/// can re-parse a CHANGED config on reinitialize (Gitea #516) rather than serving the options it was
|
|
/// constructed with — which silently discarded every edit while the deployment still sealed green.
|
|
/// </summary>
|
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
|
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
|
/// <returns>The parsed <see cref="ModbusDriverOptions"/>.</returns>
|
|
internal static ModbusDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
|
|
|
var dto = JsonSerializer.Deserialize<ModbusDriverConfigDto>(driverConfigJson, JsonOptions)
|
|
?? throw new InvalidOperationException(
|
|
$"Modbus driver config for '{driverInstanceId}' deserialised to null");
|
|
|
|
if (string.IsNullOrWhiteSpace(dto.Host))
|
|
throw new InvalidOperationException(
|
|
$"Modbus driver config for '{driverInstanceId}' missing required Host");
|
|
|
|
var options = new ModbusDriverOptions
|
|
{
|
|
Host = dto.Host!,
|
|
Port = dto.Port ?? 502,
|
|
UnitId = dto.UnitId ?? 1,
|
|
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
|
MaxRegistersPerRead = dto.MaxRegistersPerRead ?? 125,
|
|
MaxRegistersPerWrite = dto.MaxRegistersPerWrite ?? 123,
|
|
MaxCoilsPerRead = dto.MaxCoilsPerRead ?? 2000,
|
|
UseFC15ForSingleCoilWrites = dto.UseFC15ForSingleCoilWrites ?? false,
|
|
UseFC16ForSingleRegisterWrites = dto.UseFC16ForSingleRegisterWrites ?? false,
|
|
DisableFC23 = dto.DisableFC23 ?? false,
|
|
WriteOnChangeOnly = dto.WriteOnChangeOnly ?? false,
|
|
MaxReadGap = dto.MaxReadGap ?? 0,
|
|
Family = dto.Family is null ? ModbusFamily.Generic
|
|
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
|
|
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
|
|
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
|
|
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
|
|
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", 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
|
|
// tags. The driver maps each RawTagEntry's TagConfig blob through ModbusTagDefinitionFactory
|
|
// at Initialize; the legacy pre-declared DTO tag path (structured/AddressString → BuildTag)
|
|
// is retired.
|
|
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
|
Probe = new ModbusProbeOptions
|
|
{
|
|
Enabled = dto.Probe?.Enabled ?? true,
|
|
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
|
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
|
ProbeAddress = dto.Probe?.ProbeAddress ?? 0,
|
|
},
|
|
KeepAlive = dto.KeepAlive is null ? new ModbusKeepAliveOptions() : new ModbusKeepAliveOptions
|
|
{
|
|
Enabled = dto.KeepAlive.Enabled ?? true,
|
|
Time = TimeSpan.FromMilliseconds(dto.KeepAlive.TimeMs ?? 30_000),
|
|
Interval = TimeSpan.FromMilliseconds(dto.KeepAlive.IntervalMs ?? 10_000),
|
|
RetryCount = dto.KeepAlive.RetryCount ?? 3,
|
|
},
|
|
IdleDisconnectTimeout = dto.IdleDisconnectMs is { } ms ? TimeSpan.FromMilliseconds(ms) : null,
|
|
Reconnect = dto.Reconnect is null ? new ModbusReconnectOptions() : new ModbusReconnectOptions
|
|
{
|
|
InitialDelay = TimeSpan.FromMilliseconds(dto.Reconnect.InitialDelayMs ?? 0),
|
|
MaxDelay = TimeSpan.FromMilliseconds(dto.Reconnect.MaxDelayMs ?? 30_000),
|
|
BackoffMultiplier = dto.Reconnect.BackoffMultiplier ?? 2.0,
|
|
},
|
|
};
|
|
|
|
return options;
|
|
}
|
|
|
|
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
throw new InvalidOperationException(
|
|
$"Modbus tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
|
|
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
|
? v
|
|
: throw new InvalidOperationException(
|
|
$"Modbus tag '{tagName}' has unknown {field} '{raw}'. " +
|
|
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
AllowTrailingCommas = true,
|
|
};
|
|
|
|
internal sealed class ModbusDriverConfigDto
|
|
{
|
|
/// <summary>Gets or sets the host address.</summary>
|
|
public string? Host { get; init; }
|
|
/// <summary>Gets or sets the port number.</summary>
|
|
public int? Port { get; init; }
|
|
/// <summary>Gets or sets the unit identifier.</summary>
|
|
public byte? UnitId { get; init; }
|
|
/// <summary>Gets or sets the timeout in milliseconds.</summary>
|
|
public int? TimeoutMs { get; init; }
|
|
/// <summary>Gets or sets the maximum number of registers per read operation.</summary>
|
|
public ushort? MaxRegistersPerRead { get; init; }
|
|
/// <summary>Gets or sets the maximum number of registers per write operation.</summary>
|
|
public ushort? MaxRegistersPerWrite { get; init; }
|
|
/// <summary>Gets or sets the maximum number of coils per read operation.</summary>
|
|
public ushort? MaxCoilsPerRead { get; init; }
|
|
/// <summary>Gets or sets a value indicating whether to use function code 15 for single coil writes.</summary>
|
|
public bool? UseFC15ForSingleCoilWrites { get; init; }
|
|
/// <summary>Gets or sets a value indicating whether to use function code 16 for single register writes.</summary>
|
|
public bool? UseFC16ForSingleRegisterWrites { get; init; }
|
|
/// <summary>Gets or sets a value indicating whether to disable function code 23.</summary>
|
|
public bool? DisableFC23 { get; init; }
|
|
/// <summary>Gets or sets a value indicating whether to write only on change.</summary>
|
|
public bool? WriteOnChangeOnly { get; init; }
|
|
/// <summary>Gets or sets the maximum gap between register addresses for coalescing.</summary>
|
|
public ushort? MaxReadGap { get; init; }
|
|
/// <summary>Gets or sets the Modbus family type.</summary>
|
|
public string? Family { get; init; }
|
|
/// <summary>Gets or sets the Melsec subfamily.</summary>
|
|
public string? MelsecSubFamily { get; init; }
|
|
/// <summary>Gets or sets the wire transport mode (Tcp / RtuOverTcp). Null defaults to Tcp.</summary>
|
|
public string? Transport { get; init; }
|
|
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
|
|
public int? AutoProhibitReprobeMs { get; init; }
|
|
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
|
|
public bool? AutoReconnect { get; init; }
|
|
/// <summary>Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the
|
|
/// deploy artifact delivers. The driver maps each through the tag-definition factory at Initialize.</summary>
|
|
public List<RawTagEntry>? RawTags { get; init; }
|
|
/// <summary>Gets or sets the probe configuration.</summary>
|
|
public ModbusProbeDto? Probe { get; init; }
|
|
|
|
/// <summary>Gets or sets the keep-alive configuration (connection-layer knob).</summary>
|
|
public ModbusKeepAliveDto? KeepAlive { get; init; }
|
|
/// <summary>Gets or sets the idle disconnect timeout in milliseconds.</summary>
|
|
public int? IdleDisconnectMs { get; init; }
|
|
/// <summary>Gets or sets the reconnect configuration.</summary>
|
|
public ModbusReconnectDto? Reconnect { get; init; }
|
|
}
|
|
|
|
internal sealed class ModbusKeepAliveDto
|
|
{
|
|
/// <summary>Gets or sets a value indicating whether keep-alive is enabled.</summary>
|
|
public bool? Enabled { get; init; }
|
|
/// <summary>Gets or sets the keep-alive time in milliseconds.</summary>
|
|
public int? TimeMs { get; init; }
|
|
/// <summary>Gets or sets the keep-alive interval in milliseconds.</summary>
|
|
public int? IntervalMs { get; init; }
|
|
/// <summary>Gets or sets the retry count.</summary>
|
|
public int? RetryCount { get; init; }
|
|
}
|
|
|
|
internal sealed class ModbusReconnectDto
|
|
{
|
|
/// <summary>Gets or sets the initial reconnect delay in milliseconds.</summary>
|
|
public int? InitialDelayMs { get; init; }
|
|
/// <summary>Gets or sets the maximum reconnect delay in milliseconds.</summary>
|
|
public int? MaxDelayMs { get; init; }
|
|
/// <summary>Gets or sets the backoff multiplier for exponential reconnect delays.</summary>
|
|
public double? BackoffMultiplier { get; init; }
|
|
}
|
|
|
|
internal sealed class ModbusProbeDto
|
|
{
|
|
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
|
|
public bool? Enabled { get; init; }
|
|
/// <summary>Gets or sets the probe interval in milliseconds.</summary>
|
|
public int? IntervalMs { get; init; }
|
|
/// <summary>Gets or sets the probe timeout in milliseconds.</summary>
|
|
public int? TimeoutMs { get; init; }
|
|
/// <summary>Gets or sets the probe register address.</summary>
|
|
public ushort? ProbeAddress { get; init; }
|
|
}
|
|
}
|