From d605d0b20d61f3d36ff1ec6ee2585e846ee2c420 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 15:42:19 -0400 Subject: [PATCH] feat(galaxy.browser): add lazy browse session with attribute fetch --- .../GalaxyBrowseSession.cs | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs new file mode 100644 index 00000000..6349a953 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs @@ -0,0 +1,191 @@ +using System.Collections.Concurrent; +using MxGateway.Client; +using MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; + +/// +/// Galaxy browse over the gateway's . +/// The gateway returns the deployed hierarchy as a flat +/// list, so this session fetches the full set once on , +/// caches it by TagName (and GobjectId for parent lookup), and serves +/// subsequent calls in-memory. Attribute fetches are +/// per-object via DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true). +/// Owns the supplied and disposes it best-effort. +/// +internal sealed class GalaxyBrowseSession : IBrowseSession +{ + private readonly MxGatewaySession _session; + private readonly GalaxyRepositoryClient _client; + private readonly ConcurrentDictionary _byTagName = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _byGobjectId = new(); + private bool _disposed; + + /// Opaque token identifying this session in the AdminUI registry. + public Guid Token { get; } = Guid.NewGuid(); + + /// Wall-clock time of the most recent successful Root/Expand/Attributes call. + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + /// + /// Initializes a new session wrapping a connected gateway client. The factory + /// in GalaxyDriverBrowser (Task 9) constructs both the session and the + /// repository client and hands them off here for the session's lifetime. + /// + /// Gateway session to dispose when the browse closes. + /// Galaxy repository client to query for hierarchy and attributes. + internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + /// + /// Fetches the full Galaxy hierarchy from the gateway, populates the cache, + /// and returns the top-level objects (those with no parent in the deployed model). + /// + public async Task> RootAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var all = await _client.DiscoverHierarchyAsync( + new DiscoverHierarchyOptions { IncludeAttributes = false }, cancellationToken) + .ConfigureAwait(false); + + // Populate caches so later ExpandAsync calls can resolve children in-memory. + foreach (var obj in all) + { + _byTagName[obj.TagName] = obj; + _byGobjectId[obj.GobjectId] = obj; + } + + // Roots are objects whose parent isn't part of the returned set (typically + // ParentGobjectId == 0 for WinPlatforms / top-level Areas). + var roots = all + .Where(o => o.ParentGobjectId == 0 || !_byGobjectId.ContainsKey(o.ParentGobjectId)) + .ToList(); + + LastUsedUtc = DateTime.UtcNow; + return Project(roots); + } + + /// + /// Returns the direct children of the cached Galaxy object identified by + /// (the object's TagName). Throws + /// if the tag hasn't been handed out by a + /// prior Root/Expand call (i.e. it's not in the cache). + /// + public Task> ExpandAsync(string nodeId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_byTagName.TryGetValue(nodeId, out var parent)) + { + throw new ArgumentException( + $"Galaxy object '{nodeId}' is not in the current browse-session cache. " + + "Re-open the browser or expand its parent first.", nameof(nodeId)); + } + + var children = _byGobjectId.Values + .Where(o => o.ParentGobjectId == parent.GobjectId) + .ToList(); + + LastUsedUtc = DateTime.UtcNow; + return Task.FromResult(Project(children)); + } + + /// + /// Fetches the attributes of the Galaxy object identified by + /// via DiscoverHierarchyAsync(MaxDepth=0, RootTagName=nodeId, IncludeAttributes=true). + /// Returns an empty list if the gateway has no matching object. + /// + public async Task> AttributesAsync(string nodeId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var rows = await _client.DiscoverHierarchyAsync( + new DiscoverHierarchyOptions + { + RootTagName = nodeId, + MaxDepth = 0, + IncludeAttributes = true, + }, cancellationToken).ConfigureAwait(false); + + LastUsedUtc = DateTime.UtcNow; + var obj = rows.FirstOrDefault(); + if (obj is null) return Array.Empty(); + + var result = new List(obj.Attributes.Count); + foreach (var attr in obj.Attributes) + { + var driverType = !string.IsNullOrEmpty(attr.DataTypeName) + ? attr.DataTypeName + : attr.MxDataType.ToString(System.Globalization.CultureInfo.InvariantCulture); + result.Add(new AttributeInfo( + Name: attr.AttributeName, + DriverDataType: driverType, + IsArray: attr.IsArray, + SecurityClass: MapSecurityClass(attr.SecurityClassification))); + } + return result; + } + + /// + /// Projects s to s, ensuring + /// every projected object is also written to the by-tag cache so later + /// calls can find it. Galaxy nodes are always + /// — leaves only appear in the attribute + /// side-panel, never in the tree. + /// + private IReadOnlyList Project(IReadOnlyList nodes) + { + var result = new List(nodes.Count); + foreach (var obj in nodes) + { + // Belt-and-braces: ensure the cache holds every node we hand back so + // ExpandAsync can resolve it on the next round-trip. + _byTagName[obj.TagName] = obj; + _byGobjectId[obj.GobjectId] = obj; + + var displayName = !string.IsNullOrEmpty(obj.ContainedName) ? obj.ContainedName : obj.TagName; + var hasChildrenHint = _byGobjectId.Values.Any(o => o.ParentGobjectId == obj.GobjectId); + result.Add(new BrowseNode( + NodeId: obj.TagName, + DisplayName: displayName, + Kind: BrowseNodeKind.Folder, + HasChildrenHint: hasChildrenHint)); + } + return result; + } + + /// + /// Maps the Galaxy raw security-classification integer to a display string. + /// Buckets: 0=FreeAccess, 1=Operate, 2=Tune, 3=Configure, 4=ViewOnly; + /// anything else surfaces as Unknown(N). + /// + private static string MapSecurityClass(int raw) => raw switch + { + 0 => "FreeAccess", + 1 => "Operate", + 2 => "Tune", + 3 => "Configure", + 4 => "ViewOnly", + _ => $"Unknown({raw})", + }; + + /// + /// Idempotently tears down the underlying gateway session. Swallows exceptions + /// on shutdown — the registry's reaper may be racing a client-initiated close. + /// + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try + { + await _session.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Best-effort: a gateway-side close that hits a torn-down channel is normal. + } + } +}