Expand XML docs across bridge and test code

This commit is contained in:
Joseph Doherty
2026-03-25 11:45:12 -04:00
parent 3f813b3869
commit 4833765606
86 changed files with 2323 additions and 0 deletions

View File

@@ -5,9 +5,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class AppConfiguration
{
/// <summary>
/// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space.
/// </summary>
public OpcUaConfiguration OpcUa { get; set; } = new OpcUaConfiguration();
/// <summary>
/// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes.
/// </summary>
public MxAccessConfiguration MxAccess { get; set; } = new MxAccessConfiguration();
/// <summary>
/// Gets or sets the repository settings used to query Galaxy metadata for address-space construction.
/// </summary>
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new GalaxyRepositoryConfiguration();
/// <summary>
/// Gets or sets the embedded dashboard settings used to surface service health to operators.
/// </summary>
public DashboardConfiguration Dashboard { get; set; } = new DashboardConfiguration();
}
}

View File

@@ -9,6 +9,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
/// <summary>
/// Validates the effective host configuration and writes the resolved values to the startup log before service initialization continues.
/// </summary>
/// <param name="config">The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries, and dashboard behavior.</param>
/// <returns><see langword="true"/> when the required settings are present and within supported bounds; otherwise, <see langword="false"/>.</returns>
public static bool ValidateAndLog(AppConfiguration config)
{
bool valid = true;

View File

@@ -5,8 +5,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class DashboardConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state.
/// </summary>
public int Port { get; set; } = 8081;
/// <summary>
/// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot.
/// </summary>
public int RefreshIntervalSeconds { get; set; } = 10;
}
}

View File

@@ -5,9 +5,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class GalaxyRepositoryConfiguration
{
/// <summary>
/// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata.
/// </summary>
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
/// <summary>
/// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space rebuild.
/// </summary>
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model.
/// </summary>
public bool ExtendedAttributes { get; set; } = false;
}
}

View File

@@ -5,15 +5,54 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class MxAccessConfiguration
{
/// <summary>
/// Gets or sets the client name registered with the MXAccess runtime for this bridge instance.
/// </summary>
public string ClientName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node.
/// </summary>
public string? NodeName { get; set; }
/// <summary>
/// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics.
/// </summary>
public string? GalaxyName { get; set; }
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete.
/// </summary>
public int ReadTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime.
/// </summary>
public int WriteTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime.
/// </summary>
public int MaxConcurrentOperations { get; set; } = 10;
/// <summary>
/// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection.
/// </summary>
public int MonitorIntervalSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess session.
/// </summary>
public bool AutoReconnect { get; set; } = true;
/// <summary>
/// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data.
/// </summary>
public string? ProbeTag { get; set; }
/// <summary>
/// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale.
/// </summary>
public int ProbeStaleThresholdSeconds { get; set; } = 60;
}
}

View File

@@ -5,11 +5,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class OpcUaConfiguration
{
/// <summary>
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
/// </summary>
public int Port { get; set; } = 4840;
/// <summary>
/// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server.
/// </summary>
public string EndpointPath { get; set; } = "/LmxOpcUa";
/// <summary>
/// Gets or sets the server name presented to OPC UA clients and used in diagnostics.
/// </summary>
public string ServerName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy name represented by the published OPC UA namespace.
/// </summary>
public string GalaxyName { get; set; } = "ZB";
/// <summary>
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
/// </summary>
public int MaxSessions { get; set; } = 100;
/// <summary>
/// Gets or sets the session timeout, in minutes, before idle client sessions are closed.
/// </summary>
public int SessionTimeoutMinutes { get; set; } = 30;
}
}

View File

@@ -5,11 +5,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public enum ConnectionState
{
/// <summary>
/// No active session exists to the Galaxy runtime.
/// </summary>
Disconnected,
/// <summary>
/// The bridge is opening a new MXAccess session to the runtime.
/// </summary>
Connecting,
/// <summary>
/// The bridge has an active MXAccess session and can service reads, writes, and subscriptions.
/// </summary>
Connected,
/// <summary>
/// The bridge is closing the current MXAccess session and draining runtime resources.
/// </summary>
Disconnecting,
/// <summary>
/// The bridge detected a connection fault that requires operator attention or recovery logic.
/// </summary>
Error,
/// <summary>
/// The bridge is attempting to restore service after a runtime communication failure.
/// </summary>
Reconnecting
}
}

View File

@@ -7,10 +7,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// Gets the previous MXAccess connection state before the transition was raised.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the new MXAccess connection state that the bridge moved into.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets an operator-facing message that explains why the connection state changed.
/// </summary>
public string Message { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs"/> class.
/// </summary>
/// <param name="previous">The connection state being exited.</param>
/// <param name="current">The connection state being entered.</param>
/// <param name="message">Additional context about the transition, such as a connection fault or reconnect attempt.</param>
public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
{
PreviousState = previous;

View File

@@ -5,15 +5,54 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public class GalaxyAttributeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier that owns the attribute.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the attribute name as defined on the Galaxy template or instance.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets the human-readable Galaxy data type name returned by the repository query.
/// </summary>
public string DataTypeName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients.
/// </summary>
public string PrimitiveName { get; set; } = "";
/// <summary>
/// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation, or runtime data.
/// </summary>
public string AttributeSource { get; set; } = "";
}
}

View File

@@ -5,11 +5,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public class GalaxyObjectInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the contained name shown for the object inside its parent area or object.
/// </summary>
public string ContainedName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object.
/// </summary>
public bool IsArea { get; set; }
}
}

View File

@@ -10,11 +10,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public interface IGalaxyRepository
{
/// <summary>
/// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of Galaxy objects ordered for address-space construction.</returns>
Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default);
/// <summary>
/// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of attribute definitions with MXAccess references and type metadata.</returns>
Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default);
/// <summary>
/// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>The latest deploy timestamp, or <see langword="null"/> when it cannot be determined.</returns>
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
/// <summary>
/// Verifies that the service can reach the Galaxy repository before it attempts to build the address space.
/// </summary>
/// <param name="ct">A token that cancels the connectivity check.</param>
/// <returns><see langword="true"/> when repository access succeeds; otherwise, <see langword="false"/>.</returns>
Task<bool> TestConnectionAsync(CancellationToken ct = default);
/// <summary>
/// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild.
/// </summary>
event Action? OnGalaxyChanged;
}
}

View File

@@ -10,20 +10,70 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public interface IMxAccessClient : IDisposable
{
/// <summary>
/// Gets the current runtime connectivity state for the bridge.
/// </summary>
ConnectionState State { get; }
/// <summary>
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed Galaxy attribute publishes a new runtime value.
/// </summary>
event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Opens the MXAccess session required for runtime reads, writes, and subscriptions.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
Task ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Closes the MXAccess session and releases runtime resources.
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="callback">The callback to invoke when the runtime publishes a new value for the attribute.</param>
Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback);
/// <summary>
/// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
Task UnsubscribeAsync(string fullTagReference);
/// <summary>
/// Reads the current runtime value for a Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="ct">A token that cancels the read.</param>
/// <returns>The value, timestamp, and quality returned by the runtime.</returns>
Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default);
/// <summary>
/// Writes a new runtime value to a writable Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="value">The value to write to the runtime.</param>
/// <param name="ct">A token that cancels the write.</param>
/// <returns><see langword="true"/> when the write is accepted by the runtime; otherwise, <see langword="false"/>.</returns>
Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
/// <summary>
/// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
/// </summary>
int ActiveSubscriptionCount { get; }
/// <summary>
/// Gets the number of reconnect cycles attempted since the client was created.
/// </summary>
int ReconnectCount { get; }
}
}

