diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md
index 9d088d87..fbe030e6 100644
--- a/docs/drivers/AbLegacy.md
+++ b/docs/drivers/AbLegacy.md
@@ -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
diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md
index 3df703f6..f2442d1b 100644
--- a/docs/drivers/FOCAS.md
+++ b/docs/drivers/FOCAS.md
@@ -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
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs
index 5d533d33..b45e65b2 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs
@@ -15,7 +15,14 @@ public sealed record AbLegacyPlcFamilyProfile(
///
int MaxTagBytes,
bool SupportsStringFile,
- bool SupportsLongFile)
+ bool SupportsLongFile,
+ ///
+ /// PLC-5 only: the word and bit indices on I:/O: references are octal
+ /// (I:017/07), as RSLogix 5 displays them. SLC / MicroLogix / Logix-PCCC stay decimal.
+ ///
+ bool OctalIoAddressing = false,
+ /// MicroLogix only: RTC / HSC / DLS / MMI / PTO / PWM / STI / EII / IOS / BHI function files.
+ bool SupportsFunctionFiles = false)
{
/// Gets the profile for the specified PLC family.
/// The PLC family.
@@ -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)
///
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs
index 4ff25df0..f4363b8c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs
@@ -1,3 +1,5 @@
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
+
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
///
@@ -19,53 +21,87 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// - O:1/2 — output file, slot 1, bit 2.
/// - S:1 — status file, word 1.
/// - L9:0 — long-integer file (SLC 5/05+, 32-bit).
+/// - I:017/07 — PLC-5 only: I/O word + bit indices are octal (#244); stored decimal.
+/// - RTC:0.HR — MicroLogix only: function file (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI, #245).
+/// - N7:[N7:0], N[N7:0]:5 — indirect addressing (#247): parsed, not resolved at runtime.
///
-/// Pass the original string straight through to libplctag's name=... 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).
+/// The libplctag transport takes as its name=... 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).
///
public sealed record AbLegacyAddress(
string FileLetter,
int? FileNumber,
int WordNumber,
int? BitIndex,
- string? SubElement)
+ string? SubElement,
+ AbLegacyAddress? IndirectFileSource = null,
+ AbLegacyAddress? IndirectWordSource = null)
{
- /// Converts the address to the libplctag library address format.
+ ///
+ /// True when the file number or the word number is sourced from another PCCC address evaluated
+ /// at runtime (N7:[N7:0] or N[N7:0]:5). 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 BadNotSupported.
+ ///
+ public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
+
+ ///
+ /// Converts the address to the libplctag library address format. Numeric indices are always
+ /// emitted decimal (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
+ /// first.
+ ///
/// The libplctag name=... attribute value for this address.
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;
}
- /// Attempts to parse a string into an AB legacy address.
+ /// Attempts to parse a string into an AB legacy address (family-neutral: decimal indices, no function files).
/// The address string to parse.
/// The parsed address, or when is not a valid PCCC address.
- public static AbLegacyAddress? TryParse(string? value)
+ public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
+
+ ///
+ /// Family-aware parser. PLC-5 reads I:/O: word and bit indices as octal
+ /// (I:017/07 = 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.
+ ///
+ /// The address string to parse.
+ /// The device's PLC family, or for the family-neutral grammar.
+ /// The parsed address, or when the string is not valid for the family.
+ 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);
+ }
+
+ /// Parses a bracketed inner address. Depth is capped at one: the inner address must be direct.
+ 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;
}
///
@@ -141,6 +252,16 @@ public sealed record AbLegacyAddress(
_ => false,
};
+ ///
+ /// MicroLogix 1100/1400 function-file prefixes — each a single fixed instance with a sub-element
+ /// catalogue ().
+ ///
+ 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,
+ };
+
///
/// Returns for file letters that carry no explicit file number in the
/// PCCC spec. I (input), O (output), and S (status) are single-letter
@@ -150,8 +271,8 @@ public sealed record AbLegacyAddress(
///
/// Returns for file letters that may carry a sub-element suffix
- /// (.ACC, .PRE, etc.). Only Timer (T), Counter (C), and
- /// Control (R) files have structured elements in the PCCC spec.
+ /// (.ACC, .PRE, etc.): Timer (T), Counter (C), Control (R) and the
+ /// MicroLogix function files.
///
- 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);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index 982df163..f6315c78 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -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)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs
new file mode 100644
index 00000000..948c6dad
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs
@@ -0,0 +1,51 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// MicroLogix 1100/1400 function-file sub-element catalogue (#245): the driver-surface type of
+/// RTC:0.HR, HSC:0.ACC, ... Covers the commonly-addressed members per file — not exhaustive
+/// (Rockwell defines 30+ on RTC alone). Unverified on the wire: 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.
+///
+public static class AbLegacyFunctionFile
+{
+ ///
+ /// Driver-surface type for .; an unrecognised
+ /// sub-element falls back to so the driver never refuses a member the
+ /// catalogue happens to omit.
+ ///
+ /// The function-file letter (RTC, HSC, ...).
+ /// The sub-element mnemonic, or null.
+ /// The mapped driver data type.
+ 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,
+ };
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
index 6ba2c2e1..4e2fa43b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
@@ -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);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
index b8037e54..0e861e92 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
@@ -84,7 +84,7 @@ public static class FocasCapabilityMatrix
/// A set of accepted PMC letter strings.
internal static IReadOnlySet PmcLetters(FocasCncSeries series) => series switch
{
- FocasCncSeries.Sixteen_i => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
+ FocasCncSeries.Sixteen_i => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D" },
FocasCncSeries.Zero_i_D => new HashSet(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyFamilyAddressingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyFamilyAddressingTests.cs
new file mode 100644
index 00000000..40fdd620
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyFamilyAddressingTests.cs
@@ -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;
+
+/// Family-aware addressing: PLC-5 octal I/O (#244), MicroLogix function files (#245), indirect (#247).
+[Trait("Category", "Unit")]
+public sealed class AbLegacyFamilyAddressingTests
+{
+ // ---- #244 PLC-5 octal I/O ----
+
+ /// PLC-5 I/O word and bit indices parse as octal and are stored decimal.
+ [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);
+ }
+
+ /// The decimal-normalised name round-trips family-neutrally, so the native transport reads back the same numbers.
+ [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);
+ }
+
+ /// A digit 8 or 9 is not octal: a clear parse failure, not a silent misread.
+ [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();
+
+ /// Control: the same text under SLC / MicroLogix / Logix-PCCC / no family is decimal.
+ [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);
+ }
+
+ /// PLC-5 N-files (and bit indices on them) stay decimal — only I/O is octal.
+ [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 ----
+
+ /// MicroLogix parses the function-file letters, with their sub-elements.
+ [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);
+ }
+
+ /// Function files are MicroLogix-only; every other family and the neutral grammar reject them.
+ [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();
+ }
+
+ /// A function file is a fixed single instance and not bit-addressable.
+ [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();
+
+ /// The catalogue maps flags to Boolean, numeric members to Int32, and unknown members to Int32.
+ [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 ----
+
+ /// Both indirect forms parse, carry their inner address, and round-trip their text.
+ [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);
+ }
+
+ /// Nested indirection, empty or invalid inner addresses, and unbalanced brackets are rejected.
+ [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();
+
+ /// A direct address is not indirect.
+ [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);
+ }
+
+ /// A PLC-5 octal I/O tag reaches the transport as the decimal name, and its bit decodes at the octal-parsed index.
+ [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);
+ }
+
+ /// An indirect tag is answered BadNotSupported on read and write, and no runtime is ever created for it.
+ [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('['));
+ }
+
+ /// A MicroLogix function-file tag reaches the transport under its libplctag name.
+ [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;
+ }
+
+ /// The native transport refuses function files and indirect names as Unsupported rather than mis-encoding them.
+ [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(() => h.Runtime.InitializeAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.Unsupported);
+ ex.Message.ShouldContain(why, Case.Insensitive);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs
index 495a031b..12921abf 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs
@@ -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