feat(galaxy.browser): add lazy browse session with attribute fetch
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy browse over the gateway's <see cref="GalaxyRepositoryClient"/>.
|
||||
/// The gateway returns the deployed hierarchy as a flat <see cref="GalaxyObject"/>
|
||||
/// list, so this session fetches the full set once on <see cref="RootAsync"/>,
|
||||
/// caches it by <c>TagName</c> (and <c>GobjectId</c> for parent lookup), and serves
|
||||
/// subsequent <see cref="ExpandAsync"/> calls in-memory. Attribute fetches are
|
||||
/// per-object via <c>DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true)</c>.
|
||||
/// Owns the supplied <see cref="MxGatewaySession"/> and disposes it best-effort.
|
||||
/// </summary>
|
||||
internal sealed class GalaxyBrowseSession : IBrowseSession
|
||||
{
|
||||
private readonly MxGatewaySession _session;
|
||||
private readonly GalaxyRepositoryClient _client;
|
||||
private readonly ConcurrentDictionary<string, GalaxyObject> _byTagName = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<int, GalaxyObject> _byGobjectId = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Wall-clock time of the most recent successful Root/Expand/Attributes call.</summary>
|
||||
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new session wrapping a connected gateway client. The factory
|
||||
/// in <c>GalaxyDriverBrowser</c> (Task 9) constructs both the session and the
|
||||
/// repository client and hands them off here for the session's lifetime.
|
||||
/// </summary>
|
||||
/// <param name="session">Gateway session to dispose when the browse closes.</param>
|
||||
/// <param name="client">Galaxy repository client to query for hierarchy and attributes.</param>
|
||||
internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BrowseNode>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the direct children of the cached Galaxy object identified by
|
||||
/// <paramref name="nodeId"/> (the object's <c>TagName</c>). Throws
|
||||
/// <see cref="ArgumentException"/> if the tag hasn't been handed out by a
|
||||
/// prior Root/Expand call (i.e. it's not in the cache).
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<BrowseNode>> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the attributes of the Galaxy object identified by <paramref name="nodeId"/>
|
||||
/// via <c>DiscoverHierarchyAsync(MaxDepth=0, RootTagName=nodeId, IncludeAttributes=true)</c>.
|
||||
/// Returns an empty list if the gateway has no matching object.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<AttributeInfo>> 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<AttributeInfo>();
|
||||
|
||||
var result = new List<AttributeInfo>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects <see cref="GalaxyObject"/>s to <see cref="BrowseNode"/>s, ensuring
|
||||
/// every projected object is also written to the by-tag cache so later
|
||||
/// <see cref="ExpandAsync"/> calls can find it. Galaxy nodes are always
|
||||
/// <see cref="BrowseNodeKind.Folder"/> — leaves only appear in the attribute
|
||||
/// side-panel, never in the tree.
|
||||
/// </summary>
|
||||
private IReadOnlyList<BrowseNode> Project(IReadOnlyList<GalaxyObject> nodes)
|
||||
{
|
||||
var result = new List<BrowseNode>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Unknown(N)</c>.
|
||||
/// </summary>
|
||||
private static string MapSecurityClass(int raw) => raw switch
|
||||
{
|
||||
0 => "FreeAccess",
|
||||
1 => "Operate",
|
||||
2 => "Tune",
|
||||
3 => "Configure",
|
||||
4 => "ViewOnly",
|
||||
_ => $"Unknown({raw})",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently tears down the underlying gateway session. Swallows exceptions
|
||||
/// on shutdown — the registry's reaper may be racing a client-initiated close.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user