Merge b626-ablegacy-focas (batch #626+)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -1,157 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed PCCC file-based address: file letter + file number + word number, optionally a
|
||||
/// sub-element (<c>.ACC</c> on a timer) or bit index (<c>/0</c> on a bit file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Logix symbolic tags are parsed elsewhere (<see cref="AbLegacy"/> is for SLC / PLC-5 /
|
||||
/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number).</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>N7:0</c> — integer file 7, word 0 (signed 16-bit).</item>
|
||||
/// <item><c>N7:5</c> — integer file 7, word 5.</item>
|
||||
/// <item><c>F8:0</c> — float file 8, word 0 (32-bit IEEE754).</item>
|
||||
/// <item><c>B3:0/0</c> — bit file 3, word 0, bit 0.</item>
|
||||
/// <item><c>ST9:0</c> — string file 9, string 0 (82-byte fixed-length + length word).</item>
|
||||
/// <item><c>T4:0.ACC</c> — timer file 4, timer 0, accumulator sub-element.</item>
|
||||
/// <item><c>C5:0.PRE</c> — counter file 5, counter 0, preset sub-element.</item>
|
||||
/// <item><c>I:0/0</c> — input file, slot 0, bit 0 (no file-number for I/O).</item>
|
||||
/// <item><c>O:1/2</c> — output file, slot 1, bit 2.</item>
|
||||
/// <item><c>S:1</c> — status file, word 1.</item>
|
||||
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
|
||||
/// </list>
|
||||
/// <para>Pass the original string straight through to libplctag's <c>name=...</c> attribute —
|
||||
/// the PLC-side decoder handles the format. This parser only validates the shape + surfaces
|
||||
/// the structural pieces for driver-side routing (e.g. deciding whether a tag needs
|
||||
/// bit-level read-modify-write).</para>
|
||||
/// </remarks>
|
||||
public sealed record AbLegacyAddress(
|
||||
string FileLetter,
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
int? BitIndex,
|
||||
string? SubElement)
|
||||
{
|
||||
/// <summary>Converts the address to the libplctag library address format.</summary>
|
||||
/// <returns>The libplctag <c>name=...</c> attribute value for this address.</returns>
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
var wordPart = $"{file}:{WordNumber}";
|
||||
if (SubElement is not null) wordPart += $".{SubElement}";
|
||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||
return wordPart;
|
||||
}
|
||||
|
||||
/// <summary>Attempts to parse a string into an AB legacy address.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>The parsed address, or <see langword="null"/> when <paramref name="value"/> is not a valid PCCC address.</returns>
|
||||
public static AbLegacyAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
// BitIndex: trailing /N. The valid range depends on the parent word width, which is
|
||||
// determined by the file letter (16-bit N/B/I/O/S/A → 0..15, 32-bit L → 0..31). Capture
|
||||
// the raw value here and range-check it once the file letter is known (see below).
|
||||
int? bitIndex = null;
|
||||
var slashIdx = src.IndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0) return null;
|
||||
bitIndex = bit;
|
||||
src = src[..slashIdx];
|
||||
}
|
||||
|
||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
||||
string? subElement = null;
|
||||
var dotIdx = src.LastIndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
var candidate = src[(dotIdx + 1)..];
|
||||
if (candidate.Length > 0 && candidate.All(char.IsLetter))
|
||||
{
|
||||
subElement = candidate.ToUpperInvariant();
|
||||
src = src[..dotIdx];
|
||||
}
|
||||
}
|
||||
|
||||
var colonIdx = src.IndexOf(':');
|
||||
if (colonIdx <= 0) return null;
|
||||
var filePart = src[..colonIdx];
|
||||
var wordPart = src[(colonIdx + 1)..];
|
||||
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
|
||||
|
||||
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
|
||||
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||
var letterEnd = 1;
|
||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||
|
||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||
int? fileNumber = null;
|
||||
if (letterEnd < filePart.Length)
|
||||
{
|
||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
||||
fileNumber = fn;
|
||||
}
|
||||
|
||||
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
||||
if (!IsKnownFileLetter(letter)) return null;
|
||||
|
||||
// Range-check the bit index against the file's word width. A PCCC N/B/I/O/S/A word is a
|
||||
// 16-bit element, so valid bit indices are 0..15; an L-file element is 32-bit (0..31).
|
||||
// F-files are 32-bit IEEE-754 floats and are not bit-addressable at all.
|
||||
if (bitIndex is int b)
|
||||
{
|
||||
var maxBit = MaxBitIndexFor(letter);
|
||||
if (maxBit < 0 || b > maxBit) return null;
|
||||
}
|
||||
|
||||
// I/O/S are single-letter system files — they carry no file number in the PCCC spec.
|
||||
// Accepting I3:0 or S2:1 would pass a malformed address straight to libplctag; reject early.
|
||||
if (fileNumber is not null && IsNoFileNumberLetter(letter)) return null;
|
||||
|
||||
// A PCCC address cannot have both a sub-element and a bit index: the word is either
|
||||
// structured (T4:0.ACC) or bit-addressed (N7:0/3), never both.
|
||||
if (subElement is not null && bitIndex is not null) return null;
|
||||
|
||||
// Sub-elements are only meaningful on Timer (T), Counter (C), and Control (R) files —
|
||||
// those are the only structured-element file types in the PCCC spec. Accepting B3:0.DN
|
||||
// or N7:0.FOO would produce an address libplctag silently misinterprets.
|
||||
if (subElement is not null && !IsSubElementFileLetter(letter)) return null;
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Highest valid bit index for a file letter, or <c>-1</c> if the file type is not
|
||||
/// bit-addressable. 16-bit element files (N/B/I/O/S/A) permit bits 0..15; the 32-bit
|
||||
/// L-file permits 0..31.
|
||||
/// </summary>
|
||||
private static int MaxBitIndexFor(string letter) => letter switch
|
||||
{
|
||||
"L" => 31,
|
||||
"N" or "B" or "I" or "O" or "S" or "A" => 15,
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||
{
|
||||
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> for file letters that carry no explicit file number in the
|
||||
/// PCCC spec. <c>I</c> (input), <c>O</c> (output), and <c>S</c> (status) are single-letter
|
||||
/// system files; a digit after the letter (e.g. <c>I3</c>) is a malformed address.
|
||||
/// </summary>
|
||||
private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S";
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> for file letters that may carry a sub-element suffix
|
||||
/// (<c>.ACC</c>, <c>.PRE</c>, etc.). Only Timer (<c>T</c>), Counter (<c>C</c>), and
|
||||
/// Control (<c>R</c>) files have structured elements in the PCCC spec.
|
||||
/// </summary>
|
||||
private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R";
|
||||
}
|
||||
@@ -388,6 +388,21 @@ 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)
|
||||
{
|
||||
WarnMalformedOnce(device, def);
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (preParsed.IsIndirect)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int status;
|
||||
@@ -401,7 +416,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
if (status != 0)
|
||||
value = null;
|
||||
else if (IsArrayTag(def))
|
||||
@@ -488,7 +503,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
|
||||
// A malformed address is a config error, not a comms fault (BadNodeIdUnknown, warned once per tag).
|
||||
if (parsed is null)
|
||||
{
|
||||
WarnMalformedOnce(device, def);
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indirect addressing is parse-only (#247): refuse before any runtime or RMW read is created.
|
||||
if (parsed.IsIndirect)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN,
|
||||
// or any PLC-5 write) is answered BEFORE a runtime is created. Creating one first costs an init
|
||||
@@ -643,7 +673,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.EffectiveCipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: _options.Probe.ProbeAddress!,
|
||||
// Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through
|
||||
// so the transport reports the failure as it always did.
|
||||
TagName: AbLegacyAddress.TryParse(_options.Probe.ProbeAddress, state.Options.PlcFamily)?.ToLibplctagName()
|
||||
?? _options.Probe.ProbeAddress!,
|
||||
Timeout: _options.Probe.Timeout);
|
||||
|
||||
IAbLegacyTagRuntime? probeRuntime = null;
|
||||
@@ -891,6 +924,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
keep();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tag whose address does not parse for its device's PLC family (e.g. a PLC-5 octal I/O digit 8/9, a
|
||||
/// function-file letter off MicroLogix) is a configuration error: answered <c>BadNodeIdUnknown</c> with the
|
||||
/// handle/health untouched, and warned once per tag so a polled tag does not flood the log.
|
||||
/// </summary>
|
||||
private void WarnMalformedOnce(DeviceState device, AbLegacyTagDefinition def)
|
||||
{
|
||||
if (!device.MalformedAddressLogged.TryAdd(def.Name, 0)) return;
|
||||
_logger.LogWarning(
|
||||
"AbLegacy tag address is not valid for the device's PLC family. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Family={Family}",
|
||||
_driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, device.Options.PlcFamily);
|
||||
}
|
||||
|
||||
/// <summary>The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so
|
||||
/// the refusal is asked about exactly the runtime a write would otherwise create.</summary>
|
||||
private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) =>
|
||||
@@ -916,9 +962,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing;
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}.");
|
||||
|
||||
var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed));
|
||||
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
|
||||
@@ -1123,6 +1169,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// </summary>
|
||||
public bool FirstNonZeroStatusLogged { get; set; }
|
||||
|
||||
/// <summary>Tags already warned about for a malformed address (once-per-tag log).</summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, byte> MalformedAddressLogged { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Disposes all cached tag runtimes.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// MicroLogix 1100/1400 function-file sub-element catalogue (#245): the driver-surface type of
|
||||
/// <c>RTC:0.HR</c>, <c>HSC:0.ACC</c>, ... Covers the commonly-addressed members per file — not exhaustive
|
||||
/// (Rockwell defines 30+ on RTC alone). <b>Unverified on the wire:</b> the layouts are transcribed from the
|
||||
/// MicroLogix instruction-set reference; neither transport has been captured against these files and the
|
||||
/// native transport has no encoding for them at all.
|
||||
/// </summary>
|
||||
public static class AbLegacyFunctionFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Driver-surface type for <paramref name="fileLetter"/>.<paramref name="subElement"/>; an unrecognised
|
||||
/// sub-element falls back to <see cref="DriverDataType.Int32"/> so the driver never refuses a member the
|
||||
/// catalogue happens to omit.
|
||||
/// </summary>
|
||||
/// <param name="fileLetter">The function-file letter (RTC, HSC, ...).</param>
|
||||
/// <param name="subElement">The sub-element mnemonic, or null.</param>
|
||||
/// <returns>The mapped driver data type.</returns>
|
||||
public static DriverDataType SubElementType(string fileLetter, string? subElement)
|
||||
{
|
||||
if (subElement is null) return DriverDataType.Int32;
|
||||
return (fileLetter.ToUpperInvariant(), subElement.ToUpperInvariant()) switch
|
||||
{
|
||||
// Real-time clock: time fields are 16-bit words; DS (disabled) / BL (battery low) are flags.
|
||||
("RTC", "DS") or ("RTC", "BL") or ("RTC", "EN") => DriverDataType.Boolean,
|
||||
|
||||
// High-speed counter: accumulator/preset/overflow/underflow are numeric, the rest status bits.
|
||||
("HSC", "EN") or ("HSC", "UF") or ("HSC", "IF") or ("HSC", "IN") or ("HSC", "IH") or
|
||||
("HSC", "IL") or ("HSC", "DN") or ("HSC", "CD") or ("HSC", "CU") => DriverDataType.Boolean,
|
||||
|
||||
// Daylight saving + memory-module info.
|
||||
("DLS", "EN") => DriverDataType.Boolean,
|
||||
("MMI", "MP") or ("MMI", "MCP") => DriverDataType.Boolean,
|
||||
|
||||
// Pulse-train / PWM outputs.
|
||||
("PTO", "EN") or ("PTO", "DN") or ("PTO", "EH") or ("PTO", "ED") or ("PTO", "RP") or ("PTO", "OUT") => DriverDataType.Boolean,
|
||||
("PWM", "EN") or ("PWM", "DN") or ("PWM", "EH") or ("PWM", "ED") or ("PWM", "RP") or ("PWM", "OUT") => DriverDataType.Boolean,
|
||||
|
||||
// Selectable timed interrupt + event input interrupt.
|
||||
("STI", "EN") or ("STI", "TIE") or ("STI", "DN") or ("STI", "PS") or ("STI", "ED") => DriverDataType.Boolean,
|
||||
("EII", "EN") or ("EII", "TIE") or ("EII", "PE") or ("EII", "ES") or ("EII", "ED") => DriverDataType.Boolean,
|
||||
|
||||
// Everything else (HR/MIN/SEC/MON/DAY/YR/DOW, ACC/PRE/OVF/UNF, STR/STD, FT/LBN, OF/IDA/ODA/PE/PD,
|
||||
// SPM/ER/PFN, ID/TYP, OS/FRN/BSN/CC, ...) is a numeric word.
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -154,10 +154,23 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFunctionFileName(string tagName)
|
||||
{
|
||||
var colon = tagName.IndexOf(':');
|
||||
return colon > 0 && AbLegacyAddress.IsFunctionFileLetter(tagName[..colon].ToUpperInvariant());
|
||||
}
|
||||
|
||||
private static PcccAddress ToPcccAddress(string tagName)
|
||||
{
|
||||
// Family-neutral parse is correct here: the driver hands the runtime AbLegacyAddress.ToLibplctagName(), which is
|
||||
// already decimal-normalised (PLC-5 octal I/O included), so no family is needed to read it back.
|
||||
var a = AbLegacyAddress.TryParse(tagName)
|
||||
?? throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is not a valid PCCC address.");
|
||||
?? throw new AbTransportException(AbStatus.Unsupported,
|
||||
IsFunctionFileName(tagName)
|
||||
? $"'{tagName}' is a MicroLogix function file; the native transport has no encoding for it (unverified on the wire)."
|
||||
: $"'{tagName}' is not a valid PCCC address.");
|
||||
if (a.IsIndirect)
|
||||
throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is an indirect address; indirect addressing is not supported.");
|
||||
return a.SubElement is not null
|
||||
? PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, a.SubElement)
|
||||
: new PcccAddress(a.FileLetter, a.FileNumber, a.WordNumber, null, a.BitIndex);
|
||||
|
||||
Reference in New Issue
Block a user