Merge b640-646 (AbLegacy indirect addressing #646)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
|
||||
/// <item><c>I:017/07</c> — PLC-5 only: I/O word + bit indices are <b>octal</b> (#244); stored decimal.</item>
|
||||
/// <item><c>RTC:0.HR</c> — MicroLogix only: function file (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI, #245).</item>
|
||||
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (#247): parsed, not resolved at runtime.</item>
|
||||
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (#247 parse, #646 runtime): the driver reads the index first and composes the concrete address.</item>
|
||||
/// </list>
|
||||
/// <para>The libplctag transport takes <see cref="ToLibplctagName"/> as its <c>name=...</c> attribute and the
|
||||
/// native transport re-parses the same string, so both are handed identical (decimal) numbers. This parser
|
||||
@@ -42,8 +42,8 @@ public sealed record AbLegacyAddress(
|
||||
/// <summary>
|
||||
/// True when the file number or the word number is sourced from another PCCC address evaluated
|
||||
/// at runtime (<c>N7:[N7:0]</c> or <c>N[N7:0]:5</c>). Neither transport can encode the bracket
|
||||
/// form, and resolving it needs a pre-read of the index word, which is not implemented — the
|
||||
/// driver answers an indirect tag <c>BadNotSupported</c>.
|
||||
/// form, so the driver (#646) pre-reads the index, substitutes it, and hands the transport the
|
||||
/// concrete address; an indirect name never reaches a transport.
|
||||
/// </summary>
|
||||
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
|
||||
|
||||
|
||||
-8
@@ -123,12 +123,6 @@ public static class AbLegacyTagDefinitionFactory
|
||||
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
|
||||
/// onto <see cref="AbLegacyTagDefinition.Name"/> and never validates it.
|
||||
/// </summary>
|
||||
private static bool IsIndirectFor(string address, AbLegacyPlcFamily? family)
|
||||
{
|
||||
if (family is { } f) return AbLegacyAddress.TryParse(address, f)?.IsIndirect == true;
|
||||
return Enum.GetValues<AbLegacyPlcFamily>().Any(c => AbLegacyAddress.TryParse(address, c)?.IsIndirect == true);
|
||||
}
|
||||
|
||||
private const string InspectionRawPath = "(inspect)";
|
||||
|
||||
/// <summary>
|
||||
@@ -179,8 +173,6 @@ public static class AbLegacyTagDefinitionFactory
|
||||
{
|
||||
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
|
||||
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
|
||||
else if (IsIndirectFor(accepted.Address, family))
|
||||
warnings.Add($"AbLegacy address '{accepted.Address}' is indirect; indirect addressing is not supported and the tag reads BadNotSupported.");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
|
||||
@@ -388,8 +388,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
try
|
||||
{
|
||||
// Indirect addresses (N7:[N7:0]) parse but are not resolved at runtime (#247) — answer the
|
||||
// refusal before a runtime is created for a name neither transport can encode.
|
||||
var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
if (preParsed is null)
|
||||
{
|
||||
@@ -399,8 +397,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
if (preParsed.IsIndirect)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
|
||||
continue;
|
||||
// Indirect (#646): read the index word(s) NOW (never cached - the index can change between
|
||||
// polls), compose the concrete address, then run the normal path against it.
|
||||
var res = await ResolveIndirectAsync(device, def, preParsed, cancellationToken).ConfigureAwait(false);
|
||||
if (res.Def is null)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, res.Status, null, now);
|
||||
if (res.TransportFault)
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"transport fault resolving indirect address for {reference}");
|
||||
continue;
|
||||
}
|
||||
def = res.Def;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
@@ -513,11 +521,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indirect addressing is parse-only (#247): refuse before any runtime or RMW read is created.
|
||||
// Indirect (#646): resolve the index first (non-atomic with the write - the index may change
|
||||
// between the two PLC accesses), then write the concrete address through the normal path.
|
||||
if (parsed.IsIndirect)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||
continue;
|
||||
var res = await ResolveIndirectAsync(device, def, parsed, cancellationToken).ConfigureAwait(false);
|
||||
if (res.Def is null)
|
||||
{
|
||||
results[i] = new WriteResult(res.Status);
|
||||
if (res.TransportFault)
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"transport fault resolving indirect address for {w.FullReference}");
|
||||
continue;
|
||||
}
|
||||
def = res.Def;
|
||||
parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)!;
|
||||
}
|
||||
|
||||
// A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN,
|
||||
@@ -841,6 +859,137 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Largest word/element index an indirect source may yield (PCCC element offset is one byte).</summary>
|
||||
private const int MaxIndirectElement = AbLegacyArray.MaxElements - 1;
|
||||
|
||||
/// <summary>PLC-5 data files hold up to 1000 words (extended PCCC offset), unlike SLC/MicroLogix (256).</summary>
|
||||
private const int MaxIndirectElementPlc5 = 999;
|
||||
|
||||
/// <summary>Largest data-file number an indirect source may yield (PLC-5 allows up to 999).</summary>
|
||||
private const int MaxIndirectFile = 999;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an indirect address (#646). Reads each index source through the same runtime/transport path
|
||||
/// (same factory, so libplctag and Native alike), range-checks it, and composes a concrete tag definition
|
||||
/// whose <c>Name</c> is keyed by the resolved address so the normal runtime cache applies. Nothing about the
|
||||
/// index is cached: every call re-reads it. A bad index is <c>BadOutOfRange</c> (per-tag, no health impact);
|
||||
/// an inner-read failure propagates its mapped status (<c>TransportFault</c> true for a wire failure).
|
||||
/// </summary>
|
||||
private async Task<IndirectResolution> ResolveIndirectAsync(
|
||||
DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed, CancellationToken ct)
|
||||
{
|
||||
var fileNumber = parsed.FileNumber;
|
||||
var word = parsed.WordNumber;
|
||||
var maxElement = device.Options.PlcFamily is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc
|
||||
? MaxIndirectElementPlc5 : MaxIndirectElement;
|
||||
|
||||
if (parsed.IndirectFileSource is { } fileSrc)
|
||||
{
|
||||
var r = await ReadIndexAsync(device, fileSrc, ct).ConfigureAwait(false);
|
||||
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
|
||||
if (r.Value is < 0 or > MaxIndirectFile)
|
||||
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
fileNumber = (int)r.Value!;
|
||||
}
|
||||
if (parsed.IndirectWordSource is { } wordSrc)
|
||||
{
|
||||
var r = await ReadIndexAsync(device, wordSrc, ct).ConfigureAwait(false);
|
||||
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
|
||||
if (r.Value < 0 || r.Value > maxElement)
|
||||
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
word = (int)r.Value!;
|
||||
}
|
||||
|
||||
var concrete = parsed with
|
||||
{
|
||||
FileNumber = fileNumber, WordNumber = word, IndirectFileSource = null, IndirectWordSource = null,
|
||||
};
|
||||
var concreteText = concrete.ToLibplctagName();
|
||||
var resolvedName = $"{def.Name}=>{concreteText}";
|
||||
|
||||
// The index moved: drop the previous resolution's handle so a sweeping index does not accumulate one
|
||||
// libplctag handle per value it ever took.
|
||||
var previous = device.IndirectLastName.TryGetValue(def.Name, out var prior) ? prior : null;
|
||||
device.IndirectLastName[def.Name] = resolvedName;
|
||||
if (previous is not null && !string.Equals(previous, resolvedName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var oldLock = device.GetRuntimeLock(previous);
|
||||
await oldLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try { EvictRuntime(device, previous); } finally { oldLock.Release(); }
|
||||
}
|
||||
return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false);
|
||||
}
|
||||
|
||||
/// <summary>Reads one direct index address as an integer through the shared parent-runtime cache.</summary>
|
||||
private async Task<(long? Value, uint Status, bool TransportFault)> ReadIndexAsync(
|
||||
DeviceState device, AbLegacyAddress inner, CancellationToken ct)
|
||||
{
|
||||
AbLegacyDataType type;
|
||||
if (inner.BitIndex is not null) type = AbLegacyDataType.Bit;
|
||||
else if (inner.FileLetter == "L") type = AbLegacyDataType.Long;
|
||||
else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
else type = AbLegacyDataType.Int;
|
||||
|
||||
// A bit-addressed index reads its parent word and decodes the bit, like the RMW path does.
|
||||
var runtimeName = (inner with { BitIndex = null }).ToLibplctagName();
|
||||
var gate = device.GetRmwLock(runtimeName);
|
||||
await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
IAbLegacyTagRuntime runtime;
|
||||
try
|
||||
{
|
||||
runtime = await EnsureParentRuntimeAsync(device, runtimeName, ct).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (AbTransportException ex) when (IsLocalRefusal(ex))
|
||||
{
|
||||
return (null, AbLegacyStatusMapper.BadNotSupported, false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
EvictParentRuntime(device, runtimeName);
|
||||
return (null, AbLegacyStatusMapper.BadCommunicationError, true);
|
||||
}
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
EvictParentRuntime(device, runtimeName);
|
||||
return (null, AbLegacyStatusMapper.MapStatus(status), true);
|
||||
}
|
||||
var raw = runtime.DecodeValue(type, inner.BitIndex);
|
||||
try
|
||||
{
|
||||
return raw switch
|
||||
{
|
||||
bool b => (b ? 1L : 0L, 0u, false),
|
||||
null => (null, AbLegacyStatusMapper.BadOutOfRange, false),
|
||||
_ => (Convert.ToInt64(raw), 0u, false),
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
return (null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EvictParentRuntime(DeviceState device, string name)
|
||||
{
|
||||
if (device.ParentRuntimes.TryRemove(name, out var stale))
|
||||
{
|
||||
try { stale.Dispose(); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault);
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
|
||||
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
|
||||
{
|
||||
@@ -1086,6 +1235,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Indirect tag name to the resolved-address runtime key it last used (#646), so a moved index evicts its stale handle.</summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, string> IndirectLastName { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Per-key creation locks for <see cref="Runtimes"/> and <see cref="ParentRuntimes"/>.
|
||||
/// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd
|
||||
@@ -1181,6 +1334,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
Runtimes.Clear();
|
||||
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||
ParentRuntimes.Clear();
|
||||
IndirectLastName.Clear();
|
||||
// Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so
|
||||
// ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a
|
||||
// wait handle once contended).
|
||||
|
||||
Reference in New Issue
Block a user