diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 133420c..e1935e6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -130,28 +130,27 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, var allTags = new List(_options.Tags); foreach (var import in _options.L5kImports) { - if (string.IsNullOrWhiteSpace(import.DeviceHostAddress)) - throw new InvalidOperationException( - "AbCip L5K import is missing DeviceHostAddress — every imported tag needs a target device."); - IL5kSource? src = null; - if (!string.IsNullOrEmpty(import.FilePath)) - src = new FileL5kSource(import.FilePath); - else if (!string.IsNullOrEmpty(import.InlineText)) - src = new StringL5kSource(import.InlineText); - if (src is null) continue; - var doc = L5kParser.Parse(src); - var ingest = new L5kIngest - { - DefaultDeviceHostAddress = import.DeviceHostAddress, - NamePrefix = import.NamePrefix, - }; - var result = ingest.Ingest(doc); - foreach (var importedTag in result.Tags) - { - if (declaredNames.Contains(importedTag.Name)) continue; - allTags.Add(importedTag); - declaredNames.Add(importedTag.Name); - } + MergeImport( + deviceHost: import.DeviceHostAddress, + filePath: import.FilePath, + inlineText: import.InlineText, + namePrefix: import.NamePrefix, + parse: L5kParser.Parse, + formatLabel: "L5K", + declaredNames: declaredNames, + allTags: allTags); + } + foreach (var import in _options.L5xImports) + { + MergeImport( + deviceHost: import.DeviceHostAddress, + filePath: import.FilePath, + inlineText: import.InlineText, + namePrefix: import.NamePrefix, + parse: L5xParser.Parse, + formatLabel: "L5X", + declaredNames: declaredNames, + allTags: allTags); } foreach (var tag in allTags) @@ -194,6 +193,47 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, return Task.CompletedTask; } + /// + /// Shared L5K / L5X import path — keeps source-format selection (parser delegate) the + /// only behavioural axis between the two formats. Adds the parser's tags to + /// while skipping any name already covered by an earlier + /// declaration or import (declared > L5K > L5X precedence falls out from call order). + /// + private static void MergeImport( + string deviceHost, + string? filePath, + string? inlineText, + string namePrefix, + Func parse, + string formatLabel, + HashSet declaredNames, + List allTags) + { + if (string.IsNullOrWhiteSpace(deviceHost)) + throw new InvalidOperationException( + $"AbCip {formatLabel} import is missing DeviceHostAddress — every imported tag needs a target device."); + IL5kSource? src = null; + if (!string.IsNullOrEmpty(filePath)) + src = new FileL5kSource(filePath); + else if (!string.IsNullOrEmpty(inlineText)) + src = new StringL5kSource(inlineText); + if (src is null) return; + + var doc = parse(src); + var ingest = new L5kIngest + { + DefaultDeviceHostAddress = deviceHost, + NamePrefix = namePrefix, + }; + var result = ingest.Ingest(doc); + foreach (var importedTag in result.Tags) + { + if (declaredNames.Contains(importedTag.Name)) continue; + allTags.Add(importedTag); + declaredNames.Add(importedTag.Name); + } + } + public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs index 1cb5625..cba983d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs @@ -32,6 +32,16 @@ public sealed class AbCipDriverOptions /// public IReadOnlyList L5kImports { get; init; } = []; + /// + /// L5X (Studio 5000 XML controller export) imports merged into at + /// InitializeAsync. Same shape and merge semantics as — + /// the entries differ only in source format. Pre-declared entries win + /// on Name conflicts; entries already produced by also win + /// so an L5X re-export of the same controller doesn't double-emit. See + /// for the format-specific mechanics. + /// + public IReadOnlyList L5xImports { get; init; } = []; + /// Per-device probe settings. Falls back to defaults when omitted. public AbCipProbeOptions Probe { get; init; } = new(); @@ -150,6 +160,22 @@ public sealed record AbCipL5kImportOptions( string? InlineText = null, string NamePrefix = ""); +/// +/// One L5X-import entry. Mirrors field-for-field — the +/// two are kept as distinct types so configuration JSON makes the source format explicit +/// (an L5X file under an L5kImports entry would parse-fail confusingly otherwise). +/// +/// Target device HostAddress tags from this file are bound to. +/// On-disk path to a *.L5X XML export. Loaded eagerly at InitializeAsync. +/// Pre-loaded L5X body — used by tests + Admin UI uploads. +/// Optional prefix prepended to imported tag names to avoid collisions +/// when ingesting multiple files into one driver instance. +public sealed record AbCipL5xImportOptions( + string DeviceHostAddress, + string? FilePath = null, + string? InlineText = null, + string NamePrefix = ""); + /// Which AB PLC family the device is — selects the profile applied to connection params. public enum AbCipPlcFamily { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs new file mode 100644 index 0000000..6ae6e96 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs @@ -0,0 +1,211 @@ +using System.Globalization; +using System.Xml; +using System.Xml.XPath; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; + +/// +/// XML-format parser for Studio 5000 L5X controller exports. L5X is the XML sibling of L5K +/// and carries the same tag / datatype / program shape, plus richer metadata (notably the +/// AddOnInstructionDefinition catalogue and explicit TagType attributes). +/// +/// This parser produces the same bundle as +/// so consumes both formats interchangeably. +/// The two parsers share the post-parse downstream layer; the only difference is how the +/// bundle is materialized from the source bytes. +/// +/// +/// AOIs (AddOnInstructionDefinition) are surfaced as L5K-style UDT entries — their +/// parameters become rows so AOI-typed tags pick up a member layout +/// the same way UDT-typed tags do. Full Inputs/Outputs/InOut directional metadata + per-call +/// parameter scoping is deferred to PR 2.6 per plan; this PR keeps AOIs visible without +/// attempting to model their call semantics. +/// +/// +/// +/// Uses with an for read-only +/// traversal. L5X exports are typically <50 MB, so a single in-memory navigator beats +/// forward-only XmlReader on simplicity for the same throughput at this size class. +/// The parser is permissive about missing optional attributes — a real export always has +/// Name + DataType, but ExternalAccess defaults to Read/Write +/// when absent (matching Studio 5000's own default for new tags). +/// +public static class L5xParser +{ + public static L5kDocument Parse(IL5kSource source) + { + ArgumentNullException.ThrowIfNull(source); + var xml = source.ReadAll(); + + using var reader = XmlReader.Create( + new System.IO.StringReader(xml), + new XmlReaderSettings + { + // L5X exports never include a DOCTYPE, but disable DTD processing defensively. + DtdProcessing = DtdProcessing.Prohibit, + IgnoreWhitespace = true, + IgnoreComments = true, + }); + var doc = new XPathDocument(reader); + var nav = doc.CreateNavigator(); + + var tags = new List(); + var datatypes = new List(); + + // Controller-scope tags: /RSLogix5000Content/Controller/Tags/Tag + foreach (XPathNavigator tagNode in nav.Select("/RSLogix5000Content/Controller/Tags/Tag")) + { + var t = ReadTag(tagNode, programScope: null); + if (t is not null) tags.Add(t); + } + + // Program-scope tags: /RSLogix5000Content/Controller/Programs/Program/Tags/Tag + foreach (XPathNavigator programNode in nav.Select("/RSLogix5000Content/Controller/Programs/Program")) + { + var programName = programNode.GetAttribute("Name", string.Empty); + if (string.IsNullOrEmpty(programName)) continue; + foreach (XPathNavigator tagNode in programNode.Select("Tags/Tag")) + { + var t = ReadTag(tagNode, programName); + if (t is not null) tags.Add(t); + } + } + + // UDTs: /RSLogix5000Content/Controller/DataTypes/DataType + foreach (XPathNavigator dtNode in nav.Select("/RSLogix5000Content/Controller/DataTypes/DataType")) + { + var udt = ReadDataType(dtNode); + if (udt is not null) datatypes.Add(udt); + } + + // AOIs: surfaced as L5kDataType entries so AOI-typed tags pick up a member layout. + // Per the plan, full directional Input/Output/InOut modelling is deferred to PR 2.6. + foreach (XPathNavigator aoiNode in nav.Select("/RSLogix5000Content/Controller/AddOnInstructionDefinitions/AddOnInstructionDefinition")) + { + var aoi = ReadAddOnInstruction(aoiNode); + if (aoi is not null) datatypes.Add(aoi); + } + + return new L5kDocument(tags, datatypes); + } + + private static L5kTag? ReadTag(XPathNavigator tagNode, string? programScope) + { + var name = tagNode.GetAttribute("Name", string.Empty); + if (string.IsNullOrEmpty(name)) return null; + + var tagType = tagNode.GetAttribute("TagType", string.Empty); // Base | Alias | Produced | Consumed + var dataType = tagNode.GetAttribute("DataType", string.Empty); + var aliasFor = tagNode.GetAttribute("AliasFor", string.Empty); + var externalAccess = tagNode.GetAttribute("ExternalAccess", string.Empty); + + // Alias tags often omit DataType (it's inherited from the target). Surface them with + // an empty type — L5kIngest skips alias entries before TryMapAtomic ever sees the type. + if (string.IsNullOrEmpty(dataType) + && !string.Equals(tagType, "Alias", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Description child — L5X wraps description text in (sometimes inside CDATA). + string? description = null; + var descNode = tagNode.SelectSingleNode("Description"); + if (descNode is not null) + { + var raw = descNode.Value; + if (!string.IsNullOrEmpty(raw)) description = raw.Trim(); + } + + return new L5kTag( + Name: name, + DataType: string.IsNullOrEmpty(dataType) ? string.Empty : dataType, + ProgramScope: programScope, + ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess, + Description: description, + AliasFor: string.IsNullOrEmpty(aliasFor) ? null : aliasFor); + } + + private static L5kDataType? ReadDataType(XPathNavigator dtNode) + { + var name = dtNode.GetAttribute("Name", string.Empty); + if (string.IsNullOrEmpty(name)) return null; + + var members = new List(); + foreach (XPathNavigator memberNode in dtNode.Select("Members/Member")) + { + var m = ReadMember(memberNode); + if (m is not null) members.Add(m); + } + return new L5kDataType(name, members); + } + + private static L5kMember? ReadMember(XPathNavigator memberNode) + { + var name = memberNode.GetAttribute("Name", string.Empty); + if (string.IsNullOrEmpty(name)) return null; + + // Skip auto-inserted hidden host members for backing storage of BOOL packing — they're + // emitted by RSLogix as members named with the ZZZZZZZZZZ prefix and aren't useful to + // surface as OPC UA variables. + if (name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) return null; + + var dataType = memberNode.GetAttribute("DataType", string.Empty); + if (string.IsNullOrEmpty(dataType)) return null; + + var externalAccess = memberNode.GetAttribute("ExternalAccess", string.Empty); + + int? arrayDim = null; + var dimText = memberNode.GetAttribute("Dimension", string.Empty); + if (!string.IsNullOrEmpty(dimText) + && int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim) + && dim > 0) + { + arrayDim = dim; + } + + return new L5kMember( + Name: name, + DataType: dataType, + ArrayDim: arrayDim, + ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess); + } + + private static L5kDataType? ReadAddOnInstruction(XPathNavigator aoiNode) + { + var name = aoiNode.GetAttribute("Name", string.Empty); + if (string.IsNullOrEmpty(name)) return null; + + var members = new List(); + foreach (XPathNavigator paramNode in aoiNode.Select("Parameters/Parameter")) + { + var paramName = paramNode.GetAttribute("Name", string.Empty); + if (string.IsNullOrEmpty(paramName)) continue; + + // RSLogix marks the implicit EnableIn / EnableOut parameters as Hidden=true. + // Skip them — they aren't part of the AOI's user-facing surface. + var hidden = paramNode.GetAttribute("Hidden", string.Empty); + if (string.Equals(hidden, "true", StringComparison.OrdinalIgnoreCase)) continue; + + var dataType = paramNode.GetAttribute("DataType", string.Empty); + if (string.IsNullOrEmpty(dataType)) continue; + + var externalAccess = paramNode.GetAttribute("ExternalAccess", string.Empty); + + int? arrayDim = null; + var dimText = paramNode.GetAttribute("Dimension", string.Empty); + if (!string.IsNullOrEmpty(dimText) + && int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim) + && dim > 0) + { + arrayDim = dim; + } + + members.Add(new L5kMember( + Name: paramName, + DataType: dataType, + ArrayDim: arrayDim, + ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess)); + } + return new L5kDataType(name, members); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs new file mode 100644 index 0000000..f708ddf --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs @@ -0,0 +1,230 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +[Trait("Category", "Unit")] +public sealed class L5xParserTests +{ + [Fact] + public void Controller_scope_Tag_elements_parse_with_metadata() + { + const string body = """ + + + + + + + + + + + + """; + + var doc = L5xParser.Parse(new StringL5kSource(body)); + + doc.Tags.Count.ShouldBe(2); + doc.Tags[0].Name.ShouldBe("Motor1_Speed"); + doc.Tags[0].DataType.ShouldBe("DINT"); + doc.Tags[0].ExternalAccess.ShouldBe("Read/Write"); + doc.Tags[0].Description.ShouldBe("Motor 1 set point"); + doc.Tags[0].ProgramScope.ShouldBeNull(); + doc.Tags[0].AliasFor.ShouldBeNull(); + + doc.Tags[1].Name.ShouldBe("Tank_Level"); + doc.Tags[1].ExternalAccess.ShouldBe("Read Only"); + } + + [Fact] + public void Program_scope_Tag_elements_carry_program_name() + { + const string body = """ + + + + + + + + + + + + + + """; + + var doc = L5xParser.Parse(new StringL5kSource(body)); + + doc.Tags.Count.ShouldBe(2); + doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram"); + doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]); + } + + [Fact] + public void Alias_tag_carries_AliasFor_and_is_skipped_on_ingest() + { + const string body = """ + + + + + + + + + + """; + + var doc = L5xParser.Parse(new StringL5kSource(body)); + var alias = doc.Tags.Single(t => t.Name == "Aliased"); + alias.AliasFor.ShouldBe("Real"); + + var ingestResult = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc); + ingestResult.SkippedAliasCount.ShouldBe(1); + ingestResult.Tags.ShouldAllBe(t => t.Name != "Aliased"); + } + + [Fact] + public void DataType_block_collects_member_elements_and_skips_hidden_zzzz_host() + { + const string body = """ + + + + + + + + + + + + """; + + var doc = L5xParser.Parse(new StringL5kSource(body)); + + doc.DataTypes.Count.ShouldBe(1); + var udt = doc.DataTypes[0]; + udt.Name.ShouldBe("TankUDT"); + udt.Members.Count.ShouldBe(2); + udt.Members.ShouldContain(m => m.Name == "Level" && m.DataType == "REAL"); + udt.Members.ShouldContain(m => m.Name == "Active"); + } + + [Fact] + public void UDT_typed_tag_picks_up_member_layout_through_ingest() + { + const string body = """ + + + + + + + + + + + + + + + + + """; + + var doc = L5xParser.Parse(new StringL5kSource(body)); + var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc); + + var tag = result.Tags.Single(); + tag.Name.ShouldBe("Tank1"); + tag.DataType.ShouldBe(AbCipDataType.Structure); + tag.Members.ShouldNotBeNull(); + tag.Members!.Count.ShouldBe(2); + tag.Members.Select(m => m.Name).ShouldBe(["Level", "Pressure"]); + } + + [Fact] + public void AOI_definition_surfaces_as_datatype_with_visible_parameters() + { + // EnableIn / EnableOut on real exports carry Hidden="true" — the parser must skip those + // so AOI-typed tags don't end up with phantom EnableIn/EnableOut members. + const string body = """ + + + + + + + + + + + + + + + """; + + var doc = L5xParser.Parse(new StringL5kSource(body)); + // AOI definition should appear as a "DataType" entry alongside any UDTs. + var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi"); + aoi.Members.Count.ShouldBe(2); + aoi.Members.Select(m => m.Name).ShouldBe(["Cmd", "Status"]); + + var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc); + var tag = result.Tags.Single(); + tag.Name.ShouldBe("Valve_001"); + tag.DataType.ShouldBe(AbCipDataType.Structure); + tag.Members.ShouldNotBeNull(); + tag.Members!.Select(m => m.Name).ShouldBe(["Cmd", "Status"]); + } + + [Fact] + public void Empty_or_minimal_document_returns_empty_bundle_without_throwing() + { + const string body = """ + + + + + """; + + var doc = L5xParser.Parse(new StringL5kSource(body)); + doc.Tags.Count.ShouldBe(0); + doc.DataTypes.Count.ShouldBe(0); + } + + [Fact] + public void Missing_external_access_defaults_to_writable_through_ingest() + { + // L5X: ExternalAccess attribute absent → ingest treats as default (writable, not skipped). + const string body = """ + + + + + + + + + """; + + var doc = L5xParser.Parse(new StringL5kSource(body)); + var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc); + + result.Tags.Single().Writable.ShouldBeTrue(); + result.SkippedNoAccessCount.ShouldBe(0); + } +}