| @attr.Name |
@attr.DataSourceReference |
@@ -140,11 +140,11 @@
placeholder="@(attr.DataSourceReference ?? "(no default)")" />
- @if (isOpcUa)
+ @if (isBrowsable)
{
@@ -367,7 +367,7 @@
@* OPC UA Tag Browser dialog (Task 18) — rendered once; OpenBrowser
tracks which binding row's override input receives the picked node id. *@
-
}
@@ -407,7 +407,7 @@
// OPC UA tag browser (Task 18) — single dialog rendered at page bottom;
// _browserAttrInEdit tracks which row gets the picked node id on Select.
- private OpcUaBrowserDialog? _browserRef;
+ private NodeBrowserDialog? _browserRef;
private string? _browserAttrInEdit;
private string _browserSiteIdentifier = "";
private string _browserConnectionName = "";
@@ -566,13 +566,19 @@
private string? GetTemplateDefault(string attrName)
=> _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference;
- /// True when the row's selected data connection is OPC UA.
- private bool IsOpcUa(int connectionId)
- => connectionId > 0
- && string.Equals(
- _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol,
- "OpcUa",
- StringComparison.OrdinalIgnoreCase);
+ ///
+ /// True when the row's selected data connection supports address-space browsing
+ /// (the tag picker). OPC UA and MxGateway both implement
+ /// IBrowsableDataConnection site-side; other protocols return a
+ /// NotBrowsable failure, so the button is hidden for them.
+ ///
+ private bool IsBrowsable(int connectionId)
+ {
+ if (connectionId <= 0) return false;
+ var protocol = _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol;
+ return string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
+ }
///
/// Opens the OPC UA tag browser dialog for the given attribute row. Remembers
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor
index 3d8314fe..a96df290 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor
@@ -49,17 +49,45 @@
}
+
+
+ @if (_protocolLocked)
+ {
+
+ Protocol is locked after creation.
+ }
+ else
+ {
+
+ }
+
Primary endpoint
-
+ @if (_protocol == "MxGateway")
+ {
+
+ }
+ else
+ {
+
+ }
Backup endpoint
@@ -77,11 +105,21 @@
}
else
{
-
+ @if (_protocol == "MxGateway")
+ {
+
+ }
+ else
+ {
+
+ }
s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_siteLocked = true;
_formName = _editingConnection.Name;
+ _protocol = string.IsNullOrWhiteSpace(_editingConnection.Protocol) ? "OpcUa" : _editingConnection.Protocol;
+ _protocolLocked = true;
- (_primaryConfig, _primaryIsLegacy) =
- OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);
-
- if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration))
- {
- (_backupConfig, _backupIsLegacy) =
- OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration);
- _showBackup = true;
- _formFailoverRetryCount = _editingConnection.FailoverRetryCount;
- }
+ LoadConfig(_editingConnection);
}
}
else if (SiteId.HasValue)
@@ -177,32 +212,80 @@
}
}
+ private void LoadConfig(DataConnection conn)
+ {
+ if (_protocol == "MxGateway")
+ {
+ _primaryMx = MxGatewayEndpointConfigSerializer.Deserialize(conn.PrimaryConfiguration);
+ if (!string.IsNullOrWhiteSpace(conn.BackupConfiguration))
+ {
+ _backupMx = MxGatewayEndpointConfigSerializer.Deserialize(conn.BackupConfiguration);
+ _showBackup = true;
+ _formFailoverRetryCount = conn.FailoverRetryCount;
+ }
+ }
+ else
+ {
+ (_primaryConfig, _primaryIsLegacy) =
+ OpcUaEndpointConfigSerializer.Deserialize(conn.PrimaryConfiguration);
+ if (!string.IsNullOrWhiteSpace(conn.BackupConfiguration))
+ {
+ (_backupConfig, _backupIsLegacy) =
+ OpcUaEndpointConfigSerializer.Deserialize(conn.BackupConfiguration);
+ _showBackup = true;
+ _formFailoverRetryCount = conn.FailoverRetryCount;
+ }
+ }
+ }
+
private async Task SaveConnection()
{
_formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
- _primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
- _backupErrors = _showBackup
- ? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
- : null;
+ string primaryJson;
+ string? backupJson;
- if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
+ if (_protocol == "MxGateway")
{
- _formError = "Fix the errors below before saving.";
- return;
- }
+ _primaryErrors = MxGatewayEndpointConfigValidator.Validate(_primaryMx, "Primary.");
+ _backupErrors = _showBackup
+ ? MxGatewayEndpointConfigValidator.Validate(_backupMx, "Backup.")
+ : null;
- var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
- var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
+ if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
+ {
+ _formError = "Fix the errors below before saving.";
+ return;
+ }
+
+ primaryJson = MxGatewayEndpointConfigSerializer.Serialize(_primaryMx);
+ backupJson = _showBackup ? MxGatewayEndpointConfigSerializer.Serialize(_backupMx) : null;
+ }
+ else
+ {
+ _primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
+ _backupErrors = _showBackup
+ ? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
+ : null;
+
+ if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
+ {
+ _formError = "Fix the errors below before saving.";
+ return;
+ }
+
+ primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
+ backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
+ }
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
- _editingConnection.Protocol = "OpcUa";
+ _editingConnection.Protocol = _protocol;
_editingConnection.PrimaryConfiguration = primaryJson;
_editingConnection.BackupConfiguration = backupJson;
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
@@ -210,7 +293,7 @@
}
else
{
- var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId)
+ var conn = new DataConnection(_formName.Trim(), _protocol, _formSiteId)
{
PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson,
@@ -233,6 +316,7 @@
{
_showBackup = false;
_backupConfig = new OpcUaEndpointConfig();
+ _backupMx = new MxGatewayEndpointConfig();
_backupIsLegacy = false;
_formFailoverRetryCount = 3;
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
index 7db879ac..aa802860 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
@@ -50,10 +50,10 @@ public static class ServiceCollectionExtensions
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
services.AddScoped ();
- // OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseOpcUaNodeAsync
+ // OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseNodeAsync
// that enforces the CentralUI-side Design-role trust boundary and translates
// transport failures into typed BrowseFailure results for the dialog.
- services.AddScoped();
+ services.AddScoped();
// Test Bindings: facade over CommunicationService.ReadTagValuesAsync —
// same Design-role guard + typed-failure translation as the browse
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs
index 2c19d8b1..e8e1c7d5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// that enforces the
/// CentralUI-side Design-role trust boundary and translates transport
/// exceptions into a typed result. Mirrors
-/// .
+/// .
///
public sealed class BindingTester : IBindingTester
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs
similarity index 81%
rename from src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs
rename to src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs
index 1fb2d686..b27f4298 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs
@@ -7,8 +7,8 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
///
-/// Default implementation — a thin facade over
-/// that enforces the
+/// Default implementation — a thin facade over
+/// that enforces the
/// CentralUI-side Design-role trust boundary and translates transport
/// exceptions into a typed result.
///
@@ -19,24 +19,24 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// ServerError so the dialog can show an inline banner while leaving the
/// manual node-id paste field usable.
///
-public sealed class OpcUaBrowseService : IOpcUaBrowseService
+public sealed class BrowseService : IBrowseService
{
private readonly CommunicationService _communication;
private readonly AuthenticationStateProvider _auth;
///
- /// Initializes a new instance of the .
+ /// Initializes a new instance of the .
///
/// Central-side cluster communication service.
/// Authentication state provider used for the Design-role guard.
- public OpcUaBrowseService(CommunicationService communication, AuthenticationStateProvider auth)
+ public BrowseService(CommunicationService communication, AuthenticationStateProvider auth)
{
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
}
///
- public async Task BrowseChildrenAsync(
+ public async Task BrowseChildrenAsync(
string siteId,
string connectionName,
string? parentNodeId,
@@ -47,7 +47,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
var state = await _auth.GetAuthenticationStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
{
- return new BrowseOpcUaNodeResult(
+ return new BrowseNodeResult(
Array.Empty(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized."));
@@ -55,9 +55,9 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
try
{
- return await _communication.BrowseOpcUaNodeAsync(
+ return await _communication.BrowseNodeAsync(
siteId,
- new BrowseOpcUaNodeCommand(connectionName, parentNodeId),
+ new BrowseNodeCommand(connectionName, parentNodeId),
cancellationToken);
}
catch (TimeoutException ex)
@@ -65,7 +65,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
// 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 BrowseOpcUaNodeResult(
+ return new BrowseNodeResult(
Array.Empty(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.Timeout, ex.Message));
@@ -80,7 +80,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
{
// Any other transport / serialization failure: keep the dialog
// alive and let the user fall back to manual node-id paste.
- return new BrowseOpcUaNodeResult(
+ return new BrowseNodeResult(
Array.Empty(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.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
index 91a08dde..028987e8 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// 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
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs
similarity index 92%
rename from src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs
rename to src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs
index ea9aaf0e..f70f2025 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// CentralUI facade over the central-to-site OPC UA browse command. Backs the
/// OPC UA Tag Browser dialog: each tree expansion / manual node-id paste calls
/// , which forwards a
-/// to the owning site via
+/// to the owning site via
/// .
///
///
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// so the dialog can render an inline error and
/// remain usable (manual node-id paste still works).
///
-public interface IOpcUaBrowseService
+public interface IBrowseService
{
///
/// Enumerates the immediate children of an OPC UA node on the live server
@@ -29,7 +29,7 @@ public interface IOpcUaBrowseService
/// Name of the site-local data connection to browse against — the site's DataConnectionManagerActor indexes its children by name.
/// Node to browse, or null to browse from the server root.
/// Cancellation token.
- Task BrowseChildrenAsync(
+ Task BrowseChildrenAsync(
string siteId,
string connectionName,
string? parentNodeId,
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
index 73fffa58..c72c404f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
@@ -15,11 +15,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
///
/// Name of the site-local data connection to browse against.
/// Node to browse, or null to browse from the server root (ObjectsFolder).
-public record BrowseOpcUaNodeCommand(
+public record BrowseNodeCommand(
string ConnectionName,
string? ParentNodeId);
-public record BrowseOpcUaNodeResult(
+public record BrowseNodeResult(
IReadOnlyList Children,
bool Truncated,
BrowseFailure? Failure);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs
index b0416016..3be3ab78 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
///
///
/// Keyed by (not id) for the same reason as
-/// : the site-side
+/// : 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
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/MxGatewayEndpointConfigSerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/MxGatewayEndpointConfigSerializer.cs
new file mode 100644
index 00000000..40282828
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/MxGatewayEndpointConfigSerializer.cs
@@ -0,0 +1,65 @@
+using System.Globalization;
+using System.Text.Json;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Serialization;
+
+///
+/// Serializes to/from the typed JSON stored in
+/// DataConnection.PrimaryConfiguration / BackupConfiguration, and flattens
+/// it to the IDictionary<string,string> shape IDataConnection.ConnectAsync
+/// expects. MxGateway is net-new, so there is no legacy shape to recover — a row that
+/// fails to parse yields a default config.
+///
+public static class MxGatewayEndpointConfigSerializer
+{
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = false,
+ };
+
+ /// Serializes a config to the typed JSON shape.
+ /// The endpoint configuration to serialize.
+ public static string Serialize(MxGatewayEndpointConfig config)
+ => JsonSerializer.Serialize(config, JsonOpts);
+
+ /// Parses stored config JSON; null/blank/malformed yields a default config.
+ /// The stored JSON string.
+ public static MxGatewayEndpointConfig Deserialize(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return new MxGatewayEndpointConfig();
+ try { return JsonSerializer.Deserialize(json, JsonOpts) ?? new MxGatewayEndpointConfig(); }
+ catch (JsonException) { return new MxGatewayEndpointConfig(); }
+ }
+
+ /// Flattens the typed config to the key-value shape the adapter consumes.
+ /// The endpoint configuration to flatten.
+ public static IDictionary ToFlatDict(MxGatewayEndpointConfig c) => new Dictionary
+ {
+ ["Endpoint"] = c.Endpoint,
+ ["ApiKey"] = c.ApiKey,
+ ["ClientName"] = c.ClientName,
+ ["WriteUserId"] = c.WriteUserId.ToString(CultureInfo.InvariantCulture),
+ ["UseTls"] = c.UseTls.ToString(),
+ ["CaFile"] = c.CaFile,
+ ["ServerName"] = c.ServerName,
+ ["ReadTimeoutMs"] = c.ReadTimeoutMs.ToString(CultureInfo.InvariantCulture),
+ };
+
+ /// Reconstructs a config from the flat key-value shape; invalid numerics fall back to defaults.
+ /// The flat dictionary.
+ public static MxGatewayEndpointConfig FromFlatDict(IDictionary d)
+ {
+ var c = new MxGatewayEndpointConfig();
+ if (d.TryGetValue("Endpoint", out var ep) && !string.IsNullOrWhiteSpace(ep)) c.Endpoint = ep;
+ if (d.TryGetValue("ApiKey", out var ak)) c.ApiKey = ak;
+ if (d.TryGetValue("ClientName", out var cn)) c.ClientName = cn;
+ if (d.TryGetValue("WriteUserId", out var wu) && int.TryParse(wu, out var wuv)) c.WriteUserId = wuv;
+ if (d.TryGetValue("UseTls", out var tls) && bool.TryParse(tls, out var tlsv)) c.UseTls = tlsv;
+ if (d.TryGetValue("CaFile", out var ca)) c.CaFile = ca;
+ if (d.TryGetValue("ServerName", out var sn)) c.ServerName = sn;
+ if (d.TryGetValue("ReadTimeoutMs", out var rt) && int.TryParse(rt, out var rtv)) c.ReadTimeoutMs = rtv;
+ return c;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs
new file mode 100644
index 00000000..f1fdbd29
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs
@@ -0,0 +1,27 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
+
+///
+/// Per-endpoint configuration for an MxGateway data connection. Serialized to the
+/// typed JSON shape stored in DataConnection.PrimaryConfiguration /
+/// BackupConfiguration. Both primary and backup use this same shape — the
+/// backup is simply a second gateway endpoint for failover.
+///
+public class MxGatewayEndpointConfig
+{
+ /// Gateway base URL (e.g. "http://localhost:5000").
+ public string Endpoint { get; set; } = "http://localhost:5000";
+ /// API key sent to the gateway as authorization: Bearer <key>.
+ public string ApiKey { get; set; } = "";
+ /// MXAccess client registration name. Blank → derive "scadabridge-<connName>" at connect time.
+ public string ClientName { get; set; } = "";
+ /// MXAccess user id applied to every write-back. 0 = no user context.
+ public int WriteUserId { get; set; }
+ /// Use TLS to a secured gateway.
+ public bool UseTls { get; set; }
+ /// Path to the CA certificate (TLS only).
+ public string CaFile { get; set; } = "";
+ /// TLS server-name override.
+ public string ServerName { get; set; } = "";
+ /// ReadBulk per-call timeout in milliseconds.
+ public int ReadTimeoutMs { get; set; } = 5000;
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Validators/MxGatewayEndpointConfigValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Validators/MxGatewayEndpointConfigValidator.cs
new file mode 100644
index 00000000..8443e77d
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Validators/MxGatewayEndpointConfigValidator.cs
@@ -0,0 +1,46 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Validators;
+
+///
+/// Pure-function validator for . Errors carry
+/// the offending property name in
+/// (optionally prefixed, e.g. "Primary.Endpoint") so the form can render
+/// per-field messages.
+///
+public static class MxGatewayEndpointConfigValidator
+{
+ ///
+ /// Validates all fields of an , returning errors with optionally-prefixed field names.
+ ///
+ /// The MxGateway endpoint configuration to validate.
+ /// Optional prefix prepended to each field name in error entries (e.g., "Primary.").
+ public static ValidationResult Validate(MxGatewayEndpointConfig config, string fieldPrefix = "")
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(config.Endpoint))
+ errors.Add(Err("Endpoint", "Endpoint URL is required."));
+ else if (!Uri.TryCreate(config.Endpoint, UriKind.Absolute, out var uri)
+ || (uri.Scheme != "http" && uri.Scheme != "https")
+ || string.IsNullOrEmpty(uri.Host))
+ errors.Add(Err("Endpoint", "Endpoint URL must be a valid http:// or https:// URI."));
+
+ if (string.IsNullOrWhiteSpace(config.ApiKey))
+ errors.Add(Err("ApiKey", "API key is required."));
+
+ if (config.ReadTimeoutMs <= 0)
+ errors.Add(Err("ReadTimeoutMs", "Must be > 0."));
+
+ return errors.Count == 0
+ ? ValidationResult.Success()
+ : ValidationResult.FromErrors(errors.ToArray());
+
+ ValidationEntry Err(string field, string message) =>
+ ValidationEntry.Error(
+ ValidationCategory.ConnectionConfig,
+ message,
+ entityName: $"{fieldPrefix}{field}");
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs
index b82596d8..d3641c52 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs
@@ -152,10 +152,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
// DataConnectionActor children (which own the live OPC UA sessions)
// only exist on the singleton's node. The singleton then re-forwards
// to its own /user/dcl-manager, which DOES have the connection.
- Receive(msg => _deploymentManagerProxy.Forward(msg));
+ Receive(msg => _deploymentManagerProxy.Forward(msg));
// Test Bindings (interactive design-time read) — same routing rationale
- // as BrowseOpcUaNodeCommand above: the singleton always lands on the
+ // as BrowseNodeCommand 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));
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
index 8d06e110..816ff8d3 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
@@ -360,13 +360,13 @@ public class CommunicationService
/// The OPC UA browse command.
/// Cancellation token.
/// The browse result (children + truncation flag + structured failure).
- public Task BrowseOpcUaNodeAsync(
+ public Task BrowseNodeAsync(
string siteId,
- BrowseOpcUaNodeCommand command,
+ BrowseNodeCommand command,
CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, command);
- return GetActor().Ask(
+ return GetActor().Ask(
envelope, _options.QueryTimeout, cancellationToken);
}
@@ -377,7 +377,7 @@ public class CommunicationService
/// 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
+ /// as (both are interactive one-shot
/// design-time queries).
///
/// The target site identifier.
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
index 918111f7..74bd2cff 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
@@ -234,7 +234,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc);
break;
- case BrowseOpcUaNodeCommand browse:
+ case BrowseNodeCommand browse:
// Browse is an interactive design-time query; never stash. The
// adapter has no session yet in this state, so reply with a
// typed ConnectionNotConnected failure so the dialog can render
@@ -307,7 +307,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case RetryTagResolution:
HandleRetryTagResolution();
break;
- case BrowseOpcUaNodeCommand browse:
+ case BrowseNodeCommand browse:
HandleBrowse(browse);
break;
case ReadTagValuesCommand read:
@@ -432,7 +432,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc);
break;
- case BrowseOpcUaNodeCommand browse:
+ case BrowseNodeCommand browse:
// Browse is design-time and never stashed. While reconnecting
// the adapter has no live session, so the adapter call will
// throw ConnectionNotConnectedException — mapped by HandleBrowse.
@@ -982,7 +982,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// ── OPC UA Tag Browser (interactive design-time query) ──
///
- /// Handles a forwarded by the
+ /// Handles a forwarded by the
/// . The capability check (does
/// this adapter support browsing?) and all browse-failure mapping live
/// here because the adapter is held by this actor, not the manager.
@@ -999,14 +999,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
/// — so the captured is
/// safe to use from the continuation (which runs off the actor thread).
///
- private void HandleBrowse(BrowseOpcUaNodeCommand command)
+ private void HandleBrowse(BrowseNodeCommand command)
{
var sender = Sender;
if (_adapter is not IBrowsableDataConnection browsable)
{
_log.Debug("[{0}] Browse requested but adapter does not implement IBrowsableDataConnection", _connectionName);
- sender.Tell(new BrowseOpcUaNodeResult(
+ sender.Tell(new BrowseNodeResult(
Array.Empty(),
Truncated: false,
new BrowseFailure(
@@ -1021,21 +1021,21 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
{
if (t.IsCompletedSuccessfully)
{
- return new BrowseOpcUaNodeResult(t.Result.Children, t.Result.Truncated, Failure: null);
+ return new BrowseNodeResult(t.Result.Children, t.Result.Truncated, Failure: null);
}
var baseEx = t.Exception?.GetBaseException();
return baseEx switch
{
- ConnectionNotConnectedException notConnected => new BrowseOpcUaNodeResult(
+ ConnectionNotConnectedException notConnected => new BrowseNodeResult(
Array.Empty(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)),
- OperationCanceledException => new BrowseOpcUaNodeResult(
+ OperationCanceledException => new BrowseNodeResult(
Array.Empty(),
Truncated: false,
new BrowseFailure(BrowseFailureKind.Timeout, "Browse cancelled.")),
- _ => new BrowseOpcUaNodeResult(
+ _ => new BrowseNodeResult(
Array.Empty(),
Truncated: false,
new BrowseFailure(
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
index 1bf21ee0..1bab2494 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
@@ -46,7 +46,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive(HandleRouteWrite);
Receive(HandleRemoveConnection);
Receive(HandleGetAllHealthReports);
- Receive(HandleBrowse);
+ Receive(HandleBrowse);
Receive(HandleReadTagValues);
}
@@ -115,7 +115,7 @@ public class DataConnectionManagerActor : ReceiveActor
}
///
- /// Routes a from the central UI's OPC UA
+ /// Routes a from the central UI's OPC UA
/// Tag Browser to the child that owns the
/// named connection. The manager is the only actor that knows whether a
/// connection exists at this site — so it owns the
@@ -123,7 +123,7 @@ public class DataConnectionManagerActor : ReceiveActor
/// else (capability check, session state, server errors) lives inside the
/// child where the adapter is held.
///
- private void HandleBrowse(BrowseOpcUaNodeCommand command)
+ private void HandleBrowse(BrowseNodeCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
{
@@ -132,7 +132,7 @@ public class DataConnectionManagerActor : ReceiveActor
else
{
_log.Warning("No connection actor for {0} during browse", command.ConnectionName);
- Sender.Tell(new BrowseOpcUaNodeResult(
+ Sender.Tell(new BrowseNodeResult(
Array.Empty(),
Truncated: false,
new BrowseFailure(
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs
new file mode 100644
index 00000000..db9eefca
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs
@@ -0,0 +1,79 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+/// Connection parameters resolved from the flat config dict.
+public record MxGatewayConnectionOptions(
+ string Endpoint, string ApiKey, string ClientName, int WriteUserId,
+ bool UseTls, string? CaFile, string? ServerName, int ReadTimeoutMs);
+
+/// One advised-tag value change pushed from the gateway event stream.
+public record MxValueUpdate(string TagPath, object? Value, QualityCode Quality, DateTimeOffset Timestamp);
+
+/// Per-tag read outcome.
+public record MxReadOutcome(string TagPath, bool Success, object? Value, QualityCode Quality, DateTimeOffset Timestamp, string? Error);
+
+/// Per-tag write outcome.
+public record MxWriteOutcome(string TagPath, bool Success, string? Error);
+
+/// One node in a Galaxy browse level.
+public record MxBrowseChild(string NodeId, string DisplayName, BrowseNodeClass NodeClass, bool HasChildren);
+
+///
+/// Seam over the MxAccess Gateway .NET client + Galaxy repository client. Decouples
+/// from the generated gRPC/protobuf types so the
+/// adapter is unit-testable with a fake. The real implementation lives in
+/// RealMxGatewayClient.
+///
+public interface IMxGatewayClient : IAsyncDisposable
+{
+ /// Opens the gateway session and registers the client (Register → serverHandle held internally).
+ /// Resolved connection parameters.
+ /// Cancellation token.
+ Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default);
+
+ /// Closes the session.
+ /// Cancellation token.
+ Task DisconnectAsync(CancellationToken ct = default);
+
+ /// AddItem + Advise; returns the gateway item handle (as a string subscription id).
+ /// Tag address to subscribe to.
+ /// Cancellation token.
+ Task SubscribeAsync(string tagPath, CancellationToken ct = default);
+
+ /// UnAdvise + RemoveItem for a previously returned subscription id.
+ /// Subscription id returned by .
+ /// Cancellation token.
+ Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default);
+
+ /// Snapshot read of one or more tags (ReadBulk).
+ /// Tag addresses to read.
+ /// Cancellation token.
+ Task> ReadAsync(IReadOnlyList tagPaths, CancellationToken ct = default);
+
+ /// Write one or more tag/value pairs (WriteBulk with the configured WriteUserId).
+ /// Tag/value pairs to write.
+ /// Cancellation token.
+ Task> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default);
+
+ /// One Galaxy browse level (BrowseChildren). null → root.
+ /// Parent node id (Galaxy contained path), or null for root.
+ /// Cancellation token.
+ Task<(IReadOnlyList Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default);
+
+ ///
+ /// Long-running event consumer. Invokes for each advised-tag
+ /// data change. Resumes from the last delivered worker sequence on reconnect. Completes
+ /// (or throws) when the stream ends — the adapter treats that as a disconnect.
+ ///
+ /// Callback invoked per advised-tag value change.
+ /// Cancellation token; ends the loop when cancelled.
+ Task RunEventLoopAsync(Action onUpdate, CancellationToken ct = default);
+}
+
+/// Builds instances.
+public interface IMxGatewayClientFactory
+{
+ /// Creates a new, unconnected client instance.
+ IMxGatewayClient Create();
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs
new file mode 100644
index 00000000..79d9b8e2
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs
@@ -0,0 +1,220 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
+using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+///
+/// MxGateway adapter implementing + .
+/// Maps IDataConnection concepts onto the MxAccess Gateway session model via the
+/// seam:
+///
+/// - Connect → OpenSession + Register, then a background event loop.
+/// - Subscribe → AddItem + Advise; value changes arrive on the event stream.
+/// - Read/Write → ReadBulk / WriteBulk.
+/// - Browse → Galaxy repository BrowseChildren.
+///
+/// Reconnection is driven by the DataConnectionActor: a stream fault raises
+/// , the actor disposes this adapter, creates a fresh one,
+/// reconnects and re-subscribes all tags.
+///
+public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
+{
+ private readonly IMxGatewayClientFactory _clientFactory;
+ private readonly ILogger _logger;
+ private IMxGatewayClient? _client;
+ private ConnectionHealth _status = ConnectionHealth.Disconnected;
+ private CancellationTokenSource? _eventLoopCts;
+
+ // subscriptionId → (tagPath, callback) so the event loop can route updates by tag,
+ // plus tagPath → subscriptionId for reverse lookup. Concurrent because the event
+ // loop reads from a background thread while Subscribe/Unsubscribe mutate.
+ private readonly ConcurrentDictionary _subs = new();
+ private readonly ConcurrentDictionary _tagToSub = new();
+
+ // DataConnectionLayer mirror of OpcUaDataConnection's once-only guard: an int toggled
+ // with Interlocked.Exchange so only the first caller raises Disconnected.
+ // 0 = not fired, 1 = fired. Reset on (re)connect.
+ private int _disconnectFired;
+
+ /// Initializes a new instance of .
+ /// Factory used to create gateway client instances.
+ /// Logger instance.
+ public MxGatewayDataConnection(IMxGatewayClientFactory clientFactory, ILogger logger)
+ {
+ _clientFactory = clientFactory;
+ _logger = logger;
+ }
+
+ ///
+ public ConnectionHealth Status => _status;
+
+ /// Raised once when the gateway event stream faults (connection lost).
+ public event Action? Disconnected;
+
+ ///
+ public async Task ConnectAsync(IDictionary connectionDetails, CancellationToken cancellationToken = default)
+ {
+ var cfg = MxGatewayEndpointConfigSerializer.FromFlatDict(connectionDetails);
+ Interlocked.Exchange(ref _disconnectFired, 0); // reset guard on (re)connect, like OPC UA
+ _client = _clientFactory.Create();
+
+ await _client.ConnectAsync(new MxGatewayConnectionOptions(
+ cfg.Endpoint,
+ cfg.ApiKey,
+ string.IsNullOrWhiteSpace(cfg.ClientName) ? "scadabridge" : cfg.ClientName,
+ cfg.WriteUserId,
+ cfg.UseTls,
+ string.IsNullOrWhiteSpace(cfg.CaFile) ? null : cfg.CaFile,
+ string.IsNullOrWhiteSpace(cfg.ServerName) ? null : cfg.ServerName,
+ cfg.ReadTimeoutMs), cancellationToken);
+
+ _status = ConnectionHealth.Connected;
+
+ // Background event loop: route each value change to the matching subscription callback.
+ _eventLoopCts = new CancellationTokenSource();
+ _ = Task.Run(() => RunEventLoopAsync(_eventLoopCts.Token));
+ }
+
+ private async Task RunEventLoopAsync(CancellationToken ct)
+ {
+ try
+ {
+ await _client!.RunEventLoopAsync(update =>
+ {
+ if (_tagToSub.TryGetValue(update.TagPath, out var subId) && _subs.TryGetValue(subId, out var s))
+ s.Callback(update.TagPath, new TagValue(update.Value, update.Quality, update.Timestamp));
+ }, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ // Normal shutdown (DisconnectAsync / DisposeAsync cancelled the loop).
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "MxGateway event stream faulted; signalling disconnect");
+ RaiseDisconnected();
+ }
+ }
+
+ private void RaiseDisconnected()
+ {
+ if (Interlocked.Exchange(ref _disconnectFired, 1) == 0)
+ {
+ _status = ConnectionHealth.Disconnected;
+ Disconnected?.Invoke();
+ }
+ }
+
+ ///
+ public async Task DisconnectAsync(CancellationToken cancellationToken = default)
+ {
+ _eventLoopCts?.Cancel();
+ if (_client is not null)
+ await _client.DisconnectAsync(cancellationToken);
+ _status = ConnectionHealth.Disconnected;
+ }
+
+ ///
+ public async Task SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
+ {
+ var subId = await _client!.SubscribeAsync(tagPath, cancellationToken);
+ _subs[subId] = (tagPath, callback);
+ _tagToSub[tagPath] = subId;
+ return subId;
+ }
+
+ ///
+ public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
+ {
+ if (_subs.TryRemove(subscriptionId, out var s))
+ _tagToSub.TryRemove(s.TagPath, out _);
+ await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
+ }
+
+ ///
+ public async Task ReadAsync(string tagPath, CancellationToken cancellationToken = default)
+ {
+ var r = (await _client!.ReadAsync(new[] { tagPath }, cancellationToken)).Single();
+ return ToReadResult(r);
+ }
+
+ ///
+ public async Task> ReadBatchAsync(IEnumerable tagPaths, CancellationToken cancellationToken = default)
+ {
+ var list = tagPaths.ToList();
+ var results = await _client!.ReadAsync(list, cancellationToken);
+ return results.ToDictionary(r => r.TagPath, ToReadResult);
+ }
+
+ private static ReadResult ToReadResult(MxReadOutcome r) => r.Success
+ ? new ReadResult(true, new TagValue(r.Value, r.Quality, r.Timestamp), null)
+ : new ReadResult(false, null, r.Error);
+
+ ///
+ public async Task WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
+ {
+ var w = (await _client!.WriteAsync(new[] { (tagPath, value) }, cancellationToken)).Single();
+ return new WriteResult(w.Success, w.Error);
+ }
+
+ ///
+ public async Task> WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default)
+ {
+ var results = await _client!.WriteAsync(values.Select(kv => (kv.Key, kv.Value)).ToList(), cancellationToken);
+ return results.ToDictionary(w => w.TagPath, w => new WriteResult(w.Success, w.Error));
+ }
+
+ ///
+ public async Task WriteBatchAndWaitAsync(
+ IDictionary values, string flagPath, object? flagValue,
+ string responsePath, object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default)
+ {
+ await WriteBatchAsync(values, cancellationToken);
+ await WriteAsync(flagPath, flagValue, cancellationToken);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeout);
+ try
+ {
+ while (!timeoutCts.IsCancellationRequested)
+ {
+ var r = await ReadAsync(responsePath, timeoutCts.Token);
+ // r.Value is a TagValue wrapper; compare its underlying scalar. String
+ // projection tolerates numeric type differences across the gRPC boundary.
+ if (r.Success && string.Equals(r.Value?.Value?.ToString(), responseValue?.ToString(), StringComparison.Ordinal))
+ return true;
+ await Task.Delay(TimeSpan.FromMilliseconds(200), timeoutCts.Token);
+ }
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // Timeout elapsed (the linked CTS, not the caller's token) — fall through to false.
+ }
+ return false;
+ }
+
+ ///
+ public async Task BrowseChildrenAsync(string? parentNodeId, CancellationToken cancellationToken = default)
+ {
+ if (_status != ConnectionHealth.Connected || _client is null)
+ throw new ConnectionNotConnectedException($"MxGateway connection is not connected (status: {_status}).");
+
+ var (children, truncated) = await _client.BrowseChildrenAsync(parentNodeId, cancellationToken);
+ var nodes = children
+ .Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
+ .ToList();
+ return new BrowseChildrenResult(nodes, truncated);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ _eventLoopCts?.Cancel();
+ if (_client is not null)
+ await _client.DisposeAsync();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs
new file mode 100644
index 00000000..d34a9968
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs
@@ -0,0 +1,267 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using Grpc.Core;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.MxGateway.Client;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+///
+/// Production implementation over the
+/// ZB.MOM.WW.MxGateway.Client NuGet package. This is the only type in the
+/// Data Connection Layer that references the generated gRPC/protobuf contracts;
+/// the adapter and its tests run entirely against the neutral seam.
+///
+public sealed class RealMxGatewayClient : IMxGatewayClient
+{
+ private readonly ILogger _logger;
+ private readonly ILoggerFactory? _loggerFactory;
+
+ private MxGatewayClient? _client;
+ private GalaxyRepositoryClient? _galaxy;
+ private MxGatewaySession? _session;
+ private int _serverHandle;
+ private int _writeUserId;
+ private int _readTimeoutMs;
+ private ulong _lastSeq;
+
+ // tag ↔ MXAccess item handle, maintained across subscribe/write.
+ private readonly ConcurrentDictionary _tagToHandle = new();
+ private readonly ConcurrentDictionary _handleToTag = new();
+
+ /// Initializes a new instance of .
+ /// Logger factory shared with the gateway client.
+ public RealMxGatewayClient(ILoggerFactory? loggerFactory)
+ {
+ _loggerFactory = loggerFactory;
+ _logger = (loggerFactory ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
+ .CreateLogger();
+ }
+
+ ///
+ public async Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default)
+ {
+ _writeUserId = options.WriteUserId;
+ _readTimeoutMs = options.ReadTimeoutMs;
+
+ var clientOptions = new MxGatewayClientOptions
+ {
+ Endpoint = new Uri(options.Endpoint),
+ ApiKey = options.ApiKey,
+ UseTls = options.UseTls,
+ CaCertificatePath = options.CaFile,
+ ServerNameOverride = options.ServerName,
+ LoggerFactory = _loggerFactory,
+ };
+
+ _client = MxGatewayClient.Create(clientOptions);
+ _galaxy = GalaxyRepositoryClient.Create(clientOptions);
+ _session = await _client.OpenSessionAsync(cancellationToken: ct).ConfigureAwait(false);
+ _serverHandle = await _session.RegisterAsync(options.ClientName, ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task DisconnectAsync(CancellationToken ct = default)
+ {
+ if (_session is not null)
+ await _session.CloseAsync(ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task SubscribeAsync(string tagPath, CancellationToken ct = default)
+ {
+ var handle = await GetOrAddItemHandleAsync(tagPath, ct).ConfigureAwait(false);
+ await _session!.AdviseAsync(_serverHandle, handle, ct).ConfigureAwait(false);
+ return handle.ToString(CultureInfo.InvariantCulture);
+ }
+
+ ///
+ public async Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default)
+ {
+ if (!int.TryParse(subscriptionId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var handle))
+ return;
+
+ await _session!.UnAdviseAsync(_serverHandle, handle, ct).ConfigureAwait(false);
+ await _session.RemoveItemAsync(_serverHandle, handle, ct).ConfigureAwait(false);
+
+ if (_handleToTag.TryRemove(handle, out var tag))
+ _tagToHandle.TryRemove(tag, out _);
+ }
+
+ ///
+ public async Task> ReadAsync(IReadOnlyList tagPaths, CancellationToken ct = default)
+ {
+ var results = await _session!
+ .ReadBulkAsync(_serverHandle, tagPaths, TimeSpan.FromMilliseconds(_readTimeoutMs), ct)
+ .ConfigureAwait(false);
+
+ return results.Select(r => new MxReadOutcome(
+ r.TagAddress,
+ r.WasSuccessful,
+ r.WasSuccessful ? r.Value?.ToClrValue() : null,
+ MapQuality(r.Quality, r.Statuses),
+ r.SourceTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
+ r.WasSuccessful ? null : r.ErrorMessage)).ToList();
+ }
+
+ ///
+ public async Task> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default)
+ {
+ // Build entries in request order; remember the tag for each handle so the
+ // per-handle BulkWriteResult can be mapped back to its tag.
+ var entries = new List(writes.Count);
+ var orderedTags = new List(writes.Count);
+ foreach (var (tag, value) in writes)
+ {
+ var handle = await GetOrAddItemHandleAsync(tag, ct).ConfigureAwait(false);
+ entries.Add(new WriteBulkEntry
+ {
+ ItemHandle = handle,
+ Value = ToMxValue(value),
+ UserId = _writeUserId,
+ });
+ orderedTags.Add(tag);
+ }
+
+ var results = await _session!.WriteBulkAsync(_serverHandle, entries, ct).ConfigureAwait(false);
+
+ // Results are returned in request order; pair by index back to the tags.
+ return results.Select((r, i) => new MxWriteOutcome(
+ i < orderedTags.Count ? orderedTags[i] : (_handleToTag.TryGetValue(r.ItemHandle, out var t) ? t : ""),
+ r.WasSuccessful,
+ r.WasSuccessful ? null : r.ErrorMessage)).ToList();
+ }
+
+ ///
+ public async Task<(IReadOnlyList Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default)
+ {
+ var request = new BrowseChildrenRequest { IncludeAttributes = true };
+ // Object NodeIds are the Galaxy gobject id (encoded as a string); attribute
+ // NodeIds are FullTagReference leaves and never arrive here as a parent.
+ if (!string.IsNullOrEmpty(parentNodeId)
+ && int.TryParse(parentNodeId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var gobjectId))
+ {
+ request.ParentGobjectId = gobjectId;
+ }
+
+ BrowseChildrenReply reply;
+ try
+ {
+ reply = await _galaxy!.BrowseChildrenRawAsync(request, ct).ConfigureAwait(false);
+ }
+ catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
+ {
+ throw new ConnectionNotConnectedException($"MxGateway repository unavailable: {ex.Status.Detail}");
+ }
+
+ var children = new List();
+ for (var i = 0; i < reply.Children.Count; i++)
+ {
+ var obj = reply.Children[i];
+ var hasChildren = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
+ // Navigable container node, keyed by gobject id.
+ children.Add(new MxBrowseChild(
+ obj.GobjectId.ToString(CultureInfo.InvariantCulture),
+ string.IsNullOrEmpty(obj.TagName) ? obj.ContainedName : obj.TagName,
+ BrowseNodeClass.Object,
+ hasChildren || obj.Attributes.Count > 0));
+
+ // Selectable attribute leaves, keyed by their full tag reference.
+ foreach (var attr in obj.Attributes)
+ {
+ children.Add(new MxBrowseChild(
+ attr.FullTagReference,
+ attr.AttributeName,
+ BrowseNodeClass.Variable,
+ false));
+ }
+ }
+
+ return (children, !string.IsNullOrEmpty(reply.NextPageToken));
+ }
+
+ ///
+ public async Task RunEventLoopAsync(Action onUpdate, CancellationToken ct = default)
+ {
+ await foreach (var ev in _session!.StreamEventsAsync(_lastSeq, ct).ConfigureAwait(false))
+ {
+ _lastSeq = ev.WorkerSequence;
+ if (ev.Family != MxEventFamily.OnDataChange)
+ continue;
+ if (!_handleToTag.TryGetValue(ev.ItemHandle, out var tag))
+ continue;
+
+ onUpdate(new MxValueUpdate(
+ tag,
+ ev.Value?.ToClrValue(),
+ MapQuality(ev.Quality, ev.Statuses),
+ ev.SourceTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow));
+ }
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_session is not null) await _session.DisposeAsync().ConfigureAwait(false);
+ if (_client is not null) await _client.DisposeAsync().ConfigureAwait(false);
+ if (_galaxy is not null) await _galaxy.DisposeAsync().ConfigureAwait(false);
+ }
+
+ private async Task GetOrAddItemHandleAsync(string tagPath, CancellationToken ct)
+ {
+ if (_tagToHandle.TryGetValue(tagPath, out var existing))
+ return existing;
+
+ var handle = await _session!.AddItemAsync(_serverHandle, tagPath, ct).ConfigureAwait(false);
+ _tagToHandle[tagPath] = handle;
+ _handleToTag[handle] = tagPath;
+ return handle;
+ }
+
+ ///
+ /// Maps MXAccess quality. A failing status proxy is authoritative bad; otherwise
+ /// the OPC-style quality byte: ≥192 Good, ≥64 Uncertain, else Bad.
+ ///
+ private static QualityCode MapQuality(int quality, IEnumerable statuses)
+ {
+ if (statuses.Any(s => !s.IsSuccess()))
+ return QualityCode.Bad;
+ return quality switch
+ {
+ >= 192 => QualityCode.Good,
+ >= 64 => QualityCode.Uncertain,
+ _ => QualityCode.Bad,
+ };
+ }
+
+ private static MxValue ToMxValue(object? value) => value switch
+ {
+ null => new MxValue { IsNull = true },
+ bool b => b.ToMxValue(),
+ int i => i.ToMxValue(),
+ long l => l.ToMxValue(),
+ float f => f.ToMxValue(),
+ double d => d.ToMxValue(),
+ string s => s.ToMxValue(),
+ DateTimeOffset dto => dto.ToMxValue(),
+ DateTime dt => dt.ToMxValue(),
+ // Fall back to invariant string for any other CLR type.
+ _ => Convert.ToString(value, CultureInfo.InvariantCulture)!.ToMxValue(),
+ };
+}
+
+/// Builds instances.
+public sealed class RealMxGatewayClientFactory : IMxGatewayClientFactory
+{
+ private readonly ILoggerFactory? _loggerFactory;
+
+ /// Initializes a new factory.
+ /// Logger factory passed to each created client.
+ public RealMxGatewayClientFactory(ILoggerFactory? loggerFactory) => _loggerFactory = loggerFactory;
+
+ ///
+ public IMxGatewayClient Create() => new RealMxGatewayClient(_loggerFactory);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs
index 11819cd5..9c9d12f4 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs
@@ -38,6 +38,13 @@ public class DataConnectionFactory : IDataConnectionFactory
RegisterAdapter("OpcUa", details => new OpcUaDataConnection(
new RealOpcUaClientFactory(globalOptions, _loggerFactory),
_loggerFactory.CreateLogger()));
+
+ // MxGateway: gRPC to the MxAccess Gateway. The RealMxGatewayClient wraps the
+ // ZB.MOM.WW.MxGateway.Client package; per-connection config arrives via the
+ // flat details dict (see MxGatewayEndpointConfigSerializer).
+ RegisterAdapter("MxGateway", details => new MxGatewayDataConnection(
+ new RealMxGatewayClientFactory(_loggerFactory),
+ _loggerFactory.CreateLogger()));
}
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/MxGatewayGlobalOptions.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/MxGatewayGlobalOptions.cs
new file mode 100644
index 00000000..190f1017
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/MxGatewayGlobalOptions.cs
@@ -0,0 +1,11 @@
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
+
+///
+/// Deployment-wide MxGateway defaults, bound from the "MxGateway" section of
+/// appsettings.json. Per-endpoint behavior lives on MxGatewayEndpointConfig.
+///
+public class MxGatewayGlobalOptions
+{
+ /// Prefix used to derive a per-connection client registration name when the connection's ClientName is blank.
+ public string ClientNamePrefix { get; set; } = "scadabridge";
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs
index dbb169bb..42cf2e6c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs
@@ -18,6 +18,9 @@ public static class ServiceCollectionExtensions
services.AddOptions()
.BindConfiguration("OpcUa");
+ services.AddOptions()
+ .BindConfiguration("MxGateway");
+
// WP-34: Register the factory for protocol extensibility
services.AddSingleton();
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
index 78302527..7a786ed4 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
@@ -16,6 +16,7 @@
+
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
index a7175a7f..d53212e4 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
@@ -149,12 +149,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive(RouteInboundApiSetAttributes);
// OPC UA Tag Browser — singleton-only re-forward to local /user/dcl-manager.
- // BrowseOpcUaNodeCommand is routed to this singleton (active node) by
+ // BrowseNodeCommand is routed to this singleton (active node) by
// SiteCommunicationActor so the dcl-manager we forward to is guaranteed
// to be the one holding the live DataConnectionActor children. ActorSelection
// has no Forward() extension in this Akka.NET version, so we Tell with the
// original Sender preserved (semantically identical to Forward).
- Receive(msg =>
+ Receive(msg =>
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
// Test Bindings — same singleton-only re-forward as the browse handler
@@ -767,6 +767,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
return Commons.Serialization.OpcUaEndpointConfigSerializer.ToFlatDict(config);
}
+ if (string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase))
+ {
+ var config = Commons.Serialization.MxGatewayEndpointConfigSerializer.Deserialize(json);
+ return Commons.Serialization.MxGatewayEndpointConfigSerializer.ToFlatDict(config);
+ }
+
// Fallback: assume legacy flat-dict shape for any future / unknown protocol.
try
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs
index b9d378b2..aaac4756 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs
@@ -44,12 +44,55 @@ public class DataConnectionFormTests : BunitContext
}
[Fact]
- public void NoProtocolDropdown_IsRendered()
+ public void ProtocolDropdown_IsRendered_OnCreate_WithBothProtocols()
{
var cut = RenderForCreateSite(1);
- Assert.DoesNotContain("Custom", cut.Markup);
var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList();
- Assert.DoesNotContain(labels, l => l == "Protocol");
+ Assert.Contains(labels, l => l == "Protocol");
+
+ // The protocol select offers OPC UA and MxGateway.
+ var optionTexts = cut.FindAll("option").Select(o => o.TextContent.Trim()).ToList();
+ Assert.Contains("OPC UA", optionTexts);
+ Assert.Contains("MxGateway", optionTexts);
+ }
+
+ [Fact]
+ public async Task Save_MxGateway_PersistsTypedJsonAndProtocolMxGateway()
+ {
+ DataConnection? captured = null;
+ await _siteRepo.AddDataConnectionAsync(
+ Arg.Do(d => captured = d));
+
+ var cut = RenderForCreateSite(1);
+
+ // Switch protocol to MxGateway — re-renders with the MxGateway editor.
+ cut.FindAll("select")
+ .First(s => s.QuerySelectorAll("option").Any(o => o.TextContent.Trim() == "MxGateway"))
+ .Change("MxGateway");
+
+ // Name (skip readonly Site plaintext input; MxGateway editor inputs carry placeholders).
+ cut.FindAll("input[type='text']")
+ .First(i => !i.HasAttribute("readonly") && i.GetAttribute("placeholder") is null)
+ .Change("MX-1");
+ // Gateway endpoint
+ cut.FindAll("input[type='text']")
+ .First(i => i.GetAttribute("placeholder")?.StartsWith("http://") == true)
+ .Change("http://gw:5000");
+ // API key (password input)
+ cut.FindAll("input[type='password']")
+ .First(i => i.GetAttribute("placeholder")?.Contains("API key") == true)
+ .Change("secret");
+
+ await cut.FindAll("button")
+ .First(b => b.TextContent.Trim() == "Save").ClickAsync(new());
+
+ Assert.NotNull(captured);
+ Assert.Equal("MxGateway", captured!.Protocol);
+ Assert.NotNull(captured.PrimaryConfiguration);
+
+ using var doc = JsonDocument.Parse(captured.PrimaryConfiguration!);
+ Assert.Equal("http://gw:5000",
+ doc.RootElement.GetProperty("endpoint").GetString());
}
[Fact]
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 9e9f5287..a33eb66b 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs
@@ -44,10 +44,10 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For()));
Services.AddSingleton(Substitute.For());
- // The page renders and at
+ // 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());
Services.AddSingleton(Substitute.For());
// Auth: a system-wide Deployment user so SiteScope grants everything.
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs
index a2042bd3..6fa96525 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
///
-/// Verifies that is discovered by
+/// 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).
@@ -11,13 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
public class BrowseCommandsRegistryTests
{
[Fact]
- public void Registry_discovers_BrowseOpcUaNodeCommand()
+ public void Registry_discovers_BrowseNodeCommand()
{
// 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(BrowseOpcUaNodeCommand));
+ var name = ManagementCommandRegistry.GetCommandName(typeof(BrowseNodeCommand));
- Assert.Equal("BrowseOpcUaNode", name);
- Assert.Equal(typeof(BrowseOpcUaNodeCommand), ManagementCommandRegistry.Resolve(name));
+ Assert.Equal("BrowseNode", name);
+ Assert.Equal(typeof(BrowseNodeCommand), ManagementCommandRegistry.Resolve(name));
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs
new file mode 100644
index 00000000..2493111c
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs
@@ -0,0 +1,84 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
+
+public class MxGatewayEndpointConfigSerializerTests
+{
+ [Fact]
+ public void Serialize_then_Deserialize_round_trips_all_fields()
+ {
+ var original = new MxGatewayEndpointConfig
+ {
+ Endpoint = "https://gw:5001",
+ ApiKey = "secret-key",
+ ClientName = "client-a",
+ WriteUserId = 7,
+ UseTls = true,
+ CaFile = "/certs/ca.pem",
+ ServerName = "gw.local",
+ ReadTimeoutMs = 1234
+ };
+
+ var json = MxGatewayEndpointConfigSerializer.Serialize(original);
+ var round = MxGatewayEndpointConfigSerializer.Deserialize(json);
+
+ Assert.Equal(original.Endpoint, round.Endpoint);
+ Assert.Equal(original.ApiKey, round.ApiKey);
+ Assert.Equal(original.ClientName, round.ClientName);
+ Assert.Equal(original.WriteUserId, round.WriteUserId);
+ Assert.Equal(original.UseTls, round.UseTls);
+ Assert.Equal(original.CaFile, round.CaFile);
+ Assert.Equal(original.ServerName, round.ServerName);
+ Assert.Equal(original.ReadTimeoutMs, round.ReadTimeoutMs);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("{ not valid json")]
+ public void Deserialize_null_blank_or_malformed_returns_default(string? json)
+ {
+ var def = new MxGatewayEndpointConfig();
+ var result = MxGatewayEndpointConfigSerializer.Deserialize(json);
+ Assert.Equal(def.Endpoint, result.Endpoint);
+ Assert.Equal(def.ReadTimeoutMs, result.ReadTimeoutMs);
+ }
+
+ [Fact]
+ public void ToFlatDict_FromFlatDict_round_trips()
+ {
+ var original = new MxGatewayEndpointConfig
+ {
+ Endpoint = "http://x:5000",
+ ApiKey = "k",
+ ClientName = "c",
+ WriteUserId = 3,
+ UseTls = true,
+ CaFile = "/ca",
+ ServerName = "s",
+ ReadTimeoutMs = 999
+ };
+
+ var dict = MxGatewayEndpointConfigSerializer.ToFlatDict(original);
+ var round = MxGatewayEndpointConfigSerializer.FromFlatDict(dict);
+
+ Assert.Equal(original.Endpoint, round.Endpoint);
+ Assert.Equal(original.ApiKey, round.ApiKey);
+ Assert.Equal(original.ClientName, round.ClientName);
+ Assert.Equal(original.WriteUserId, round.WriteUserId);
+ Assert.Equal(original.UseTls, round.UseTls);
+ Assert.Equal(original.CaFile, round.CaFile);
+ Assert.Equal(original.ServerName, round.ServerName);
+ Assert.Equal(original.ReadTimeoutMs, round.ReadTimeoutMs);
+ }
+
+ [Fact]
+ public void FromFlatDict_invalid_numeric_falls_back_to_default()
+ {
+ var back = MxGatewayEndpointConfigSerializer.FromFlatDict(
+ new Dictionary { ["ReadTimeoutMs"] = "not-a-number" });
+ Assert.Equal(new MxGatewayEndpointConfig().ReadTimeoutMs, back.ReadTimeoutMs);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs
new file mode 100644
index 00000000..0813132a
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs
@@ -0,0 +1,77 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.Commons.Validators;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Validators;
+
+public class MxGatewayEndpointConfigValidatorTests
+{
+ private static MxGatewayEndpointConfig Valid() => new()
+ {
+ Endpoint = "http://gw:5000",
+ ApiKey = "key",
+ };
+
+ [Fact]
+ public void Validate_ValidConfig_IsValid()
+ {
+ var result = MxGatewayEndpointConfigValidator.Validate(Valid());
+ Assert.True(result.IsValid);
+ Assert.Empty(result.Errors);
+ }
+
+ [Fact]
+ public void Validate_MissingEndpoint_Fails()
+ {
+ var c = Valid();
+ c.Endpoint = "";
+ var r = MxGatewayEndpointConfigValidator.Validate(c);
+ Assert.False(r.IsValid);
+ Assert.Contains(r.Errors, e =>
+ e.EntityName == "Endpoint"
+ && e.Category == ValidationCategory.ConnectionConfig
+ && e.Message.Contains("required", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Theory]
+ [InlineData("opc.tcp://x:4840")]
+ [InlineData("ftp://x")]
+ [InlineData("not a url")]
+ public void Validate_BadEndpointScheme_Fails(string url)
+ {
+ var c = Valid();
+ c.Endpoint = url;
+ var r = MxGatewayEndpointConfigValidator.Validate(c);
+ Assert.False(r.IsValid);
+ Assert.Contains(r.Errors, e => e.EntityName == "Endpoint");
+ }
+
+ [Fact]
+ public void Validate_MissingApiKey_Fails()
+ {
+ var c = Valid();
+ c.ApiKey = "";
+ var r = MxGatewayEndpointConfigValidator.Validate(c);
+ Assert.False(r.IsValid);
+ Assert.Contains(r.Errors, e => e.EntityName == "ApiKey");
+ }
+
+ [Fact]
+ public void Validate_NonPositiveReadTimeout_Fails()
+ {
+ var c = Valid();
+ c.ReadTimeoutMs = 0;
+ var r = MxGatewayEndpointConfigValidator.Validate(c);
+ Assert.False(r.IsValid);
+ Assert.Contains(r.Errors, e => e.EntityName == "ReadTimeoutMs");
+ }
+
+ [Fact]
+ public void Validate_PrefixedFieldNames_AppearInErrors()
+ {
+ var c = Valid();
+ c.Endpoint = "";
+ var r = MxGatewayEndpointConfigValidator.Validate(c, "Primary.");
+ Assert.Contains(r.Errors, e => e.EntityName == "Primary.Endpoint");
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs
index 110fdce3..20933a6d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// Task 10 (opcua-tag-browser): the site-side
/// + child
/// together resolve
-/// against the live adapter and surface
+/// against the live adapter and surface
/// every browse outcome as a typed . The split is:
/// the manager owns (only it
/// knows the per-site connection set); everything else lives in the child where
@@ -50,9 +50,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
// No CreateConnectionCommand sent — the manager has zero children, so a
// browse against any name must be rejected with ConnectionNotFound
// (the manager is the only actor with site-level visibility).
- manager.Tell(new BrowseOpcUaNodeCommand("unknown-connection", ParentNodeId: null));
+ manager.Tell(new BrowseNodeCommand("unknown-connection", ParentNodeId: null));
- var reply = ExpectMsg();
+ var reply = ExpectMsg();
Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind);
Assert.Empty(reply.Children);
@@ -80,9 +80,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
- manager.Tell(new BrowseOpcUaNodeCommand("conn-bare", ParentNodeId: null));
+ manager.Tell(new BrowseNodeCommand("conn-bare", ParentNodeId: null));
- var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
Assert.Empty(reply.Children);
@@ -120,9 +120,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
- manager.Tell(new BrowseOpcUaNodeCommand("conn-ok", ParentNodeId: null));
+ manager.Tell(new BrowseNodeCommand("conn-ok", ParentNodeId: null));
- var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
Assert.Null(reply.Failure);
Assert.Equal(2, reply.Children.Count);
Assert.Equal("ns=2;s=A", reply.Children[0].NodeId);
@@ -155,9 +155,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
- manager.Tell(new BrowseOpcUaNodeCommand("conn-down", ParentNodeId: null));
+ manager.Tell(new BrowseNodeCommand("conn-down", ParentNodeId: null));
- var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
Assert.Empty(reply.Children);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs
new file mode 100644
index 00000000..1118dedc
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs
@@ -0,0 +1,65 @@
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
+
+///
+/// In-memory fake for adapter unit tests. Lets tests
+/// drive the event loop (push updates / fault the stream) and stub read/write/browse.
+///
+public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFactory
+{
+ public MxGatewayConnectionOptions? ConnectedWith;
+ public readonly List Subscribed = new();
+ public readonly List Unsubscribed = new();
+ public readonly TaskCompletionSource EventLoopGate = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public Action? OnUpdate;
+ public Func, IReadOnlyList>? ReadHandler;
+ public Func, IReadOnlyList>? WriteHandler;
+ public Func, bool)>? BrowseHandler;
+ private int _nextHandle;
+
+ public IMxGatewayClient Create() => this;
+
+ public Task ConnectAsync(MxGatewayConnectionOptions o, CancellationToken ct = default)
+ {
+ ConnectedWith = o;
+ return Task.CompletedTask;
+ }
+
+ public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
+
+ public Task SubscribeAsync(string tag, CancellationToken ct = default)
+ {
+ var id = (++_nextHandle).ToString();
+ Subscribed.Add(tag);
+ return Task.FromResult(id);
+ }
+
+ public Task UnsubscribeAsync(string id, CancellationToken ct = default)
+ {
+ Unsubscribed.Add(id);
+ return Task.CompletedTask;
+ }
+
+ public Task> ReadAsync(IReadOnlyList tags, CancellationToken ct = default)
+ => Task.FromResult(ReadHandler!(tags));
+
+ public Task> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> w, CancellationToken ct = default)
+ => Task.FromResult(WriteHandler!(w));
+
+ public Task<(IReadOnlyList Children, bool Truncated)> BrowseChildrenAsync(string? p, CancellationToken ct = default)
+ => Task.FromResult(BrowseHandler!(p));
+
+ public async Task RunEventLoopAsync(Action onUpdate, CancellationToken ct = default)
+ {
+ OnUpdate = onUpdate;
+ using var reg = ct.Register(() => EventLoopGate.TrySetResult());
+ await EventLoopGate.Task; // test completes this to end the loop…
+ ct.ThrowIfCancellationRequested(); // …or FaultEventLoop() faults it to simulate a stream break
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ /// Simulate a stream break so the adapter raises Disconnected.
+ public void FaultEventLoop() => EventLoopGate.TrySetException(new Exception("stream broke"));
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs
new file mode 100644
index 00000000..a6548105
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs
@@ -0,0 +1,259 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
+
+public class MxGatewayDataConnectionTests
+{
+ private static MxGatewayDataConnection NewAdapter(FakeMxGatewayClient fake) =>
+ new(fake, NullLogger.Instance);
+
+ private static Dictionary Details(int writeUserId = 0) => new()
+ {
+ ["Endpoint"] = "http://gw:5000",
+ ["ApiKey"] = "key",
+ ["ClientName"] = "client-a",
+ ["WriteUserId"] = writeUserId.ToString(),
+ ["ReadTimeoutMs"] = "2000",
+ };
+
+ // ── Task 6: connect / status / Disconnected ──
+
+ [Fact]
+ public async Task ConnectAsync_resolves_options_and_sets_status_connected()
+ {
+ var fake = new FakeMxGatewayClient();
+ var adapter = NewAdapter(fake);
+
+ await adapter.ConnectAsync(Details(writeUserId: 7));
+
+ Assert.Equal(ConnectionHealth.Connected, adapter.Status);
+ Assert.NotNull(fake.ConnectedWith);
+ Assert.Equal("http://gw:5000", fake.ConnectedWith!.Endpoint);
+ Assert.Equal("client-a", fake.ConnectedWith.ClientName);
+ Assert.Equal(7, fake.ConnectedWith.WriteUserId);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_blank_client_name_defaults_to_scadabridge()
+ {
+ var fake = new FakeMxGatewayClient();
+ var adapter = NewAdapter(fake);
+ var details = Details();
+ details["ClientName"] = "";
+
+ await adapter.ConnectAsync(details);
+
+ Assert.Equal("scadabridge", fake.ConnectedWith!.ClientName);
+ }
+
+ [Fact]
+ public async Task Disconnected_fires_exactly_once_when_event_loop_faults()
+ {
+ var fake = new FakeMxGatewayClient();
+ var adapter = NewAdapter(fake);
+ int raised = 0;
+ adapter.Disconnected += () => Interlocked.Increment(ref raised);
+
+ await adapter.ConnectAsync(Details());
+ // Wait for the event loop to attach.
+ await WaitUntil(() => fake.OnUpdate is not null);
+
+ fake.FaultEventLoop();
+ await WaitUntil(() => raised >= 1);
+
+ Assert.Equal(1, raised);
+ Assert.Equal(ConnectionHealth.Disconnected, adapter.Status);
+ }
+
+ // ── Task 7: subscribe / unsubscribe + event routing ──
+
+ [Fact]
+ public async Task Subscribed_tag_update_invokes_callback_with_mapped_TagValue()
+ {
+ var fake = new FakeMxGatewayClient();
+ var adapter = NewAdapter(fake);
+ await adapter.ConnectAsync(Details());
+ await WaitUntil(() => fake.OnUpdate is not null);
+
+ TagValue? received = null;
+ await adapter.SubscribeAsync("Area.Pump.Speed", (_, v) => received = v);
+ Assert.Contains("Area.Pump.Speed", fake.Subscribed);
+
+ var ts = DateTimeOffset.UtcNow;
+ fake.OnUpdate!(new MxValueUpdate("Area.Pump.Speed", 42.0, QualityCode.Good, ts));
+
+ Assert.NotNull(received);
+ Assert.Equal(42.0, received!.Value);
+ Assert.Equal(QualityCode.Good, received.Quality);
+ Assert.Equal(ts, received.Timestamp);
+ }
+
+ [Fact]
+ public async Task Unsubscribe_stops_routing_updates()
+ {
+ var fake = new FakeMxGatewayClient();
+ var adapter = NewAdapter(fake);
+ await adapter.ConnectAsync(Details());
+ await WaitUntil(() => fake.OnUpdate is not null);
+
+ int hits = 0;
+ var subId = await adapter.SubscribeAsync("T", (_, _) => hits++);
+ await adapter.UnsubscribeAsync(subId);
+
+ fake.OnUpdate!(new MxValueUpdate("T", 1, QualityCode.Good, DateTimeOffset.UtcNow));
+
+ Assert.Equal(0, hits);
+ Assert.Contains(subId, fake.Unsubscribed);
+ }
+
+ // ── Task 8: read / write + error classification ──
+
+ [Fact]
+ public async Task ReadAsync_maps_success_and_failure()
+ {
+ var fake = new FakeMxGatewayClient
+ {
+ ReadHandler = tags => tags.Select(t => t == "ok"
+ ? new MxReadOutcome(t, true, 5, QualityCode.Good, DateTimeOffset.UtcNow, null)
+ : new MxReadOutcome(t, false, null, QualityCode.Bad, default, "bad tag")).ToList()
+ };
+ var adapter = NewAdapter(fake);
+ await adapter.ConnectAsync(Details());
+
+ var ok = await adapter.ReadAsync("ok");
+ Assert.True(ok.Success);
+ Assert.Equal(5, ok.Value!.Value);
+
+ var bad = await adapter.ReadAsync("nope");
+ Assert.False(bad.Success);
+ Assert.Equal("bad tag", bad.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task ReadBatchAsync_returns_dictionary_keyed_by_tag()
+ {
+ var fake = new FakeMxGatewayClient
+ {
+ ReadHandler = tags => tags.Select(t =>
+ new MxReadOutcome(t, true, t.Length, QualityCode.Good, DateTimeOffset.UtcNow, null)).ToList()
+ };
+ var adapter = NewAdapter(fake);
+ await adapter.ConnectAsync(Details());
+
+ var results = await adapter.ReadBatchAsync(new[] { "aa", "bbb" });
+ Assert.Equal(2, results["aa"].Value!.Value);
+ Assert.Equal(3, results["bbb"].Value!.Value);
+ }
+
+ [Fact]
+ public async Task WriteAsync_maps_failure_to_unsuccessful_WriteResult()
+ {
+ var fake = new FakeMxGatewayClient
+ {
+ WriteHandler = writes => writes.Select(w =>
+ new MxWriteOutcome(w.TagPath, false, "rejected")).ToList()
+ };
+ var adapter = NewAdapter(fake);
+ await adapter.ConnectAsync(Details());
+
+ var r = await adapter.WriteAsync("T", 1);
+ Assert.False(r.Success);
+ Assert.Equal("rejected", r.ErrorMessage);
+ }
+
+ // ── Task 9: WriteBatchAndWait ──
+
+ [Fact]
+ public async Task WriteBatchAndWait_returns_true_when_response_matches()
+ {
+ var writeCalls = new List();
+ var fake = new FakeMxGatewayClient
+ {
+ WriteHandler = writes =>
+ {
+ writeCalls.AddRange(writes.Select(w => w.TagPath));
+ return writes.Select(w => new MxWriteOutcome(w.TagPath, true, null)).ToList();
+ },
+ // Response path already reads the expected value.
+ ReadHandler = tags => tags.Select(t =>
+ new MxReadOutcome(t, true, "DONE", QualityCode.Good, DateTimeOffset.UtcNow, null)).ToList()
+ };
+ var adapter = NewAdapter(fake);
+ await adapter.ConnectAsync(Details());
+
+ var ok = await adapter.WriteBatchAndWaitAsync(
+ new Dictionary { ["V"] = 1 }, "Flag", 1, "Resp", "DONE",
+ TimeSpan.FromSeconds(5));
+
+ Assert.True(ok);
+ // Values written before the flag, and the flag itself.
+ Assert.Contains("V", writeCalls);
+ Assert.Contains("Flag", writeCalls);
+ }
+
+ [Fact]
+ public async Task WriteBatchAndWait_returns_false_on_timeout()
+ {
+ var fake = new FakeMxGatewayClient
+ {
+ WriteHandler = writes => writes.Select(w => new MxWriteOutcome(w.TagPath, true, null)).ToList(),
+ ReadHandler = tags => tags.Select(t =>
+ new MxReadOutcome(t, true, "NEVER", QualityCode.Good, DateTimeOffset.UtcNow, null)).ToList()
+ };
+ var adapter = NewAdapter(fake);
+ await adapter.ConnectAsync(Details());
+
+ var ok = await adapter.WriteBatchAndWaitAsync(
+ new Dictionary { ["V"] = 1 }, "Flag", 1, "Resp", "DONE",
+ TimeSpan.FromMilliseconds(300));
+
+ Assert.False(ok);
+ }
+
+ // ── Task 10: browse ──
+
+ [Fact]
+ public async Task BrowseChildrenAsync_maps_children_and_truncated()
+ {
+ var fake = new FakeMxGatewayClient
+ {
+ BrowseHandler = _ => (new List
+ {
+ new("Area1", "Area1", BrowseNodeClass.Object, true),
+ new("Area1.Pump.Speed", "Speed", BrowseNodeClass.Variable, false),
+ }, true)
+ };
+ var adapter = NewAdapter(fake);
+ await adapter.ConnectAsync(Details());
+
+ var result = await adapter.BrowseChildrenAsync(null);
+
+ Assert.True(result.Truncated);
+ Assert.Equal(2, result.Children.Count);
+ Assert.Equal(BrowseNodeClass.Object, result.Children[0].NodeClass);
+ Assert.True(result.Children[0].HasChildren);
+ Assert.Equal("Area1.Pump.Speed", result.Children[1].NodeId);
+ Assert.Equal(BrowseNodeClass.Variable, result.Children[1].NodeClass);
+ }
+
+ [Fact]
+ public async Task BrowseChildrenAsync_throws_when_not_connected()
+ {
+ var fake = new FakeMxGatewayClient();
+ var adapter = NewAdapter(fake);
+ // Not connected.
+ await Assert.ThrowsAsync(
+ () => adapter.BrowseChildrenAsync(null));
+ }
+
+ private static async Task WaitUntil(Func condition, int timeoutMs = 2000)
+ {
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ while (!condition() && sw.ElapsedMilliseconds < timeoutMs)
+ await Task.Delay(10);
+ Assert.True(condition(), "Condition not met within timeout.");
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs
index d7a31039..97d31170 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs
@@ -18,6 +18,16 @@ public class DataConnectionFactoryTests
Assert.IsType(connection);
}
+ [Fact]
+ public void Create_MxGateway_ReturnsMxGatewayAdapter()
+ {
+ var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
+
+ var connection = factory.Create("MxGateway", new Dictionary());
+
+ Assert.IsType(connection);
+ }
+
[Fact]
public void Create_CaseInsensitive()
{
|