Auto: ablegacy-9 — per-device timeout / retry overrides

Closes #252
This commit is contained in:
Joseph Doherty
2026-04-26 03:32:45 -04:00
parent 4ff1537d8a
commit c292dcc1db
9 changed files with 585 additions and 51 deletions

View File

@@ -209,6 +209,24 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
/// <summary>
/// PR 9 — per-device timeout precedence: device-level override wins, otherwise the
/// driver-wide default. Probe loop has its own timeout knob via
/// <see cref="AbLegacyProbeOptions.Timeout"/> but still falls back to the per-device
/// value when the probe override is absent (handled at the call site).
/// </summary>
internal TimeSpan ResolveTimeout(DeviceState device) =>
device.Options.Timeout ?? _options.Timeout;
/// <summary>
/// PR 9 — per-device retry count: device-level override wins, otherwise the driver-wide
/// default, otherwise zero (single attempt). The driver-wide default itself is
/// <c>null</c> by default so a vanilla AbLegacy config still issues exactly one read per
/// reference, matching pre-PR-9 behaviour.
/// </summary>
internal int ResolveRetries(DeviceState device) =>
device.Options.Retries ?? _options.Retries ?? 0;
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
@@ -232,57 +250,77 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
continue;
}
try
// PR 9 — per-device retry loop: on transient BadCommunicationError (libplctag throw
// OR a non-zero status that maps to BadCommunicationError) retry up to N times. A
// terminal mapped status (e.g. BadNodeIdUnknown for a missing PLC tag, BadTypeMismatch
// for a decoder mismatch) is surfaced as-is — retrying won't fix it. Cancellation
// always rethrows.
var retries = ResolveRetries(device);
DataValueSnapshot? snapshot = null;
for (var attempt = 0; attempt <= retries; attempt++)
{
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
try
{
results[i] = new DataValueSnapshot(null,
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
continue;
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
// PR 7 — array contiguous block. Decode N consecutive elements via the runtime's
// per-index accessor and box the result as a typed .NET array. The parser has
// already rejected array+bit and array+sub-element combinations, so the array
// path can ignore the bit/sub-element decoders entirely.
int arrayCount;
if (parsed is not null && (def.ArrayLength is not null || (parsed.ArrayCount ?? 1) > 1))
{
arrayCount = ResolveElementCount(def, parsed);
}
else arrayCount = 1;
var status = runtime.GetStatus();
if (status != 0)
{
var mappedStatus = AbLegacyStatusMapper.MapLibplctagStatus(status);
// Transient: BadCommunicationError → eligible for retry.
if (mappedStatus == AbLegacyStatusMapper.BadCommunicationError && attempt < retries)
{
continue;
}
snapshot = new DataValueSnapshot(null, mappedStatus, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
break;
}
if (arrayCount > 1)
{
var arr = DecodeArrayAs(runtime, def.DataType, arrayCount);
results[i] = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now);
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
// PR 7 — array contiguous block. Decode N consecutive elements via the runtime's
// per-index accessor and box the result as a typed .NET array. The parser has
// already rejected array+bit and array+sub-element combinations, so the array
// path can ignore the bit/sub-element decoders entirely.
int arrayCount;
if (parsed is not null && (def.ArrayLength is not null || (parsed.ArrayCount ?? 1) > 1))
{
arrayCount = ResolveElementCount(def, parsed);
}
else arrayCount = 1;
if (arrayCount > 1)
{
var arr = DecodeArrayAs(runtime, def.DataType, arrayCount);
snapshot = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
break;
}
// Timer/Counter/Control status bits route through GetBit at the parent-word
// address — translate the .DN/.EN/etc. sub-element to its standard bit position
// and pass it down to the runtime as a synthetic bitIndex.
var decodeBit = parsed?.BitIndex
?? AbLegacyDataTypeExtensions.StatusBitIndex(def.DataType, parsed?.SubElement);
var value = runtime.DecodeValue(def.DataType, decodeBit);
snapshot = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
continue;
break;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
// Transient — exhaust retries before reporting BadCommunicationError.
if (attempt < retries) continue;
snapshot = new DataValueSnapshot(null,
AbLegacyStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
// Timer/Counter/Control status bits route through GetBit at the parent-word
// address — translate the .DN/.EN/etc. sub-element to its standard bit position
// and pass it down to the runtime as a synthetic bitIndex.
var decodeBit = parsed?.BitIndex
?? AbLegacyDataTypeExtensions.StatusBitIndex(def.DataType, parsed?.SubElement);
var value = runtime.DecodeValue(def.DataType, decodeBit);
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null,
AbLegacyStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
results[i] = snapshot ?? new DataValueSnapshot(null,
AbLegacyStatusMapper.BadCommunicationError, null, now);
}
return results;
@@ -441,13 +479,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
// PR 9 — per-device timeout wins over the probe's own timeout. Slow chassis (SLC 5/01
// RS-232 ~5 s round-trip) need their per-device override to flow into the probe too,
// otherwise the probe times out before the device ever has a chance to respond.
var probeTimeout = state.Options.Timeout ?? _options.Probe.Timeout;
var probeParams = new AbLegacyTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
CipPath: state.ParsedAddress.CipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: _options.Probe.ProbeAddress!,
Timeout: _options.Probe.Timeout);
Timeout: probeTimeout);
IAbLegacyTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
@@ -553,7 +595,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentName,
Timeout: _options.Timeout));
Timeout: ResolveTimeout(device)));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -601,7 +643,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: tagName,
Timeout: _options.Timeout,
Timeout: ResolveTimeout(device),
ElementCount: elementCount));
try
{

View File

@@ -38,7 +38,10 @@ public static class AbLegacyDriverFactoryExtensions
$"AB Legacy config for '{driverInstanceId}' has a device missing HostAddress"),
PlcFamily: ParseEnum<AbLegacyPlcFamily>(d.PlcFamily, driverInstanceId, "PlcFamily",
fallback: AbLegacyPlcFamily.Slc500),
DeviceName: d.DeviceName))]
DeviceName: d.DeviceName,
// PR 9 — per-device timeout / retry overrides. Device-level wins over driver-wide.
Timeout: d.TimeoutMs is int devMs ? TimeSpan.FromMilliseconds(devMs) : null,
Retries: d.Retries))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => new AbLegacyTagDefinition(
@@ -64,6 +67,9 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
// PR 9 — driver-wide retry default. null ≡ 0 retries (single attempt). Per-device
// Retries on AbLegacyDeviceOptions still wins.
Retries = dto.Retries,
};
return new AbLegacyDriver(options, driverInstanceId);
@@ -95,6 +101,12 @@ public static class AbLegacyDriverFactoryExtensions
internal sealed class AbLegacyDriverConfigDto
{
public int? TimeoutMs { get; init; }
/// <summary>
/// PR 9 — driver-wide retry count for transient <c>BadCommunicationError</c> reads.
/// <c>null</c> ≡ <c>0</c> (single attempt). A per-device override on
/// <see cref="AbLegacyDeviceDto.Retries"/> wins.
/// </summary>
public int? Retries { get; init; }
public List<AbLegacyDeviceDto>? Devices { get; init; }
public List<AbLegacyTagDto>? Tags { get; init; }
public AbLegacyProbeDto? Probe { get; init; }
@@ -105,6 +117,20 @@ public static class AbLegacyDriverFactoryExtensions
public string? HostAddress { get; init; }
public string? PlcFamily { get; init; }
public string? DeviceName { get; init; }
/// <summary>
/// PR 9 — optional per-device timeout in ms. Wins over the driver-wide
/// <see cref="AbLegacyDriverConfigDto.TimeoutMs"/>. Tune this per chassis: SLC 5/01
/// RS-232 ≈ 5000, SLC 5/05 ≈ 2000, MicroLogix 1100 ≈ 3000.
/// </summary>
public int? TimeoutMs { get; init; }
/// <summary>
/// PR 9 — optional per-device retry count for transient <c>BadCommunicationError</c>
/// reads. Wins over the driver-wide <see cref="AbLegacyDriverConfigDto.Retries"/>.
/// <c>null</c> at both levels = single attempt.
/// </summary>
public int? Retries { get; init; }
}
internal sealed class AbLegacyTagDto

