Merge b626-ablegacy-focas (batch #626+)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:43:44 -04:00
17 changed files with 946 additions and 176 deletions
+40
View File
@@ -67,6 +67,9 @@ pieces for driver-side routing (e.g. deciding a bit needs read-modify-write):
| `ST9:0` | String file (82-byte fixed-length) |
| `T4:0.ACC` / `C5:0.PRE` | Timer / counter sub-element |
| `I:0/0` / `O:1/2` / `S:1` | Input / output / status system files (no file number) |
| `I:017/07` | **PLC-5 only:** `I:`/`O:` word and bit indices are **octal** (#244); a digit 8/9 is a parse error |
| `RTC:0.HR` / `HSC:0.ACC` | **MicroLogix only:** function files RTC, HSC, DLS, MMI, PTO, PWM, STI, EII, IOS, BHI (#245) |
| `N7:[N7:0]` / `N[N7:0]:5` | Indirect (index word read from the PLC) — parsed, **not supported at runtime** (#247) |
`AbLegacyDataType` covers the corresponding PCCC types: `Bit`, `Int` (N), `Long`
(L), `Float` (F), `AnalogInt` (A), `String` (ST), and the `TimerElement` /
@@ -85,6 +88,42 @@ this is an inherent limitation of any software-level RMW. Input-image (`I`) bit
writes are forwarded to the PLC as-is; the device drives the input image from
physical inputs and may reject the write.
### Family-specific address forms (#244 / #245 / #247)
`AbLegacyAddress.TryParse(text, family)` is family-aware; the driver passes the device's `PlcFamily`
(the one-argument overload is the family-neutral grammar: decimal, no function files).
- **PLC-5 octal I/O (#244).** RSLogix 5 shows `I:`/`O:` word and bit indices in octal, so `I:017/07` is word 15,
bit 7 and `I:017/17` is bit 15. Only `I`/`O` on PLC-5 (`OctalIoAddressing` on the family profile) — SLC,
MicroLogix and Logix-PCCC stay decimal, and PLC-5 `N`/`B`/... files stay decimal. The record stores decimal and
`ToLibplctagName()` **emits decimal**, so the libplctag `name=` string and the string the native transport
re-parses carry identical numbers. This is not a double conversion: libplctag's `pccc.c`
(`parse_pccc_elem_num` / `parse_pccc_bit_num`) parses decimal numerals only (a FIXME there says octal is
unsupported), so the driver must convert. Pinned by `Plc5_io_indices_are_octal` (`I:017/17` -> `I:15/15`).
The connectivity-probe address is normalised the same way.
- ⚠️ **Behaviour change / migration.** Before this change a PLC-5 `I:`/`O:` address was passed to libplctag as
written, i.e. read as **decimal**. An address copied from RSLogix in octal (e.g. `I:017`) therefore read the
wrong word, and an authored decimal such as `I:1/12` now means bit 10 (octal 12). A previously "working"
address containing the digits 8 or 9 (`I:018`, `O:1/9`) is no longer valid on PLC-5. Re-author any PLC-5
I/O tags in RSLogix octal. Such a tag is refused (see below), never silently misread.
- **A malformed / family-invalid address** (also: a function-file letter off MicroLogix) reads and writes
`BadNodeIdUnknown` for that tag only, creates no runtime, leaves driver health alone, and logs **one**
Warning per tag. The same check (`AbLegacyAddress.Validate`) runs in the AdminUI tag editor (family-independent:
rejects only what *no* family accepts) and the deploy gate (`EquipmentTagConfigInspector`, using the device's
`DeviceConfig.PlcFamily`, absent = SLC 500, unrecognised = unknown) — so `I:018` on a PLC-5 device warns at
deploy (an error under `TagConfigValidationMode.Error`), and the editor is never stricter than the gate.
- **MicroLogix function files (#245).** `RTC`, `HSC`, `DLS`, `MMI`, `PTO`, `PWM`, `STI`, `EII`, `IOS`, `BHI`
(single fixed instance — no file number — with a `.SUBELEMENT`; no bit index) parse on MicroLogix devices only.
`AbLegacyFunctionFile.SubElementType` carries the sub-element catalogue (flags → Boolean, words → Int32; unknown
members default to Int32). ⚠️ **Wire-unverified on both transports:** there is no capture and no simulator
coverage; the libplctag path passes the name through and the native transport has no file-type code for them, so
it refuses the tag as `Unsupported` (`BadNotSupported` to the client; see Wire evidence).
- **Indirect addressing (#247).** `N7:[N7:0]` (word from another address) and `N[N7:0]:5` (file from another
address) parse, one level deep (the inner address must be direct, and carries the family's octal rule).
**Runtime resolution is not implemented** (it needs a pre-read of the index word and per-resolved-address
runtimes): reads and writes of an indirect tag answer `BadNotSupported` before any runtime is created, on both
transports.
## Configuration
`AbLegacyDriverOptions` (`Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`)
@@ -172,6 +211,7 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h
| N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) |
| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
| MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O, indirect addressing | **none** — parsed only; native refuses function files and indirect names as Unsupported |
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
### Native known gaps
+4
View File
@@ -91,6 +91,10 @@ on.
| `MACRO:500` | Custom macro variable | System / user macro variable number |
| `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only |
PMC letters are series-gated by `FocasCapabilityMatrix.PmcLetters`; the 16i accepts `X Y F G R D` (F/G are
the CNC-to-PMC / PMC-to-CNC handshake groups its ladders use, #265 — reads only, FOCAS stays read-only), while
`M C E A K T` remain 0i-F / 30i-family.
The first three name a location the driver asks the CNC for on demand.
`FIXED:` does not: it names a field of the per-device fixed-tree snapshot the
driver's own poll loop already maintains, so a `FIXED:` read issues no wire
@@ -0,0 +1,311 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
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>
/// <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>
/// </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
/// 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,
AbLegacyAddress? IndirectFileSource = null,
AbLegacyAddress? IndirectWordSource = null)
{
/// <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>.
/// </summary>
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
/// <summary>
/// Converts the address to the libplctag library address format. Numeric indices are always
/// emitted <b>decimal</b> (a PLC-5 octal I/O address is normalised at parse time). This is correct, not a
/// double conversion: libplctag's <c>pccc.c</c> (<c>parse_pccc_elem_num</c> / <c>parse_pccc_bit_num</c>) parses
/// decimal numerals only (a FIXME there notes octal is unsupported), so handing it the RSLogix octal text would
/// read the wrong word. An indirect
/// address re-emits its bracket form, which neither transport accepts — check
/// <see cref="IsIndirect"/> first.
/// </summary>
/// <returns>The libplctag <c>name=...</c> attribute value for this address.</returns>
public string ToLibplctagName()
{
string file;
if (IndirectFileSource is not null) file = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]";
else file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
var word = IndirectWordSource is not null ? $"[{IndirectWordSource.ToLibplctagName()}]" : WordNumber.ToString();
var wordPart = $"{file}:{word}";
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 (family-neutral: decimal indices, no function files).</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) => TryParse(value, family: null);
/// <summary>
/// Family-aware parser. PLC-5 reads <c>I:</c>/<c>O:</c> word and bit indices as octal
/// (<c>I:017/07</c> = word 15, bit 7; a digit 8 or 9 is a parse error); MicroLogix additionally
/// accepts the function-file letters. The record always stores decimal values.
/// </summary>
/// <param name="value">The address string to parse.</param>
/// <param name="family">The device's PLC family, or <see langword="null"/> for the family-neutral grammar.</param>
/// <returns>The parsed address, or <see langword="null"/> when the string is not valid for the family.</returns>
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
return ParseCore(value.Trim(), profile, allowIndirect: true);
}
/// <summary>
/// The single authoring / deploy-gate / runtime address check. With a known <paramref name="family"/> the
/// address must parse for that family; with an unknown one (<see langword="null"/>) it must parse for at
/// least one family, so a caller that cannot see the device never rejects what the runtime would accept.
/// </summary>
/// <param name="address">The authored address text.</param>
/// <param name="family">The device's PLC family, or <see langword="null"/> when it is not known.</param>
/// <returns>An error message, or <see langword="null"/> when the address is acceptable.</returns>
public static string? Validate(string? address, AbLegacyPlcFamily? family)
{
if (string.IsNullOrWhiteSpace(address)) return "Address is required.";
if (family is { } f)
{
if (TryParse(address, f) is not null) return null;
var hint = f == AbLegacyPlcFamily.Plc5 && TryParse(address, AbLegacyPlcFamily.Slc500) is not null
? " (PLC-5 I:/O: word and bit indices are octal - 8 and 9 are invalid)"
: IsFunctionLetterPrefix(address) ? " (function files are MicroLogix-only)" : "";
return $"'{address}' is not a valid address for a {f} device{hint}.";
}
foreach (var candidate in Enum.GetValues<AbLegacyPlcFamily>())
if (TryParse(address, candidate) is not null) return null;
return $"'{address}' is not a valid PCCC file address for any PLC family.";
}
private static bool IsFunctionLetterPrefix(string address)
{
var colon = address.IndexOf(':');
return colon > 0 && IsFunctionFileLetter(address[..colon].Trim().ToUpperInvariant());
}
private static AbLegacyAddress? ParseCore(string src, AbLegacyPlcFamilyProfile? profile, bool allowIndirect)
{
// BitIndex: trailing /N outside any bracketed inner address. The numeric parse is deferred until the
// file letter is known (PLC-5 I/O bits are octal); the valid range depends on the parent word width.
string? bitText = null;
var slashIdx = src.LastIndexOf('/');
if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']'))
{
bitText = src[(slashIdx + 1)..];
src = src[..slashIdx];
}
// SubElement: trailing .NAME (ACC / PRE / EN / DN / HR ...) — top level only, since the inner
// address of an indirect form may carry its own dot (N[T4:0.ACC]:5).
string? subElement = null;
var dotIdx = LastIndexOfTopLevel(src, '.');
if (dotIdx >= 0)
{
var candidate = src[(dotIdx + 1)..];
if (candidate.Length > 0 && candidate.All(char.IsLetter))
{
subElement = candidate.ToUpperInvariant();
src = src[..dotIdx];
}
}
var colonIdx = IndexOfTopLevel(src, ':');
if (colonIdx <= 0) return null;
var filePart = src[..colonIdx];
var wordPart = src[(colonIdx + 1)..];
// File letter + optional file number (decimal digits, or a bracketed indirect address).
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;
AbLegacyAddress? indirectFile = null;
if (letterEnd < filePart.Length)
{
var fileTail = filePart[letterEnd..];
if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']')
{
if (!allowIndirect) return null;
indirectFile = ParseInner(fileTail[1..^1], profile);
if (indirectFile is null) return null;
}
else
{
if (!int.TryParse(fileTail, out var fn) || fn < 0) return null;
fileNumber = fn;
}
}
// Reject unknown file letters. Function-file letters are MicroLogix-only.
var functionFile = IsFunctionFileLetter(letter);
if (!IsKnownFileLetter(letter) && !(functionFile && profile?.SupportsFunctionFiles == true)) return null;
// I/O/S are single-letter system files and each function file is a single fixed instance: none carries
// a file number. Accepting I3:0 would pass a malformed address straight to the transport; reject early.
if ((fileNumber is not null || indirectFile is not null) && (IsNoFileNumberLetter(letter) || functionFile)) return null;
// Word part: a numeric literal (octal for PLC-5 I:/O:) or a bracketed indirect address.
var octal = profile?.OctalIoAddressing == true && letter is "I" or "O";
var word = 0;
AbLegacyAddress? indirectWord = null;
if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']')
{
if (!allowIndirect) return null;
indirectWord = ParseInner(wordPart[1..^1], profile);
if (indirectWord is null) return null;
}
else if (!TryParseIndex(wordPart, octal, out word) || word < 0) return null;
int? bitIndex = null;
if (bitText is not null)
{
if (!TryParseIndex(bitText, octal, out var bit) || bit < 0) return null;
bitIndex = bit;
// Range-check against the file's word width: 16-bit N/B/I/O/S/A → 0..15, 32-bit L → 0..31.
// F-files are floats and function files are structured — neither is bit-addressable.
var maxBit = MaxBitIndexFor(letter);
if (maxBit < 0 || bit > maxBit) return null;
}
// A PCCC address cannot have both a sub-element and a bit index.
if (subElement is not null && bitIndex is not null) return null;
// Sub-elements only exist on Timer / Counter / Control files and the function files — accepting
// B3:0.DN or N7:0.FOO would produce an address the transport silently misinterprets.
if (subElement is not null && !IsSubElementFileLetter(letter)) return null;
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord);
}
/// <summary>Parses a bracketed inner address. Depth is capped at one: the inner address must be direct.</summary>
private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile)
{
var src = inner.Trim();
if (src.Length == 0 || src.IndexOfAny(['[', ']']) >= 0) return null;
return ParseCore(src, profile, allowIndirect: false);
}
private static int IndexOfTopLevel(string s, char c)
{
var depth = 0;
for (var i = 0; i < s.Length; i++)
{
if (s[i] == '[') depth++;
else if (s[i] == ']') depth--;
else if (depth == 0 && s[i] == c) return i;
}
return -1;
}
private static int LastIndexOfTopLevel(string s, char c)
{
var depth = 0;
var last = -1;
for (var i = 0; i < s.Length; i++)
{
if (s[i] == '[') depth++;
else if (s[i] == ']') depth--;
else if (depth == 0 && s[i] == c) last = i;
}
return last;
}
private static bool TryParseIndex(string text, bool octal, out int value)
{
if (!octal) return int.TryParse(text, out value);
// Octal accepts only the digits 0-7 (no sign); 8 and 9 are a parse error rather than a silent misread.
value = 0;
if (text.Length == 0) return false;
var acc = 0;
foreach (var c in text)
{
if (c < '0' || c > '7') return false;
acc = (acc * 8) + (c - '0');
if (acc > ushort.MaxValue) return false;
}
value = acc;
return true;
}
/// <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>
/// MicroLogix 1100/1400 function-file prefixes — each a single fixed instance with a sub-element
/// catalogue (<c>AbLegacyFunctionFile</c>).
/// </summary>
public static bool IsFunctionFileLetter(string letter) => letter switch
{
"RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => 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.): Timer (<c>T</c>), Counter (<c>C</c>), Control (<c>R</c>) and the
/// MicroLogix function files.
/// </summary>
private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R" || IsFunctionFileLetter(letter);
}
@@ -15,7 +15,14 @@ public sealed record AbLegacyPlcFamilyProfile(
/// </summary>
int MaxTagBytes,
bool SupportsStringFile,
bool SupportsLongFile)
bool SupportsLongFile,
/// <summary>
/// PLC-5 only: the word and bit indices on <c>I:</c>/<c>O:</c> references are octal
/// (<c>I:017/07</c>), as RSLogix 5 displays them. SLC / MicroLogix / Logix-PCCC stay decimal.
/// </summary>
bool OctalIoAddressing = false,
/// <summary>MicroLogix only: RTC / HSC / DLS / MMI / PTO / PWM / STI / EII / IOS / BHI function files.</summary>
bool SupportsFunctionFiles = false)
{
/// <summary>Gets the profile for the specified PLC family.</summary>
/// <param name="family">The PLC family.</param>
@@ -41,21 +48,23 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0",
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
SupportsStringFile: true, // ST file available SLC 5/04+
SupportsLongFile: true); // L file available SLC 5/05+
SupportsLongFile: true); // L file available SLC 5/05+ (decimal I:/O: indices, RSLogix 500)
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
LibplctagPlcAttribute: "micrologix",
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
MaxTagBytes: 232,
SupportsStringFile: true,
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
SupportsFunctionFiles: true); // ML 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
LibplctagPlcAttribute: "plc5",
DefaultCipPath: "1,0",
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
SupportsStringFile: true,
SupportsLongFile: false); // PLC-5 predates L files
SupportsLongFile: false, // PLC-5 predates L files
OctalIoAddressing: true); // RSLogix 5 displays I:/O: word + bit indices as octal (#244)
/// <summary>
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
@@ -1,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -122,6 +123,12 @@ 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>
@@ -145,7 +152,18 @@ public static class AbLegacyTagDefinitionFactory
/// </remarks>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
public static IReadOnlyList<string> Inspect(string reference) => Inspect(reference, family: null);
/// <summary>
/// <see cref="Inspect(string)"/> plus the family-aware address check (<see cref="AbLegacyAddress.Validate"/>,
/// the same authority the driver runtime uses). <paramref name="family"/> is the tag's device's PLC family, or
/// <see langword="null"/> when unknown (then only an address no family accepts is flagged). An indirect
/// address parses but the runtime always answers it <c>BadNotSupported</c>, so it is warned about too.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <param name="family">The owning device's PLC family, when known.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference, AbLegacyPlcFamily? family)
{
var warnings = new List<string>();
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
@@ -157,7 +175,14 @@ public static class AbLegacyTagDefinitionFactory
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
if (FromTagConfig(reference, InspectionRawPath, out var accepted))
{
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;
}
try
{
@@ -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);
@@ -84,7 +84,7 @@ public static class FocasCapabilityMatrix
/// <returns>A set of accepted PMC letter strings.</returns>
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
{
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D" },
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
@@ -68,8 +68,11 @@ public sealed class AbLegacyTagConfigModel
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(_bag, "dataType") is { } dtError)
return dtError;
if (string.IsNullOrWhiteSpace(Address))
return "Address is required.";
// The editor cannot see the device's PLC family, so it checks only what no family accepts (the same
// AbLegacyAddress authority the deploy gate and runtime use) — never stricter than the gate, which adds the
// family-specific verdict (PLC-5 octal I/O, MicroLogix-only function files).
if (AbLegacyAddress.Validate(Address, family: null) is { } addressError)
return addressError;
// DELIBERATELY STRICTER THAN THE RUNTIME, and re-confirmed as such by the Gitea #554 survey — do not
// "align" it. PCCC data-file elements are limited to AbLegacyArray.MaxElements (256). The reader
@@ -262,12 +262,16 @@ public sealed class AdminOperationsActor : ReceiveActor
var driverByDevice = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal);
var deviceConfigById = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DeviceConfig, StringComparer.Ordinal);
var tagConfigWarnings = new List<(string TagId, string Text)>();
foreach (var t in draft.Tags)
{
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue;
foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig))
foreach (var w in EquipmentTagConfigInspector.Inspect(
driverType, t.TagConfig, deviceConfigById.GetValueOrDefault(t.DeviceId)))
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
}
if (tagConfigWarnings.Count > 0)
@@ -1,6 +1,7 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -78,6 +79,47 @@ public static class EquipmentTagConfigInspector
return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty<string>();
}
/// <summary>
/// <see cref="Inspect(string?, string?)"/> with the owning device's <c>DeviceConfig</c>, so a driver whose
/// address grammar depends on the device (AbLegacy: the <c>PlcFamily</c>) is checked against the family the
/// runtime will use. Other drivers ignore the device config. A missing family key is the runtime default
/// (SLC 500); an unrecognised value is "unknown" and only the family-independent check applies — the gate
/// is never stricter than the runtime.
/// </summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <param name="tagConfig">The equipment tag's <c>TagConfig</c> JSON.</param>
/// <param name="deviceConfig">The owning device's <c>DeviceConfig</c> JSON (may be null).</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string? driverType, string? tagConfig, string? deviceConfig)
{
if (string.IsNullOrEmpty(driverType) || tagConfig is null) return Array.Empty<string>();
if (string.Equals(driverType, DriverTypeNames.AbLegacy, StringComparison.OrdinalIgnoreCase))
return AbLegacyTagDefinitionFactory.Inspect(tagConfig, ReadAbLegacyFamily(deviceConfig));
return Inspect(driverType, tagConfig);
}
private static AbLegacyPlcFamily? ReadAbLegacyFamily(string? deviceConfig)
{
if (string.IsNullOrWhiteSpace(deviceConfig)) return AbLegacyPlcFamily.Slc500;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(deviceConfig);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return AbLegacyPlcFamily.Slc500;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "PlcFamily", StringComparison.OrdinalIgnoreCase)) continue;
var raw = prop.Value.ValueKind == System.Text.Json.JsonValueKind.String ? prop.Value.GetString() : null;
if (string.IsNullOrWhiteSpace(raw)) return AbLegacyPlcFamily.Slc500;
return Enum.TryParse<AbLegacyPlcFamily>(raw, ignoreCase: true, out var f) && Enum.IsDefined(f) ? f : null;
}
return AbLegacyPlcFamily.Slc500;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
/// <summary>Whether the given driver type has a mapped inspector.</summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> when an inspector is registered for the driver type.</returns>
@@ -0,0 +1,320 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>Family-aware addressing: PLC-5 octal I/O (#244), MicroLogix function files (#245), indirect (#247).</summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyFamilyAddressingTests
{
// ---- #244 PLC-5 octal I/O ----
/// <summary>PLC-5 I/O word and bit indices parse as octal and are stored decimal.</summary>
[Theory]
[InlineData("I:017/07", "I", 15, 7, "I:15/7")]
[InlineData("I:017/17", "I", 15, 15, "I:15/15")]
[InlineData("O:010/10", "O", 8, 8, "O:8/8")]
[InlineData("I:000", "I", 0, null, "I:0")]
[InlineData("I:12", "I", 10, null, "I:10")]
public void Plc5_io_indices_are_octal(string text, string letter, int word, int? bit, string libplctag)
{
var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldNotBeNull();
a.FileLetter.ShouldBe(letter);
a.WordNumber.ShouldBe(word);
a.BitIndex.ShouldBe(bit);
a.ToLibplctagName().ShouldBe(libplctag);
}
/// <summary>The decimal-normalised name round-trips family-neutrally, so the native transport reads back the same numbers.</summary>
[Fact]
public void Plc5_octal_address_round_trips_through_the_family_neutral_parser()
{
var a = AbLegacyAddress.TryParse("I:017/07", AbLegacyPlcFamily.Plc5)!;
AbLegacyAddress.TryParse(a.ToLibplctagName()).ShouldBe(a);
}
/// <summary>A digit 8 or 9 is not octal: a clear parse failure, not a silent misread.</summary>
[Theory]
[InlineData("I:018")]
[InlineData("I:019/07")]
[InlineData("I:017/08")]
[InlineData("O:9/1")]
[InlineData("O:1/9")]
public void Plc5_io_rejects_8_and_9(string text) =>
AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldBeNull();
/// <summary>Control: the same text under SLC / MicroLogix / Logix-PCCC / no family is decimal.</summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500)]
[InlineData(AbLegacyPlcFamily.MicroLogix)]
[InlineData(AbLegacyPlcFamily.LogixPccc)]
public void Non_plc5_io_stays_decimal(AbLegacyPlcFamily family)
{
AbLegacyAddress.TryParse("I:12/9", family).ShouldNotBeNull().WordNumber.ShouldBe(12);
AbLegacyAddress.TryParse("I:12/9", family)!.BitIndex.ShouldBe(9);
AbLegacyAddress.TryParse("I:12/9").ShouldNotBeNull().WordNumber.ShouldBe(12);
}
/// <summary>PLC-5 N-files (and bit indices on them) stay decimal — only I/O is octal.</summary>
[Fact]
public void Plc5_non_io_files_stay_decimal()
{
var a = AbLegacyAddress.TryParse("N7:12/9", AbLegacyPlcFamily.Plc5).ShouldNotBeNull();
a.WordNumber.ShouldBe(12);
a.BitIndex.ShouldBe(9);
}
// ---- #245 MicroLogix function files ----
/// <summary>MicroLogix parses the function-file letters, with their sub-elements.</summary>
[Theory]
[InlineData("RTC:0.HR", "RTC", 0, "HR")]
[InlineData("HSC:0.ACC", "HSC", 0, "ACC")]
[InlineData("pto:0.EN", "PTO", 0, "EN")]
[InlineData("STI:0.SPM", "STI", 0, "SPM")]
[InlineData("BHI:0.FRN", "BHI", 0, "FRN")]
public void MicroLogix_parses_function_files(string text, string letter, int element, string sub)
{
var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
a.FileLetter.ShouldBe(letter);
a.FileNumber.ShouldBeNull();
a.WordNumber.ShouldBe(element);
a.SubElement.ShouldBe(sub);
}
/// <summary>Function files are MicroLogix-only; every other family and the neutral grammar reject them.</summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500)]
[InlineData(AbLegacyPlcFamily.Plc5)]
[InlineData(AbLegacyPlcFamily.LogixPccc)]
public void Other_families_reject_function_files(AbLegacyPlcFamily family)
{
AbLegacyAddress.TryParse("RTC:0.HR", family).ShouldBeNull();
AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull();
}
/// <summary>A function file is a fixed single instance and not bit-addressable.</summary>
[Theory]
[InlineData("RTC1:0.HR")]
[InlineData("RTC:0/3")]
[InlineData("HSC:0.ACC/2")]
public void MicroLogix_function_file_shape_is_validated(string text) =>
AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldBeNull();
/// <summary>The catalogue maps flags to Boolean, numeric members to Int32, and unknown members to Int32.</summary>
[Theory]
[InlineData("RTC", "HR", DriverDataType.Int32)]
[InlineData("RTC", "BL", DriverDataType.Boolean)]
[InlineData("HSC", "ACC", DriverDataType.Int32)]
[InlineData("HSC", "DN", DriverDataType.Boolean)]
[InlineData("PTO", "OUT", DriverDataType.Boolean)]
[InlineData("STI", "SPM", DriverDataType.Int32)]
[InlineData("RTC", "NOPE", DriverDataType.Int32)]
public void Function_file_catalogue(string file, string sub, DriverDataType expected) =>
AbLegacyFunctionFile.SubElementType(file, sub).ShouldBe(expected);
// ---- #247 indirect addressing ----
/// <summary>Both indirect forms parse, carry their inner address, and round-trip their text.</summary>
[Theory]
[InlineData("N7:[N7:0]", false, "N7:[N7:0]")]
[InlineData("N[N7:0]:5", true, "N[N7:0]:5")]
[InlineData("N7:[T4:1.ACC]", false, "N7:[T4:1.ACC]")]
[InlineData("B3:[N7:0]/4", false, "B3:[N7:0]/4")]
public void Indirect_forms_parse(string text, bool fileIndirect, string roundTrip)
{
var a = AbLegacyAddress.TryParse(text).ShouldNotBeNull();
a.IsIndirect.ShouldBeTrue();
(a.IndirectFileSource is not null).ShouldBe(fileIndirect);
(a.IndirectWordSource is not null).ShouldBe(!fileIndirect);
a.ToLibplctagName().ShouldBe(roundTrip);
}
/// <summary>Nested indirection, empty or invalid inner addresses, and unbalanced brackets are rejected.</summary>
[Theory]
[InlineData("N7:[N[N7:0]:1]")]
[InlineData("N7:[]")]
[InlineData("N7:[Q9:0]")]
[InlineData("N7:[N7:0")]
[InlineData("N7:N7:0]")]
[InlineData("N7:[N7:[N7:0]]")]
public void Malformed_indirect_is_rejected(string text) =>
AbLegacyAddress.TryParse(text).ShouldBeNull();
/// <summary>A direct address is not indirect.</summary>
[Fact]
public void Direct_address_is_not_indirect() =>
AbLegacyAddress.TryParse("N7:3/2")!.IsIndirect.ShouldBeFalse();
// ---- driver level ----
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
AbLegacyPlcFamily family, params AbLegacyTagDefinition[] tags)
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
RawTags = AbLegacyRawTags.Entries(tags),
}, "drv-1", factory);
return (drv, factory);
}
/// <summary>A PLC-5 octal I/O tag reaches the transport as the decimal name, and its bit decodes at the octal-parsed index.</summary>
[Fact]
public async Task Plc5_octal_io_tag_is_handed_to_the_transport_decimal()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Plc5,
new AbLegacyTagDefinition("In", "ab://10.0.0.5/1,0", "I:017/17", AbLegacyDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = true };
var snap = (await drv.ReadAsync(["In"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.ContainsKey("I:15/15").ShouldBeTrue();
factory.Tags["I:15/15"].ReadCount.ShouldBe(1);
}
/// <summary>An indirect tag is answered BadNotSupported on read and write, and no runtime is ever created for it.</summary>
[Fact]
public async Task Indirect_tag_is_BadNotSupported_and_creates_no_runtime()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Ind", "ab://10.0.0.5/1,0", "N7:[N7:0]", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Ok", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5 };
var read = await drv.ReadAsync(["Ind", "Ok"], CancellationToken.None);
var write = await drv.WriteAsync(
[new WriteRequest("Ind", 1), new WriteRequest("Ok", 1)], CancellationToken.None);
read[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
// positive control: the neighbouring direct tag in the same batch still works, so the refusal is per-tag
read[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
write[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.Keys.ShouldNotContain(k => k.Contains('['));
}
/// <summary>A MicroLogix function-file tag reaches the transport under its libplctag name.</summary>
[Fact]
public async Task MicroLogix_function_file_tag_reaches_the_transport()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.MicroLogix,
new AbLegacyTagDefinition("Hour", "ab://10.0.0.5/1,0", "RTC:0.HR", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 14 };
var snap = (await drv.ReadAsync(["Hour"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snap.Value.ShouldBe(14);
factory.Tags.ContainsKey("RTC:0.HR").ShouldBeTrue();
}
// ---- native transport ----
private sealed class NativeHarness : IAsyncDisposable
{
private readonly FakeCipChannel _channel = new();
private readonly RequestScheduler _scheduler;
public NativeAbLegacyTagRuntime Runtime { get; }
public NativeHarness(string plc, string tag)
{
_scheduler = new RequestScheduler(_channel, allowPacking: false);
Runtime = new NativeAbLegacyTagRuntime(
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(2), 1),
_scheduler, 0x1234, 0x01020304);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>The native transport refuses function files and indirect names as Unsupported rather than mis-encoding them.</summary>
[Theory]
[InlineData("RTC:0.HR", "function file")]
[InlineData("N7:[N7:0]", "indirect")]
public async Task Native_transport_refuses_function_file_and_indirect_as_Unsupported(string tag, string why)
{
await using var h = new NativeHarness("micrologix", tag);
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.Unsupported);
ex.Message.ShouldContain(why, Case.Insensitive);
}
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<AbLegacyDriver>
{
public readonly System.Collections.Concurrent.ConcurrentQueue<string> Warnings = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel level, Microsoft.Extensions.Logging.EventId id,
TState state, Exception? ex, Func<TState, Exception?, string> f)
{
if (level == Microsoft.Extensions.Logging.LogLevel.Warning) Warnings.Enqueue(f(state, ex));
}
}
/// <summary>
/// A PLC-5 address that no longer parses (the pre-octal-era decimal 8/9 digit) is a config error: BadNodeIdUnknown on
/// read and write, no runtime created, health untouched, and ONE warning per tag however often it is polled.
/// </summary>
[Fact]
public async Task Malformed_address_is_BadNodeIdUnknown_warned_once_and_never_throws()
{
var factory = new FakeAbLegacyTagFactory();
var logger = new CapturingLogger();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("Bad", "ab://10.0.0.5/1,0", "I:018", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Good", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
var first = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
var second = await drv.ReadAsync(["Bad"], CancellationToken.None);
var write = await drv.WriteAsync([new WriteRequest("Bad", 1)], CancellationToken.None);
first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
factory.Tags.Keys.ShouldNotContain(k => k.StartsWith("I:"));
logger.Warnings.Count(w => w.Contains("not valid for the device's PLC family") && w.Contains("I:018")).ShouldBe(1);
}
/// <summary>The connectivity-probe address is normalised like tag paths (PLC-5 octal I/O handed over decimal).</summary>
[Fact]
public async Task Probe_address_is_normalised_like_tag_paths()
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
Probe = new AbLegacyProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(200),
ProbeAddress = "I:017",
},
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!factory.Tags.ContainsKey("I:15") && DateTime.UtcNow < deadline) await Task.Delay(20);
await drv.ShutdownAsync(CancellationToken.None);
factory.Tags.ContainsKey("I:15").ShouldBeTrue();
factory.Tags.ContainsKey("I:017").ShouldBeFalse();
}
}
@@ -69,8 +69,9 @@ public sealed class FocasCapabilityMatrixTests
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
[InlineData(FocasCncSeries.Sixteen_i, "F", true)] // #265: F/G handshakes are documented on 16i ladders
[InlineData(FocasCncSeries.Sixteen_i, "G", true)]
[InlineData(FocasCncSeries.Sixteen_i, "M", false)]
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>#244/#245/#247: one address authority (AbLegacyAddress.Validate) behind the editor and the deploy gate.</summary>
public sealed class AbLegacyFamilyAddressGateTests
{
private static string Cfg(string address) => "{\"address\":\"" + address + "\",\"dataType\":\"Int\"}";
[Theory]
[InlineData("{\"PlcFamily\":\"Plc5\"}", "I:018", true)] // octal 8 on PLC-5
[InlineData("{\"PlcFamily\":\"Plc5\"}", "I:017/07", false)]
[InlineData("{\"PlcFamily\":\"Slc500\"}", "I:018", false)] // control: decimal on SLC
[InlineData("{\"plcFamily\":\"plc5\"}", "I:018", true)] // key + value case-insensitive
[InlineData("{\"PlcFamily\":\"Slc500\"}", "RTC:0.HR", true)] // function file off MicroLogix
[InlineData("{\"PlcFamily\":\"MicroLogix\"}", "RTC:0.HR", false)]
[InlineData(null, "RTC:0.HR", true)] // absent family = runtime default SLC 500
[InlineData("{\"PlcFamily\":\"Bogus\"}", "I:018", false)] // unknown family: never stricter than the runtime
[InlineData("{\"PlcFamily\":\"Slc500\"}", "Q9:0", true)]
public void Gate_checks_the_address_against_the_device_family(string? deviceConfig, string address, bool warns)
{
var w = EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg(address), deviceConfig);
(w.Count > 0).ShouldBe(warns, string.Join("; ", w));
}
[Fact]
public void Gate_warns_about_indirect_addresses_which_never_read()
{
EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg("N7:[N7:0]"), "{\"PlcFamily\":\"Slc500\"}")
.ShouldContain(x => x.Contains("indirect"));
}
[Theory]
[InlineData("N7:0", null)]
[InlineData("I:018", null)] // family unknown to the editor: another family accepts it
[InlineData("RTC:0.HR", null)] // MicroLogix accepts it
[InlineData("Q9:0", "any PLC family")]
public void Editor_validates_only_the_family_independent_shape(string address, string? error)
{
var result = AbLegacyTagConfigModel.FromJson(Cfg(address)).Validate();
if (error is null) result.ShouldBeNull(); else result!.ShouldContain(error);
}
[Fact]
public void Editor_is_never_stricter_than_the_gate()
{
const string address = "Q9:0";
AbLegacyTagConfigModel.FromJson(Cfg(address)).Validate().ShouldNotBeNull();
EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg(address), null).ShouldNotBeEmpty();
}
}
@@ -346,7 +346,7 @@ public sealed class RawTagConfigGateTests
public void A_browse_committed_config_passes_the_gate(string driverType)
{
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
driverType, "SomeAddress", BrowseFieldsFor(driverType));
driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType));
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
$"browse-commit for {driverType} must not author a config the import refuses");