174 lines
9.2 KiB
C#
174 lines
9.2 KiB
C#
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
|
|
|
/// <summary>
|
|
/// Translates a Galaxy object hierarchy (from <see cref="IGalaxyHierarchySource"/>) into
|
|
/// <see cref="IAddressSpaceBuilder"/> calls — folders for each gobject, variables for
|
|
/// each dynamic attribute. Alarm-bearing attributes get all five sub-attribute refs
|
|
/// populated via <see cref="AlarmRefBuilder"/> so the server-level alarm subsystem
|
|
/// (PR 2.2) can subscribe + ack without help from the driver.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Hierarchy materialisation rules (mirror legacy <c>MxAccessGalaxyBackend.DiscoverAsync</c>):
|
|
/// <list type="bullet">
|
|
/// <item>Browse name = <c>contained_name</c> when present; falls back to <c>tag_name</c>.</item>
|
|
/// <item>Folder per gobject; variables placed inside their owner folder.</item>
|
|
/// <item>Variable's full reference = <c>tag_name.attribute_name</c> — the format MXAccess
|
|
/// expects for read/write addressing (translated from the contained-name browse path).</item>
|
|
/// <item>Hierarchy is nested by <c>parent_gobject_id</c>: each gobject's folder is created
|
|
/// under the folder of the gobject named by its <c>parent_gobject_id</c> (resolved
|
|
/// order-independently and memoised, so the parent can appear before or after the child).
|
|
/// A gobject degrades to the driver root when its <c>parent_gobject_id</c> is <c>0</c>,
|
|
/// self-referential, or names a gobject not present in the returned set — so a model
|
|
/// that carries no parentage still renders flat. If a parent-reference cycle exists
|
|
/// (e.g. A→B→A), EVERY member of the cycle degrades to the root — none nests under
|
|
/// another cycle member — so materialisation stays finite and flat.</item>
|
|
/// </list>
|
|
/// </remarks>
|
|
public sealed class GalaxyDiscoverer
|
|
{
|
|
private readonly IGalaxyHierarchySource _source;
|
|
|
|
/// <summary>Initializes a new GalaxyDiscoverer with the specified hierarchy source.</summary>
|
|
/// <param name="source">The Galaxy hierarchy source to use for discovery.</param>
|
|
public GalaxyDiscoverer(IGalaxyHierarchySource source)
|
|
{
|
|
_source = source ?? throw new ArgumentNullException(nameof(source));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drive the supplied builder with one folder + N variables per Galaxy object the
|
|
/// gateway returns. Idempotent — caller can re-invoke after a redeploy event.
|
|
/// </summary>
|
|
/// <param name="builder">The address space builder to populate with discovery results.</param>
|
|
/// <param name="cancellationToken">The cancellation token for the operation.</param>
|
|
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<GalaxyObject>();
|
|
var byId = new Dictionary<int, GalaxyObject>();
|
|
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<int, IAddressSpaceBuilder>();
|
|
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, or absent from the returned set. Before nesting, the gobject's parent
|
|
// chain is walked once (IsRootedTo) to ensure it terminates at a real root — if the chain ever
|
|
// revisits a gobject (a parent-reference cycle), EVERY member of the cycle degrades to the root
|
|
// rather than the outermost member nesting under another cycle member. 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<int, GalaxyObject> byId,
|
|
Dictionary<int, IAddressSpaceBuilder> folders)
|
|
{
|
|
if (obj.GobjectId != 0 && folders.TryGetValue(obj.GobjectId, out var existing)) return existing;
|
|
|
|
var browseName = string.IsNullOrEmpty(obj.ContainedName) ? obj.TagName : obj.ContainedName;
|
|
|
|
IAddressSpaceBuilder parentBuilder = root;
|
|
if (obj.ParentGobjectId != 0
|
|
&& obj.ParentGobjectId != obj.GobjectId
|
|
&& byId.TryGetValue(obj.ParentGobjectId, out var parentObj)
|
|
&& IsRootedTo(obj, byId))
|
|
{
|
|
parentBuilder = EnsureFolder(parentObj, root, byId, folders);
|
|
}
|
|
|
|
var folder = parentBuilder.Folder(browseName, browseName);
|
|
if (obj.GobjectId != 0)
|
|
{
|
|
folders[obj.GobjectId] = folder;
|
|
}
|
|
return folder;
|
|
}
|
|
|
|
// Walk a gobject's parent chain to decide whether it can safely nest. Returns true when the chain
|
|
// terminates at a real root (parent == 0, self-parent, or a parent absent from the returned set);
|
|
// returns false when the walk revisits a gobject already on the chain — i.e. the gobject sits on a
|
|
// parent-reference cycle, so it (and every other member) must degrade to the driver root instead of
|
|
// nesting under another cycle member. The visited-set is seeded with the starting gobject so a
|
|
// mutual cycle (A→B→A) is detected the moment the walk loops back to it.
|
|
private static bool IsRootedTo(GalaxyObject obj, IReadOnlyDictionary<int, GalaxyObject> byId)
|
|
{
|
|
var visited = new HashSet<int> { obj.GobjectId };
|
|
var current = obj;
|
|
while (current.ParentGobjectId != 0
|
|
&& current.ParentGobjectId != current.GobjectId
|
|
&& byId.TryGetValue(current.ParentGobjectId, out var parent))
|
|
{
|
|
if (!visited.Add(current.ParentGobjectId)) return false; // revisited → cycle
|
|
current = parent;
|
|
}
|
|
return true; // reached a terminal root (parent 0 / self / absent)
|
|
}
|
|
|
|
// 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;
|
|
}
|