View File

@@ -6,6 +6,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Delegate matching LMXProxyServer.OnDataChange COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that raised the change.</param>
/// <param name="phItemHandle">The runtime item handle for the attribute that changed.</param>
/// <param name="pvItemValue">The new raw runtime value for the attribute.</param>
/// <param name="pwItemQuality">The OPC DA quality code supplied by the runtime.</param>
/// <param name="pftItemTimeStamp">The timestamp object supplied by the runtime for the value.</param>
/// <param name="ItemStatus">The MXAccess status payload associated with the callback.</param>
public delegate void MxDataChangeHandler(
int hLMXServerHandle,
int phItemHandle,
@@ -17,6 +23,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Delegate matching LMXProxyServer.OnWriteComplete COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that processed the write.</param>
/// <param name="phItemHandle">The runtime item handle that was written.</param>
/// <param name="ItemStatus">The MXAccess status payload describing the write outcome.</param>
public delegate void MxWriteCompleteHandler(
int hLMXServerHandle,
int phItemHandle,
@@ -27,15 +36,65 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public interface IMxProxy
{
/// <summary>
/// Registers the bridge as an MXAccess client with the runtime proxy.
/// </summary>
/// <param name="clientName">The client identity reported to the runtime for diagnostics and session tracking.</param>
/// <returns>The runtime connection handle assigned to the client session.</returns>
int Register(string clientName);
/// <summary>
/// Unregisters the bridge from the runtime proxy and releases the connection handle.
/// </summary>
/// <param name="handle">The connection handle returned by <see cref="Register(string)"/>.</param>
void Unregister(int handle);
/// <summary>
/// Adds a Galaxy attribute reference to the active runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified attribute reference to resolve.</param>
/// <returns>The runtime item handle assigned to the attribute.</returns>
int AddItem(int handle, string address);
/// <summary>
/// Removes a previously registered attribute from the runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle returned by <see cref="AddItem(int, string)"/>.</param>
void RemoveItem(int handle, int itemHandle);
/// <summary>
/// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to monitor.</param>
void AdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Stops supervisory updates for an attribute.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to stop monitoring.</param>
void UnAdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Writes a new value to a runtime attribute through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
/// <param name="value">The new value to push into the runtime.</param>
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
void Write(int handle, int itemHandle, object value, int securityClassification);
/// <summary>
/// Occurs when the runtime pushes a data-change callback for a subscribed attribute.
/// </summary>
event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the runtime acknowledges completion of a write request.
/// </summary>
event MxWriteCompleteHandler? OnWriteComplete;
}
}

View File

@@ -12,6 +12,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// Maps mx_data_type to OPC UA DataType NodeId numeric identifier.
/// Unknown types default to String (i=12).
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA built-in data type node identifier.</returns>
public static uint MapToOpcUaDataType(int mxDataType)
{
return mxDataType switch
@@ -35,6 +37,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Maps mx_data_type to the corresponding CLR type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The CLR type used to represent runtime values for the MX type.</returns>
public static Type MapToClrType(int mxDataType)
{
return mxDataType switch
@@ -58,6 +62,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Returns the OPC UA type name for a given mx_data_type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA type name used in diagnostics.</returns>
public static string GetOpcUaTypeName(int mxDataType)
{
return mxDataType switch

View File

@@ -5,13 +5,41 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public static class MxErrorCodes
{
/// <summary>
/// The requested Galaxy attribute reference does not resolve in the runtime.
/// </summary>
public const int MX_E_InvalidReference = 1008;
/// <summary>
/// The supplied value does not match the attribute's configured data type.
/// </summary>
public const int MX_E_WrongDataType = 1012;
/// <summary>
/// The target attribute cannot be written because it is read-only or protected.
/// </summary>
public const int MX_E_NotWritable = 1013;
/// <summary>
/// The runtime did not complete the operation within the configured timeout.
/// </summary>
public const int MX_E_RequestTimedOut = 1014;
/// <summary>
/// Communication with the MXAccess runtime failed during the operation.
/// </summary>
public const int MX_E_CommFailure = 1015;
/// <summary>
/// The operation was attempted without an active MXAccess session.
/// </summary>
public const int MX_E_NotConnected = 1016;
/// <summary>
/// Converts a numeric MXAccess error code into an operator-facing message.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>A human-readable description of the runtime failure.</returns>
public static string GetMessage(int errorCode)
{
return errorCode switch
@@ -26,6 +54,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
};
}
/// <summary>
/// Maps an MXAccess error code to the OPC quality state that should be exposed to clients.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>The quality classification that best represents the runtime failure.</returns>
public static Quality MapToQuality(int errorCode)
{
return errorCode switch

View File

@@ -6,31 +6,96 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
public enum Quality : byte
{
// Bad family (0-63)
/// <summary>
/// No valid process value is available.
/// </summary>
Bad = 0,
/// <summary>
/// The value is invalid because the Galaxy attribute definition or mapping is wrong.
/// </summary>
BadConfigError = 4,
/// <summary>
/// The bridge is not currently connected to the Galaxy runtime.
/// </summary>
BadNotConnected = 8,
/// <summary>
/// The runtime device or adapter failed while obtaining the value.
/// </summary>
BadDeviceFailure = 12,
/// <summary>
/// The underlying field source reported a bad sensor condition.
/// </summary>
BadSensorFailure = 16,
/// <summary>
/// Communication with the runtime failed while retrieving the value.
/// </summary>
BadCommFailure = 20,
/// <summary>
/// The attribute is intentionally unavailable for service, such as a locked or unwritable value.
/// </summary>
BadOutOfService = 24,
/// <summary>
/// The bridge is still waiting for the first usable value after startup or resubscription.
/// </summary>
BadWaitingForInitialData = 32,
// Uncertain family (64-191)
/// <summary>
/// A value is available, but it should be treated cautiously.
/// </summary>
Uncertain = 64,
/// <summary>
/// The last usable value is being repeated because a newer one is unavailable.
/// </summary>
UncertainLastUsable = 68,
/// <summary>
/// The sensor or source is providing a value with reduced accuracy.
/// </summary>
UncertainSensorNotAccurate = 80,
/// <summary>
/// The value exceeds its engineered limits.
/// </summary>
UncertainEuExceeded = 84,
/// <summary>
/// The source is operating in a degraded or subnormal state.
/// </summary>
UncertainSubNormal = 88,
// Good family (192+)
/// <summary>
/// The value is current and suitable for normal client use.
/// </summary>
Good = 192,
/// <summary>
/// The value is good but currently overridden locally rather than flowing from the live source.
/// </summary>
GoodLocalOverride = 216
}
/// <summary>
/// Helper methods for reasoning about OPC quality families used by the bridge.
/// </summary>
public static class QualityExtensions
{
/// <summary>
/// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true"/> when the value is in the good quality range; otherwise, <see langword="false"/>.</returns>
public static bool IsGood(this Quality q) => (byte)q >= 192;
/// <summary>
/// Determines whether the quality represents an uncertain runtime value that should be treated cautiously.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true"/> when the value is in the uncertain range; otherwise, <see langword="false"/>.</returns>
public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 192;
/// <summary>
/// Determines whether the quality represents a bad runtime value that should not be used as valid process data.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true"/> when the value is in the bad range; otherwise, <see langword="false"/>.</returns>
public static bool IsBad(this Quality q) => (byte)q < 64;
}
}

View File

@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality.
/// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad.
/// </summary>
/// <param name="mxQuality">The raw MXAccess quality integer.</param>
/// <returns>The mapped bridge quality value.</returns>
public static Quality MapFromMxAccessQuality(int mxQuality)
{
var b = (byte)(mxQuality & 0xFF);
@@ -26,6 +28,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Maps domain Quality to OPC UA StatusCode uint32.
/// </summary>
/// <param name="quality">The bridge quality value.</param>
/// <returns>The OPC UA status code represented as a 32-bit unsigned integer.</returns>
public static uint MapToOpcUaStatusCode(Quality quality)
{
return quality switch

View File

@@ -7,10 +7,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public readonly struct Vtq : IEquatable<Vtq>
{
/// <summary>
/// Gets the runtime value returned for the Galaxy attribute.
/// </summary>
public object? Value { get; }
/// <summary>
/// Gets the timestamp associated with the runtime value.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets the quality classification that tells OPC UA clients whether the value is usable.
/// </summary>
public Quality Quality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Vtq"/> struct for a Galaxy attribute value.
/// </summary>
/// <param name="value">The runtime value returned by MXAccess.</param>
/// <param name="timestamp">The timestamp assigned to the runtime value.</param>
/// <param name="quality">The quality classification for the runtime value.</param>
public Vtq(object? value, DateTime timestamp, Quality quality)
{
Value = value;
@@ -18,15 +35,42 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
Quality = quality;
}
/// <summary>
/// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value.
/// </summary>
/// <param name="value">The runtime value to wrap.</param>
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and good quality.</returns>
public static Vtq Good(object? value) => new Vtq(value, DateTime.UtcNow, Quality.Good);
/// <summary>
/// Creates a bad-quality VTQ snapshot when no usable runtime value is available.
/// </summary>
/// <param name="quality">The specific bad quality reason to expose to clients.</param>
/// <returns>A VTQ with no value, the current UTC timestamp, and the requested bad quality.</returns>
public static Vtq Bad(Quality quality = Quality.Bad) => new Vtq(null, DateTime.UtcNow, quality);
/// <summary>
/// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously.
/// </summary>
/// <param name="value">The runtime value to wrap.</param>
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and uncertain quality.</returns>
public static Vtq Uncertain(object? value) => new Vtq(value, DateTime.UtcNow, Quality.Uncertain);
/// <summary>
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
/// </summary>
/// <param name="other">The other VTQ snapshot to compare.</param>
/// <returns><see langword="true"/> when all fields match; otherwise, <see langword="false"/>.</returns>
public bool Equals(Vtq other) =>
Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
/// <inheritdoc />
public override bool Equals(object? obj) => obj is Vtq other && Equals(other);
/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(Value, Timestamp, Quality);
/// <inheritdoc />
public override string ToString() => $"Vtq({Value}, {Timestamp:O}, {Quality})";
}
}

View File

@@ -19,9 +19,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
private CancellationTokenSource? _cts;
private DateTime? _lastKnownDeployTime;
/// <summary>
/// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt.
/// </summary>
public event Action? OnGalaxyChanged;
/// <summary>
/// Gets the last deploy timestamp observed by the polling loop.
/// </summary>
public DateTime? LastKnownDeployTime => _lastKnownDeployTime;
/// <summary>
/// Initializes a new change detector for Galaxy deploy timestamps.
/// </summary>
/// <param name="repository">The repository used to query the latest deploy timestamp.</param>
/// <param name="intervalSeconds">The polling interval, in seconds, between deploy checks.</param>
/// <param name="initialDeployTime">An optional deploy timestamp already known at service startup.</param>
public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds, DateTime? initialDeployTime = null)
{
_repository = repository;
@@ -29,6 +42,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
_lastKnownDeployTime = initialDeployTime;
}
/// <summary>
/// Starts the background polling loop that watches for Galaxy deploy changes.
/// </summary>
public void Start()
{
_cts = new CancellationTokenSource();
@@ -36,6 +52,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
Log.Information("Change detection started (interval={Interval}s)", _intervalSeconds);
}
/// <summary>
/// Stops the background polling loop.
/// </summary>
public void Stop()
{
_cts?.Cancel();
@@ -88,6 +107,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
}
}
/// <summary>
/// Stops the polling loop and disposes the underlying cancellation resources.
/// </summary>
public void Dispose()
{
Stop();

View File

@@ -18,6 +18,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
private readonly GalaxyRepositoryConfiguration _config;
/// <summary>
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
/// </summary>
public event Action? OnGalaxyChanged;
#region SQL Queries (GR-006: const string, no dynamic SQL)
@@ -204,11 +207,20 @@ ORDER BY tag_name, primitive_name, attribute_name";
#endregion
/// <summary>
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
/// </summary>
/// <param name="config">The repository connection, timeout, and attribute-selection settings.</param>
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
{
_config = config;
}
/// <summary>
/// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The deployed Galaxy objects that should appear in the namespace.</returns>
public async Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
{
var results = new List<GalaxyObjectInfo>();
@@ -240,6 +252,11 @@ ORDER BY tag_name, primitive_name, attribute_name";
return results;
}
/// <summary>
/// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The attribute rows required to build runtime tag mappings and variable metadata.</returns>
public async Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
{
var results = new List<GalaxyAttributeInfo>();
@@ -304,6 +321,11 @@ ORDER BY tag_name, primitive_name, attribute_name";
};
}
/// <summary>
/// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The most recent deploy timestamp, or <see langword="null"/> when none is available.</returns>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
using var conn = new SqlConnection(_config.ConnectionString);
@@ -315,6 +337,11 @@ ORDER BY tag_name, primitive_name, attribute_name";
return result is DateTime dt ? dt : null;
}
/// <summary>
/// Executes a lightweight query to confirm that the repository database is reachable.
/// </summary>
/// <param name="ct">A token that cancels the connectivity check.</param>
/// <returns><see langword="true"/> when the query succeeds; otherwise, <see langword="false"/>.</returns>
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
{
try
@@ -335,6 +362,9 @@ ORDER BY tag_name, primitive_name, attribute_name";
}
}
/// <summary>
/// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy.
/// </summary>
public void RaiseGalaxyChanged() => OnGalaxyChanged?.Invoke();
}
}

