+}
+
+@code {
+ ///
+ /// A single binding row to test — built by the page at click time from its
+ /// _bindings table and passed in via . Carries
+ /// the effective tag path (override ?? template default) so the dialog
+ /// doesn't need page-side knowledge to compute it.
+ ///
+ public sealed record BindingRowToTest(
+ string AttributeName,
+ string ConnectionName,
+ string EffectiveTagPath);
+
+ [Parameter] public EventCallback OnCancelled { get; set; }
+
+ private sealed record ConnectionBanner(string ConnectionName, string Message);
+
+ private bool _isVisible;
+ private bool _loading;
+ private string _instanceLabel = "";
+ private string _runtimeSiteId = "";
+ private DateTimeOffset _lastReadAt;
+ private List _rows = new();
+ private readonly List _connectionBanners = new();
+ // Keyed by (connection, tagPath) so two connections referencing the same
+ // tag path don't collide.
+ private readonly Dictionary<(string Connection, string TagPath), TagReadOutcome> _outcomes = new();
+
+ ///
+ /// Opens the dialog and triggers an immediate one-shot read. Method-arg
+ /// pattern (mirroring OpcUaBrowserDialog.ShowAsync) — Razor
+ /// parameter binding would propagate on the next render and race the
+ /// LoadAsync below.
+ ///
+ /// Site identifier (machine name) used by for routing.
+ /// Rows to test (one per attribute with a connection + effective tag path).
+ /// Optional label rendered in the modal header (instance unique name).
+ public async Task ShowAsync(
+ string siteId,
+ IReadOnlyList rows,
+ string instanceLabel = "")
+ {
+ _runtimeSiteId = siteId;
+ _instanceLabel = instanceLabel;
+ _rows = rows.ToList();
+ _outcomes.Clear();
+ _connectionBanners.Clear();
+ _isVisible = true;
+ await LoadAsync();
+ }
+
+ private async Task LoadAsync()
+ {
+ if (_rows.Count == 0)
+ {
+ _loading = false;
+ StateHasChanged();
+ return;
+ }
+
+ _loading = true;
+ _outcomes.Clear();
+ _connectionBanners.Clear();
+ StateHasChanged();
+
+ // Group by connection name — one ReadTagValuesCommand per connection,
+ // issued in parallel. Distinct tag paths only (a single attribute may
+ // appear multiple times in the page, but reads are per (conn, tag)).
+ var groups = _rows
+ .GroupBy(r => r.ConnectionName, StringComparer.Ordinal)
+ .Select(g => new
+ {
+ Connection = g.Key,
+ TagPaths = g.Select(r => r.EffectiveTagPath).Distinct(StringComparer.Ordinal).ToList(),
+ })
+ .ToList();
+
+ var tasks = groups.Select(g => Tester.ReadAsync(_runtimeSiteId, g.Connection, g.TagPaths)).ToArray();
+ ReadTagValuesResult[] results;
+ try
+ {
+ results = await Task.WhenAll(tasks);
+ }
+ catch (Exception ex)
+ {
+ // Last-ditch: a service-layer exception that escaped the typed
+ // failure mapping (shouldn't happen — BindingTester translates
+ // everything but OperationCanceledException). Surface as a single
+ // banner so the dialog stays usable.
+ _connectionBanners.Add(new ConnectionBanner("(all)", $"Read failed: {ex.Message}"));
+ _loading = false;
+ _lastReadAt = DateTimeOffset.UtcNow;
+ StateHasChanged();
+ return;
+ }
+
+ for (var i = 0; i < groups.Count; i++)
+ {
+ var group = groups[i];
+ var result = results[i];
+
+ if (result.Failure is not null)
+ {
+ _connectionBanners.Add(new ConnectionBanner(group.Connection, FormatFailure(result.Failure)));
+ continue;
+ }
+
+ foreach (var outcome in result.Outcomes)
+ {
+ _outcomes[(group.Connection, outcome.TagPath)] = outcome;
+ }
+ }
+
+ _lastReadAt = DateTimeOffset.UtcNow;
+ _loading = false;
+ StateHasChanged();
+ }
+
+ private Task RefreshAsync() => LoadAsync();
+
+ private TagReadOutcome? LookupOutcome(BindingRowToTest row)
+ => _outcomes.GetValueOrDefault((row.ConnectionName, row.EffectiveTagPath));
+
+ // Maps ReadTagValuesFailureKind to a friendly banner. Raw failure.Message
+ // is surfaced verbatim only for ServerError (which carries the adapter's
+ // own message text).
+ private static string FormatFailure(ReadTagValuesFailure failure) => failure.Kind switch
+ {
+ ReadTagValuesFailureKind.ConnectionNotFound => "Connection no longer exists at the site.",
+ ReadTagValuesFailureKind.ConnectionNotConnected => "Connection not yet established — retry shortly.",
+ ReadTagValuesFailureKind.Timeout => "Read timed out — the server may be slow. Try Refresh.",
+ ReadTagValuesFailureKind.ServerError => $"Server error: {failure.Message}",
+ _ => failure.Message,
+ };
+
+ private static string FormatValue(object? value) => value switch
+ {
+ null => "(null)",
+ string s => s,
+ IFormattable f => f.ToString(null, System.Globalization.CultureInfo.InvariantCulture),
+ _ => value.ToString() ?? "(null)",
+ };
+
+ private static string QualityBadge(string quality) => quality switch
+ {
+ "Good" => "bg-success",
+ "Uncertain" => "bg-warning text-dark",
+ _ => "bg-danger",
+ };
+
+ private async Task Close()
+ {
+ _isVisible = false;
+ await OnCancelled.InvokeAsync();
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
index 3e2c5d8b..e2f717e7 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
@@ -154,8 +154,18 @@
}
-
+
+ @* Test Bindings: one-shot live read of every bound attribute
+ whose row has a connection picked AND an effective tag
+ path. Disabled when no testable rows. Currently OPC UA
+ only — other protocols (none yet) would need their own
+ wire+adapter support to round-trip through ReadTagValuesCommand. *@
+
}
@@ -362,6 +372,11 @@
ConnectionName="@_browserConnectionName"
InitialNodeId="@_browserInitial"
OnSelected="OnBrowserSelected" />
+
+ @* Test Bindings dialog — one-shot live read of every bound attribute.
+ Method-arg ShowAsync(siteId, rows) — no Razor parameter propagation
+ race (same pattern as OpcUaBrowserDialog). *@
+
}
@@ -399,6 +414,10 @@
private string? _browserInitial;
private string _siteIdentifier = "";
+ // Test Bindings dialog — single instance, args passed via ShowAsync (no
+ // Razor parameter propagation race; same pattern as the OPC UA browser).
+ private TestBindingsDialog? _testBindingsRef;
+
// Overrides
private List _overrideAttrs = new();
private Dictionary _overrideValues = new();
@@ -583,6 +602,48 @@
_browserAttrInEdit = null;
}
+ // ── Test Bindings (one-shot live read of bound tags) ────────────────────
+
+ ///
+ /// Builds the list of testable rows: attributes that have a connection
+ /// picked AND a non-empty effective tag path AND an OPC UA connection
+ /// (the only protocol routed through ReadTagValuesCommand today).
+ ///
+ private List BuildTestableRows()
+ {
+ var rows = new List();
+ foreach (var attr in _bindingDataSourceAttrs)
+ {
+ var connId = GetBindingConnectionId(attr.Name);
+ if (connId <= 0) continue;
+
+ var conn = _siteConnections.FirstOrDefault(c => c.Id == connId);
+ if (conn is null) continue;
+
+ // OPC UA only — other protocols don't have a site-side
+ // ReadTagValuesCommand handler wired up yet.
+ if (!string.Equals(conn.Protocol, "OpcUa", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var effectivePath = _bindingOverrides.GetValueOrDefault(attr.Name)
+ ?? GetTemplateDefault(attr.Name);
+ if (string.IsNullOrWhiteSpace(effectivePath)) continue;
+
+ rows.Add(new TestBindingsDialog.BindingRowToTest(attr.Name, conn.Name, effectivePath));
+ }
+ return rows;
+ }
+
+ private bool HasTestableBindings() => BuildTestableRows().Count > 0;
+
+ private async Task OpenTestBindings()
+ {
+ if (_testBindingsRef is null) return;
+ var rows = BuildTestableRows();
+ if (rows.Count == 0) return;
+ await _testBindingsRef.ShowAsync(_siteIdentifier, rows, _instance?.UniqueName ?? "");
+ }
+
private async Task SaveBindings()
{
_saving = true;
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
index 6cfebd3f..7db879ac 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
@@ -55,6 +55,13 @@ public static class ServiceCollectionExtensions
// transport failures into typed BrowseFailure results for the dialog.
services.AddScoped();
+ // Test Bindings: facade over CommunicationService.ReadTagValuesAsync —
+ // same Design-role guard + typed-failure translation as the browse
+ // service. Backs the Test Bindings dialog on the Configure Instance
+ // page (one-shot live read of every bound attribute, grouped by
+ // connection).
+ services.AddScoped();
+
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs
new file mode 100644
index 00000000..2c19d8b1
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs
@@ -0,0 +1,81 @@
+using Microsoft.AspNetCore.Components.Authorization;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.Communication;
+using ZB.MOM.WW.ScadaBridge.Security;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+///
+/// Default implementation — a thin facade over
+/// that enforces the
+/// CentralUI-side Design-role trust boundary and translates transport
+/// exceptions into a typed result. Mirrors
+/// .
+///
+public sealed class BindingTester : IBindingTester
+{
+ private readonly CommunicationService _communication;
+ private readonly AuthenticationStateProvider _auth;
+
+ ///
+ /// Initializes a new instance of the .
+ ///
+ /// Central-side cluster communication service.
+ /// Authentication state provider used for the Design-role guard.
+ public BindingTester(CommunicationService communication, AuthenticationStateProvider auth)
+ {
+ _communication = communication ?? throw new ArgumentNullException(nameof(communication));
+ _auth = auth ?? throw new ArgumentNullException(nameof(auth));
+ }
+
+ ///
+ public async Task ReadAsync(
+ string siteId,
+ string connectionName,
+ IReadOnlyList tagPaths,
+ CancellationToken ct = default)
+ {
+ // CentralUI-side role guard — sites don't enforce envelope-level
+ // roles, so the Design check must happen here before any cross-cluster
+ // traffic. Use HasClaim against JwtTokenService.RoleClaimType (not
+ // IsInRole, per c1e16cf).
+ var state = await _auth.GetAuthenticationStateAsync();
+ if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
+ {
+ return new ReadTagValuesResult(
+ Array.Empty(),
+ new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, "Not authorized."));
+ }
+
+ try
+ {
+ return await _communication.ReadTagValuesAsync(
+ siteId,
+ new ReadTagValuesCommand(connectionName, tagPaths),
+ ct);
+ }
+ catch (TimeoutException ex)
+ {
+ // Akka Ask timed out — the site (or its OPC UA session) didn't
+ // answer within CommunicationOptions.QueryTimeout. Surface as a
+ // typed Timeout failure so the dialog can render an inline banner.
+ return new ReadTagValuesResult(
+ Array.Empty(),
+ new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, ex.Message));
+ }
+ catch (OperationCanceledException)
+ {
+ // Caller-initiated cancel — propagate so Blazor can drop the
+ // response cleanly. Distinct from Timeout.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Any other transport / serialization failure: keep the dialog
+ // alive with a typed banner.
+ return new ReadTagValuesResult(
+ Array.Empty(),
+ new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, ex.Message));
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs
new file mode 100644
index 00000000..91a08dde
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs
@@ -0,0 +1,39 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+///
+/// CentralUI facade over the central-to-site "Test Bindings" read command.
+/// Backs the Test Bindings dialog on the Configure Instance page: on open and
+/// on Refresh, the dialog issues one per distinct
+/// connection (grouped from the page's bindings table) and renders the
+/// per-tag outcomes.
+///
+///
+/// The service is the trust boundary for the read capability: it enforces the
+/// Design role at central before any cross-cluster traffic is
+/// generated, because site-side actors do not unwrap the central trust
+/// envelope. Transport failures (timeouts, unreachable sites) are translated
+/// into a typed so the dialog can render an
+/// inline banner without crashing — same shape as
+/// .
+///
+public interface IBindingTester
+{
+ ///
+ /// Reads the current value of one or more tags on the live server backing
+ /// at . The
+ /// caller is expected to group its bindings by connection name and issue
+ /// one call per group (in parallel — the dialog uses
+ /// Task.WhenAll).
+ ///
+ /// The target site identifier.
+ /// Name of the site-local data connection — the site's DataConnectionManagerActor indexes its children by name.
+ /// Tag paths to read.
+ /// Cancellation token.
+ Task ReadAsync(
+ string siteId,
+ string connectionName,
+ IReadOnlyList tagPaths,
+ CancellationToken ct = default);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs
new file mode 100644
index 00000000..b0416016
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs
@@ -0,0 +1,70 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+
+///
+/// Sent from CentralUI to a specific site to read the current value of one or
+/// more tags on the live server backing a named data connection. Backs the
+/// "Test Bindings" dialog on the Configure Instance page — a one-shot read of
+/// every bound attribute, grouped by connection, with no subscription.
+///
+///
+/// Keyed by (not id) for the same reason as
+/// : the site-side
+/// DataConnectionManagerActor indexes its children by connection name,
+/// and the central UI already has the connection name in scope from the
+/// bindings table. The central DataConnections table's id is not
+/// exposed at the site.
+///
+/// Name of the site-local data connection to read against.
+/// Tag paths to read (one batch per connection — caller groups by connection name).
+public record ReadTagValuesCommand(
+ string ConnectionName,
+ IReadOnlyList TagPaths);
+
+///
+/// Per-tag outcome of a . The site returns
+/// one outcome per requested tag path; a single failing tag never aborts the
+/// batch (the underlying IDataConnection.ReadBatchAsync contract).
+///
+/// Tag path that was read — matches an entry in the request.
+/// True when the read returned a value; false when the per-tag read failed.
+/// Read value (may be null even on success); always null on failure.
+/// Quality code as a string (Good/Bad/Uncertain); always Bad on failure.
+/// Source timestamp on success; the central-noted UTC time of the failure otherwise.
+/// Per-tag error message on failure; null on success.
+public record TagReadOutcome(
+ string TagPath,
+ bool Success,
+ object? Value,
+ string Quality,
+ DateTimeOffset Timestamp,
+ string? ErrorMessage);
+
+///
+/// Reply to a . Either
+/// is populated (one entry per requested tag, in any order) and
+/// is null, or is set and
+/// is empty — the latter is the connection-level
+/// short-circuit (unknown connection, not connected, server error, etc.) where
+/// no per-tag attempt was made.
+///
+public record ReadTagValuesResult(
+ IReadOnlyList Outcomes,
+ ReadTagValuesFailure? Failure);
+
+///
+/// Connection-level failure carried by . The
+/// dialog maps each to a friendly
+/// banner; is surfaced verbatim for the
+/// case.
+///
+public record ReadTagValuesFailure(
+ ReadTagValuesFailureKind Kind,
+ string Message);
+
+public enum ReadTagValuesFailureKind
+{
+ ConnectionNotFound,
+ ConnectionNotConnected,
+ Timeout,
+ ServerError
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs
index 57c8ee1e..b82596d8 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs
@@ -154,6 +154,12 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
// to its own /user/dcl-manager, which DOES have the connection.
Receive(msg => _deploymentManagerProxy.Forward(msg));
+ // Test Bindings (interactive design-time read) — same routing rationale
+ // as BrowseOpcUaNodeCommand above: the singleton always lands on the
+ // active site node, which is the node that owns the DataConnectionActor
+ // children holding the live OPC UA sessions.
+ Receive(msg => _deploymentManagerProxy.Forward(msg));
+
// Pattern 7: Remote Queries
Receive(msg =>
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
index f91ddfe2..8d06e110 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
@@ -370,6 +370,30 @@ public class CommunicationService
envelope, _options.QueryTimeout, cancellationToken);
}
+ // ── Test Bindings (one-shot live read of bound tags) ──
+
+ ///
+ /// Asks a site to read the current value of one or more tags on the live
+ /// server backing the given data connection. Used by the CentralUI "Test
+ /// Bindings" dialog on the Configure Instance page. The Ask is bounded by
+ /// — same latency budget
+ /// as (both are interactive one-shot
+ /// design-time queries).
+ ///
+ /// The target site identifier.
+ /// The read-tag-values command (connection name + tag paths).
+ /// Cancellation token.
+ /// The read result — per-tag outcomes plus an optional connection-level failure.
+ public Task ReadTagValuesAsync(
+ string siteId,
+ ReadTagValuesCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var envelope = new SiteEnvelope(siteId, command);
+ return GetActor().Ask(
+ envelope, _options.QueryTimeout, cancellationToken);
+ }
+
// ── Pattern 8: Heartbeat (site→central, Tell) ──
// Heartbeats are received by central, not sent. No method needed here.
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
index dc5ab774..918111f7 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
@@ -241,6 +241,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// an inline banner.
HandleBrowse(browse);
break;
+ case ReadTagValuesCommand read:
+ // Same rule as browse — never stash; adapter is not yet
+ // connected, so HandleReadTagValues short-circuits to
+ // ConnectionNotConnected.
+ HandleReadTagValues(read);
+ break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -304,6 +310,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case BrowseOpcUaNodeCommand browse:
HandleBrowse(browse);
break;
+ case ReadTagValuesCommand read:
+ HandleReadTagValues(read);
+ break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -429,6 +438,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// throw ConnectionNotConnectedException — mapped by HandleBrowse.
HandleBrowse(browse);
break;
+ case ReadTagValuesCommand read:
+ // Same rule as browse — never stashed; while reconnecting the
+ // adapter is not Connected so HandleReadTagValues short-circuits
+ // to a ConnectionNotConnected failure.
+ HandleReadTagValues(read);
+ break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -1030,6 +1045,100 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
}).PipeTo(sender);
}
+ // ── Test Bindings (one-shot live read of bound tags) ──
+
+ ///
+ /// Handles a forwarded by the
+ /// . Short-circuits to a
+ /// failure
+ /// when the adapter is not currently Connected (Connecting / Reconnecting
+ /// states) so the dialog can render an inline banner without waiting for
+ /// the adapter to fail per-tag with a generic "client is not connected"
+ /// message. Otherwise calls _adapter.ReadBatchAsync and maps the
+ /// resulting per-tag map onto a list of
+ /// (preserving every requested tag — missing
+ /// adapter entries become failure outcomes).
+ ///
+ /// Failure mapping mirrors :
+ ///
+ /// — adapter status is not .
+ /// — batch cancelled ().
+ /// — any other exception, message carried verbatim.
+ ///
+ ///
+ /// The reply is sent via PipeTo(sender) — same pattern as
+ /// and — so the
+ /// captured is safe to use from the continuation.
+ ///
+ private void HandleReadTagValues(ReadTagValuesCommand command)
+ {
+ var sender = Sender;
+
+ if (_adapter.Status != ConnectionHealth.Connected)
+ {
+ _log.Debug("[{0}] Test-bindings read requested but adapter status is {1}", _connectionName, _adapter.Status);
+ sender.Tell(new ReadTagValuesResult(
+ Array.Empty(),
+ new ReadTagValuesFailure(
+ ReadTagValuesFailureKind.ConnectionNotConnected,
+ "Connection is not yet established.")));
+ return;
+ }
+
+ _log.Debug("[{0}] Test-bindings read of {1} tag(s)", _connectionName, command.TagPaths.Count);
+
+ var tagPaths = command.TagPaths.ToList();
+ _adapter.ReadBatchAsync(tagPaths).ContinueWith(t =>
+ {
+ if (t.IsCompletedSuccessfully)
+ {
+ var nowUtc = DateTimeOffset.UtcNow;
+ var outcomes = new List(tagPaths.Count);
+ foreach (var tagPath in tagPaths)
+ {
+ if (t.Result.TryGetValue(tagPath, out var result) && result.Success && result.Value is not null)
+ {
+ outcomes.Add(new TagReadOutcome(
+ tagPath,
+ Success: true,
+ Value: result.Value.Value,
+ Quality: result.Value.Quality.ToString(),
+ Timestamp: result.Value.Timestamp,
+ ErrorMessage: null));
+ }
+ else
+ {
+ var errMsg = result?.ErrorMessage
+ ?? (t.Result.ContainsKey(tagPath)
+ ? "Read returned no value."
+ : "Tag missing from adapter result.");
+ outcomes.Add(new TagReadOutcome(
+ tagPath,
+ Success: false,
+ Value: null,
+ Quality: "Bad",
+ Timestamp: nowUtc,
+ ErrorMessage: errMsg));
+ }
+ }
+ return new ReadTagValuesResult(outcomes, Failure: null);
+ }
+
+ var baseEx = t.Exception?.GetBaseException();
+ return baseEx switch
+ {
+ OperationCanceledException => new ReadTagValuesResult(
+ Array.Empty(),
+ new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, "Read cancelled.")),
+ _ => new ReadTagValuesResult(
+ Array.Empty(),
+ new ReadTagValuesFailure(
+ ReadTagValuesFailureKind.ServerError,
+ baseEx?.Message ?? "Unknown read error.")),
+ };
+ }).PipeTo(sender);
+ }
+
// ── Tag Resolution Retry (WP-12) ──
private void HandleRetryTagResolution()
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
index 18e0998b..1bf21ee0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
@@ -47,6 +47,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive(HandleRemoveConnection);
Receive(HandleGetAllHealthReports);
Receive(HandleBrowse);
+ Receive(HandleReadTagValues);
}
private void HandleCreateConnection(CreateConnectionCommand command)
@@ -140,6 +141,33 @@ public class DataConnectionManagerActor : ReceiveActor
}
}
+ ///
+ /// Routes a from the CentralUI's Test
+ /// Bindings dialog to the child that
+ /// owns the named connection. Same split as —
+ /// the manager owns
+ /// because it is
+ /// the only actor with site-level visibility; every other failure
+ /// (not connected, server error, timeout) is resolved by the child where
+ /// the adapter is held.
+ ///
+ private void HandleReadTagValues(ReadTagValuesCommand command)
+ {
+ if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
+ {
+ actor.Forward(command);
+ }
+ else
+ {
+ _log.Warning("No connection actor for {0} during test-bindings read", command.ConnectionName);
+ Sender.Tell(new ReadTagValuesResult(
+ Array.Empty(),
+ new ReadTagValuesFailure(
+ ReadTagValuesFailureKind.ConnectionNotFound,
+ $"No data connection named '{command.ConnectionName}' at this site.")));
+ }
+ }
+
private void HandleRemoveConnection(RemoveConnectionCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
index 6cd65aa3..a7175a7f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
@@ -157,6 +157,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive(msg =>
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
+ // Test Bindings — same singleton-only re-forward as the browse handler
+ // above. Routed to this singleton (active node) by SiteCommunicationActor
+ // so the dcl-manager we forward to is guaranteed to hold the live
+ // DataConnectionActor children.
+ Receive(msg =>
+ Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
+
// Internal startup messages
Receive(HandleStartupConfigsLoaded);
Receive(HandleSharedScriptsLoaded);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs
index 02be7c87..9e9f5287 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
@@ -43,6 +44,12 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For()));
Services.AddSingleton(Substitute.For());
+ // The page renders and at
+ // the bottom; their @inject directives need a registered service even
+ // though this test doesn't open either dialog.
+ Services.AddSingleton(Substitute.For());
+ Services.AddSingleton(Substitute.For());
+
// Auth: a system-wide Deployment user so SiteScope grants everything.
var claims = new[]
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs
new file mode 100644
index 00000000..a25d8181
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs
@@ -0,0 +1,24 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
+
+///
+/// Verifies that is discovered by
+/// so it travels over the management
+/// boundary as a known command (resolvable by wire name and round-trippable
+/// through GetCommandName / Resolve). Mirrors
+/// .
+///
+public class ReadTagValuesCommandRegistryTests
+{
+ [Fact]
+ public void Registry_discovers_ReadTagValuesCommand()
+ {
+ // GetCommandName throws ArgumentException for any type the registry
+ // does not contain, so a successful call here is proof of discovery.
+ var name = ManagementCommandRegistry.GetCommandName(typeof(ReadTagValuesCommand));
+
+ Assert.Equal("ReadTagValues", name);
+ Assert.Equal(typeof(ReadTagValuesCommand), ManagementCommandRegistry.Resolve(name));
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs
new file mode 100644
index 00000000..a4f05dc5
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs
@@ -0,0 +1,174 @@
+using Akka.Actor;
+using Akka.TestKit.Xunit2;
+using NSubstitute;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
+using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
+
+///
+/// Test Bindings (one-shot live read of bound tags): the site-side
+/// + child
+/// together resolve
+/// against the live adapter and surface
+/// every outcome as either a per-tag or a typed
+/// connection-level . The split mirrors the
+/// browse handler: the manager owns
+/// (only it knows
+/// the per-site connection set); everything else lives in the child where the
+/// adapter is held —
+/// from the pre-call status check,
+/// / from the adapter call.
+///
+public class DataConnectionManagerReadTagValuesHandlerTests : TestKit
+{
+ private readonly IDataConnectionFactory _factory;
+ private readonly ISiteHealthCollector _healthCollector;
+ private readonly DataConnectionOptions _options;
+
+ public DataConnectionManagerReadTagValuesHandlerTests()
+ : base(@"akka.loglevel = WARNING")
+ {
+ _factory = Substitute.For();
+ _healthCollector = Substitute.For();
+ _options = new DataConnectionOptions
+ {
+ ReconnectInterval = TimeSpan.FromSeconds(30),
+ TagResolutionRetryInterval = TimeSpan.FromSeconds(30),
+ };
+ }
+
+ [Fact]
+ public void Unknown_connection_name_returns_ConnectionNotFound()
+ {
+ var manager = Sys.ActorOf(Props.Create(() =>
+ new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
+
+ // No CreateConnectionCommand sent — the manager has zero children, so
+ // any read must be rejected with ConnectionNotFound (only the manager
+ // has site-level visibility into the connection set).
+ manager.Tell(new ReadTagValuesCommand("unknown-connection", new[] { "ns=2;s=A" }));
+
+ var reply = ExpectMsg();
+ Assert.NotNull(reply.Failure);
+ Assert.Equal(ReadTagValuesFailureKind.ConnectionNotFound, reply.Failure!.Kind);
+ Assert.Empty(reply.Outcomes);
+ }
+
+ [Fact]
+ public void Adapter_not_connected_returns_ConnectionNotConnected()
+ {
+ // Adapter that reports Disconnected — the child actor's pre-call
+ // status check must short-circuit to ConnectionNotConnected without
+ // calling ReadBatchAsync (avoids the per-tag "client is not
+ // connected" noise that the OpcUa adapter would otherwise produce).
+ var adapter = Substitute.For();
+ adapter.ConnectAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.FromException(new InvalidOperationException("simulated failure")));
+ adapter.Status.Returns(ConnectionHealth.Disconnected);
+ _factory.Create("OpcUa", Arg.Any>()).Returns(adapter);
+
+ var manager = Sys.ActorOf(Props.Create(() =>
+ new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
+ manager.Tell(new CreateConnectionCommand(
+ "conn-down", "OpcUa", new Dictionary(), null, 3));
+
+ // Wait for the child actor to spin up; the read handler runs in every
+ // lifecycle state, so we don't need to wait for a specific Become.
+ AwaitCondition(
+ () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
+ TimeSpan.FromSeconds(2));
+
+ manager.Tell(new ReadTagValuesCommand("conn-down", new[] { "ns=2;s=A" }));
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ Assert.NotNull(reply.Failure);
+ Assert.Equal(ReadTagValuesFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
+ Assert.Empty(reply.Outcomes);
+
+ // ReadBatchAsync must NOT be called when the status guard short-circuits.
+ adapter.DidNotReceive().ReadBatchAsync(Arg.Any>(), Arg.Any());
+ }
+
+ [Fact]
+ public void Success_path_maps_results_to_TagReadOutcomes()
+ {
+ var adapter = Substitute.For();
+ adapter.ConnectAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ adapter.Status.Returns(ConnectionHealth.Connected);
+
+ var ts = new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero);
+ var batch = new Dictionary
+ {
+ ["ns=2;s=A"] = new ReadResult(true, new TagValue(42.7, QualityCode.Good, ts), null),
+ // Adapter-reported per-tag failure (e.g. unknown node id): mapped to
+ // a failure TagReadOutcome with Quality=Bad and Value=null.
+ ["ns=2;s=B"] = new ReadResult(false, null, "BadNodeIdUnknown"),
+ };
+ adapter.ReadBatchAsync(Arg.Any>(), Arg.Any())
+ .Returns(batch);
+ _factory.Create("OpcUa", Arg.Any>()).Returns(adapter);
+
+ var manager = Sys.ActorOf(Props.Create(() =>
+ new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
+ manager.Tell(new CreateConnectionCommand(
+ "conn-ok", "OpcUa", new Dictionary(), null, 3));
+
+ AwaitCondition(
+ () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
+ TimeSpan.FromSeconds(2));
+
+ manager.Tell(new ReadTagValuesCommand("conn-ok", new[] { "ns=2;s=A", "ns=2;s=B" }));
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ Assert.Null(reply.Failure);
+ Assert.Equal(2, reply.Outcomes.Count);
+
+ var aOutcome = reply.Outcomes.Single(o => o.TagPath == "ns=2;s=A");
+ Assert.True(aOutcome.Success);
+ Assert.Equal(42.7, aOutcome.Value);
+ Assert.Equal("Good", aOutcome.Quality);
+ Assert.Equal(ts, aOutcome.Timestamp);
+ Assert.Null(aOutcome.ErrorMessage);
+
+ var bOutcome = reply.Outcomes.Single(o => o.TagPath == "ns=2;s=B");
+ Assert.False(bOutcome.Success);
+ Assert.Null(bOutcome.Value);
+ Assert.Equal("Bad", bOutcome.Quality);
+ Assert.Equal("BadNodeIdUnknown", bOutcome.ErrorMessage);
+ }
+
+ [Fact]
+ public void Adapter_OperationCancelled_returns_Timeout()
+ {
+ var adapter = Substitute.For();
+ adapter.ConnectAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ adapter.Status.Returns(ConnectionHealth.Connected);
+ adapter.ReadBatchAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.FromException>(
+ new OperationCanceledException("test cancel")));
+ _factory.Create("OpcUa", Arg.Any>()).Returns(adapter);
+
+ var manager = Sys.ActorOf(Props.Create(() =>
+ new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
+ manager.Tell(new CreateConnectionCommand(
+ "conn-slow", "OpcUa", new Dictionary(), null, 3));
+
+ AwaitCondition(
+ () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
+ TimeSpan.FromSeconds(2));
+
+ manager.Tell(new ReadTagValuesCommand("conn-slow", new[] { "ns=2;s=A" }));
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ Assert.NotNull(reply.Failure);
+ Assert.Equal(ReadTagValuesFailureKind.Timeout, reply.Failure!.Kind);
+ Assert.Empty(reply.Outcomes);
+ }
+}