feat: OPC UA address-space search plumbing — actor + comm + BrowseService (T15)

This commit is contained in:
Joseph Doherty
2026-06-18 02:51:57 -04:00
parent c00c8241b3
commit 74dd26eebd
7 changed files with 468 additions and 0 deletions
@@ -269,6 +269,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// an inline banner.
HandleBrowse(browse);
break;
case SearchAddressSpaceCommand search:
// Search is the address-space analogue of browse — same rule:
// never stash; the adapter has no session yet here, so
// HandleSearch short-circuits to ConnectionNotConnected.
HandleSearch(search);
break;
case ReadTagValuesCommand read:
// Same rule as browse — never stash; adapter is not yet
// connected, so HandleReadTagValues short-circuits to
@@ -353,6 +359,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case BrowseNodeCommand browse:
HandleBrowse(browse);
break;
case SearchAddressSpaceCommand search:
HandleSearch(search);
break;
case ReadTagValuesCommand read:
HandleReadTagValues(read);
break;
@@ -497,6 +506,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// throw ConnectionNotConnectedException — mapped by HandleBrowse.
HandleBrowse(browse);
break;
case SearchAddressSpaceCommand search:
// Same rule as browse — never stashed; while reconnecting the
// adapter is not Connected so the adapter call throws
// ConnectionNotConnectedException, mapped by HandleSearch.
HandleSearch(search);
break;
case ReadTagValuesCommand read:
// Same rule as browse — never stashed; while reconnecting the
// adapter is not Connected so HandleReadTagValues short-circuits
@@ -1203,6 +1218,83 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
}).PipeTo(sender);
}
/// <summary>
/// Handles a <see cref="SearchAddressSpaceCommand"/> forwarded by the
/// <see cref="DataConnectionManagerActor"/> — the address-space analogue of
/// <see cref="HandleBrowse"/>. The capability check (does this adapter support
/// search?) and all failure mapping live here because the adapter is held by
/// this actor, not the manager. The search path reuses the browse
/// <see cref="BrowseFailure"/> kinds rather than inventing a parallel set.
///
/// Failure mapping:
/// <list type="bullet">
/// <item><see cref="BrowseFailureKind.NotBrowsable"/> — adapter is not <see cref="IAddressSpaceSearchable"/>, or it threw <see cref="NotSupportedException"/> (searchable adapter, but the server/protocol cannot search); message carried verbatim in the latter case.</item>
/// <item><see cref="BrowseFailureKind.ConnectionNotConnected"/> — adapter threw <see cref="ConnectionNotConnectedException"/>.</item>
/// <item><see cref="BrowseFailureKind.Timeout"/> — adapter threw <see cref="OperationCanceledException"/>.</item>
/// <item><see cref="BrowseFailureKind.ServerError"/> — any other exception, message carried verbatim.</item>
/// </list>
///
/// The adapter already caps the match list at <c>MaxResults</c>, so unlike
/// <see cref="HandleBrowse"/> there is no frame-budget clip here — the bound is
/// supplied by the caller (B6) and chosen to stay well under Akka's remote
/// frame size. The reply is sent via <c>PipeTo(sender)</c>, the same pattern
/// used by <see cref="HandleBrowse"/>.
/// </summary>
private void HandleSearch(SearchAddressSpaceCommand command)
{
var sender = Sender;
if (_adapter is not IAddressSpaceSearchable searchable)
{
_log.Debug("[{0}] Search requested but adapter does not implement IAddressSpaceSearchable", _connectionName);
sender.Tell(new SearchAddressSpaceResult(
Array.Empty<AddressSpaceMatch>(),
CapReached: false,
new BrowseFailure(
BrowseFailureKind.NotBrowsable,
$"Connection '{_connectionName}' does not support search.")));
return;
}
_log.Debug("[{0}] Searching address space for '{1}' (maxDepth={2}, maxResults={3})",
_connectionName, command.Query, command.MaxDepth, command.MaxResults);
searchable.SearchAddressSpaceAsync(command.Query, command.MaxDepth, command.MaxResults).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
{
// The adapter already bounded the walk by MaxResults — pass the
// matches and the cap flag straight through.
return new SearchAddressSpaceResult(t.Result.Matches, t.Result.CapReached, Failure: null);
}
var baseEx = t.Exception?.GetBaseException();
return baseEx switch
{
ConnectionNotConnectedException notConnected => new SearchAddressSpaceResult(
Array.Empty<AddressSpaceMatch>(),
CapReached: false,
new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)),
OperationCanceledException => new SearchAddressSpaceResult(
Array.Empty<AddressSpaceMatch>(),
CapReached: false,
new BrowseFailure(BrowseFailureKind.Timeout, "Search cancelled.")),
// Adapter reachable but the protocol/server cannot search. Carry
// the adapter's explanatory message through as NotBrowsable.
NotSupportedException notSupported => new SearchAddressSpaceResult(
Array.Empty<AddressSpaceMatch>(),
CapReached: false,
new BrowseFailure(BrowseFailureKind.NotBrowsable, notSupported.Message)),
_ => new SearchAddressSpaceResult(
Array.Empty<AddressSpaceMatch>(),
CapReached: false,
new BrowseFailure(
BrowseFailureKind.ServerError,
baseEx?.Message ?? "Unknown search error.")),
};
}).PipeTo(sender);
}
/// <summary>
/// Estimated-byte ceiling for a single <see cref="BrowseNodeResult"/>, kept
/// comfortably below Akka's default 128 KB remote frame size. A browse reply
@@ -50,6 +50,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
Receive<GetAllHealthReports>(HandleGetAllHealthReports);
Receive<BrowseNodeCommand>(HandleBrowse);
Receive<SearchAddressSpaceCommand>(HandleSearch);
Receive<ReadTagValuesCommand>(HandleReadTagValues);
}
@@ -189,6 +190,32 @@ public class DataConnectionManagerActor : ReceiveActor
}
}
/// <summary>
/// Routes a <see cref="SearchAddressSpaceCommand"/> from the central UI's OPC
/// UA tag picker to the child <see cref="DataConnectionActor"/> that owns the
/// named connection — the address-space analogue of <see cref="HandleBrowse"/>.
/// Same split: the manager owns <see cref="BrowseFailureKind.ConnectionNotFound"/>
/// (only it knows the per-site connection set); the capability check and every
/// other failure live inside the child where the adapter is held.
/// </summary>
private void HandleSearch(SearchAddressSpaceCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
{
actor.Forward(command);
}
else
{
_log.Warning("No connection actor for {0} during search", command.ConnectionName);
Sender.Tell(new SearchAddressSpaceResult(
Array.Empty<AddressSpaceMatch>(),
CapReached: false,
new BrowseFailure(
BrowseFailureKind.ConnectionNotFound,
$"No data connection named '{command.ConnectionName}' at this site.")));
}
}
/// <summary>
/// Routes a <see cref="ReadTagValuesCommand"/> from the CentralUI's Test
/// Bindings dialog to the child <see cref="DataConnectionActor"/> that