View File

@@ -7,11 +7,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
/// </summary>
public class GalaxyRepositoryStats
{
/// <summary>
/// Gets or sets the Galaxy name currently being represented by the bridge.
/// </summary>
public string GalaxyName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the Galaxy repository database is reachable.
/// </summary>
public bool DbConnected { get; set; }
/// <summary>
/// Gets or sets the latest deploy timestamp read from the Galaxy repository.
/// </summary>
public DateTime? LastDeployTime { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects currently published into the OPC UA address space.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space.
/// </summary>
public int AttributeCount { get; set; }
/// <summary>
/// Gets or sets the UTC time when the address space was last rebuilt from repository data.
/// </summary>
public DateTime? LastRebuildTime { get; set; }
}
}

View File

@@ -13,6 +13,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
/// </summary>
public interface ITimingScope : IDisposable
{
/// <summary>
/// Marks whether the timed bridge operation completed successfully.
/// </summary>
/// <param name="success">A value indicating whether the measured operation succeeded.</param>
void SetSuccess(bool success);
}
@@ -21,12 +25,39 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
/// </summary>
public class MetricsStatistics
{
/// <summary>
/// Gets or sets the total number of recorded executions for the operation.
/// </summary>
public long TotalCount { get; set; }
/// <summary>
/// Gets or sets the number of recorded executions that completed successfully.
/// </summary>
public long SuccessCount { get; set; }
/// <summary>
/// Gets or sets the ratio of successful executions to total executions.
/// </summary>
public double SuccessRate { get; set; }
/// <summary>
/// Gets or sets the mean execution time in milliseconds across the recorded sample.
/// </summary>
public double AverageMilliseconds { get; set; }
/// <summary>
/// Gets or sets the fastest recorded execution time in milliseconds.
/// </summary>
public double MinMilliseconds { get; set; }
/// <summary>
/// Gets or sets the slowest recorded execution time in milliseconds.
/// </summary>
public double MaxMilliseconds { get; set; }
/// <summary>
/// Gets or sets the 95th percentile execution time in milliseconds.
/// </summary>
public double Percentile95Milliseconds { get; set; }
}
@@ -43,6 +74,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
private double _minMilliseconds = double.MaxValue;
private double _maxMilliseconds;
/// <summary>
/// Records the outcome and duration of a single bridge operation invocation.
/// </summary>
/// <param name="duration">The elapsed time for the operation.</param>
/// <param name="success">A value indicating whether the operation completed successfully.</param>
public void Record(TimeSpan duration, bool success)
{
lock (_lock)
@@ -61,6 +97,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
}
/// <summary>
/// Creates a snapshot of the current statistics for this operation type.
/// </summary>
/// <returns>A statistics snapshot suitable for logs, status reporting, and tests.</returns>
public MetricsStatistics GetStatistics()
{
lock (_lock)
@@ -98,28 +138,51 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
private readonly Timer _reportingTimer;
private bool _disposed;
/// <summary>
/// Initializes a new metrics collector and starts periodic performance reporting.
/// </summary>
public PerformanceMetrics()
{
_reportingTimer = new Timer(ReportMetrics, null,
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
}
/// <summary>
/// Records a completed bridge operation under the specified metrics bucket.
/// </summary>
/// <param name="operationName">The logical operation name, such as read, write, or subscribe.</param>
/// <param name="duration">The elapsed time for the operation.</param>
/// <param name="success">A value indicating whether the operation completed successfully.</param>
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
{
var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
metrics.Record(duration, success);
}
/// <summary>
/// Starts timing a bridge operation and returns a disposable scope that records the result when disposed.
/// </summary>
/// <param name="operationName">The logical operation name to record.</param>
/// <returns>A timing scope that reports elapsed time back into this collector.</returns>
public ITimingScope BeginOperation(string operationName)
{
return new TimingScope(this, operationName);
}
/// <summary>
/// Retrieves the raw metrics bucket for a named operation.
/// </summary>
/// <param name="operationName">The logical operation name to look up.</param>
/// <returns>The metrics bucket when present; otherwise, <see langword="null"/>.</returns>
public OperationMetrics? GetMetrics(string operationName)
{
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
}
/// <summary>
/// Produces a statistics snapshot for all recorded bridge operations.
/// </summary>
/// <returns>A dictionary keyed by operation name containing current metrics statistics.</returns>
public Dictionary<string, MetricsStatistics> GetStatistics()
{
var result = new Dictionary<string, MetricsStatistics>(StringComparer.OrdinalIgnoreCase);
@@ -144,6 +207,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
}
/// <summary>
/// Stops periodic reporting and emits a final metrics snapshot.
/// </summary>
public void Dispose()
{
if (_disposed) return;
@@ -152,6 +218,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
ReportMetrics(null);
}
/// <summary>
/// Timing scope that records one operation result into the owning metrics collector.
/// </summary>
private class TimingScope : ITimingScope
{
private readonly PerformanceMetrics _metrics;
@@ -160,6 +229,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
private bool _success = true;
private bool _disposed;
/// <summary>
/// Initializes a timing scope for a named bridge operation.
/// </summary>
/// <param name="metrics">The metrics collector that should receive the result.</param>
/// <param name="operationName">The logical operation name being timed.</param>
public TimingScope(PerformanceMetrics metrics, string operationName)
{
_metrics = metrics;
@@ -167,8 +241,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
_stopwatch = Stopwatch.StartNew();
}
/// <summary>
/// Marks whether the timed operation should be recorded as successful.
/// </summary>
/// <param name="success">A value indicating whether the operation succeeded.</param>
public void SetSuccess(bool success) => _success = success;
/// <summary>
/// Stops timing and records the operation result once.
/// </summary>
public void Dispose()
{
if (_disposed) return;

View File

@@ -9,6 +9,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
public async Task ConnectAsync(CancellationToken ct = default)
{
if (_state == ConnectionState.Connected) return;
@@ -54,6 +58,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
}
/// <summary>
/// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations.
/// </summary>
public async Task DisconnectAsync()
{
if (_state == ConnectionState.Disconnected) return;
@@ -100,6 +107,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
}
/// <summary>
/// Attempts to recover from a runtime fault by disconnecting and reconnecting the client.
/// </summary>
public async Task ReconnectAsync()
{
SetState(ConnectionState.Reconnecting);

View File

@@ -8,6 +8,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness.
/// </summary>
public void StartMonitor()
{
_monitorCts = new CancellationTokenSource();
@@ -15,6 +18,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
Log.Information("MxAccess monitor started (interval={Interval}s)", _config.MonitorIntervalSeconds);
}
/// <summary>
/// Stops the background monitor loop.
/// </summary>
public void StopMonitor()
{
_monitorCts?.Cancel();

View File

@@ -8,6 +8,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to read.</param>
/// <param name="ct">A token that cancels the read.</param>
/// <returns>The resulting VTQ value or a bad-quality fallback on timeout or failure.</returns>
public async Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
{
if (_state != ConnectionState.Connected)
@@ -79,6 +85,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
}
/// <summary>
/// Writes a value to a Galaxy tag and waits for the runtime write-complete callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="ct">A token that cancels the write.</param>
/// <returns><see langword="true"/> when the runtime acknowledges success; otherwise, <see langword="false"/>.</returns>
public async Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
{
if (_state != ConnectionState.Connected) return false;

View File

@@ -7,6 +7,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to monitor.</param>
/// <param name="callback">The callback that should receive runtime value changes.</param>
public async Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
{
_storedSubscriptions[fullTagReference] = callback;
@@ -15,6 +20,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
await SubscribeInternalAsync(fullTagReference);
}
/// <summary>
/// Removes a persistent subscription callback and tears down the runtime item when appropriate.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to stop monitoring.</param>
public async Task UnsubscribeAsync(string fullTagReference)
{
_storedSubscriptions.TryRemove(fullTagReference, out _);

View File

@@ -49,13 +49,38 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
private DateTime _lastProbeValueTime = DateTime.UtcNow;
private int _reconnectCount;
/// <summary>
/// Gets the current runtime connection state for the MXAccess client.
/// </summary>
public ConnectionState State => _state;
/// <summary>
/// Gets the number of active tag subscriptions currently maintained against the runtime.
/// </summary>
public int ActiveSubscriptionCount => _storedSubscriptions.Count;
/// <summary>
/// Gets the number of reconnect attempts performed since the client was created.
/// </summary>
public int ReconnectCount => _reconnectCount;
/// <summary>
/// Occurs when the MXAccess connection state changes.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed runtime tag publishes a new value.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
/// </summary>
/// <param name="staThread">The STA thread used to marshal COM interactions.</param>
/// <param name="proxy">The COM proxy abstraction used to talk to the runtime.</param>
/// <param name="config">The runtime timeout, throttling, and reconnect settings.</param>
/// <param name="metrics">The metrics collector used to time MXAccess operations.</param>
public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config, PerformanceMetrics metrics)
{
_staThread = staThread;
@@ -74,6 +99,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message));
}
/// <summary>
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
/// </summary>
public void Dispose()
{
try

View File

@@ -13,9 +13,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
private LMXProxyServer? _lmxProxy;
/// <summary>
/// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute.
/// </summary>
public event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the COM proxy confirms completion of a write request.
/// </summary>
public event MxWriteCompleteHandler? OnWriteComplete;
/// <summary>
/// Creates and registers the COM proxy session that backs live MXAccess operations.
/// </summary>
/// <param name="clientName">The client name reported to the Wonderware runtime.</param>
/// <returns>The runtime connection handle assigned by the COM server.</returns>
public int Register(string clientName)
{
_lmxProxy = new LMXProxyServer();
@@ -30,6 +42,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
return handle;
}
/// <summary>
/// Unregisters the COM proxy session and releases the underlying COM object.
/// </summary>
/// <param name="handle">The runtime connection handle returned by <see cref="Register(string)"/>.</param>
public void Unregister(int handle)
{
if (_lmxProxy != null)
@@ -48,10 +64,42 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
}
/// <summary>
/// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified Galaxy attribute reference.</param>
/// <returns>The item handle assigned by the COM proxy.</returns>
public int AddItem(int handle, string address) => _lmxProxy!.AddItem(handle, address);
/// <summary>
/// Removes an item handle from the active COM proxy session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to remove.</param>
public void RemoveItem(int handle, int itemHandle) => _lmxProxy!.RemoveItem(handle, itemHandle);
/// <summary>
/// Enables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to monitor.</param>
public void AdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.AdviseSupervisory(handle, itemHandle);
/// <summary>
/// Disables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to stop monitoring.</param>
public void UnAdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.UnAdvise(handle, itemHandle);
/// <summary>
/// Writes a value to the specified runtime item through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
public void Write(int handle, int itemHandle, object value, int securityClassification)
=> _lmxProxy!.Write(handle, itemHandle, value, securityClassification);

View File

@@ -31,6 +31,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
private long _workItemsExecuted;
private DateTime _lastLogTime;
/// <summary>
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
/// </summary>
public StaComThread()
{
_thread = new Thread(ThreadEntry)
@@ -41,8 +44,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
_thread.SetApartmentState(ApartmentState.STA);
}
/// <summary>
/// Gets a value indicating whether the STA thread is running and able to accept work.
/// </summary>
public bool IsRunning => _nativeThreadId != 0 && !_disposed;
/// <summary>
/// Starts the STA thread and waits until its message pump is ready for COM work.
/// </summary>
public void Start()
{
_thread.Start();
@@ -50,6 +59,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
}
/// <summary>
/// Queues an action to execute on the STA thread.
/// </summary>
/// <param name="action">The work item to execute on the STA thread.</param>
/// <returns>A task that completes when the action has finished executing.</returns>
public Task RunAsync(Action action)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
@@ -71,6 +85,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
return tcs.Task;
}
/// <summary>
/// Queues a function to execute on the STA thread and returns its result.
/// </summary>
/// <typeparam name="T">The result type produced by the function.</typeparam>
/// <param name="func">The work item to execute on the STA thread.</param>
/// <returns>A task that completes with the function result.</returns>
public Task<T> RunAsync<T>(Func<T> func)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
@@ -91,6 +111,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
return tcs.Task;
}
/// <summary>
/// Stops the STA thread and releases the message-pump resources used for COM interop.
/// </summary>
public void Dispose()
{
if (_disposed) return;

View File

@@ -19,21 +19,70 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public class NodeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier represented by this address-space node.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier used to assemble the tree.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the node represents a Galaxy area folder.
/// </summary>
public bool IsArea { get; set; }
/// <summary>
/// Gets or sets the attribute nodes published beneath this object.
/// </summary>
public List<AttributeNodeInfo> Attributes { get; set; } = new();
/// <summary>
/// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
/// </summary>
public List<NodeInfo> Children { get; set; } = new();
}
/// <summary>
/// Lightweight description of an attribute node that will become an OPC UA variable.
/// </summary>
public class AttributeNodeInfo
{
/// <summary>
/// Gets or sets the Galaxy attribute name published under the object.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is modeled as an array.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the declared array length when the attribute is a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
}
@@ -42,12 +91,33 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public class AddressSpaceModel
{
/// <summary>
/// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
/// </summary>
public List<NodeInfo> RootNodes { get; set; } = new();
/// <summary>
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
/// </summary>
public Dictionary<string, string> NodeIdToTagReference { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets the number of non-area Galaxy objects included in the model.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of variable nodes created from Galaxy attributes.
/// </summary>
public int VariableCount { get; set; }
}
/// <summary>
/// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes nodes.
/// </summary>
/// <param name="hierarchy">The Galaxy object hierarchy returned by the repository.</param>
/// <param name="attributes">The Galaxy attribute rows associated with the hierarchy.</param>
/// <returns>An address-space model containing roots, variables, and tag-reference mappings.</returns>
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
var model = new AddressSpaceModel();

View File

@@ -9,6 +9,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public static class DataValueConverter
{
/// <summary>
/// Converts a bridge VTQ snapshot into an OPC UA data value.
/// </summary>
/// <param name="vtq">The VTQ snapshot to convert.</param>
/// <returns>An OPC UA data value suitable for reads and subscriptions.</returns>
public static DataValue FromVtq(Vtq vtq)
{
var statusCode = new StatusCode(QualityMapper.MapToOpcUaStatusCode(vtq.Quality));
@@ -24,6 +29,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
return dataValue;
}
/// <summary>
/// Converts an OPC UA data value back into a bridge VTQ snapshot.
/// </summary>
/// <param name="dataValue">The OPC UA data value to convert.</param>
/// <returns>A VTQ snapshot containing the converted value, timestamp, and derived quality.</returns>
public static Vtq ToVtq(DataValue dataValue)
{
var quality = MapStatusCodeToQuality(dataValue.StatusCode);

View File

@@ -31,10 +31,29 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly object _lock = new object();
private IDictionary<NodeId, IList<IReference>>? _externalReferences;
/// <summary>
/// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O.
/// </summary>
public IReadOnlyDictionary<string, string> NodeIdToTagReference => _nodeIdToTagReference;
/// <summary>
/// Gets the number of variable nodes currently published from Galaxy attributes.
/// </summary>
public int VariableNodeCount { get; private set; }
/// <summary>
/// Gets the number of non-area object nodes currently published from the Galaxy hierarchy.
/// </summary>
public int ObjectNodeCount { get; private set; }
/// <summary>
/// Initializes a new node manager for the Galaxy-backed OPC UA namespace.
/// </summary>
/// <param name="server">The hosting OPC UA server internals.</param>
/// <param name="configuration">The OPC UA application configuration for the host.</param>
/// <param name="namespaceUri">The namespace URI that identifies the Galaxy model to clients.</param>
/// <param name="mxAccessClient">The runtime client used to service reads, writes, and subscriptions.</param>
/// <param name="metrics">The metrics collector used to track node manager activity.</param>
public LmxNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
@@ -51,6 +70,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
}
/// <inheritdoc />
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{
lock (Lock)
@@ -63,6 +83,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <summary>
/// Builds the address space from Galaxy hierarchy and attributes data. (OPC-002, OPC-003)
/// </summary>
/// <param name="hierarchy">The Galaxy object hierarchy that defines folders and objects in the namespace.</param>
/// <param name="attributes">The Galaxy attributes that become OPC UA variable nodes.</param>
public void BuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
lock (Lock)
@@ -145,6 +167,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <summary>
/// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010)
/// </summary>
/// <param name="hierarchy">The latest Galaxy object hierarchy to publish.</param>
/// <param name="attributes">The latest Galaxy attributes to publish.</param>
public void RebuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
lock (Lock)
@@ -316,6 +340,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
#region Read/Write Handlers
/// <inheritdoc />
public override void Read(OperationContext context, double maxAge, IList<ReadValueId> nodesToRead,
IList<DataValue> results, IList<ServiceResult> errors)
{
@@ -346,6 +371,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <inheritdoc />
public override void Write(OperationContext context, IList<WriteValue> nodesToWrite,
IList<ServiceResult> errors)
{
@@ -444,6 +470,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// Called by the OPC UA framework after monitored items are created on nodes in our namespace.
/// Triggers ref-counted MXAccess subscriptions for the underlying tags.
/// </summary>
/// <inheritdoc />
protected override void OnCreateMonitoredItemsComplete(ServerSystemContext context, IList<IMonitoredItem> monitoredItems)
{
foreach (var item in monitoredItems)
@@ -458,6 +485,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// Called by the OPC UA framework after monitored items are deleted.
/// Decrements ref-counted MXAccess subscriptions.
/// </summary>
/// <inheritdoc />
protected override void OnDeleteMonitoredItemsComplete(ServerSystemContext context, IList<IMonitoredItem> monitoredItems)
{
foreach (var item in monitoredItems)
@@ -475,6 +503,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
return null;
}
/// <summary>
/// Increments the subscription reference count for a Galaxy tag and opens the runtime subscription when the first OPC UA monitored item appears.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to subscribe.</param>
internal void SubscribeTag(string fullTagReference)
{
lock (_lock)
@@ -491,6 +523,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <summary>
/// Decrements the subscription reference count for a Galaxy tag and closes the runtime subscription when no OPC UA monitored items remain.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to unsubscribe.</param>
internal void UnsubscribeTag(string fullTagReference)
{
lock (_lock)

View File

@@ -16,7 +16,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly PerformanceMetrics _metrics;
private LmxNodeManager? _nodeManager;
/// <summary>
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
/// </summary>
public LmxNodeManager? NodeManager => _nodeManager;
/// <summary>
/// Gets the number of active OPC UA sessions currently connected to the server.
/// </summary>
public int ActiveSessionCount
{
get
@@ -26,6 +33,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <summary>
/// Initializes a custom OPC UA server for the specified Galaxy namespace.
/// </summary>
/// <param name="galaxyName">The Galaxy name used to construct the namespace URI and product URI.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live data access.</param>
/// <param name="metrics">The metrics collector shared with the node manager.</param>
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics)
{
_galaxyName = galaxyName;
@@ -33,6 +46,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_metrics = metrics;
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
{
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
@@ -42,6 +56,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
}
/// <inheritdoc />
protected override ServerProperties LoadServerProperties()
{
var properties = new ServerProperties

View File

@@ -8,11 +8,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public static class OpcUaQualityMapper
{
/// <summary>
/// Converts bridge quality values into OPC UA status codes.
/// </summary>
/// <param name="quality">The bridge quality value.</param>
/// <returns>The OPC UA status code to publish.</returns>
public static StatusCode ToStatusCode(Quality quality)
{
return new StatusCode(QualityMapper.MapToOpcUaStatusCode(quality));
}
/// <summary>
/// Converts an OPC UA status code back into a bridge quality category.
/// </summary>
/// <param name="statusCode">The OPC UA status code to interpret.</param>
/// <returns>The bridge quality category represented by the status code.</returns>
public static Quality FromStatusCode(StatusCode statusCode)
{
if (StatusCode.IsGood(statusCode)) return Quality.Good;

View File

@@ -23,10 +23,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private ApplicationInstance? _application;
private LmxOpcUaServer? _server;
/// <summary>
/// Gets the active node manager that holds the published Galaxy namespace.
/// </summary>
public LmxNodeManager? NodeManager => _server?.NodeManager;
/// <summary>
/// Gets the number of currently connected OPC UA client sessions.
/// </summary>
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
/// <summary>
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
/// </summary>
public bool IsRunning => _server != null;
/// <summary>
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
/// </summary>
/// <param name="config">The endpoint and session settings for the OPC UA host.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live reads, writes, and subscriptions.</param>
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics)
{
_config = config;
@@ -34,6 +51,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_metrics = metrics;
}
/// <summary>
/// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured endpoint.
/// </summary>
public async Task StartAsync()
{
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
@@ -142,6 +162,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_config.Port, _config.EndpointPath, namespaceUri);
}
/// <summary>
/// Stops the OPC UA application instance and releases its in-memory server objects.
/// </summary>
public void Stop()
{
try
@@ -160,6 +183,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <summary>
/// Stops the host and releases server resources.
/// </summary>
public void Dispose() => Stop();
}
}

View File

@@ -62,6 +62,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// <summary>
/// Test constructor. Accepts injected dependencies.
/// </summary>
/// <param name="config">The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard behavior during the test run.</param>
/// <param name="mxProxy">The MXAccess proxy substitute used when a test wants to exercise COM-style wiring.</param>
/// <param name="galaxyRepository">The repository substitute that supplies Galaxy hierarchy and deploy metadata for address-space builds.</param>
/// <param name="mxAccessClientOverride">An optional direct MXAccess client substitute that bypasses STA thread setup and COM interop.</param>
/// <param name="hasMxAccessClientOverride">A value indicating whether the override client should be used instead of creating a client from <paramref name="mxProxy"/>.</param>
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false)
{
@@ -72,6 +77,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
_hasMxAccessClientOverride = hasMxAccessClientOverride;
}
/// <summary>
/// Starts the bridge by validating configuration, connecting runtime dependencies, building the Galaxy-backed OPC UA address space, and optionally hosting the status dashboard.
/// </summary>
public void Start()
{
Log.Information("LmxOpcUa service starting");
@@ -212,6 +220,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
}
}
/// <summary>
/// Stops the bridge, cancels monitoring loops, disconnects runtime integrations, and releases hosted resources in shutdown order.
/// </summary>
public void Stop()
{
Log.Information("LmxOpcUa service stopping");
@@ -282,13 +293,44 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
internal void TriggerRebuild() => OnGalaxyChanged();
// Accessors for testing
/// <summary>
/// Gets the MXAccess client instance currently wired into the service for test inspection.
/// </summary>
internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring;
/// <summary>
/// Gets the metrics collector that tracks bridge operation timings during the service lifetime.
/// </summary>
internal PerformanceMetrics? Metrics => _metrics;
/// <summary>
/// Gets the OPC UA server host that owns the runtime endpoint.
/// </summary>
internal OpcUaServerHost? ServerHost => _serverHost;
/// <summary>
/// Gets the node manager instance that holds the current Galaxy-derived address space.
/// </summary>
internal LmxNodeManager? NodeManagerInstance => _nodeManager;
/// <summary>
/// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild.
/// </summary>
internal ChangeDetectionService? ChangeDetectionInstance => _changeDetection;
/// <summary>
/// Gets the hosted status web server when the dashboard is enabled.
/// </summary>
internal StatusWebServer? StatusWeb => _statusWebServer;
/// <summary>
/// Gets the dashboard report generator used to assemble operator-facing status snapshots.
/// </summary>
internal StatusReportService? StatusReportInstance => _statusReport;
/// <summary>
/// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds.
/// </summary>
internal GalaxyRepositoryStats? GalaxyStatsInstance => _galaxyStats;
}
@@ -297,18 +339,75 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// </summary>
internal sealed class NullMxAccessClient : IMxAccessClient
{
/// <summary>
/// Gets the disconnected state reported when the bridge is running without live MXAccess connectivity.
/// </summary>
public ConnectionState State => ConnectionState.Disconnected;
/// <summary>
/// Gets the active subscription count, which is always zero for the null runtime client.
/// </summary>
public int ActiveSubscriptionCount => 0;
/// <summary>
/// Gets the reconnect count, which is always zero because the null client never establishes a session.
/// </summary>
public int ReconnectCount => 0;
/// <summary>
/// Occurs when the runtime connection state changes. The null client never raises this event.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed tag value changes. The null client never raises this event.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Completes immediately because no live runtime connection is available or required.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
public System.Threading.Tasks.Task ConnectAsync(CancellationToken ct = default) => System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Completes immediately because there is no live runtime session to close.
/// </summary>
public System.Threading.Tasks.Task DisconnectAsync() => System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Completes immediately because the null client does not subscribe to live Galaxy attributes.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been subscribed.</param>
/// <param name="callback">The callback that would have received runtime value changes.</param>
public System.Threading.Tasks.Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback) => System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Completes immediately because the null client does not maintain runtime subscriptions.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been unsubscribed.</param>
public System.Threading.Tasks.Task UnsubscribeAsync(string fullTagReference) => System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Returns a bad-quality value because no live runtime source exists.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been read from the runtime.</param>
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
/// <returns>A bad-quality VTQ indicating that runtime data is unavailable.</returns>
public System.Threading.Tasks.Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(Vtq.Bad());
/// <summary>
/// Rejects writes because there is no live runtime endpoint behind the null client.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been written.</param>
/// <param name="value">The value that would have been sent to the runtime.</param>
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
/// <returns>A completed task returning <see langword="false"/>.</returns>
public System.Threading.Tasks.Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(false);
/// <summary>
/// Releases the null client. No unmanaged runtime resources exist.
/// </summary>
public void Dispose() { }
}
}

View File

@@ -18,24 +18,44 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
private bool _galaxyRepositorySet;
private bool _mxAccessClientSet;
/// <summary>
/// Replaces the default service configuration used by the test host.
/// </summary>
/// <param name="config">The full configuration snapshot to inject into the service under test.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithConfig(AppConfiguration config)
{
_config = config;
return this;
}
/// <summary>
/// Sets the OPC UA port used by the test host so multiple integration runs can coexist.
/// </summary>
/// <param name="port">The TCP port to expose for the test server.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithOpcUaPort(int port)
{
_config.OpcUa.Port = port;
return this;
}
/// <summary>
/// Sets the Galaxy name represented by the test address space.
/// </summary>
/// <param name="name">The Galaxy name to expose through OPC UA and diagnostics.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithGalaxyName(string name)
{
_config.OpcUa.GalaxyName = name;
return this;
}
/// <summary>
/// Injects an MXAccess proxy substitute for tests that exercise the proxy-driven runtime path.
/// </summary>
/// <param name="proxy">The proxy fake or stub to supply to the service.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithMxProxy(IMxProxy? proxy)
{
_mxProxy = proxy;
@@ -43,6 +63,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
return this;
}
/// <summary>
/// Injects a repository substitute for tests that control Galaxy hierarchy and deploy metadata.
/// </summary>
/// <param name="repository">The repository fake or stub to supply to the service.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithGalaxyRepository(IGalaxyRepository? repository)
{
_galaxyRepository = repository;
@@ -54,6 +79,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// Override the MxAccessClient directly, skipping STA thread and COM interop entirely.
/// When set, the service will use this client instead of creating one from IMxProxy.
/// </summary>
/// <param name="client">The direct MXAccess client substitute to inject into the service.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithMxAccessClient(IMxAccessClient? client)
{
_mxAccessClient = client;
@@ -61,6 +88,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
return this;
}
/// <summary>
/// Seeds a convenience fake repository with Galaxy hierarchy and attribute rows for address-space tests.
/// </summary>
/// <param name="hierarchy">The object hierarchy to expose through the test OPC UA namespace.</param>
/// <param name="attributes">The attribute rows to attach to the hierarchy.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithHierarchy(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
if (!_galaxyRepositorySet)
@@ -79,18 +112,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
return this;
}
/// <summary>
/// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener.
/// </summary>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder DisableDashboard()
{
_config.Dashboard.Enabled = false;
return this;
}
/// <summary>
/// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations.
/// </summary>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder DisableChangeDetection()
{
_config.GalaxyRepository.ChangeDetectionIntervalSeconds = int.MaxValue;
return this;
}
/// <summary>
/// Creates an <see cref="OpcUaService"/> using the accumulated test doubles and configuration overrides.
/// </summary>
/// <returns>A service instance ready for integration-style testing.</returns>
public OpcUaService Build()
{
return new OpcUaService(
@@ -106,16 +151,50 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// </summary>
private class FakeBuilderGalaxyRepository : IGalaxyRepository
{
/// <summary>
/// Occurs when the fake repository wants to simulate a Galaxy deploy change.
/// </summary>
public event System.Action? OnGalaxyChanged;
/// <summary>
/// Gets or sets the hierarchy rows that the fake repository returns to the service.
/// </summary>
public List<GalaxyObjectInfo> Hierarchy { get; set; } = new();
/// <summary>
/// Gets or sets the attribute rows that the fake repository returns to the service.
/// </summary>
public List<GalaxyAttributeInfo> Attributes { get; set; } = new();
/// <summary>
/// Returns the seeded hierarchy rows for address-space construction.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The configured hierarchy rows.</returns>
public System.Threading.Tasks.Task<List<GalaxyObjectInfo>> GetHierarchyAsync(System.Threading.CancellationToken ct = default)
=> System.Threading.Tasks.Task.FromResult(Hierarchy);
/// <summary>
/// Returns the seeded attribute rows for address-space construction.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The configured attribute rows.</returns>
public System.Threading.Tasks.Task<List<GalaxyAttributeInfo>> GetAttributesAsync(System.Threading.CancellationToken ct = default)
=> System.Threading.Tasks.Task.FromResult(Attributes);
/// <summary>
/// Returns the current UTC time so change-detection tests have a deploy timestamp to compare against.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The current UTC time.</returns>
public System.Threading.Tasks.Task<System.DateTime?> GetLastDeployTimeAsync(System.Threading.CancellationToken ct = default)
=> System.Threading.Tasks.Task.FromResult<System.DateTime?>(System.DateTime.UtcNow);
/// <summary>
/// Reports a healthy repository connection for builder-based test setups.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>A completed task returning <see langword="true"/>.</returns>
public System.Threading.Tasks.Task<bool> TestConnectionAsync(System.Threading.CancellationToken ct = default)
=> System.Threading.Tasks.Task.FromResult(true);
}

View File

@@ -8,6 +8,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
/// </summary>
public class HealthCheckService
{
/// <summary>
/// Evaluates bridge health from runtime connectivity and recorded performance metrics.
/// </summary>
/// <param name="connectionState">The current MXAccess connection state.</param>
/// <param name="metrics">The recorded performance metrics, if available.</param>
/// <returns>A dashboard health snapshot describing the current service condition.</returns>
public HealthInfo CheckHealth(ConnectionState connectionState, PerformanceMetrics? metrics)
{
// Rule 1: Not connected → Unhealthy
@@ -48,6 +54,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
};
}
/// <summary>
/// Determines whether the bridge should currently be treated as healthy.
/// </summary>
/// <param name="connectionState">The current MXAccess connection state.</param>
/// <param name="metrics">The recorded performance metrics, if available.</param>
/// <returns><see langword="true"/> when the bridge is not unhealthy; otherwise, <see langword="false"/>.</returns>
public bool IsHealthy(ConnectionState connectionState, PerformanceMetrics? metrics)
{
var health = CheckHealth(connectionState, metrics);

View File

@@ -9,46 +9,139 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
/// </summary>
public class StatusData
{
/// <summary>
/// Gets or sets the current MXAccess and service connectivity summary shown on the dashboard.
/// </summary>
public ConnectionInfo Connection { get; set; } = new();
/// <summary>
/// Gets or sets the overall health state communicated to operators.
/// </summary>
public HealthInfo Health { get; set; } = new();
/// <summary>
/// Gets or sets subscription counts that show how many live tag streams the bridge is maintaining.
/// </summary>
public SubscriptionInfo Subscriptions { get; set; } = new();
/// <summary>
/// Gets or sets Galaxy-specific metadata such as deploy timing and address-space counts.
/// </summary>
public GalaxyInfo Galaxy { get; set; } = new();
/// <summary>
/// Gets or sets per-operation performance statistics used to diagnose bridge throughput and latency.
/// </summary>
public Dictionary<string, MetricsStatistics> Operations { get; set; } = new();
/// <summary>
/// Gets or sets footer details such as the snapshot timestamp and service version.
/// </summary>
public FooterInfo Footer { get; set; } = new();
}
/// <summary>
/// Dashboard model for current runtime connection details.
/// </summary>
public class ConnectionInfo
{
/// <summary>
/// Gets or sets the current MXAccess connection state shown to operators.
/// </summary>
public string State { get; set; } = "Disconnected";
/// <summary>
/// Gets or sets how many reconnect attempts have occurred since the service started.
/// </summary>
public int ReconnectCount { get; set; }
/// <summary>
/// Gets or sets the number of active OPC UA sessions connected to the bridge.
/// </summary>
public int ActiveSessions { get; set; }
}
/// <summary>
/// Dashboard model for the overall health banner.
/// </summary>
public class HealthInfo
{
/// <summary>
/// Gets or sets the high-level health state, such as Healthy, Degraded, or Unhealthy.
/// </summary>
public string Status { get; set; } = "Unknown";
/// <summary>
/// Gets or sets the operator-facing explanation for the current health state.
/// </summary>
public string Message { get; set; } = "";
/// <summary>
/// Gets or sets the color token used by the dashboard UI to render the health banner.
/// </summary>
public string Color { get; set; } = "gray";
}
/// <summary>
/// Dashboard model for subscription load.
/// </summary>
public class SubscriptionInfo
{
/// <summary>
/// Gets or sets the number of active tag subscriptions mirrored from MXAccess into OPC UA.
/// </summary>
public int ActiveCount { get; set; }
}
/// <summary>
/// Dashboard model for Galaxy metadata and rebuild status.
/// </summary>
public class GalaxyInfo
{
/// <summary>
/// Gets or sets the Galaxy name currently being bridged into OPC UA.
/// </summary>
public string GalaxyName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the repository database is currently reachable.
/// </summary>
public bool DbConnected { get; set; }
/// <summary>
/// Gets or sets the most recent deploy timestamp observed in the Galaxy repository.
/// </summary>
public DateTime? LastDeployTime { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects currently represented in the address space.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy attributes currently represented as OPC UA variables.
/// </summary>
public int AttributeCount { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the last completed address-space rebuild.
/// </summary>
public DateTime? LastRebuildTime { get; set; }
}
/// <summary>
/// Dashboard model for the status page footer.
/// </summary>
public class FooterInfo
{
/// <summary>
/// Gets or sets the UTC time when the status snapshot was generated.
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the service version displayed to operators for support and traceability.
/// </summary>
public string Version { get; set; } = "";
}
}

View File

@@ -21,12 +21,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
private GalaxyRepositoryStats? _galaxyStats;
private OpcUaServerHost? _serverHost;
/// <summary>
/// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh interval.
/// </summary>
/// <param name="healthCheck">The health-check component used to derive the overall dashboard health status.</param>
/// <param name="refreshIntervalSeconds">The HTML auto-refresh interval, in seconds, for the dashboard page.</param>
public StatusReportService(HealthCheckService healthCheck, int refreshIntervalSeconds)
{
_healthCheck = healthCheck;
_refreshIntervalSeconds = refreshIntervalSeconds;
}
/// <summary>
/// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots.
/// </summary>
/// <param name="mxAccessClient">The runtime client whose connection and subscription state should be reported.</param>
/// <param name="metrics">The performance metrics collector whose operation statistics should be reported.</param>
/// <param name="galaxyStats">The Galaxy repository statistics to surface on the dashboard.</param>
/// <param name="serverHost">The OPC UA server host whose active session count should be reported.</param>
public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics,
GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost)
{
@@ -36,6 +48,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
_serverHost = serverHost;
}
/// <summary>
/// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers.
/// </summary>
/// <returns>The current dashboard status data for the bridge.</returns>
public StatusData GetStatusData()
{
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
@@ -71,6 +87,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
};
}
/// <summary>
/// Generates the operator-facing HTML dashboard for the current bridge status.
/// </summary>
/// <returns>An HTML document containing the latest dashboard snapshot.</returns>
public string GenerateHtml()
{
var data = GetStatusData();
@@ -131,12 +151,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
return sb.ToString();
}
/// <summary>
/// Generates an indented JSON status payload for API consumers.
/// </summary>
/// <returns>A JSON representation of the current dashboard snapshot.</returns>
public string GenerateJson()
{
var data = GetStatusData();
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
}
/// <summary>
/// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint.
/// </summary>
/// <returns><see langword="true"/> when the bridge meets the health policy; otherwise, <see langword="false"/>.</returns>
public bool IsHealthy()
{
var state = _mxAccessClient?.State ?? ConnectionState.Disconnected;

View File

@@ -19,14 +19,25 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
private HttpListener? _listener;
private CancellationTokenSource? _cts;
/// <summary>
/// Gets a value indicating whether the dashboard listener is currently accepting requests.
/// </summary>
public bool IsRunning => _listener?.IsListening ?? false;
/// <summary>
/// Initializes a new dashboard web server bound to the supplied report service and HTTP port.
/// </summary>
/// <param name="reportService">The report service used to generate dashboard responses.</param>
/// <param name="port">The HTTP port to listen on.</param>
public StatusWebServer(StatusReportService reportService, int port)
{
_reportService = reportService;
_port = port;
}
/// <summary>
/// Starts the HTTP listener and background request loop for the status dashboard.
/// </summary>
public void Start()
{
try
@@ -47,6 +58,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
}
}
/// <summary>
/// Stops the dashboard listener and releases its HTTP resources.
/// </summary>
public void Stop()
{
_cts?.Cancel();
@@ -139,6 +153,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
response.Close();
}
/// <summary>
/// Stops the dashboard listener and releases its resources.
/// </summary>
public void Dispose() => Stop();
}
}