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; /// public Guid Token { get; } = Guid.NewGuid(); /// 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)); } /// 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(); } } /// 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); } /// 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), IsAlarm: attr.IsAlarm)); } 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. /// Matches the SecurityClassification enum ordinals and the runtime /// SecurityMap in Driver.Galaxy: /// 0=FreeAccess, 1=Operate, 2=SecuredWrite, 3=VerifiedWrite, /// 4=Tune, 5=Configure, 6=ViewOnly; anything else surfaces as Unknown(N). /// /// The raw Galaxy security-classification integer. /// The display string for the classification, or Unknown(N) if unrecognized. // internal for unit-test access (InternalsVisibleTo on the src project). internal static string MapSecurityClass(int raw) => raw switch { 0 => "FreeAccess", 1 => "Operate", 2 => "SecuredWrite", 3 => "VerifiedWrite", 4 => "Tune", 5 => "Configure", 6 => "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. /// Both and the client dispose are wrapped /// in try/catch so a concurrent second dispose call never propagates. /// /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; try { _rootGate.Dispose(); } catch (ObjectDisposedException) { // Concurrent DisposeAsync caller already disposed the gate — safe to ignore. } try { await _client.DisposeAsync().ConfigureAwait(false); } catch { // Best-effort: a gateway-side close that hits a torn-down channel is normal. } } }