using System.Collections.Concurrent; using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; /// /// Lazy Galaxy browse over . /// returns the top-level s /// directly from the gateway; fetches the direct children /// of a previously-handed-out node via /// (one wire call per click, paginated internally by the client). 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 GalaxyRepositoryClient _client; private readonly ConcurrentDictionary _byTagName = new(StringComparer.Ordinal); private readonly SemaphoreSlim _rootGate = new(1, 1); private volatile bool _disposed; private IReadOnlyList? _roots; /// 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 repository client. The factory /// in GalaxyDriverBrowser constructs the client via /// and hands it off here for the /// session's lifetime. /// /// Galaxy repository client to query for browse and attributes. internal GalaxyBrowseSession(GalaxyRepositoryClient client) { _client = client ?? throw new ArgumentNullException(nameof(client)); } /// /// Fetches the top-level s from the gateway and /// returns them as s. Result is cached; a second call /// returns the cached roots without a re-fetch. /// public async Task> RootAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); await _rootGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { _roots ??= await _client.BrowseAsync(new BrowseChildrenOptions(), cancellationToken) .ConfigureAwait(false); LastUsedUtc = DateTime.UtcNow; return Project(_roots); } finally { _rootGate.Release(); } } /// /// Fetches the direct children of the cached node identified by /// (the object's TagName) via /// . Throws /// if the tag hasn't been handed out by a prior Root/Expand call. /// public async Task> ExpandAsync(string nodeId, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); if (!_byTagName.TryGetValue(nodeId, out var node)) { 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)); } await node.ExpandAsync(cancellationToken).ConfigureAwait(false); LastUsedUtc = DateTime.UtcNow; return Project(node.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, caching /// each by TagName so a subsequent can locate /// it. Galaxy nodes are always — leaves only /// appear in the attribute side-panel. /// private IReadOnlyList Project(IReadOnlyList nodes) { var result = new List(nodes.Count); foreach (var n in nodes) { _byTagName[n.Object.TagName] = n; var displayName = !string.IsNullOrEmpty(n.Object.ContainedName) ? n.Object.ContainedName : n.Object.TagName; result.Add(new BrowseNode( NodeId: n.Object.TagName, DisplayName: displayName, Kind: BrowseNodeKind.Folder, HasChildrenHint: n.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 repository client. Swallows exceptions /// on shutdown — the registry's reaper may be racing a client-initiated close. /// public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; _rootGate.Dispose(); try { await _client.DisposeAsync().ConfigureAwait(false); } catch { // Best-effort: a gateway-side close that hits a torn-down channel is normal. } } }