diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index 9d088d87..09b46101 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,42 @@ this is an inherent limitation of any software-level RMW. Input-image (`I`) bit writes are forwarded to the PLC as-is; the device drives the input image from physical inputs and may reject the write. +### Family-specific address forms (#244 / #245 / #247) + +`AbLegacyAddress.TryParse(text, family)` is family-aware; the driver passes the device's `PlcFamily` +(the one-argument overload is the family-neutral grammar: decimal, no function files). + +- **PLC-5 octal I/O (#244).** RSLogix 5 shows `I:`/`O:` word and bit indices in octal, so `I:017/07` is word 15, + bit 7 and `I:017/17` is bit 15. Only `I`/`O` on PLC-5 (`OctalIoAddressing` on the family profile) — SLC, + MicroLogix and Logix-PCCC stay decimal, and PLC-5 `N`/`B`/... files stay decimal. The record stores decimal and + `ToLibplctagName()` **emits decimal**, so the libplctag `name=` string and the string the native transport + re-parses carry identical numbers. This is not a double conversion: libplctag's `pccc.c` + (`parse_pccc_elem_num` / `parse_pccc_bit_num`) parses decimal numerals only (a FIXME there says octal is + unsupported), so the driver must convert. Pinned by `Plc5_io_indices_are_octal` (`I:017/17` -> `I:15/15`). + The connectivity-probe address is normalised the same way. + - ⚠️ **Behaviour change / migration.** Before this change a PLC-5 `I:`/`O:` address was passed to libplctag as + written, i.e. read as **decimal**. An address copied from RSLogix in octal (e.g. `I:017`) therefore read the + wrong word, and an authored decimal such as `I:1/12` now means bit 10 (octal 12). A previously "working" + address containing the digits 8 or 9 (`I:018`, `O:1/9`) is no longer valid on PLC-5. Re-author any PLC-5 + I/O tags in RSLogix octal. Such a tag is refused (see below), never silently misread. + - **A malformed / family-invalid address** (also: a function-file letter off MicroLogix) reads and writes + `BadNodeIdUnknown` for that tag only, creates no runtime, leaves driver health alone, and logs **one** + Warning per tag. The same check (`AbLegacyAddress.Validate`) runs in the AdminUI tag editor (family-independent: + rejects only what *no* family accepts) and the deploy gate (`EquipmentTagConfigInspector`, using the device's + `DeviceConfig.PlcFamily`, absent = SLC 500, unrecognised = unknown) — so `I:018` on a PLC-5 device warns at + deploy (an error under `TagConfigValidationMode.Error`), and the editor is never stricter than the gate. +- **MicroLogix function files (#245).** `RTC`, `HSC`, `DLS`, `MMI`, `PTO`, `PWM`, `STI`, `EII`, `IOS`, `BHI` + (single fixed instance — no file number — with a `.SUBELEMENT`; no bit index) parse on MicroLogix devices only. + `AbLegacyFunctionFile.SubElementType` carries the sub-element catalogue (flags → Boolean, words → Int32; unknown + members default to Int32). ⚠️ **Wire-unverified on both transports:** there is no capture and no simulator + coverage; the libplctag path passes the name through and the native transport has no file-type code for them, so + it refuses the tag as `Unsupported` (`BadNotSupported` to the client; see Wire evidence). +- **Indirect addressing (#247).** `N7:[N7:0]` (word from another address) and `N[N7:0]:5` (file from another + address) parse, one level deep (the inner address must be direct, and carries the family's octal rule). + **Runtime resolution is not implemented** (it needs a pre-read of the index word and per-resolved-address + runtimes): reads and writes of an indirect tag answer `BadNotSupported` before any runtime is created, on both + transports. + ## Configuration `AbLegacyDriverOptions` (`Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`) @@ -172,6 +211,7 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h | N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) | | F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) | | S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table | +| MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O, indirect addressing | **none** — parsed only; native refuses function files and indirect names as Unsupported | | T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** | ### Native known gaps 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/AbLegacyAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyAddress.cs new file mode 100644 index 00000000..aac67c41 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyAddress.cs @@ -0,0 +1,311 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Parsed PCCC file-based address: file letter + file number + word number, optionally a +/// sub-element (.ACC on a timer) or bit index (/0 on a bit file). +/// +/// +/// Logix symbolic tags are parsed elsewhere ( is for SLC / PLC-5 / +/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number). +/// +/// N7:0 — integer file 7, word 0 (signed 16-bit). +/// N7:5 — integer file 7, word 5. +/// F8:0 — float file 8, word 0 (32-bit IEEE754). +/// B3:0/0 — bit file 3, word 0, bit 0. +/// ST9:0 — string file 9, string 0 (82-byte fixed-length + length word). +/// T4:0.ACC — timer file 4, timer 0, accumulator sub-element. +/// C5:0.PRE — counter file 5, counter 0, preset sub-element. +/// I:0/0 — input file, slot 0, bit 0 (no file-number for I/O). +/// 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. +/// +/// 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, + AbLegacyAddress? IndirectFileSource = null, + AbLegacyAddress? IndirectWordSource = null) +{ + /// + /// 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). This is correct, not a + /// double conversion: libplctag's pccc.c (parse_pccc_elem_num / parse_pccc_bit_num) 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 + /// first. + /// + /// The libplctag name=... attribute value for this address. + 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; + } + + /// 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) => 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 profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value); + return ParseCore(value.Trim(), profile, allowIndirect: true); + } + + /// + /// The single authoring / deploy-gate / runtime address check. With a known the + /// address must parse for that family; with an unknown one () it must parse for at + /// least one family, so a caller that cannot see the device never rejects what the runtime would accept. + /// + /// The authored address text. + /// The device's PLC family, or when it is not known. + /// An error message, or when the address is acceptable. + 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()) + 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); + } + + /// 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; + } + + /// + /// Highest valid bit index for a file letter, or -1 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. + /// + 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, + }; + + /// + /// MicroLogix 1100/1400 function-file prefixes — each a single fixed instance with a sub-element + /// catalogue (AbLegacyFunctionFile). + /// + 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, + }; + + /// + /// Returns for file letters that carry no explicit file number in the + /// PCCC spec. I (input), O (output), and S (status) are single-letter + /// system files; a digit after the letter (e.g. I3) is a malformed address. + /// + private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S"; + + /// + /// Returns for file letters that may carry a sub-element suffix + /// (.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" || IsFunctionFileLetter(letter); +} 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.Contracts/AbLegacyTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs index 79b48a1b..1bbfa9af 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; using System.Text.Json; using System.Text.Json.Nodes; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -122,6 +123,12 @@ public static class AbLegacyTagDefinitionFactory /// The RawPath handed to during inspection — the mapper only copies it /// onto and never validates it. /// + private static bool IsIndirectFor(string address, AbLegacyPlcFamily? family) + { + if (family is { } f) return AbLegacyAddress.TryParse(address, f)?.IsIndirect == true; + return Enum.GetValues().Any(c => AbLegacyAddress.TryParse(address, c)?.IsIndirect == true); + } + private const string InspectionRawPath = "(inspect)"; /// @@ -145,7 +152,18 @@ public static class AbLegacyTagDefinitionFactory /// /// The equipment tag's TagConfig JSON. /// The warnings; empty when clean. - public static IReadOnlyList Inspect(string reference) + public static IReadOnlyList Inspect(string reference) => Inspect(reference, family: null); + + /// + /// plus the family-aware address check (, + /// the same authority the driver runtime uses). is the tag's device's PLC family, or + /// when unknown (then only an address no family accepts is flagged). An indirect + /// address parses but the runtime always answers it BadNotSupported, so it is warned about too. + /// + /// The equipment tag's TagConfig JSON. + /// The owning device's PLC family, when known. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference, AbLegacyPlcFamily? family) { var warnings = new List(); // Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to @@ -157,7 +175,14 @@ public static class AbLegacyTagDefinitionFactory // The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the // mapper — the driver reads the stored blob, not a trimmed copy of it. - if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings; + if (FromTagConfig(reference, InspectionRawPath, out var accepted)) + { + if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError) + warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown."); + else if (IsIndirectFor(accepted.Address, family)) + warnings.Add($"AbLegacy address '{accepted.Address}' is indirect; indirect addressing is not supported and the tag reads BadNotSupported."); + return warnings; + } try { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs deleted file mode 100644 index 4ff25df0..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs +++ /dev/null @@ -1,157 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; - -/// -/// Parsed PCCC file-based address: file letter + file number + word number, optionally a -/// sub-element (.ACC on a timer) or bit index (/0 on a bit file). -/// -/// -/// Logix symbolic tags are parsed elsewhere ( is for SLC / PLC-5 / -/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number). -/// -/// N7:0 — integer file 7, word 0 (signed 16-bit). -/// N7:5 — integer file 7, word 5. -/// F8:0 — float file 8, word 0 (32-bit IEEE754). -/// B3:0/0 — bit file 3, word 0, bit 0. -/// ST9:0 — string file 9, string 0 (82-byte fixed-length + length word). -/// T4:0.ACC — timer file 4, timer 0, accumulator sub-element. -/// C5:0.PRE — counter file 5, counter 0, preset sub-element. -/// I:0/0 — input file, slot 0, bit 0 (no file-number for I/O). -/// 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). -/// -/// 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). -/// -public sealed record AbLegacyAddress( - string FileLetter, - int? FileNumber, - int WordNumber, - int? BitIndex, - string? SubElement) -{ - /// Converts the address to the libplctag library address format. - /// The libplctag name=... attribute value for this address. - 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; - } - - /// Attempts to parse a string into an AB legacy address. - /// The address string to parse. - /// The parsed address, or when is not a valid PCCC address. - 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); - } - - /// - /// Highest valid bit index for a file letter, or -1 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. - /// - 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, - }; - - /// - /// Returns for file letters that carry no explicit file number in the - /// PCCC spec. I (input), O (output), and S (status) are single-letter - /// system files; a digit after the letter (e.g. I3) is a malformed address. - /// - private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S"; - - /// - /// 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. - /// - private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R"; -} 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 e23eb5d1..f1ea75e7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -388,6 +388,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover try { + // Indirect addresses (N7:[N7:0]) parse but are not resolved at runtime (#247) — answer the + // refusal before a runtime is created for a name neither transport can encode. + var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); + if (preParsed is null) + { + WarnMalformedOnce(device, def); + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + if (preParsed.IsIndirect) + { + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); + continue; + } + var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); int status; @@ -401,7 +416,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { await runtime.ReadAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); - var parsed = AbLegacyAddress.TryParse(def.Address); + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); if (status != 0) value = null; else if (IsArrayTag(def)) @@ -488,7 +503,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover try { - var parsed = AbLegacyAddress.TryParse(def.Address); + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); + + // A malformed address is a config error, not a comms fault (BadNodeIdUnknown, warned once per tag). + if (parsed is null) + { + WarnMalformedOnce(device, def); + results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); + continue; + } + + // Indirect addressing is parse-only (#247): refuse before any runtime or RMW read is created. + if (parsed.IsIndirect) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); + continue; + } // A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN, // or any PLC-5 write) is answered BEFORE a runtime is created. Creating one first costs an init @@ -643,7 +673,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover Port: state.ParsedAddress.Port, CipPath: state.EffectiveCipPath, LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, - TagName: _options.Probe.ProbeAddress!, + // Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through + // so the transport reports the failure as it always did. + TagName: AbLegacyAddress.TryParse(_options.Probe.ProbeAddress, state.Options.PlcFamily)?.ToLibplctagName() + ?? _options.Probe.ProbeAddress!, Timeout: _options.Probe.Timeout); IAbLegacyTagRuntime? probeRuntime = null; @@ -891,6 +924,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover keep(); } + /// + /// 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 BadNodeIdUnknown with the + /// handle/health untouched, and warned once per tag so a polled tag does not flood the log. + /// + 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); + } + /// 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. private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) => @@ -916,9 +962,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing; - var parsed = AbLegacyAddress.TryParse(def.Address) + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily) ?? throw new InvalidOperationException( - $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); + $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}."); var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed)); await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct) @@ -1123,6 +1169,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public bool FirstNonZeroStatusLogged { get; set; } + /// Tags already warned about for a malformed address (once-per-tag log). + public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new(); + /// /// Disposes all cached tag runtimes. /// 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/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs index 06d9d5c6..948438cb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs @@ -68,8 +68,11 @@ public sealed class AbLegacyTagConfigModel if (TagConfigJson.DescribeInvalidEnum(_bag, "dataType") is { } dtError) return dtError; - if (string.IsNullOrWhiteSpace(Address)) - return "Address is required."; + // The editor cannot see the device's PLC family, so it checks only what no family accepts (the same + // AbLegacyAddress authority the deploy gate and runtime use) — never stricter than the gate, which adds the + // family-specific verdict (PLC-5 octal I/O, MicroLogix-only function files). + if (AbLegacyAddress.Validate(Address, family: null) is { } addressError) + return addressError; // DELIBERATELY STRICTER THAN THE RUNTIME, and re-confirmed as such by the Gitea #554 survey — do not // "align" it. PCCC data-file elements are limited to AbLegacyArray.MaxElements (256). The reader diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index 7c5112a6..afcf2e0a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -262,12 +262,16 @@ public sealed class AdminOperationsActor : ReceiveActor var driverByDevice = draft.Devices .GroupBy(d => d.DeviceId, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal); + var deviceConfigById = draft.Devices + .GroupBy(d => d.DeviceId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().DeviceConfig, StringComparer.Ordinal); var tagConfigWarnings = new List<(string TagId, string Text)>(); foreach (var t in draft.Tags) { if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue; if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue; - foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig)) + foreach (var w in EquipmentTagConfigInspector.Inspect( + driverType, t.TagConfig, deviceConfigById.GetValueOrDefault(t.DeviceId))) tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}")); } if (tagConfigWarnings.Count > 0) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs index 57163a36..aa12b674 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs @@ -1,6 +1,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; using ZB.MOM.WW.OtOpcUa.Driver.Calculation; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; @@ -78,6 +79,47 @@ public static class EquipmentTagConfigInspector return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty(); } + /// + /// with the owning device's DeviceConfig, so a driver whose + /// address grammar depends on the device (AbLegacy: the PlcFamily) 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. + /// + /// The bound driver's DriverType. + /// The equipment tag's TagConfig JSON. + /// The owning device's DeviceConfig JSON (may be null). + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string? driverType, string? tagConfig, string? deviceConfig) + { + if (string.IsNullOrEmpty(driverType) || tagConfig is null) return Array.Empty(); + 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(raw, ignoreCase: true, out var f) && Enum.IsDefined(f) ? f : null; + } + return AbLegacyPlcFamily.Slc500; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } + /// Whether the given driver type has a mapped inspector. /// The bound driver's DriverType. /// when an inspector is registered for the driver type. 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..595b4797 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyFamilyAddressingTests.cs @@ -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; + +/// 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); + } + + private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger + { + public readonly System.Collections.Concurrent.ConcurrentQueue Warnings = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + public void Log(Microsoft.Extensions.Logging.LogLevel level, Microsoft.Extensions.Logging.EventId id, + TState state, Exception? ex, Func f) + { + if (level == Microsoft.Extensions.Logging.LogLevel.Warning) Warnings.Enqueue(f(state, ex)); + } + } + + /// + /// 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. + /// + [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); + } + + /// The connectivity-probe address is normalised like tag paths (PLC-5 octal I/O handed over decimal). + [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(); + } +} 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 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyFamilyAddressGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyFamilyAddressGateTests.cs new file mode 100644 index 00000000..b0404868 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyFamilyAddressGateTests.cs @@ -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; + +/// #244/#245/#247: one address authority (AbLegacyAddress.Validate) behind the editor and the deploy gate. +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(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs index 8acd8e63..65930cf0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs @@ -346,7 +346,7 @@ public sealed class RawTagConfigGateTests public void A_browse_committed_config_passes_the_gate(string driverType) { var tagConfig = RawBrowseCommitMapper.BuildTagConfig( - driverType, "SomeAddress", BrowseFieldsFor(driverType)); + driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType)); TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull( $"browse-commit for {driverType} must not author a config the import refuses");