Merge feat/universal-discovery-browser — Wave-0 universal Discover-backed browser
18 tasks / 9 batch commits. One generic DiscoveryDriverBrowser captures any discovery-capable driver's ITagDiscovery.DiscoverAsync into a browse tree and serves it via the existing picker plumbing, lighting up AbCip/TwinCAT/FOCAS pickers with zero per-driver browser code. Live-gated on docker-dev (binding + PatchForBrowse verified in prod; full tree-render fixture-blocked by ab_server's missing @tags walk). See docs/plans/2026-07-15-universal-discovery-browser-*.
This commit is contained in:
@@ -215,5 +215,9 @@ MELSEC is out of the sequence (deferred).
|
|||||||
|
|
||||||
## 9. Status
|
## 9. Status
|
||||||
|
|
||||||
Research: **done** (8 reports). Designs: **done** (7 design docs). Code: **not started.**
|
Research: **done** (8 reports). Designs: **done** (7 design docs). Code: **Wave 0 landed** —
|
||||||
Next action: execute Wave 0 (universal browser), then Wave 1.
|
the universal Discover-backed browser (P1) is code-complete and unit-tested on
|
||||||
|
`feat/universal-discovery-browser` (`CapturingAddressSpaceBuilder` + `CapturedTreeBrowseSession`
|
||||||
|
+ `DiscoveryDriverBrowser`/`IUniversalDriverBrowser` + `BrowserSessionService` fallback +
|
||||||
|
`ITagDiscovery.SupportsOnlineDiscovery` gate + AbCip/TwinCAT/FOCAS opt-ins & picker browse UI),
|
||||||
|
with the live `/run` gate outstanding. Next action: complete the Wave-0 live gate, then Wave 1.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Universal Discover-backed browser (`DiscoveryDriverBrowser`) — design
|
# Universal Discover-backed browser (`DiscoveryDriverBrowser`) — design
|
||||||
|
|
||||||
**Status:** draft, 2026-07-15. Foundational Wave-0 item of the next-driver roadmap
|
**Status:** P1 implemented 2026-07-15 (code-complete + unit-tested, live `/run` gate pending);
|
||||||
|
P2/P3 pending. Foundational Wave-0 item of the next-driver roadmap
|
||||||
(`docs/research/drivers/README.md`).
|
(`docs/research/drivers/README.md`).
|
||||||
|
|
||||||
## 1. Motivation
|
## 1. Motivation
|
||||||
|
|||||||
@@ -1083,6 +1083,40 @@ Identical shape (confirm DriverType string from the FOCAS `Register()`). Note in
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Live-gate result (Task 18 — docker-dev, 2026-07-15)
|
||||||
|
|
||||||
|
Executed against the local docker-dev rig (both centrals rebuilt from `feat/universal-discovery-browser`)
|
||||||
|
with the ControlLogix `ab_server` fixture up on `10.100.0.35:44818`. An AbCip driver (device
|
||||||
|
`ab://10.100.0.35:44818/1,0`, **`EnableControllerBrowse` deliberately left off**) was authored under an
|
||||||
|
Equipment-kind namespace and bound to an equipment; a tag was added on the Tags-tab TagModal.
|
||||||
|
|
||||||
|
- ✅ **Positive binding** — the AbCip address picker renders the **"Browse controller"** button. This
|
||||||
|
exercises the whole `CanBrowse` chain end-to-end: `SupportsOnlineDiscovery=true` → `BrowserSessionService.CanBrowse`
|
||||||
|
→ `DiscoveryDriverBrowser.CanBrowse` (constructs a throwaway driver with `PatchForBrowse`) → TagModal
|
||||||
|
`DriverType`/`GetDriverConfigJson` params → `AbCipTagConfigEditor` pass-through → `AbCipAddressPickerBody._canBrowse`.
|
||||||
|
- ✅ **Browse invocation runs in production** — clicking Browse drove the full capture path; central-2 logged
|
||||||
|
`BrowserSessionService.OpenUniversalAsync → DiscoveryDriverBrowser.OpenAsync → RunCaptureAsync → CaptureAsync
|
||||||
|
→ DiscoverTreeAsync`. Because the driver's authored config did **not** set `EnableControllerBrowse` yet the
|
||||||
|
capture attempted the controller `@tags` walk (which only runs when that flag is true), **`PatchForBrowse`
|
||||||
|
is demonstrably applied end-to-end**. The error surfaced as a graceful UI chip — no dead binding, no crash,
|
||||||
|
no hang; bounded shutdown ran.
|
||||||
|
- ✅ **Negative binding** — the Modbus picker shows only the manual Register/Offset/Length builder, **no Browse
|
||||||
|
button** (`SupportsOnlineDiscovery` false; picker body unchanged).
|
||||||
|
- ⚠️ **Not verifiable on this fixture** — a *populated* tree render + leaf-commit. The libplctag `ab_server`
|
||||||
|
test sim returns `ErrorUnsupported` for controller symbol enumeration (it implements tag read/write, not the
|
||||||
|
`@tags` Symbol Object walk). This is a **fixture limitation, not a code defect** — the binding is proven alive.
|
||||||
|
|
||||||
|
### Follow-ups discovered during the gate
|
||||||
|
|
||||||
|
1. **Full tree-render verification is fixture-blocked.** `ab_server` doesn't implement the `@tags` controller
|
||||||
|
walk. To live-verify a populated tree + `TagConfig.FullName` leaf commit, point the universal browser at a
|
||||||
|
symbol-enumeration-capable AB backend (a real ControlLogix, or a sim that serves `@tags`).
|
||||||
|
2. **Driver-page address pickers pass no live config.** `AbCipDriverPage`/`TwinCATDriverPage`/`FOCASDriverPage`
|
||||||
|
host the same `*AddressPickerBody` but do **not** pass `DriverType`/`GetConfigJson`, so their (now-visible)
|
||||||
|
Browse button would capture against `"{}"` (no device). Out of Task 14's scope (which listed only the
|
||||||
|
TagEditor pass-through); wire the live form config through there too, or hide the Browse affordance on the
|
||||||
|
driver-config picker.
|
||||||
|
|
||||||
## Out of scope (explicitly)
|
## Out of scope (explicitly)
|
||||||
|
|
||||||
- TwinCAT/FOCAS live-fixture verification (no shipped fixtures assumed reachable; AbCip is the gate).
|
- TwinCAT/FOCAS live-fixture verification (no shipped fixtures assumed reachable; AbCip is the gate).
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-07-15-universal-discovery-browser-implementation.md",
|
"planPath": "docs/plans/2026-07-15-universal-discovery-browser-implementation.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 0, "subject": "Task 0: Branch setup (feat/universal-discovery-browser)", "status": "pending"},
|
{"id": 0, "subject": "Task 0: Branch setup (feat/universal-discovery-browser)", "status": "completed"},
|
||||||
{"id": 1, "subject": "Task 1: ITagDiscovery.SupportsOnlineDiscovery default member", "status": "pending", "blockedBy": [0], "parallelWith": [2, 13]},
|
{"id": 1, "subject": "Task 1: ITagDiscovery.SupportsOnlineDiscovery default member", "status": "completed", "blockedBy": [0], "parallelWith": [2, 13]},
|
||||||
{"id": 2, "subject": "Task 2: Commons project references (Core.Abstractions)", "status": "pending", "blockedBy": [0], "parallelWith": [1, 13]},
|
{"id": 2, "subject": "Task 2: Commons project references (Core.Abstractions)", "status": "completed", "blockedBy": [0], "parallelWith": [1, 13]},
|
||||||
{"id": 3, "subject": "Task 3: AbCip opt-in SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [1], "parallelWith": [4, 5]},
|
{"id": 3, "subject": "Task 3: AbCip opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [4, 5]},
|
||||||
{"id": 4, "subject": "Task 4: TwinCAT opt-in SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [1], "parallelWith": [3, 5]},
|
{"id": 4, "subject": "Task 4: TwinCAT opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 5]},
|
||||||
{"id": 5, "subject": "Task 5: FOCAS opt-in SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [1], "parallelWith": [3, 4]},
|
{"id": 5, "subject": "Task 5: FOCAS opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 4]},
|
||||||
{"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "pending", "blockedBy": [1, 2]},
|
{"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "completed", "blockedBy": [1, 2]},
|
||||||
{"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "pending", "blockedBy": [6]},
|
{"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "completed", "blockedBy": [6]},
|
||||||
{"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "pending", "blockedBy": [7]},
|
{"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "pending", "blockedBy": [8], "parallelWith": [11]},
|
{"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "completed", "blockedBy": [8], "parallelWith": [11]},
|
||||||
{"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]},
|
{"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "completed", "blockedBy": [9], "parallelWith": [12]},
|
||||||
{"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "pending", "blockedBy": [8], "parallelWith": [9]},
|
{"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "completed", "blockedBy": [8], "parallelWith": [9]},
|
||||||
{"id": 12, "subject": "Task 12: DI registration in AddAdminUI", "status": "pending", "blockedBy": [11], "parallelWith": [10]},
|
{"id": 12, "subject": "Task 12: DI registration in AddAdminUI", "status": "completed", "blockedBy": [11], "parallelWith": [10]},
|
||||||
{"id": 13, "subject": "Task 13: TagModal -> editor parameter plumbing (DriverType + GetDriverConfigJson)", "status": "pending", "blockedBy": [0], "parallelWith": [1, 2]},
|
{"id": 13, "subject": "Task 13: TagModal -> editor parameter plumbing (DriverType + GetDriverConfigJson)", "status": "completed", "blockedBy": [0], "parallelWith": [1, 2]},
|
||||||
{"id": 14, "subject": "Task 14: AbCip picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 3], "parallelWith": [15, 16]},
|
{"id": 14, "subject": "Task 14: AbCip picker body universal browse mode", "status": "completed", "blockedBy": [10, 12, 13, 3], "parallelWith": [15, 16]},
|
||||||
{"id": 15, "subject": "Task 15: TwinCAT picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 4], "parallelWith": [14, 16]},
|
{"id": 15, "subject": "Task 15: TwinCAT picker body universal browse mode", "status": "completed", "blockedBy": [10, 12, 13, 4], "parallelWith": [14, 16]},
|
||||||
{"id": 16, "subject": "Task 16: FOCAS picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 5], "parallelWith": [14, 15]},
|
{"id": 16, "subject": "Task 16: FOCAS picker body universal browse mode", "status": "completed", "blockedBy": [10, 12, 13, 5], "parallelWith": [14, 15]},
|
||||||
{"id": 17, "subject": "Task 17: Full build/test sweep + doc status flip", "status": "pending", "blockedBy": [14, 15, 16]},
|
{"id": 17, "subject": "Task 17: Full build/test sweep + doc status flip", "status": "completed", "blockedBy": [14, 15, 16]},
|
||||||
{"id": 18, "subject": "Task 18: Live /run verify on docker-dev (GATE)", "status": "pending", "blockedBy": [17]}
|
{"id": 18, "subject": "Task 18: Live /run verify on docker-dev (GATE)", "status": "completed", "blockedBy": [17]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-15T17:30:00Z"
|
"lastUpdated": "2026-07-15T17:30:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
|
||||||
|
/// <summary>One node captured from a driver's DiscoverAsync stream. Folder ids are
|
||||||
|
/// path-composed ("/parent/child"); leaf ids are the driver FullName (the commit value).</summary>
|
||||||
|
public sealed class CapturedNode
|
||||||
|
{
|
||||||
|
/// <summary>Stable identifier. Folder = path-composed ("/parent/child"); leaf = driver FullName.</summary>
|
||||||
|
public required string Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Browse name (the path segment under the parent).</summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable display name.</summary>
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True for a variable (terminal); false for a folder.</summary>
|
||||||
|
public required bool IsLeaf { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Driver-side attribute metadata; set only on leaves.</summary>
|
||||||
|
public DriverAttributeInfo? Attribute { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True when the driver marked this leaf as an alarm condition during discovery.</summary>
|
||||||
|
public bool IsAlarmMarked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Captured child nodes (folders first-or-interleaved in stream order).</summary>
|
||||||
|
public List<CapturedNode> Children { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Captured static properties attached to this node via AddProperty.</summary>
|
||||||
|
public List<(string Name, DriverDataType Type, object? Value)> Properties { get; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Immutable-after-Build snapshot of a whole discovery capture. May be shared by
|
||||||
|
/// multiple coalesced browse sessions — sessions drop their reference on dispose, never mutate.</summary>
|
||||||
|
public sealed class CapturedTree
|
||||||
|
{
|
||||||
|
/// <summary>Synthetic root; its Children are the top-level captured nodes.</summary>
|
||||||
|
public required CapturedNode Root { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True when the node cap was hit and recording stopped short.</summary>
|
||||||
|
public required bool Truncated { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Number of nodes actually recorded (bounded by the node cap).</summary>
|
||||||
|
public required int Count { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Flat lookup from node id to node (for O(1) Expand/Attributes serving).</summary>
|
||||||
|
public required IReadOnlyDictionary<string, CapturedNode> ById { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves a captured discovery snapshot through the standard picker session interface.
|
||||||
|
/// No I/O after open; the tree is point-in-time (picker offers Refresh = close + re-open).
|
||||||
|
/// The tree may be shared by coalesced sessions — dispose drops only this session's
|
||||||
|
/// reference. See design §4.3.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSession
|
||||||
|
{
|
||||||
|
private CapturedTree? _tree = tree;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid Token { get; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var t = Tree();
|
||||||
|
var nodes = t.Root.Children.Select(Map).ToList();
|
||||||
|
if (t.Truncated)
|
||||||
|
nodes.Add(new BrowseNode("__truncated__",
|
||||||
|
"⚠ Results truncated — narrow the driver config or use manual entry",
|
||||||
|
BrowseNodeKind.Folder, HasChildrenHint: false));
|
||||||
|
LastUsedUtc = DateTime.UtcNow;
|
||||||
|
return Task.FromResult<IReadOnlyList<BrowseNode>>(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var t = Tree();
|
||||||
|
LastUsedUtc = DateTime.UtcNow;
|
||||||
|
return Task.FromResult<IReadOnlyList<BrowseNode>>(
|
||||||
|
t.ById.TryGetValue(nodeId, out var n) ? n.Children.Select(Map).ToList() : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var t = Tree();
|
||||||
|
LastUsedUtc = DateTime.UtcNow;
|
||||||
|
if (!t.ById.TryGetValue(nodeId, out var n))
|
||||||
|
return Task.FromResult<IReadOnlyList<AttributeInfo>>([]);
|
||||||
|
var attrs = new List<AttributeInfo>();
|
||||||
|
if (n.Attribute is { } a)
|
||||||
|
attrs.Add(new AttributeInfo(n.Name, a.DriverDataType.ToString(), a.IsArray,
|
||||||
|
a.SecurityClass.ToString(), a.IsAlarm || n.IsAlarmMarked));
|
||||||
|
attrs.AddRange(n.Properties.Select(p =>
|
||||||
|
new AttributeInfo(p.Name, p.Type.ToString(), false, "", false)));
|
||||||
|
return Task.FromResult<IReadOnlyList<AttributeInfo>>(attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_tree = null; // drop this session's reference only — tree may be shared (coalescing)
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CapturedTree Tree() => _tree ?? throw new ObjectDisposedException(nameof(CapturedTreeBrowseSession));
|
||||||
|
|
||||||
|
private static BrowseNode Map(CapturedNode n) => n.IsLeaf
|
||||||
|
? new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Leaf, false)
|
||||||
|
: new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Folder, n.Children.Count > 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory <see cref="IAddressSpaceBuilder"/> that records the streamed discovery tree
|
||||||
|
/// for the universal browser instead of materializing OPC UA nodes. Single-threaded like
|
||||||
|
/// every builder a driver streams into during DiscoverAsync. Enforces a node cap: once
|
||||||
|
/// exceeded it stops recording (drivers keep streaming harmlessly) and marks the tree
|
||||||
|
/// truncated. See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.1.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
private sealed class CaptureState(int nodeCap)
|
||||||
|
{
|
||||||
|
public int Count;
|
||||||
|
public bool Truncated;
|
||||||
|
public readonly int NodeCap = nodeCap;
|
||||||
|
public readonly Dictionary<string, CapturedNode> ById = new(StringComparer.Ordinal);
|
||||||
|
public bool TryReserve()
|
||||||
|
{
|
||||||
|
if (Count >= NodeCap) { Truncated = true; return false; }
|
||||||
|
Count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly CaptureState _state;
|
||||||
|
private readonly CapturedNode _scope;
|
||||||
|
|
||||||
|
/// <summary>Create a root capturing builder with the given node cap (default 50 000).</summary>
|
||||||
|
/// <param name="nodeCap">Maximum number of nodes recorded before the tree is marked truncated.</param>
|
||||||
|
public CapturingAddressSpaceBuilder(int nodeCap = 50_000)
|
||||||
|
{
|
||||||
|
_state = new CaptureState(nodeCap);
|
||||||
|
_scope = new CapturedNode { Id = "", Name = "", DisplayName = "", IsLeaf = false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private CapturingAddressSpaceBuilder(CaptureState state, CapturedNode scope)
|
||||||
|
{
|
||||||
|
_state = state;
|
||||||
|
_scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{
|
||||||
|
var node = new CapturedNode
|
||||||
|
{
|
||||||
|
Id = _scope.Id + "/" + browseName,
|
||||||
|
Name = browseName,
|
||||||
|
DisplayName = displayName,
|
||||||
|
IsLeaf = false,
|
||||||
|
};
|
||||||
|
if (_state.TryReserve())
|
||||||
|
{
|
||||||
|
_scope.Children.Add(node);
|
||||||
|
_state.ById.TryAdd(node.Id, node);
|
||||||
|
}
|
||||||
|
// Over cap: the child builder writes into a detached node — recording stops, streaming doesn't break.
|
||||||
|
return new CapturingAddressSpaceBuilder(_state, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||||
|
{
|
||||||
|
var node = new CapturedNode
|
||||||
|
{
|
||||||
|
Id = attributeInfo.FullName,
|
||||||
|
Name = browseName,
|
||||||
|
DisplayName = displayName,
|
||||||
|
IsLeaf = true,
|
||||||
|
Attribute = attributeInfo,
|
||||||
|
};
|
||||||
|
if (_state.TryReserve())
|
||||||
|
{
|
||||||
|
_scope.Children.Add(node);
|
||||||
|
_state.ById.TryAdd(node.Id, node);
|
||||||
|
}
|
||||||
|
return new CapturedVariableHandle(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) =>
|
||||||
|
_scope.Properties.Add((browseName, dataType, value));
|
||||||
|
|
||||||
|
/// <summary>Snapshot the captured tree. Only valid on the root builder (the one the browser constructed).</summary>
|
||||||
|
/// <returns>An immutable-after-build <see cref="CapturedTree"/>.</returns>
|
||||||
|
public CapturedTree Build() => new()
|
||||||
|
{
|
||||||
|
Root = _scope,
|
||||||
|
Truncated = _state.Truncated,
|
||||||
|
Count = _state.Count,
|
||||||
|
ById = _state.ById,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => node.Id;
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||||
|
{
|
||||||
|
node.IsAlarmMarked = true;
|
||||||
|
return NoopAlarmSink.Instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopAlarmSink : IAlarmConditionSink
|
||||||
|
{
|
||||||
|
public static readonly NoopAlarmSink Instance = new();
|
||||||
|
public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
|
||||||
|
/// <summary>See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.2.</summary>
|
||||||
|
public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
||||||
|
{
|
||||||
|
/// <summary>Whole-open bound: construct + connect + discover (+ settle). Separate from the
|
||||||
|
/// 20 s per-call browse timeout, which only covers serving from memory.</summary>
|
||||||
|
public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
/// <summary>Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged
|
||||||
|
/// in discovery may be equally wedged in shutdown).</summary>
|
||||||
|
public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// <summary>Node-count cap the capturing builder enforces before marking the tree truncated.</summary>
|
||||||
|
public const int NodeCap = 50_000;
|
||||||
|
|
||||||
|
/// <summary>Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot.</summary>
|
||||||
|
public const int MaxConcurrentCaptures = 4;
|
||||||
|
|
||||||
|
/// <summary>Per-type declarative config patch guaranteeing browse mode at open time (§5.1).
|
||||||
|
/// A dictionary entry per config-gated driver — not code.</summary>
|
||||||
|
internal static readonly IReadOnlyDictionary<string, string> BrowsePatches =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["AbCip"] = """{ "EnableControllerBrowse": true }""",
|
||||||
|
["TwinCAT"] = """{ "EnableControllerBrowse": true }""",
|
||||||
|
["FOCAS"] = """{ "FixedTree": { "Enabled": true } }""",
|
||||||
|
// OpcUaClient/BACnet/MTConnect: no patch — their discovery is unconditional.
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IDriverFactory _driverFactory;
|
||||||
|
private readonly ILogger<DiscoveryDriverBrowser> _logger;
|
||||||
|
private readonly TimeSpan _openTimeout;
|
||||||
|
private readonly TimeSpan _shutdownTimeout;
|
||||||
|
private readonly ConcurrentDictionary<string, Lazy<Task<CapturedTree>>> _inflight = new();
|
||||||
|
private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures);
|
||||||
|
|
||||||
|
/// <summary>Production constructor: standard open + shutdown bounds.</summary>
|
||||||
|
/// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
|
||||||
|
/// <param name="logger">Logger for shutdown-timeout warnings.</param>
|
||||||
|
public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger)
|
||||||
|
: this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { }
|
||||||
|
|
||||||
|
// Test seam: short timeouts.
|
||||||
|
internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger,
|
||||||
|
TimeSpan openTimeout, TimeSpan shutdownTimeout)
|
||||||
|
{
|
||||||
|
_driverFactory = driverFactory;
|
||||||
|
_logger = logger;
|
||||||
|
_openTimeout = openTimeout;
|
||||||
|
_shutdownTimeout = shutdownTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool CanBrowse(string driverType, string configJson)
|
||||||
|
{
|
||||||
|
IDriver? probe = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
probe = _driverFactory.TryCreate(driverType, $"browse-can-{Guid.NewGuid():N}",
|
||||||
|
PatchForBrowse(driverType, configJson));
|
||||||
|
return probe is ITagDiscovery { SupportsOnlineDiscovery: true };
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// TryCreate parses configJson and can throw on mid-authoring JSON. UI affordance
|
||||||
|
// gate — must never throw into the page (§4.2).
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (probe is not null) _ = BoundedShutdownAsync(probe, driverType); // best-effort, never leak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var key = CaptureKey(driverType, configJson);
|
||||||
|
// Lazy: exactly one RunCaptureAsync per key even under GetOrAdd races.
|
||||||
|
var capture = _inflight.GetOrAdd(key,
|
||||||
|
_ => new Lazy<Task<CapturedTree>>(() => RunCaptureAsync(key, driverType, configJson)));
|
||||||
|
// The shared capture runs independent of any single caller (its own OpenTimeout bounds it);
|
||||||
|
// each caller — including a queued one — observes ITS OWN ct via WaitAsync.
|
||||||
|
var tree = await capture.Value.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
return new CapturedTreeBrowseSession(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CapturedTree> RunCaptureAsync(string key, string driverType, string configJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _captureSlots.WaitAsync().ConfigureAwait(false); // FIFO-ish queue for a slot
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await CaptureAsync(driverType, configJson, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_captureSlots.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_inflight.TryRemove(key, out _); // in-flight only — no result caching (Refresh = fresh capture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string CaptureKey(string driverType, string configJson) =>
|
||||||
|
driverType.ToLowerInvariant() + ":" +
|
||||||
|
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(configJson ?? "")));
|
||||||
|
|
||||||
|
private async Task<CapturedTree> CaptureAsync(string driverType, string configJson, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var patched = PatchForBrowse(driverType, configJson);
|
||||||
|
var driver = _driverFactory.TryCreate(driverType, $"browse-{Guid.NewGuid():N}", patched)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Driver type '{driverType}' is not available in this host.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (driver is not ITagDiscovery disc || !disc.SupportsOnlineDiscovery)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Driver type '{driverType}' has no online discovery — author tags manually.");
|
||||||
|
|
||||||
|
using var openCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
openCts.CancelAfter(_openTimeout);
|
||||||
|
|
||||||
|
await driver.InitializeAsync(patched, openCts.Token).ConfigureAwait(false);
|
||||||
|
return await DiscoverTreeAsync(disc, driverType, openCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await BoundedShutdownAsync(driver, driverType).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly TimeSpan SettleInterval = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
private async Task<CapturedTree> DiscoverTreeAsync(ITagDiscovery disc, string driverType, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (disc.RediscoverPolicy != DiscoveryRediscoverPolicy.UntilStable)
|
||||||
|
{
|
||||||
|
var builder = new CapturingAddressSpaceBuilder(NodeCap);
|
||||||
|
#pragma warning disable OTOPCUA0001 // One-shot browse capture, NOT a runtime dispatch path: the driver is a throwaway instance we construct/connect/discover/discard for the picker. CapabilityInvoker (retry/breaker/telemetry) governs live polling, not this admin-side capture.
|
||||||
|
await disc.DiscoverAsync(builder, ct).ConfigureAwait(false);
|
||||||
|
#pragma warning restore OTOPCUA0001
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// UntilStable (FOCAS): the discovered shape fills in asynchronously after connect —
|
||||||
|
// re-capture until non-empty and stable across two consecutive passes, bounded by the
|
||||||
|
// open-timeout. Mirrors the deploy-time contract in DriverInstanceActor.
|
||||||
|
CapturedTree? previous = null;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var builder = new CapturingAddressSpaceBuilder(NodeCap);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#pragma warning disable OTOPCUA0001 // One-shot browse capture, NOT a runtime dispatch path (see above).
|
||||||
|
await disc.DiscoverAsync(builder, ct).ConfigureAwait(false);
|
||||||
|
#pragma warning restore OTOPCUA0001
|
||||||
|
var current = builder.Build();
|
||||||
|
if (current.Count > 0 && previous?.Count == current.Count) return current;
|
||||||
|
previous = current;
|
||||||
|
await Task.Delay(SettleInterval, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (previous is { Count: > 0 })
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"UntilStable settle for {DriverType} hit the open-timeout before stabilising; returning the last capture ({Count} nodes)",
|
||||||
|
driverType, previous.Count);
|
||||||
|
return previous; // best effort: a fresh-but-unsettled tree beats a failed open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BoundedShutdownAsync(IDriver driver, string driverType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(_shutdownTimeout);
|
||||||
|
// WaitAsync: a token-ignoring wedged driver is ABANDONED (dropped), never awaited forever.
|
||||||
|
await driver.ShutdownAsync(cts.Token).WaitAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Browse-capture shutdown for {DriverType} failed/timed out; instance abandoned", driverType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string PatchForBrowse(string driverType, string configJson)
|
||||||
|
{
|
||||||
|
if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson;
|
||||||
|
var config = JsonNode.Parse(string.IsNullOrWhiteSpace(configJson) ? "{}" : configJson)
|
||||||
|
?.AsObject() ?? new JsonObject();
|
||||||
|
Merge(config, JsonNode.Parse(patch)!.AsObject());
|
||||||
|
return config.ToJsonString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Merge(JsonObject target, JsonObject patch)
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in patch)
|
||||||
|
{
|
||||||
|
if (value is JsonObject po && target[key] is JsonObject to) Merge(to, po);
|
||||||
|
else target[key] = value?.DeepClone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Universal fallback browser: captures any discovery-capable driver's DiscoverAsync
|
||||||
|
/// output and serves it as a browse session. Resolved by BrowserSessionService only
|
||||||
|
/// when no bespoke IDriverBrowser matches the driver type (bespoke-first). A separate
|
||||||
|
/// interface — NOT an IDriverBrowser — because BrowserSessionService indexes those
|
||||||
|
/// ToDictionary-by-DriverType and the universal browser has no single type.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUniversalDriverBrowser
|
||||||
|
{
|
||||||
|
/// <summary>Cheap gate (construct + inspect, no connect): can this driver type + config be
|
||||||
|
/// browsed? Never throws. Drives the picker's Browse-button visibility.</summary>
|
||||||
|
/// <param name="driverType">The driver type name.</param>
|
||||||
|
/// <param name="configJson">The authoring DriverConfig JSON (may be mid-edit / malformed).</param>
|
||||||
|
/// <returns>True when a discovery-capable driver can be constructed for this type + config.</returns>
|
||||||
|
bool CanBrowse(string driverType, string configJson);
|
||||||
|
|
||||||
|
/// <summary>Construct the driver (with the per-type browse patch), connect, capture one
|
||||||
|
/// full discovery pass (with UntilStable settle), shut the driver down (bounded), and
|
||||||
|
/// return a session over the captured snapshot. Throws on failure — the caller
|
||||||
|
/// (BrowserSessionService) converts to BrowseOpenResult.</summary>
|
||||||
|
/// <param name="driverType">The driver type name.</param>
|
||||||
|
/// <param name="configJson">The DriverConfig JSON to connect with.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the caller's open request.</param>
|
||||||
|
/// <returns>A browse session over the captured discovery snapshot.</returns>
|
||||||
|
Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -7,7 +7,16 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Akka"/>
|
<PackageReference Include="Akka"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||||||
<PackageReference Include="ZB.MOM.WW.Audit"/>
|
<PackageReference Include="ZB.MOM.WW.Audit"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Commons.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -30,4 +30,12 @@ public interface ITagDiscovery
|
|||||||
|
|
||||||
/// <summary>Post-connect re-discovery policy. Default preserves the original retry-until-stable behavior.</summary>
|
/// <summary>Post-connect re-discovery policy. Default preserves the original retry-until-stable behavior.</summary>
|
||||||
DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;
|
DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <see cref="DiscoverAsync"/> enumerates the tag set from the live backend
|
||||||
|
/// (browsable by the universal discovery browser), rather than replaying
|
||||||
|
/// pre-declared/authored tags. Default false — flat-address drivers stay manual-entry.
|
||||||
|
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
|
||||||
|
/// </summary>
|
||||||
|
bool SupportsOnlineDiscovery => false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -999,6 +999,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||||||
|
|
||||||
|
/// <summary>Controller enumeration is real (CIP Symbol Object walk) — config-gated on
|
||||||
|
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
|
||||||
|
public bool SupportsOnlineDiscovery => true;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -427,6 +427,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// node set is non-empty and stable.
|
// node set is non-empty and stable.
|
||||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;
|
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;
|
||||||
|
|
||||||
|
/// <summary>The FixedTree device folder is real enumeration — config-gated on FixedTree.Enabled,
|
||||||
|
/// which the universal browser's PatchForBrowse turns on. FOCAS is the UntilStable driver: the
|
||||||
|
/// FixedTree cache fills a couple of seconds post-connect, so the browser's settle loop re-captures
|
||||||
|
/// until the node set is non-empty and stable.</summary>
|
||||||
|
public bool SupportsOnlineDiscovery => true;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -380,6 +380,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
// discovery pass is sufficient.
|
// discovery pass is sufficient.
|
||||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||||||
|
|
||||||
|
/// <summary>ADS symbol upload is real device enumeration — config-gated on
|
||||||
|
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
|
||||||
|
public bool SupportsOnlineDiscovery => true;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
|||||||
public sealed class BrowserSessionService(
|
public sealed class BrowserSessionService(
|
||||||
IEnumerable<IDriverBrowser> browsers,
|
IEnumerable<IDriverBrowser> browsers,
|
||||||
BrowseSessionRegistry registry,
|
BrowseSessionRegistry registry,
|
||||||
ILogger<BrowserSessionService> logger) : IBrowserSessionService
|
ILogger<BrowserSessionService> logger,
|
||||||
|
IUniversalDriverBrowser universalBrowser) : IBrowserSessionService
|
||||||
{
|
{
|
||||||
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
|
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
|
||||||
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
|
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
|
||||||
@@ -25,7 +26,7 @@ public sealed class BrowserSessionService(
|
|||||||
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!_browsersByType.TryGetValue(driverType, out var browser))
|
if (!_browsersByType.TryGetValue(driverType, out var browser))
|
||||||
return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty);
|
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false);
|
var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false);
|
||||||
@@ -40,6 +41,28 @@ public sealed class BrowserSessionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool CanBrowse(string driverType, string configJson) =>
|
||||||
|
_browsersByType.ContainsKey(driverType) || universalBrowser.CanBrowse(driverType, configJson);
|
||||||
|
|
||||||
|
private async Task<BrowseOpenResult> OpenUniversalAsync(string driverType, string configJson, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!universalBrowser.CanBrowse(driverType, configJson))
|
||||||
|
return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var session = await universalBrowser.OpenAsync(driverType, configJson, ct).ConfigureAwait(false);
|
||||||
|
registry.Register(session);
|
||||||
|
return new(true, null, session.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogInformation(ex,
|
||||||
|
"Universal browse open failed for driverType={DriverType}: {Message}", driverType, ex.Message);
|
||||||
|
return new(false, ex.Message, Guid.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) =>
|
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) =>
|
||||||
InvokeAsync(token, ct, (s, c) => s.RootAsync(c));
|
InvokeAsync(token, ct, (s, c) => s.RootAsync(c));
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ public interface IBrowserSessionService
|
|||||||
/// <param name="token">Registry handle for the browse session to close.</param>
|
/// <param name="token">Registry handle for the browse session to close.</param>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task CloseAsync(Guid token);
|
Task CloseAsync(Guid token);
|
||||||
|
|
||||||
|
/// <summary>True when a browse affordance exists for this driver type: a bespoke browser
|
||||||
|
/// is registered, or the universal browser can serve it. Cheap (no connect); never throws.
|
||||||
|
/// Drives the picker's Browse-button visibility.</summary>
|
||||||
|
/// <param name="driverType">The driver type name.</param>
|
||||||
|
/// <param name="configJson">The authoring DriverConfig JSON (may be mid-edit).</param>
|
||||||
|
/// <returns>True when the picker should offer a Browse affordance for this driver.</returns>
|
||||||
|
bool CanBrowse(string driverType, string configJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+109
-3
@@ -1,4 +1,14 @@
|
|||||||
@* Static AB CIP address builder: tag name + optional element index → tag[idx] or tag *@
|
@* AB CIP address picker:
|
||||||
|
1. Static tag name + optional element index → tag[idx] or tag (always available).
|
||||||
|
2. (DriverOperator-gated, when the driver supports online discovery) Browse the controller
|
||||||
|
symbol tree captured by the universal DiscoveryDriverBrowser. *@
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
|
||||||
|
@inject IBrowserSessionService BrowserService
|
||||||
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
@inject IAuthorizationService AuthorizationService
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -22,6 +32,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (_canBrowse)
|
||||||
|
{
|
||||||
|
<div class="mt-3 d-flex align-items-center gap-2">
|
||||||
|
@if (_token == Guid.Empty)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
|
disabled="@_opening" @onclick="OpenBrowseAsync">
|
||||||
|
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Browse controller
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="chip chip-ok">Browser open</span>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
|
disabled="@_opening" @onclick="RefreshBrowseAsync">
|
||||||
|
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_token != Guid.Empty)
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="form-text mb-1">Snapshot taken at open/refresh time — click Refresh to re-read the controller.</div>
|
||||||
|
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
|
||||||
|
SelectedNodeId="@_tagName" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<span class="text-muted small">Result:</span>
|
<span class="text-muted small">Result:</span>
|
||||||
<code class="mono ms-2">@_built</code>
|
<code class="mono ms-2">@_built</code>
|
||||||
@@ -30,16 +76,63 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public string CurrentAddress { get; set; } = "";
|
[Parameter] public string CurrentAddress { get; set; } = "";
|
||||||
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
|
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
|
||||||
|
/// <summary>Driver type used to open a universal browse session (defaults to AbCip).</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "AbCip";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private string _tagName = "";
|
private string _tagName = "";
|
||||||
private int _elementIndex = 0;
|
private int _elementIndex = 0;
|
||||||
private bool _useIndex = false;
|
private bool _useIndex = false;
|
||||||
private string _built = "";
|
private string _built = "";
|
||||||
|
|
||||||
protected override void OnInitialized()
|
private Guid _token = Guid.Empty;
|
||||||
|
private bool _opening;
|
||||||
|
private bool _canBrowse;
|
||||||
|
private string? _openError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
var auth = await AuthState.GetAuthenticationStateAsync();
|
||||||
|
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
||||||
|
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
|
||||||
|
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
|
||||||
_built = Build();
|
_built = Build();
|
||||||
_ = CurrentAddressChanged.InvokeAsync(_built);
|
await CurrentAddressChanged.InvokeAsync(_built);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenBrowseAsync()
|
||||||
|
{
|
||||||
|
_opening = true; _openError = null; StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = GetConfigJson() ?? "{}";
|
||||||
|
var result = await BrowserService.OpenAsync(DriverType, json, default);
|
||||||
|
if (result.Ok) _token = result.Token;
|
||||||
|
else _openError = result.Message;
|
||||||
|
}
|
||||||
|
finally { _opening = false; StateHasChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshBrowseAsync()
|
||||||
|
{
|
||||||
|
await CloseBrowseAsync();
|
||||||
|
await OpenBrowseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseBrowseAsync()
|
||||||
|
{
|
||||||
|
var t = _token; _token = Guid.Empty; StateHasChanged();
|
||||||
|
if (t != Guid.Empty) await BrowserService.CloseAsync(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnTreeSelectAsync(BrowseNode node)
|
||||||
|
{
|
||||||
|
// Folders fire the same callback — commit only on a leaf (the captured NodeId is the CIP tag path).
|
||||||
|
if (node.Kind != BrowseNodeKind.Leaf) return;
|
||||||
|
_tagName = node.NodeId;
|
||||||
|
_useIndex = false;
|
||||||
|
await OnChangedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnChangedAsync()
|
private async Task OnChangedAsync()
|
||||||
@@ -54,4 +147,17 @@
|
|||||||
return "";
|
return "";
|
||||||
return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName;
|
return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string TruncatedError() =>
|
||||||
|
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_token != Guid.Empty)
|
||||||
|
{
|
||||||
|
// Fire-and-forget — don't block circuit teardown on a slow capture.
|
||||||
|
_ = BrowserService.CloseAsync(_token);
|
||||||
|
}
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-3
@@ -1,5 +1,17 @@
|
|||||||
@* Static FOCAS address builder: parameter group + parameter ID → axis:5 *@
|
@* FOCAS address picker:
|
||||||
|
1. Static parameter group + parameter ID → axis:5 (always available).
|
||||||
|
2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
|
||||||
|
device model captured by the universal DiscoveryDriverBrowser. FOCAS opens take a few
|
||||||
|
seconds longer than other drivers — its FixedTree cache fills post-connect and the browser
|
||||||
|
runs an UntilStable settle loop; the _opening spinner covers the wait. *@
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
|
||||||
|
@inject IBrowserSessionService BrowserService
|
||||||
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
@inject IAuthorizationService AuthorizationService
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -18,6 +30,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (_canBrowse)
|
||||||
|
{
|
||||||
|
<div class="mt-3 d-flex align-items-center gap-2">
|
||||||
|
@if (_token == Guid.Empty)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
|
disabled="@_opening" @onclick="OpenBrowseAsync">
|
||||||
|
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Browse machine
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="chip chip-ok">Browser open</span>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
|
disabled="@_opening" @onclick="RefreshBrowseAsync">
|
||||||
|
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_token != Guid.Empty)
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="form-text mb-1">Snapshot taken at open/refresh time — click Refresh to re-read the machine.</div>
|
||||||
|
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
|
||||||
|
SelectedNodeId="@_built" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<span class="text-muted small">Result:</span>
|
<span class="text-muted small">Result:</span>
|
||||||
<code class="mono ms-2">@_built</code>
|
<code class="mono ms-2">@_built</code>
|
||||||
@@ -26,15 +74,63 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public string CurrentAddress { get; set; } = "";
|
[Parameter] public string CurrentAddress { get; set; } = "";
|
||||||
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
|
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
|
||||||
|
/// <summary>Driver type used to open a universal browse session (defaults to FOCAS).</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "FOCAS";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private string _group = "axis";
|
private string _group = "axis";
|
||||||
private int _parameterId = 0;
|
private int _parameterId = 0;
|
||||||
private string _built = "";
|
private string _built = "";
|
||||||
|
|
||||||
protected override void OnInitialized()
|
private Guid _token = Guid.Empty;
|
||||||
|
private bool _opening;
|
||||||
|
private bool _canBrowse;
|
||||||
|
private string? _openError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
var auth = await AuthState.GetAuthenticationStateAsync();
|
||||||
|
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
||||||
|
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
|
||||||
|
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
|
||||||
_built = FocasAddressBuilder.Build(_group, _parameterId);
|
_built = FocasAddressBuilder.Build(_group, _parameterId);
|
||||||
_ = CurrentAddressChanged.InvokeAsync(_built);
|
await CurrentAddressChanged.InvokeAsync(_built);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenBrowseAsync()
|
||||||
|
{
|
||||||
|
_opening = true; _openError = null; StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = GetConfigJson() ?? "{}";
|
||||||
|
var result = await BrowserService.OpenAsync(DriverType, json, default);
|
||||||
|
if (result.Ok) _token = result.Token;
|
||||||
|
else _openError = result.Message;
|
||||||
|
}
|
||||||
|
finally { _opening = false; StateHasChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshBrowseAsync()
|
||||||
|
{
|
||||||
|
await CloseBrowseAsync();
|
||||||
|
await OpenBrowseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseBrowseAsync()
|
||||||
|
{
|
||||||
|
var t = _token; _token = Guid.Empty; StateHasChanged();
|
||||||
|
if (t != Guid.Empty) await BrowserService.CloseAsync(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnTreeSelectAsync(BrowseNode node)
|
||||||
|
{
|
||||||
|
// Folders fire the same callback — commit only on a leaf. The captured NodeId is the
|
||||||
|
// driver FullName (FixedTree address), which the group/id builder cannot reconstruct, so
|
||||||
|
// commit it directly rather than through OnChangedAsync.
|
||||||
|
if (node.Kind != BrowseNodeKind.Leaf) return;
|
||||||
|
_built = node.NodeId;
|
||||||
|
await CurrentAddressChanged.InvokeAsync(_built);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnChangedAsync()
|
private async Task OnChangedAsync()
|
||||||
@@ -42,4 +138,17 @@
|
|||||||
_built = FocasAddressBuilder.Build(_group, _parameterId);
|
_built = FocasAddressBuilder.Build(_group, _parameterId);
|
||||||
await CurrentAddressChanged.InvokeAsync(_built);
|
await CurrentAddressChanged.InvokeAsync(_built);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string TruncatedError() =>
|
||||||
|
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_token != Guid.Empty)
|
||||||
|
{
|
||||||
|
// Fire-and-forget — don't block circuit teardown on a slow capture.
|
||||||
|
_ = BrowserService.CloseAsync(_token);
|
||||||
|
}
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-3
@@ -1,4 +1,14 @@
|
|||||||
@* Static TwinCAT address builder: ADS variable name (free text) → verbatim *@
|
@* TwinCAT address picker:
|
||||||
|
1. Static ADS variable name (free text) → verbatim (always available).
|
||||||
|
2. (DriverOperator-gated, when the driver supports online discovery) Browse the ADS symbol
|
||||||
|
tree captured by the universal DiscoveryDriverBrowser. *@
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
|
||||||
|
@inject IBrowserSessionService BrowserService
|
||||||
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
@inject IAuthorizationService AuthorizationService
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -11,6 +21,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (_canBrowse)
|
||||||
|
{
|
||||||
|
<div class="mt-3 d-flex align-items-center gap-2">
|
||||||
|
@if (_token == Guid.Empty)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
|
disabled="@_opening" @onclick="OpenBrowseAsync">
|
||||||
|
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Browse controller
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="chip chip-ok">Browser open</span>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
|
disabled="@_opening" @onclick="RefreshBrowseAsync">
|
||||||
|
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_token != Guid.Empty)
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="form-text mb-1">Snapshot taken at open/refresh time — click Refresh to re-read the controller.</div>
|
||||||
|
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
|
||||||
|
SelectedNodeId="@_varName" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<span class="text-muted small">Result:</span>
|
<span class="text-muted small">Result:</span>
|
||||||
<code class="mono ms-2">@_built</code>
|
<code class="mono ms-2">@_built</code>
|
||||||
@@ -19,14 +65,60 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public string CurrentAddress { get; set; } = "";
|
[Parameter] public string CurrentAddress { get; set; } = "";
|
||||||
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
|
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
|
||||||
|
/// <summary>Driver type used to open a universal browse session (defaults to TwinCAT).</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "TwinCAT";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private string _varName = "";
|
private string _varName = "";
|
||||||
private string _built = "";
|
private string _built = "";
|
||||||
|
|
||||||
protected override void OnInitialized()
|
private Guid _token = Guid.Empty;
|
||||||
|
private bool _opening;
|
||||||
|
private bool _canBrowse;
|
||||||
|
private string? _openError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
var auth = await AuthState.GetAuthenticationStateAsync();
|
||||||
|
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
||||||
|
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
|
||||||
|
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
|
||||||
_built = _varName;
|
_built = _varName;
|
||||||
_ = CurrentAddressChanged.InvokeAsync(_built);
|
await CurrentAddressChanged.InvokeAsync(_built);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenBrowseAsync()
|
||||||
|
{
|
||||||
|
_opening = true; _openError = null; StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = GetConfigJson() ?? "{}";
|
||||||
|
var result = await BrowserService.OpenAsync(DriverType, json, default);
|
||||||
|
if (result.Ok) _token = result.Token;
|
||||||
|
else _openError = result.Message;
|
||||||
|
}
|
||||||
|
finally { _opening = false; StateHasChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshBrowseAsync()
|
||||||
|
{
|
||||||
|
await CloseBrowseAsync();
|
||||||
|
await OpenBrowseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseBrowseAsync()
|
||||||
|
{
|
||||||
|
var t = _token; _token = Guid.Empty; StateHasChanged();
|
||||||
|
if (t != Guid.Empty) await BrowserService.CloseAsync(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnTreeSelectAsync(BrowseNode node)
|
||||||
|
{
|
||||||
|
// Folders fire the same callback — commit only on a leaf (the captured NodeId is the ADS symbol path).
|
||||||
|
if (node.Kind != BrowseNodeKind.Leaf) return;
|
||||||
|
_varName = node.NodeId;
|
||||||
|
await OnChangedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnChangedAsync()
|
private async Task OnChangedAsync()
|
||||||
@@ -34,4 +126,17 @@
|
|||||||
_built = _varName;
|
_built = _varName;
|
||||||
await CurrentAddressChanged.InvokeAsync(_built);
|
await CurrentAddressChanged.InvokeAsync(_built);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string TruncatedError() =>
|
||||||
|
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_token != Guid.Empty)
|
||||||
|
{
|
||||||
|
// Fire-and-forget — don't block circuit teardown on a slow capture.
|
||||||
|
_ = BrowserService.CloseAsync(_token);
|
||||||
|
}
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -34,13 +34,19 @@
|
|||||||
CurrentAddress="@_pickerAddress"
|
CurrentAddress="@_pickerAddress"
|
||||||
OnPickAddress="@OnAddressPicked">
|
OnPickAddress="@OnAddressPicked">
|
||||||
<AbCipAddressPickerBody CurrentAddress="@_pickerAddress"
|
<AbCipAddressPickerBody CurrentAddress="@_pickerAddress"
|
||||||
CurrentAddressChanged="@((s) => _pickerAddress = s)" />
|
CurrentAddressChanged="@((s) => _pickerAddress = s)"
|
||||||
|
DriverType="@DriverType"
|
||||||
|
GetConfigJson="@GetDriverConfigJson" />
|
||||||
</DriverTagPicker>
|
</DriverTagPicker>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string? ConfigJson { get; set; }
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>DriverType of the selected driver — lets browse-capable pickers open a universal session.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private AbCipTagConfigModel _m = new();
|
private AbCipTagConfigModel _m = new();
|
||||||
private string? _lastConfigJson;
|
private string? _lastConfigJson;
|
||||||
|
|||||||
+4
@@ -41,6 +41,10 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public string? ConfigJson { get; set; }
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>DriverType of the selected driver — lets browse-capable pickers open a universal session.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private AbLegacyTagConfigModel _m = new();
|
private AbLegacyTagConfigModel _m = new();
|
||||||
private string? _lastConfigJson;
|
private string? _lastConfigJson;
|
||||||
|
|||||||
+7
-1
@@ -32,13 +32,19 @@
|
|||||||
CurrentAddress="@_pickerAddress"
|
CurrentAddress="@_pickerAddress"
|
||||||
OnPickAddress="@OnAddressPicked">
|
OnPickAddress="@OnAddressPicked">
|
||||||
<FOCASAddressPickerBody CurrentAddress="@_pickerAddress"
|
<FOCASAddressPickerBody CurrentAddress="@_pickerAddress"
|
||||||
CurrentAddressChanged="@((s) => _pickerAddress = s)" />
|
CurrentAddressChanged="@((s) => _pickerAddress = s)"
|
||||||
|
DriverType="@DriverType"
|
||||||
|
GetConfigJson="@GetDriverConfigJson" />
|
||||||
</DriverTagPicker>
|
</DriverTagPicker>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string? ConfigJson { get; set; }
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>DriverType of the selected driver — lets browse-capable pickers open a universal session.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private FocasTagConfigModel _m = new();
|
private FocasTagConfigModel _m = new();
|
||||||
private string? _lastConfigJson;
|
private string? _lastConfigJson;
|
||||||
|
|||||||
+4
@@ -49,6 +49,10 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public string? ConfigJson { get; set; }
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>DriverType of the selected driver — lets browse-capable pickers open a universal session.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private ModbusTagConfigModel _m = new();
|
private ModbusTagConfigModel _m = new();
|
||||||
private string? _lastConfigJson;
|
private string? _lastConfigJson;
|
||||||
|
|||||||
+4
@@ -11,6 +11,10 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public string? ConfigJson { get; set; }
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>DriverType of the selected driver — lets browse-capable pickers open a universal session.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private OpcUaClientTagConfigModel _m = new();
|
private OpcUaClientTagConfigModel _m = new();
|
||||||
private string? _lastConfigJson;
|
private string? _lastConfigJson;
|
||||||
|
|||||||
+4
@@ -41,6 +41,10 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public string? ConfigJson { get; set; }
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>DriverType of the selected driver — lets browse-capable pickers open a universal session.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private S7TagConfigModel _m = new();
|
private S7TagConfigModel _m = new();
|
||||||
private string? _lastConfigJson;
|
private string? _lastConfigJson;
|
||||||
|
|||||||
+7
-1
@@ -34,13 +34,19 @@
|
|||||||
CurrentAddress="@_pickerAddress"
|
CurrentAddress="@_pickerAddress"
|
||||||
OnPickAddress="@OnAddressPicked">
|
OnPickAddress="@OnAddressPicked">
|
||||||
<TwinCATAddressPickerBody CurrentAddress="@_pickerAddress"
|
<TwinCATAddressPickerBody CurrentAddress="@_pickerAddress"
|
||||||
CurrentAddressChanged="@((s) => _pickerAddress = s)" />
|
CurrentAddressChanged="@((s) => _pickerAddress = s)"
|
||||||
|
DriverType="@DriverType"
|
||||||
|
GetConfigJson="@GetDriverConfigJson" />
|
||||||
</DriverTagPicker>
|
</DriverTagPicker>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string? ConfigJson { get; set; }
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>DriverType of the selected driver — lets browse-capable pickers open a universal session.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
private TwinCATTagConfigModel _m = new();
|
private TwinCATTagConfigModel _m = new();
|
||||||
private string? _lastConfigJson;
|
private string? _lastConfigJson;
|
||||||
|
|||||||
@@ -353,6 +353,8 @@
|
|||||||
{
|
{
|
||||||
["ConfigJson"] = _form.TagConfig,
|
["ConfigJson"] = _form.TagConfig,
|
||||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||||
|
["DriverType"] = SelectedDriverType ?? "",
|
||||||
|
["GetDriverConfigJson"] = (Func<string>)(() => SelectedDriverConfig),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
|
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Components;
|
|||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||||
|
|
||||||
@@ -48,6 +50,12 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||||
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
||||||
|
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
|
||||||
|
// fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
|
||||||
|
// → CanBrowse false → pickers gracefully stay manual-entry (design §6).
|
||||||
|
services.AddSingleton<IUniversalDriverBrowser>(sp => new DiscoveryDriverBrowser(
|
||||||
|
sp.GetService<IDriverFactory>() ?? NullDriverFactory.Instance,
|
||||||
|
sp.GetRequiredService<ILogger<DiscoveryDriverBrowser>>()));
|
||||||
|
|
||||||
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
|
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
|
||||||
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
|
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
|
||||||
|
|
||||||
|
public class CapturedTreeBrowseSessionTests
|
||||||
|
{
|
||||||
|
private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) =>
|
||||||
|
new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm);
|
||||||
|
|
||||||
|
// A small captured graph: a folder Plc1 with a Speed leaf and an alarm leaf, plus a root-level leaf.
|
||||||
|
// truncated=true builds with a tiny cap so recording stops short and Truncated flips.
|
||||||
|
private static CapturedTree SampleTree(bool truncated = false)
|
||||||
|
{
|
||||||
|
var b = new CapturingAddressSpaceBuilder(nodeCap: truncated ? 2 : 100);
|
||||||
|
var plc = b.Folder("Plc1", "PLC 1");
|
||||||
|
plc.Variable("Speed", "Speed", Attr("Plc1.Speed"));
|
||||||
|
plc.Variable("Alarm", "Alarm", Attr("Plc1.Alarm")).MarkAsAlarmCondition(
|
||||||
|
new AlarmConditionInfo("Alarm", AlarmSeverity.High, null));
|
||||||
|
b.Variable("Direct", "Direct", Attr("Root.Direct"));
|
||||||
|
return b.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RootReturnsTopLevelNodes()
|
||||||
|
{
|
||||||
|
await using var s = new CapturedTreeBrowseSession(SampleTree());
|
||||||
|
var root = await s.RootAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var folder = root.Single(n => n.NodeId == "/Plc1");
|
||||||
|
folder.Kind.ShouldBe(BrowseNodeKind.Folder);
|
||||||
|
folder.HasChildrenHint.ShouldBeTrue();
|
||||||
|
|
||||||
|
var leaf = root.Single(n => n.NodeId == "Root.Direct");
|
||||||
|
leaf.Kind.ShouldBe(BrowseNodeKind.Leaf);
|
||||||
|
leaf.NodeId.ShouldBe("Root.Direct"); // leaf NodeId IS the driver FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExpandReturnsCapturedChildren()
|
||||||
|
{
|
||||||
|
await using var s = new CapturedTreeBrowseSession(SampleTree());
|
||||||
|
var kids = await s.ExpandAsync("/Plc1", CancellationToken.None);
|
||||||
|
kids.Select(n => n.NodeId).ShouldBe(new[] { "Plc1.Speed", "Plc1.Alarm" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExpandUnknownNodeReturnsEmpty()
|
||||||
|
{
|
||||||
|
await using var s = new CapturedTreeBrowseSession(SampleTree());
|
||||||
|
(await s.ExpandAsync("/nope", CancellationToken.None)).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AttributesForLeafMapsDriverAttributeInfo()
|
||||||
|
{
|
||||||
|
await using var s = new CapturedTreeBrowseSession(SampleTree());
|
||||||
|
var attrs = await s.AttributesAsync("Plc1.Speed", CancellationToken.None);
|
||||||
|
var a = attrs.Single();
|
||||||
|
a.Name.ShouldBe("Speed");
|
||||||
|
a.DriverDataType.ShouldBe(DriverDataType.Float64.ToString());
|
||||||
|
a.IsArray.ShouldBeFalse();
|
||||||
|
a.SecurityClass.ShouldBe(SecurityClassification.Operate.ToString());
|
||||||
|
a.IsAlarm.ShouldBeFalse();
|
||||||
|
|
||||||
|
// Alarm marked during discovery surfaces IsAlarm=true even though attr.IsAlarm is false.
|
||||||
|
var alarmAttrs = await s.AttributesAsync("Plc1.Alarm", CancellationToken.None);
|
||||||
|
alarmAttrs.Single().IsAlarm.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TruncatedTreeAppendsMarkerFolderAtRoot()
|
||||||
|
{
|
||||||
|
await using var s = new CapturedTreeBrowseSession(SampleTree(truncated: true));
|
||||||
|
var root = await s.RootAsync(CancellationToken.None);
|
||||||
|
var marker = root[^1];
|
||||||
|
marker.Kind.ShouldBe(BrowseNodeKind.Folder);
|
||||||
|
marker.HasChildrenHint.ShouldBeFalse();
|
||||||
|
marker.DisplayName.ToLowerInvariant().ShouldContain("truncated");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallsRefreshLastUsedUtc()
|
||||||
|
{
|
||||||
|
await using var s = new CapturedTreeBrowseSession(SampleTree());
|
||||||
|
var before = s.LastUsedUtc;
|
||||||
|
await Task.Delay(5, TestContext.Current.CancellationToken);
|
||||||
|
await s.RootAsync(CancellationToken.None);
|
||||||
|
s.LastUsedUtc.ShouldBeGreaterThan(before);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposedSessionThrowsObjectDisposed()
|
||||||
|
{
|
||||||
|
var s = new CapturedTreeBrowseSession(SampleTree());
|
||||||
|
await s.DisposeAsync();
|
||||||
|
await Should.ThrowAsync<ObjectDisposedException>(() => s.RootAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TwoSessionsOverSameTreeAreIndependent()
|
||||||
|
{
|
||||||
|
var tree = SampleTree();
|
||||||
|
var s1 = new CapturedTreeBrowseSession(tree);
|
||||||
|
var s2 = new CapturedTreeBrowseSession(tree);
|
||||||
|
s1.Token.ShouldNotBe(s2.Token);
|
||||||
|
|
||||||
|
await s1.DisposeAsync();
|
||||||
|
// Disposing s1 must not affect s2 — the tree is immutable and shared.
|
||||||
|
(await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty();
|
||||||
|
await s2.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
|
||||||
|
|
||||||
|
public class CapturingAddressSpaceBuilderTests
|
||||||
|
{
|
||||||
|
private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) =>
|
||||||
|
new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CapturesNestedFoldersAndLeaves()
|
||||||
|
{
|
||||||
|
var b = new CapturingAddressSpaceBuilder(nodeCap: 100);
|
||||||
|
var plc = b.Folder("Plc1", "PLC 1");
|
||||||
|
plc.Variable("Speed", "Speed", Attr("Program:Main.Speed"));
|
||||||
|
var udt = plc.Folder("Motor", "Motor");
|
||||||
|
udt.Variable("Amps", "Amps", Attr("Program:Main.Motor.Amps"));
|
||||||
|
|
||||||
|
var tree = b.Build();
|
||||||
|
tree.Truncated.ShouldBeFalse();
|
||||||
|
tree.Root.Children.Count.ShouldBe(1);
|
||||||
|
var plcNode = tree.Root.Children[0];
|
||||||
|
plcNode.Id.ShouldBe("/Plc1");
|
||||||
|
plcNode.IsLeaf.ShouldBeFalse();
|
||||||
|
plcNode.Children.Select(c => c.Id).ShouldBe(new[] { "Program:Main.Speed", "/Plc1/Motor" });
|
||||||
|
tree.ById["Program:Main.Motor.Amps"].Attribute!.FullName.ShouldBe("Program:Main.Motor.Amps");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeafNodeIdIsTheDriverFullName()
|
||||||
|
{
|
||||||
|
var b = new CapturingAddressSpaceBuilder(100);
|
||||||
|
var handle = b.Variable("T", "T", Attr("Device.Tag1"));
|
||||||
|
handle.FullReference.ShouldBe("Device.Tag1");
|
||||||
|
b.Build().ById["Device.Tag1"].IsLeaf.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NodeCapTruncatesInsteadOfGrowing()
|
||||||
|
{
|
||||||
|
var b = new CapturingAddressSpaceBuilder(nodeCap: 3);
|
||||||
|
for (var i = 0; i < 10; i++) b.Variable($"T{i}", $"T{i}", Attr($"Dev.T{i}"));
|
||||||
|
var tree = b.Build();
|
||||||
|
tree.Truncated.ShouldBeTrue();
|
||||||
|
tree.Count.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MarkAsAlarmConditionRecordsAlarmAndReturnsNoopSink()
|
||||||
|
{
|
||||||
|
var b = new CapturingAddressSpaceBuilder(100);
|
||||||
|
var h = b.Variable("A", "A", Attr("Dev.Alarm1"));
|
||||||
|
var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("A", AlarmSeverity.High, null));
|
||||||
|
sink.ShouldNotBeNull();
|
||||||
|
Should.NotThrow(() => sink.OnTransition(null!)); // no-op — browse never delivers transitions
|
||||||
|
b.Build().ById["Dev.Alarm1"].IsAlarmMarked.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddPropertyAttachesToCurrentScope()
|
||||||
|
{
|
||||||
|
var b = new CapturingAddressSpaceBuilder(100);
|
||||||
|
var f = b.Folder("Dev", "Dev");
|
||||||
|
f.AddProperty("Model", DriverDataType.String, "1756-L83E");
|
||||||
|
b.Build().ById["/Dev"].Properties.ShouldContain(p => p.Name == "Model" && (string?)p.Value == "1756-L83E");
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
|
||||||
|
|
||||||
|
public class DiscoveryDriverBrowserConcurrencyTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan LongOpen = TimeSpan.FromSeconds(30);
|
||||||
|
private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200);
|
||||||
|
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private static DiscoveryDriverBrowser Browser(IDriverFactory factory) =>
|
||||||
|
new(factory, NullLogger<DiscoveryDriverBrowser>.Instance, LongOpen, ShortShutdown);
|
||||||
|
|
||||||
|
/// <summary>A driver gate: signals when Initialize is entered, blocks until released.</summary>
|
||||||
|
private sealed class Gate
|
||||||
|
{
|
||||||
|
public readonly SemaphoreSlim Entered = new(0);
|
||||||
|
public readonly TaskCompletionSource Release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeDiscoveryDriver Gated(Gate gate) => new()
|
||||||
|
{
|
||||||
|
OnInitialize = async ct =>
|
||||||
|
{
|
||||||
|
gate.Entered.Release();
|
||||||
|
await gate.Release.Task.WaitAsync(ct);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_TwoConcurrentOpens_OneCapture_DistinctTokens()
|
||||||
|
{
|
||||||
|
var gate = new Gate();
|
||||||
|
var factory = FakeDriverFactory.Of(() => Gated(gate));
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken); // one driver entered Initialize
|
||||||
|
factory.CreateCalls.ShouldBe(1); // coalesced — only one driver constructed
|
||||||
|
gate.Release.SetResult();
|
||||||
|
|
||||||
|
var s1 = await open1;
|
||||||
|
var s2 = await open2;
|
||||||
|
s1.Token.ShouldNotBe(s2.Token); // distinct sessions...
|
||||||
|
(await s1.RootAsync(CancellationToken.None)).Count
|
||||||
|
.ShouldBe((await s2.RootAsync(CancellationToken.None)).Count); // ...over the same tree
|
||||||
|
((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_DisposingOneSession_LeavesOtherBrowsable()
|
||||||
|
{
|
||||||
|
var gate = new Gate();
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => Gated(gate)));
|
||||||
|
|
||||||
|
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken);
|
||||||
|
gate.Release.SetResult();
|
||||||
|
|
||||||
|
var s1 = await open1;
|
||||||
|
var s2 = await open2;
|
||||||
|
await s1.DisposeAsync();
|
||||||
|
(await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty(); // shared immutable tree survives
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_FailedCapture_FailsBothAwaiters()
|
||||||
|
{
|
||||||
|
var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
OnDiscover = (_, _) => throw new InvalidOperationException("shared-boom"),
|
||||||
|
});
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
(await Should.ThrowAsync<InvalidOperationException>(() => open1)).Message.ShouldBe("shared-boom");
|
||||||
|
(await Should.ThrowAsync<InvalidOperationException>(() => open2)).Message.ShouldBe("shared-boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_OpenAfterCompletion_RunsFreshCapture()
|
||||||
|
{
|
||||||
|
var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver()); // ungated, completes fast
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
factory.CreateCalls.ShouldBe(2); // in-flight-only map: second open is a fresh capture (Refresh path)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DistinctKeys_CapLimitsInflightCaptures()
|
||||||
|
{
|
||||||
|
const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4
|
||||||
|
const int total = cap + 2; // 6
|
||||||
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var entered = 0;
|
||||||
|
var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
OnInitialize = async ct => { Interlocked.Increment(ref entered); await release.Task.WaitAsync(ct); },
|
||||||
|
});
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
var opens = Enumerable.Range(0, total)
|
||||||
|
.Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Give the semaphore time to admit exactly `cap` captures into Initialize.
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken);
|
||||||
|
Volatile.Read(ref entered).ShouldBe(cap); // at most 4 in flight; the other 2 queue on the semaphore
|
||||||
|
|
||||||
|
release.SetResult();
|
||||||
|
var sessions = await Task.WhenAll(opens);
|
||||||
|
sessions.Length.ShouldBe(total);
|
||||||
|
factory.CreateCalls.ShouldBe(total); // all eventually run once a slot frees
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueuedOpen_ObservesCallerCancellation()
|
||||||
|
{
|
||||||
|
const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4
|
||||||
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
OnInitialize = async ct => { await release.Task.WaitAsync(ct); },
|
||||||
|
});
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
// Fill all slots with distinct-key captures.
|
||||||
|
var inflight = Enumerable.Range(0, cap)
|
||||||
|
.Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None))
|
||||||
|
.ToArray();
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// A queued open with a cancelable token — it waits for a slot, then the caller cancels.
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var queued = browser.OpenAsync("Fake", """{"i":"queued"}""", cts.Token);
|
||||||
|
cts.Cancel();
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(() => queued);
|
||||||
|
|
||||||
|
// The in-flight captures are unaffected.
|
||||||
|
release.SetResult();
|
||||||
|
var done = await Task.WhenAll(inflight);
|
||||||
|
done.Length.ShouldBe(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CoalescedAwaiter_CancellationDoesNotCancelSharedCapture()
|
||||||
|
{
|
||||||
|
var gate = new Gate();
|
||||||
|
var factory = FakeDriverFactory.Of(() => Gated(gate));
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
using var cts1 = new CancellationTokenSource();
|
||||||
|
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", cts1.Token);
|
||||||
|
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken);
|
||||||
|
cts1.Cancel(); // first caller abandons
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(() => open1);
|
||||||
|
|
||||||
|
gate.Release.SetResult(); // shared capture proceeds regardless
|
||||||
|
var s2 = await open2;
|
||||||
|
(await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty();
|
||||||
|
((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
|
||||||
|
|
||||||
|
/// <summary>Shared test doubles for the DiscoveryDriverBrowser suites (Tasks 8–10).</summary>
|
||||||
|
internal sealed class FakeDiscoveryDriver : IDriver, ITagDiscovery
|
||||||
|
{
|
||||||
|
public int InitializeCalls;
|
||||||
|
public int DiscoverCalls;
|
||||||
|
public int ShutdownCalls;
|
||||||
|
|
||||||
|
/// <summary>Ordered lifecycle trace ("init"/"discover"/"shutdown").</summary>
|
||||||
|
public List<string> Trace { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Completes once ShutdownAsync has finished (even the fire-and-forget CanBrowse path).</summary>
|
||||||
|
public TaskCompletionSource ShutdownDone { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public bool SupportsOnlineDiscovery { get; init; } = true;
|
||||||
|
public DiscoveryRediscoverPolicy RediscoverPolicy { get; init; } = DiscoveryRediscoverPolicy.Once;
|
||||||
|
|
||||||
|
/// <summary>Discovery behavior: (builder, 1-based call index) → task. Default streams one leaf.</summary>
|
||||||
|
public Func<IAddressSpaceBuilder, int, Task>? OnDiscover { get; init; }
|
||||||
|
public Func<CancellationToken, Task>? OnInitialize { get; init; }
|
||||||
|
public Func<CancellationToken, Task>? OnShutdown { get; init; }
|
||||||
|
|
||||||
|
public string DriverInstanceId { get; }
|
||||||
|
public string DriverType { get; }
|
||||||
|
public string? LastConfigJson { get; private set; }
|
||||||
|
|
||||||
|
public FakeDiscoveryDriver(string driverType = "Fake", string id = "fake-1")
|
||||||
|
{
|
||||||
|
DriverType = driverType;
|
||||||
|
DriverInstanceId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastConfigJson = driverConfigJson;
|
||||||
|
Interlocked.Increment(ref InitializeCalls);
|
||||||
|
lock (Trace) Trace.Add("init");
|
||||||
|
if (OnInitialize is not null) await OnInitialize(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var idx = Interlocked.Increment(ref DiscoverCalls);
|
||||||
|
lock (Trace) Trace.Add("discover");
|
||||||
|
if (OnDiscover is not null)
|
||||||
|
{
|
||||||
|
await OnDiscover(builder, idx).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Default: stream one leaf so the captured tree is non-empty.
|
||||||
|
builder.Variable("Tag1", "Tag1",
|
||||||
|
new DriverAttributeInfo("Dev.Tag1", DriverDataType.Float64, false, null,
|
||||||
|
SecurityClassification.Operate, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref ShutdownCalls);
|
||||||
|
lock (Trace) Trace.Add("shutdown");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OnShutdown is not null) await OnShutdown(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ShutdownDone.TrySetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An IDriver that is NOT ITagDiscovery — exercises the not-discovery-capable gate.</summary>
|
||||||
|
internal sealed class FakeNonDiscoveryDriver(string driverType = "Flat", string id = "flat-1") : IDriver
|
||||||
|
{
|
||||||
|
public int ShutdownCalls;
|
||||||
|
public TaskCompletionSource ShutdownDone { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
public string DriverInstanceId { get; } = id;
|
||||||
|
public string DriverType { get; } = driverType;
|
||||||
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref ShutdownCalls);
|
||||||
|
ShutdownDone.TrySetResult();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Configurable IDriverFactory: delegates construction to a supplied func and records what it made.</summary>
|
||||||
|
internal sealed class FakeDriverFactory(Func<string, string, string, IDriver?> create) : IDriverFactory
|
||||||
|
{
|
||||||
|
public int CreateCalls;
|
||||||
|
public List<IDriver> Created { get; } = [];
|
||||||
|
|
||||||
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref CreateCalls);
|
||||||
|
var d = create(driverType, driverInstanceId, driverConfigJson);
|
||||||
|
if (d is not null) lock (Created) Created.Add(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>Factory that always returns a fresh default FakeDiscoveryDriver.</summary>
|
||||||
|
public static FakeDriverFactory Of(Func<FakeDiscoveryDriver> make) =>
|
||||||
|
new((_, _, _) => make());
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
|
||||||
|
|
||||||
|
public class DiscoveryDriverBrowserSettleTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200);
|
||||||
|
|
||||||
|
private static DiscoveryDriverBrowser Browser(IDriverFactory factory, TimeSpan openTimeout) =>
|
||||||
|
new(factory, NullLogger<DiscoveryDriverBrowser>.Instance, openTimeout, ShortShutdown);
|
||||||
|
|
||||||
|
private static void StreamN(IAddressSpaceBuilder builder, int n)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
builder.Variable($"T{i}", $"T{i}",
|
||||||
|
new DriverAttributeInfo($"Dev.T{i}", DriverDataType.Float64, false, null,
|
||||||
|
SecurityClassification.Operate, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UntilStable_RerunsUntilNonEmptyAndStable()
|
||||||
|
{
|
||||||
|
// Pass 1 streams nothing (FixedTree cache still filling); passes 2+ stream 3 nodes.
|
||||||
|
var driver = new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
RediscoverPolicy = DiscoveryRediscoverPolicy.UntilStable,
|
||||||
|
OnDiscover = (b, idx) => { if (idx >= 2) StreamN(b, 3); return Task.CompletedTask; },
|
||||||
|
};
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
|
||||||
|
var root = await session.RootAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
root.Count.ShouldBe(3);
|
||||||
|
driver.DiscoverCalls.ShouldBeGreaterThanOrEqualTo(3); // empty -> 3 -> 3-stable
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UntilStable_NeverStable_ReturnsLastNonEmptyAtTimeout()
|
||||||
|
{
|
||||||
|
// Node count grows every pass — never stabilises; on open-timeout return the last capture.
|
||||||
|
var driver = new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
RediscoverPolicy = DiscoveryRediscoverPolicy.UntilStable,
|
||||||
|
OnDiscover = (b, idx) => { StreamN(b, idx); return Task.CompletedTask; },
|
||||||
|
};
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
|
||||||
|
var root = await session.RootAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
root.ShouldNotBeEmpty(); // last non-empty capture, not an exception
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UntilStable_NeverAnyNodes_FailsAtOpenTimeout()
|
||||||
|
{
|
||||||
|
// Every pass empty — no non-empty capture to fall back to, so the timeout surfaces.
|
||||||
|
var driver = new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
RediscoverPolicy = DiscoveryRediscoverPolicy.UntilStable,
|
||||||
|
OnDiscover = (_, _) => Task.CompletedTask,
|
||||||
|
};
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(
|
||||||
|
() => browser.OpenAsync("Fake", "{}", CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OncePolicy_SingleDiscoverPass()
|
||||||
|
{
|
||||||
|
var driver = new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
RediscoverPolicy = DiscoveryRediscoverPolicy.Once,
|
||||||
|
OnDiscover = (b, _) => { StreamN(b, 3); return Task.CompletedTask; },
|
||||||
|
};
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
await browser.OpenAsync("Fake", "{}", CancellationToken.None);
|
||||||
|
driver.DiscoverCalls.ShouldBe(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
|
||||||
|
|
||||||
|
public class DiscoveryDriverBrowserTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan ShortOpen = TimeSpan.FromSeconds(5);
|
||||||
|
private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200);
|
||||||
|
|
||||||
|
private static DiscoveryDriverBrowser Browser(IDriverFactory factory) =>
|
||||||
|
new(factory, NullLogger<DiscoveryDriverBrowser>.Instance, ShortOpen, ShortShutdown);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenAsync_RunsInitializeDiscoverShutdown_InOrder()
|
||||||
|
{
|
||||||
|
var driver = new FakeDiscoveryDriver();
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => driver));
|
||||||
|
|
||||||
|
var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
|
||||||
|
|
||||||
|
session.ShouldNotBeNull();
|
||||||
|
driver.Trace.ShouldBe(new[] { "init", "discover", "shutdown" });
|
||||||
|
driver.InitializeCalls.ShouldBe(1);
|
||||||
|
driver.ShutdownCalls.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenAsync_ShutdownRuns_EvenWhenDiscoverThrows()
|
||||||
|
{
|
||||||
|
var driver = new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
OnDiscover = (_, _) => throw new InvalidOperationException("boom-discovery"),
|
||||||
|
};
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => driver));
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => browser.OpenAsync("Fake", "{}", CancellationToken.None));
|
||||||
|
ex.Message.ShouldBe("boom-discovery"); // discovery error surfaces, not a shutdown error
|
||||||
|
driver.ShutdownCalls.ShouldBe(1); // shutdown still ran
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenAsync_UnknownType_ThrowsCleanMessage()
|
||||||
|
{
|
||||||
|
var browser = Browser(new FakeDriverFactory((_, _, _) => null));
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => browser.OpenAsync("Ghost", "{}", CancellationToken.None));
|
||||||
|
ex.Message.ShouldContain("Ghost");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenAsync_NotDiscoveryCapable_Throws()
|
||||||
|
{
|
||||||
|
var browser = Browser(new FakeDriverFactory((_, _, _) => new FakeNonDiscoveryDriver()));
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => browser.OpenAsync("Flat", "{}", CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenAsync_FlagFalse_Throws()
|
||||||
|
{
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => new FakeDiscoveryDriver { SupportsOnlineDiscovery = false }));
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => browser.OpenAsync("Fake", "{}", CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatchForBrowse_MergesAbCipPatch()
|
||||||
|
{
|
||||||
|
var patched = DiscoveryDriverBrowser.PatchForBrowse("AbCip", """{ "Host": "1.2.3.4" }""");
|
||||||
|
var obj = System.Text.Json.Nodes.JsonNode.Parse(patched)!.AsObject();
|
||||||
|
((string?)obj["Host"]).ShouldBe("1.2.3.4");
|
||||||
|
((bool?)obj["EnableControllerBrowse"]).ShouldBe(true);
|
||||||
|
|
||||||
|
// Unknown type: unchanged.
|
||||||
|
DiscoveryDriverBrowser.PatchForBrowse("Modbus", """{ "X": 1 }""").ShouldBe("""{ "X": 1 }""");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PatchForBrowse_DeepMergesFocasFixedTree()
|
||||||
|
{
|
||||||
|
var patched = DiscoveryDriverBrowser.PatchForBrowse("FOCAS", """{ "FixedTree": { "Series": "30i" } }""");
|
||||||
|
var fixedTree = System.Text.Json.Nodes.JsonNode.Parse(patched)!["FixedTree"]!.AsObject();
|
||||||
|
((string?)fixedTree["Series"]).ShouldBe("30i"); // kept
|
||||||
|
((bool?)fixedTree["Enabled"]).ShouldBe(true); // added
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanBrowse_TrueForDiscoveryCapable()
|
||||||
|
{
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => new FakeDiscoveryDriver()));
|
||||||
|
browser.CanBrowse("Fake", "{}").ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanBrowse_FalseWhenTryCreateReturnsNull()
|
||||||
|
{
|
||||||
|
var browser = Browser(new FakeDriverFactory((_, _, _) => null));
|
||||||
|
browser.CanBrowse("Ghost", "{}").ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanBrowse_FalseWhenTryCreateThrows_NoExceptionEscapes()
|
||||||
|
{
|
||||||
|
var browser = Browser(new FakeDriverFactory((_, _, _) => throw new System.Text.Json.JsonException("bad json")));
|
||||||
|
Should.NotThrow(() => browser.CanBrowse("Fake", "{ half-typed").ShouldBeFalse());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CanBrowse_ShutsDownThrowawayInstance()
|
||||||
|
{
|
||||||
|
var driver = new FakeDiscoveryDriver { SupportsOnlineDiscovery = false };
|
||||||
|
var factory = FakeDriverFactory.Of(() => driver);
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
browser.CanBrowse("Fake", "{}").ShouldBeFalse();
|
||||||
|
// Teardown is fire-and-forget — await its completion.
|
||||||
|
await driver.ShutdownDone.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
|
||||||
|
driver.ShutdownCalls.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoundedShutdown_AbandonsWedgedDriver()
|
||||||
|
{
|
||||||
|
// Shutdown ignores its token and would never complete; the browser must abandon it
|
||||||
|
// and still return the successfully-captured session within the shutdown bound.
|
||||||
|
var driver = new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
OnShutdown = _ => Task.Delay(Timeout.Infinite, CancellationToken.None),
|
||||||
|
};
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => driver));
|
||||||
|
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
session.ShouldNotBeNull(); // discovery succeeded → usable session
|
||||||
|
driver.ShutdownCalls.ShouldBe(1); // shutdown was attempted
|
||||||
|
sw.Elapsed.ShouldBeLessThan(ShortOpen); // returned on the shutdown bound, not the open timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||||
|
|
||||||
|
public class TagDiscoveryDefaultsTests
|
||||||
|
{
|
||||||
|
private sealed class MinimalDiscovery : ITagDiscovery
|
||||||
|
{
|
||||||
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SupportsOnlineDiscovery_DefaultsToFalse() =>
|
||||||
|
((ITagDiscovery)new MinimalDiscovery()).SupportsOnlineDiscovery.ShouldBeFalse();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RediscoverPolicy_DefaultRemainsUntilStable() =>
|
||||||
|
((ITagDiscovery)new MinimalDiscovery()).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipDiscoveryGateTests
|
||||||
|
{
|
||||||
|
/// <summary>AbCip opts into the universal discovery browser — its CIP Symbol Object walk is real
|
||||||
|
/// device enumeration (config-gated on EnableControllerBrowse, guaranteed by PatchForBrowse at open).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SupportsOnlineDiscovery_IsTrue()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||||
|
((ITagDiscovery)drv).SupportsOnlineDiscovery.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasDiscoveryGateTests
|
||||||
|
{
|
||||||
|
/// <summary>FOCAS opts into the universal discovery browser — its FixedTree device folder is real
|
||||||
|
/// enumeration (config-gated on FixedTree.Enabled, turned on by PatchForBrowse). FOCAS is the
|
||||||
|
/// UntilStable driver: the browser's settle loop re-captures until the FixedTree cache fills.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SupportsOnlineDiscovery_IsTrue()
|
||||||
|
{
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
|
||||||
|
((ITagDiscovery)drv).SupportsOnlineDiscovery.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class TwinCATDiscoveryGateTests
|
||||||
|
{
|
||||||
|
/// <summary>TwinCAT opts into the universal discovery browser — its ADS symbol upload is real
|
||||||
|
/// device enumeration (config-gated on EnableControllerBrowse, guaranteed by PatchForBrowse at open).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SupportsOnlineDiscovery_IsTrue()
|
||||||
|
{
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||||
|
((ITagDiscovery)drv).SupportsOnlineDiscovery.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
-1
@@ -13,7 +13,11 @@ public sealed class BrowserSessionServiceTests
|
|||||||
{
|
{
|
||||||
private static BrowserSessionService NewService(
|
private static BrowserSessionService NewService(
|
||||||
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
|
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
|
||||||
new(browsers, registry, NullLogger<BrowserSessionService>.Instance);
|
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser());
|
||||||
|
|
||||||
|
private static BrowserSessionService NewService(
|
||||||
|
BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
|
||||||
|
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
|
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
|
||||||
@@ -68,6 +72,89 @@ public sealed class BrowserSessionServiceTests
|
|||||||
result.Message!.ShouldContain("boom");
|
result.Message!.ShouldContain("boom");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Open_BespokeBrowserWinsOverUniversal()
|
||||||
|
{
|
||||||
|
var registry = new BrowseSessionRegistry();
|
||||||
|
var bespokeSession = new FakeBrowseSession();
|
||||||
|
var bespoke = new FakeDriverBrowser("AbCip")
|
||||||
|
{
|
||||||
|
OpenHandler = (_, _) => Task.FromResult<IBrowseSession>(bespokeSession),
|
||||||
|
};
|
||||||
|
var universal = new FakeUniversalDriverBrowser
|
||||||
|
{
|
||||||
|
CanBrowsePredicate = (_, _) => true, // universal would also serve it
|
||||||
|
OpenHandler = (_, _, _) => Task.FromResult<IBrowseSession>(new FakeBrowseSession()),
|
||||||
|
};
|
||||||
|
var service = NewService(registry, universal, bespoke);
|
||||||
|
|
||||||
|
var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Ok.ShouldBeTrue();
|
||||||
|
result.Token.ShouldBe(bespokeSession.Token); // bespoke session, not the universal one
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Open_FallsBackToUniversal_WhenNoBespokeAndCanBrowse()
|
||||||
|
{
|
||||||
|
var registry = new BrowseSessionRegistry();
|
||||||
|
var universalSession = new FakeBrowseSession();
|
||||||
|
var universal = new FakeUniversalDriverBrowser
|
||||||
|
{
|
||||||
|
CanBrowsePredicate = (t, _) => t == "AbCip",
|
||||||
|
OpenHandler = (_, _, _) => Task.FromResult<IBrowseSession>(universalSession),
|
||||||
|
};
|
||||||
|
var service = NewService(registry, universal); // no bespoke browsers
|
||||||
|
|
||||||
|
var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Ok.ShouldBeTrue();
|
||||||
|
result.Token.ShouldBe(universalSession.Token);
|
||||||
|
registry.TryGet(result.Token, out _).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage()
|
||||||
|
{
|
||||||
|
var registry = new BrowseSessionRegistry();
|
||||||
|
var service = NewService(registry, new FakeUniversalDriverBrowser()); // CanBrowse=false
|
||||||
|
|
||||||
|
var result = await service.OpenAsync("Modbus", "{}", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Ok.ShouldBeFalse();
|
||||||
|
result.Token.ShouldBe(Guid.Empty);
|
||||||
|
result.Message!.ShouldContain("Modbus");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Open_UniversalThrow_ReturnsOkFalseWithMessage()
|
||||||
|
{
|
||||||
|
var registry = new BrowseSessionRegistry();
|
||||||
|
var universal = new FakeUniversalDriverBrowser
|
||||||
|
{
|
||||||
|
CanBrowsePredicate = (_, _) => true,
|
||||||
|
OpenHandler = (_, _, _) => throw new InvalidOperationException("universal-boom"),
|
||||||
|
};
|
||||||
|
var service = NewService(registry, universal);
|
||||||
|
|
||||||
|
var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Ok.ShouldBeFalse();
|
||||||
|
result.Message!.ShouldContain("universal-boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise()
|
||||||
|
{
|
||||||
|
var registry = new BrowseSessionRegistry();
|
||||||
|
var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (t, _) => t == "AbCip" };
|
||||||
|
var service = NewService(registry, universal, new FakeDriverBrowser("Galaxy"));
|
||||||
|
|
||||||
|
service.CanBrowse("Galaxy", "{}").ShouldBeTrue(); // bespoke registered
|
||||||
|
service.CanBrowse("AbCip", "{}").ShouldBeTrue(); // universal can serve
|
||||||
|
service.CanBrowse("Modbus", "{}").ShouldBeFalse(); // neither
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException()
|
public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
|
||||||
|
|
||||||
|
/// <summary>Test double for <see cref="IUniversalDriverBrowser"/>. CanBrowse is driven by a
|
||||||
|
/// predicate (default: never); OpenAsync delegates to a handler or returns a fresh session.</summary>
|
||||||
|
internal sealed class FakeUniversalDriverBrowser : IUniversalDriverBrowser
|
||||||
|
{
|
||||||
|
/// <summary>CanBrowse gate. Defaults to "no universal affordance for any type".</summary>
|
||||||
|
public Func<string, string, bool> CanBrowsePredicate { get; init; } = (_, _) => false;
|
||||||
|
|
||||||
|
/// <summary>OpenAsync behavior. Defaults to a fresh FakeBrowseSession.</summary>
|
||||||
|
public Func<string, string, CancellationToken, Task<IBrowseSession>> OpenHandler { get; init; } =
|
||||||
|
(_, _, _) => Task.FromResult<IBrowseSession>(new FakeBrowseSession());
|
||||||
|
|
||||||
|
public bool CanBrowse(string driverType, string configJson) => CanBrowsePredicate(driverType, configJson);
|
||||||
|
|
||||||
|
public Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct) =>
|
||||||
|
OpenHandler(driverType, configJson, ct);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user