using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; /// /// Translates a Galaxy object hierarchy (from ) into /// calls — folders for each gobject, variables for /// each dynamic attribute. Alarm-bearing attributes get all five sub-attribute refs /// populated via so the server-level alarm subsystem /// (PR 2.2) can subscribe + ack without help from the driver. /// /// /// Hierarchy materialisation rules (mirror legacy MxAccessGalaxyBackend.DiscoverAsync): /// /// Browse name = contained_name when present; falls back to tag_name. /// Folder per gobject; variables placed inside their owner folder. /// Variable's full reference = tag_name.attribute_name — the format MXAccess /// expects for read/write addressing (translated from the contained-name browse path). /// Hierarchy is nested by parent_gobject_id: each gobject's folder is created /// under the folder of the gobject named by its parent_gobject_id (resolved /// order-independently and memoised, so the parent can appear before or after the child). /// A gobject degrades to the driver root when its parent_gobject_id is 0, /// self-referential, or names a gobject not present in the returned set — so a model /// that carries no parentage still renders flat. /// /// public sealed class GalaxyDiscoverer { private readonly IGalaxyHierarchySource _source; /// Initializes a new GalaxyDiscoverer with the specified hierarchy source. /// The Galaxy hierarchy source to use for discovery. public GalaxyDiscoverer(IGalaxyHierarchySource source) { _source = source ?? throw new ArgumentNullException(nameof(source)); } /// /// Drive the supplied builder with one folder + N variables per Galaxy object the /// gateway returns. Idempotent — caller can re-invoke after a redeploy event. /// /// The address space builder to populate with discovery results. /// The cancellation token for the operation. public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); var objects = await _source.GetHierarchyAsync(cancellationToken).ConfigureAwait(false); // Pass 1 — collect gobjects with a usable browse identity (order preserved), and index // them by gobject_id so pass 2 can resolve a parent the source returned AFTER the child // (order-independent). Only the FIRST gobject per non-zero id is indexed as a parent // target; the index excludes id 0 so identity-less / un-keyed objects can't collide. var usable = new List(); var byId = new Dictionary(); foreach (var obj in objects) { var browseName = string.IsNullOrEmpty(obj.ContainedName) ? obj.TagName : obj.ContainedName; if (string.IsNullOrEmpty(browseName)) continue; // skip objects with no usable identity usable.Add(obj); if (obj.GobjectId != 0) { byId.TryAdd(obj.GobjectId, obj); } } // Pass 2 — create each gobject's folder under its parent (memoised), then its variables. // Iterating the ordered list (not the index) keeps the flat case stable and emits a // folder even for objects whose gobject_id is 0 or duplicated. var folders = new Dictionary(); foreach (var obj in usable) { var folder = EnsureFolder(obj, builder, byId, folders); foreach (var attr in obj.Attributes) { if (string.IsNullOrEmpty(attr.AttributeName)) continue; var fullReference = !string.IsNullOrEmpty(attr.FullTagReference) ? StripArraySuffix(attr.FullTagReference) : obj.TagName + "." + attr.AttributeName; var info = new DriverAttributeInfo( FullName: fullReference, DriverDataType: DataTypeMap.Map(attr.MxDataType), IsArray: attr.IsArray, ArrayDim: attr.IsArray && attr.ArrayDimensionPresent && attr.ArrayDimension > 0 ? (uint)attr.ArrayDimension : null, SecurityClass: SecurityMap.Map(attr.SecurityClassification), IsHistorized: attr.IsHistorized, IsAlarm: attr.IsAlarm); var handle = folder.Variable(attr.AttributeName, attr.AttributeName, info); // Alarm-bearing attributes ship the full sub-attribute ref set so the server's // AlarmConditionService can subscribe + ack-write without re-deriving the names. if (attr.IsAlarm) { handle.MarkAsAlarmCondition(AlarmRefBuilder.Build(fullReference)); } } } } // Resolve (and memoise) the folder for a gobject, recursively creating its parent's folder // first so the result nests under it. Falls back to the driver root when the parent_gobject_id // is 0, self-referential, absent from the returned set, or already mid-resolution (cycle guard). // Memoisation is keyed by gobject_id and only applies to non-zero ids — an id-0 object always // gets its own freshly-created folder (it can be neither a parent target nor shared). private static IAddressSpaceBuilder EnsureFolder( GalaxyObject obj, IAddressSpaceBuilder root, IReadOnlyDictionary byId, Dictionary folders, HashSet? building = null) { if (obj.GobjectId != 0 && folders.TryGetValue(obj.GobjectId, out var existing)) return existing; var browseName = string.IsNullOrEmpty(obj.ContainedName) ? obj.TagName : obj.ContainedName; building ??= []; IAddressSpaceBuilder parentBuilder = root; if (obj.ParentGobjectId != 0 && obj.ParentGobjectId != obj.GobjectId && !building.Contains(obj.ParentGobjectId) && byId.TryGetValue(obj.ParentGobjectId, out var parentObj)) { building.Add(obj.GobjectId); parentBuilder = EnsureFolder(parentObj, root, byId, folders, building); building.Remove(obj.GobjectId); } var folder = parentBuilder.Folder(browseName, browseName); if (obj.GobjectId != 0) { folders[obj.GobjectId] = folder; } return folder; } // PR 5.W workaround for mxaccessgw GalaxyRepository.cs:173-175 — the gateway's // SQL appends `[]` to array-typed `full_tag_reference` values, but MxAccess COM // `IInstance.AddItem` doesn't accept `[]`-suffixed addresses (so any downstream // Subscribe/Read/Write through the worker would fail with the suffixed form). // Strip defensively here so the parity matrix can run today; remove once the // gw fix (mxaccessgw/requirements-array-suffix-fix.md) lands. private static string StripArraySuffix(string fullReference) => fullReference.EndsWith("[]", StringComparison.Ordinal) ? fullReference[..^2] : fullReference; }