View File

@@ -13,13 +13,35 @@ public sealed class AbLegacyDriverOptions
public IReadOnlyList<AbLegacyDeviceOptions> Devices { get; init; } = [];
public IReadOnlyList<AbLegacyTagDefinition> Tags { get; init; } = [];
public AbLegacyProbeOptions Probe { get; init; } = new();
/// <summary>
/// Driver-wide default per-operation timeout. Applies to every device unless that device
/// overrides it via <see cref="AbLegacyDeviceOptions.Timeout"/> (PR 9).
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// PR 9 — driver-wide default retry count for transient
/// <c>BadCommunicationError</c> reads. <c>null</c> ≡ <c>0</c> (single attempt). Applies
/// to every device unless that device overrides it via
/// <see cref="AbLegacyDeviceOptions.Retries"/>.
/// </summary>
public int? Retries { get; init; }
}
/// <summary>
/// Per-device options for the AB Legacy driver. PR 9 added optional <see cref="Timeout"/>
/// and <see cref="Retries"/> overrides — chassis families have very different per-operation
/// latency floors (SLC 5/01 RS-232 ~5 s; SLC 5/05 ~2 s; ML1100 ~3 s) so a single driver-wide
/// timeout always misfires on at least one device. Both fields are optional and fall back
/// to the driver-wide default on <see cref="AbLegacyDriverOptions"/>.
/// </summary>
public sealed record AbLegacyDeviceOptions(
string HostAddress,
AbLegacyPlcFamily PlcFamily = AbLegacyPlcFamily.Slc500,
string? DeviceName = null);
string? DeviceName = null,
TimeSpan? Timeout = null,
int? Retries = null);
/// <summary>
/// One PCCC-backed OPC UA variable. <c>Address</c> is the canonical PCCC file-address