Merge b626-ablegacy-focas (batch #626+)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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) |
|
| `ST9:0` | String file (82-byte fixed-length) |
|
||||||
| `T4:0.ACC` / `C5:0.PRE` | Timer / counter sub-element |
|
| `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: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`
|
`AbLegacyDataType` covers the corresponding PCCC types: `Bit`, `Int` (N), `Long`
|
||||||
(L), `Float` (F), `AnalogInt` (A), `String` (ST), and the `TimerElement` /
|
(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
|
writes are forwarded to the PLC as-is; the device drives the input image from
|
||||||
physical inputs and may reject the write.
|
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
|
## Configuration
|
||||||
|
|
||||||
`AbLegacyDriverOptions` (`Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`)
|
`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) |
|
| 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) |
|
| 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 |
|
| 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** |
|
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
|
||||||
|
|
||||||
### Native known gaps
|
### Native known gaps
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ on.
|
|||||||
| `MACRO:500` | Custom macro variable | System / user macro variable number |
|
| `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 |
|
| `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.
|
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
|
`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
|
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);
|
||||||
|
}
|
||||||
+13
-4
@@ -15,7 +15,14 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
int MaxTagBytes,
|
int MaxTagBytes,
|
||||||
bool SupportsStringFile,
|
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>
|
/// <summary>Gets the profile for the specified PLC family.</summary>
|
||||||
/// <param name="family">The PLC family.</param>
|
/// <param name="family">The PLC family.</param>
|
||||||
@@ -41,21 +48,23 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||||
SupportsStringFile: true, // ST file available SLC 5/04+
|
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(
|
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
||||||
LibplctagPlcAttribute: "micrologix",
|
LibplctagPlcAttribute: "micrologix",
|
||||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||||
MaxTagBytes: 232,
|
MaxTagBytes: 232,
|
||||||
SupportsStringFile: true,
|
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(
|
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
||||||
LibplctagPlcAttribute: "plc5",
|
LibplctagPlcAttribute: "plc5",
|
||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||||
SupportsStringFile: true,
|
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>
|
/// <summary>
|
||||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||||
|
|||||||
+27
-2
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
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
|
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
|
||||||
/// onto <see cref="AbLegacyTagDefinition.Name"/> and never validates it.
|
/// onto <see cref="AbLegacyTagDefinition.Name"/> and never validates it.
|
||||||
/// </summary>
|
/// </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)";
|
private const string InspectionRawPath = "(inspect)";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -145,7 +152,18 @@ public static class AbLegacyTagDefinitionFactory
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||||
/// <returns>The warnings; empty when clean.</returns>
|
/// <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>();
|
var warnings = new List<string>();
|
||||||
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
|
// 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
|
// 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.
|
// 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
|
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
|
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);
|
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
int status;
|
int status;
|
||||||
@@ -401,7 +416,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
{
|
{
|
||||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
status = runtime.GetStatus();
|
status = runtime.GetStatus();
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||||
if (status != 0)
|
if (status != 0)
|
||||||
value = null;
|
value = null;
|
||||||
else if (IsArrayTag(def))
|
else if (IsArrayTag(def))
|
||||||
@@ -488,7 +503,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
|
|
||||||
try
|
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,
|
// 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
|
// 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,
|
Port: state.ParsedAddress.Port,
|
||||||
CipPath: state.EffectiveCipPath,
|
CipPath: state.EffectiveCipPath,
|
||||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
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);
|
Timeout: _options.Probe.Timeout);
|
||||||
|
|
||||||
IAbLegacyTagRuntime? probeRuntime = null;
|
IAbLegacyTagRuntime? probeRuntime = null;
|
||||||
@@ -891,6 +924,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
keep();
|
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
|
/// <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>
|
/// the refusal is asked about exactly the runtime a write would otherwise create.</summary>
|
||||||
private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) =>
|
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;
|
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(
|
?? 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));
|
var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed));
|
||||||
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
|
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
|
||||||
@@ -1123,6 +1169,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool FirstNonZeroStatusLogged { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Disposes all cached tag runtimes.
|
/// Disposes all cached tag runtimes.
|
||||||
/// </summary>
|
/// </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)
|
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)
|
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
|
return a.SubElement is not null
|
||||||
? PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, a.SubElement)
|
? PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, a.SubElement)
|
||||||
: new PcccAddress(a.FileLetter, a.FileNumber, a.WordNumber, null, a.BitIndex);
|
: 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>
|
/// <returns>A set of accepted PMC letter strings.</returns>
|
||||||
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
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_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
|
||||||
FocasCncSeries.Zero_i_F or
|
FocasCncSeries.Zero_i_F or
|
||||||
FocasCncSeries.Zero_i_MF or
|
FocasCncSeries.Zero_i_MF or
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ public sealed class AbLegacyTagConfigModel
|
|||||||
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(_bag, "dataType") is { } dtError)
|
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(_bag, "dataType") is { } dtError)
|
||||||
return dtError;
|
return dtError;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(Address))
|
// The editor cannot see the device's PLC family, so it checks only what no family accepts (the same
|
||||||
return "Address is required.";
|
// 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
|
// 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
|
// "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
|
var driverByDevice = draft.Devices
|
||||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||||
.ToDictionary(g => g.Key, g => g.First().DriverInstanceId, 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)>();
|
var tagConfigWarnings = new List<(string TagId, string Text)>();
|
||||||
foreach (var t in draft.Tags)
|
foreach (var t in draft.Tags)
|
||||||
{
|
{
|
||||||
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
|
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
|
||||||
if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) 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}"));
|
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
|
||||||
}
|
}
|
||||||
if (tagConfigWarnings.Count > 0)
|
if (tagConfigWarnings.Count > 0)
|
||||||
|
|||||||
+42
@@ -1,6 +1,7 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
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.Calculation;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
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>();
|
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>
|
/// <summary>Whether the given driver type has a mapped inspector.</summary>
|
||||||
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
|
/// <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>
|
/// <returns><see langword="true"/> when an inspector is registered for the driver type.</returns>
|
||||||
|
|||||||
+320
@@ -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, "X", true)]
|
||||||
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
|
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
|
||||||
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
|
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
|
||||||
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
|
[InlineData(FocasCncSeries.Sixteen_i, "F", true)] // #265: F/G handshakes are documented on 16i ladders
|
||||||
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
|
[InlineData(FocasCncSeries.Sixteen_i, "G", true)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, "M", false)]
|
||||||
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
|
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
|
||||||
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
|
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
|
||||||
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 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)
|
public void A_browse_committed_config_passes_the_gate(string driverType)
|
||||||
{
|
{
|
||||||
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
|
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
|
||||||
driverType, "SomeAddress", BrowseFieldsFor(driverType));
|
driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType));
|
||||||
|
|
||||||
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
|
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
|
||||||
$"browse-commit for {driverType} must not author a config the import refuses");
|
$"browse-commit for {driverType} must not author a config the import refuses");
|
||||||
|
|||||||
Reference in New Issue
Block a user