feat(ablegacy,focas): PLC-5 octal I/O, MicroLogix function files, indirect-address parse, 16i PMC F/G (#244 #245 #247 #265)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:28:22 -04:00
parent d898262c4a
commit 0dc09329b7
10 changed files with 552 additions and 58 deletions
+28
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,30 @@ 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. ⚠️ *Unverified:* whether libplctag itself reads PLC-5 I/O indices as octal
when handed the raw RSLogix text was not checked (no libplctag source/PLC here); the driver therefore never
hands it octal text. Native PLC-5 is N-file read-only anyway, so I/O never reaches the native wire.
- **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 +199,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
@@ -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,5 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
@@ -19,53 +21,87 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <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>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>
/// <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)
string? SubElement,
AbLegacyAddress? IndirectFileSource = null,
AbLegacyAddress? IndirectWordSource = null)
{
/// <summary>Converts the address to the libplctag library address format.</summary>
/// <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). 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()
{
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
var wordPart = $"{file}:{WordNumber}";
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.</summary>
/// <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)
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 src = value.Trim();
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
return ParseCore(value.Trim(), profile, allowIndirect: true);
}
// 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)
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(']'))
{
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0) return null;
bitIndex = bit;
bitText = src[(slashIdx + 1)..];
src = src[..slashIdx];
}
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
// 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 = src.LastIndexOf('.');
var dotIdx = LastIndexOfTopLevel(src, '.');
if (dotIdx >= 0)
{
var candidate = src[(dotIdx + 1)..];
@@ -76,51 +112,126 @@ public sealed record AbLegacyAddress(
}
}
var colonIdx = src.IndexOf(':');
var colonIdx = IndexOfTopLevel(src, ':');
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).
// 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)
{
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
fileNumber = fn;
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 — these cover SLC/ML/PLC-5 canonical families.
if (!IsKnownFileLetter(letter)) return null;
// Reject unknown file letters. Function-file letters are MicroLogix-only.
var functionFile = IsFunctionFileLetter(letter);
if (!IsKnownFileLetter(letter) && !(functionFile && profile?.SupportsFunctionFiles == true)) 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)
// 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 || b > maxBit) return null;
if (maxBit < 0 || bit > 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.
// 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 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.
// 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);
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>
@@ -141,6 +252,16 @@ public sealed record AbLegacyAddress(
_ => false,
};
/// <summary>
/// MicroLogix 1100/1400 function-file prefixes — each a single fixed instance with a sub-element
/// catalogue (<see cref="AbLegacyFunctionFile"/>).
/// </summary>
internal 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
@@ -150,8 +271,8 @@ public sealed record AbLegacyAddress(
/// <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.
/// (<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";
private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R" || IsFunctionFileLetter(letter);
}
@@ -355,6 +355,14 @@ 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.
if (AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily) is { IsIndirect: true })
{
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
continue;
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
int status;
@@ -368,7 +376,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))
@@ -455,7 +463,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
try
{
var parsed = AbLegacyAddress.TryParse(def.Address);
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
// Indirect addressing is parse-only (#247): refuse before any runtime or RMW read is created.
if (parsed is { IsIndirect: true })
{
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
@@ -883,9 +898,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)
@@ -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
@@ -0,0 +1,252 @@
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);
}
}
@@ -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