667 lines
29 KiB
C#
667 lines
29 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|
|
|
/// <summary>
|
|
/// OPC UA Client (gateway) driver. Opens a <see cref="Session"/> against a remote OPC UA
|
|
/// server and re-exposes its address space through the local OtOpcUa server. PR 66 ships
|
|
/// the scaffold: <see cref="IDriver"/> only (connect / close / health). Browse, read,
|
|
/// write, subscribe, and probe land in PRs 67-69.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Builds its own <see cref="ApplicationConfiguration"/> rather than reusing
|
|
/// <c>Client.Shared</c> — Client.Shared is oriented at the interactive CLI; this
|
|
/// driver is an always-on service component with different session-lifetime needs
|
|
/// (keep-alive monitor, session transfer on reconnect, multi-year uptime).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Session lifetime</b>: a single <see cref="Session"/> per driver instance.
|
|
/// Subscriptions multiplex onto that session; SDK reconnect handler takes the session
|
|
/// down and brings it back up on remote-server restart — the driver must re-send
|
|
/// subscriptions + TransferSubscriptions on reconnect to avoid dangling
|
|
/// monitored-item handles. That mechanic lands in PR 69.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
|
|
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
|
{
|
|
// ---- ISubscribable + IHostConnectivityProbe state ----
|
|
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, RemoteSubscription> _subscriptions = new();
|
|
private long _nextSubscriptionId;
|
|
private readonly object _probeLock = new();
|
|
private HostState _hostState = HostState.Unknown;
|
|
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
|
private KeepAliveEventHandler? _keepAliveHandler;
|
|
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
|
|
// OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server
|
|
// StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" —
|
|
// downstream clients need to distinguish 'remote source down' from 'local driver failure'.
|
|
private const uint StatusBadNodeIdInvalid = 0x80330000u;
|
|
private const uint StatusBadInternalError = 0x80020000u;
|
|
private const uint StatusBadCommunicationError = 0x80050000u;
|
|
|
|
private readonly OpcUaClientDriverOptions _options = options;
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|
|
|
/// <summary>Active OPC UA session. Null until <see cref="InitializeAsync"/> returns cleanly.</summary>
|
|
internal ISession? Session { get; private set; }
|
|
|
|
/// <summary>Per-connection gate. PRs 67+ serialize read/write/browse on this.</summary>
|
|
internal SemaphoreSlim Gate => _gate;
|
|
|
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
private bool _disposed;
|
|
|
|
public string DriverInstanceId => driverInstanceId;
|
|
public string DriverType => "OpcUaClient";
|
|
|
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
|
try
|
|
{
|
|
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
// Endpoint selection: let the stack pick the best matching endpoint for the
|
|
// requested security policy/mode so the driver doesn't have to hand-validate.
|
|
// UseSecurity=false when SecurityMode=None shortcuts around cert validation
|
|
// entirely and is the typical dev-bench configuration.
|
|
var useSecurity = _options.SecurityMode != OpcUaSecurityMode.None;
|
|
// The non-obsolete SelectEndpointAsync overloads all require an ITelemetryContext
|
|
// parameter. Passing null is valid — the SDK falls through to its built-in default
|
|
// trace sink. Plumbing a telemetry context through every driver surface is out of
|
|
// scope; the driver emits its own logs via the health surface anyway.
|
|
var selected = await CoreClientUtils.SelectEndpointAsync(
|
|
appConfig, _options.EndpointUrl, useSecurity,
|
|
telemetry: null!,
|
|
ct: cancellationToken).ConfigureAwait(false);
|
|
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
|
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
|
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
|
|
|
var identity = _options.AuthType switch
|
|
{
|
|
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
|
// The UserIdentity(string, string) overload was removed in favour of
|
|
// (string, byte[]) to make the password encoding explicit. UTF-8 is the
|
|
// overwhelmingly common choice for Basic256Sha256-secured sessions.
|
|
OpcUaAuthType.Username => new UserIdentity(
|
|
_options.Username ?? string.Empty,
|
|
System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)),
|
|
OpcUaAuthType.Certificate => throw new NotSupportedException(
|
|
"Certificate authentication lands in a follow-up PR; for now use Anonymous or Username"),
|
|
_ => new UserIdentity(new AnonymousIdentityToken()),
|
|
};
|
|
|
|
// All Session.Create* static methods are marked [Obsolete] in SDK 1.5.378; the
|
|
// non-obsolete path is DefaultSessionFactory.Instance.CreateAsync (which is the
|
|
// 8-arg signature matching our driver config — ApplicationConfiguration +
|
|
// ConfiguredEndpoint, no transport-waiting-connection or reverse-connect-manager
|
|
// required for the standard opc.tcp direct-connect case).
|
|
// DefaultSessionFactory's parameterless ctor is also obsolete in 1.5.378; the
|
|
// current constructor requires an ITelemetryContext. Passing null is tolerated —
|
|
// the factory falls back to its internal default sink, same as the telemetry:null
|
|
// on SelectEndpointAsync above.
|
|
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
|
appConfig,
|
|
endpoint,
|
|
false, // updateBeforeConnect
|
|
_options.SessionName,
|
|
(uint)_options.SessionTimeout.TotalMilliseconds,
|
|
identity,
|
|
null, // preferredLocales
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
|
|
|
// Wire the session's keep-alive channel into HostState. OPC UA keep-alives are
|
|
// authoritative for session liveness: the SDK pings on KeepAliveInterval and sets
|
|
// KeepAliveStopped when N intervals elapse without a response. That's strictly
|
|
// better than a driver-side polling probe — no extra round-trip, no duplicate
|
|
// semantic.
|
|
_keepAliveHandler = (_, e) =>
|
|
{
|
|
var healthy = !ServiceResult.IsBad(e.Status);
|
|
TransitionTo(healthy ? HostState.Running : HostState.Stopped);
|
|
};
|
|
session.KeepAlive += _keepAliveHandler;
|
|
|
|
Session = session;
|
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
|
TransitionTo(HostState.Running);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { }
|
|
Session = null;
|
|
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a minimal in-memory <see cref="ApplicationConfiguration"/>. Certificates live
|
|
/// under the OS user profile — on Windows that's <c>%LocalAppData%\OtOpcUa\pki</c>
|
|
/// — so multiple driver instances in the same OtOpcUa server process share one
|
|
/// certificate store without extra config.
|
|
/// </summary>
|
|
private async Task<ApplicationConfiguration> BuildApplicationConfigurationAsync(CancellationToken ct)
|
|
{
|
|
// The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
|
|
// locally rather than plumbing a telemetry context all the way through the driver
|
|
// surface — the driver emits no per-request telemetry of its own and the SDK's
|
|
// internal fallback is fine for a gateway use case.
|
|
#pragma warning disable CS0618
|
|
var app = new ApplicationInstance
|
|
{
|
|
ApplicationName = _options.SessionName,
|
|
ApplicationType = ApplicationType.Client,
|
|
};
|
|
#pragma warning restore CS0618
|
|
|
|
var pkiRoot = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"OtOpcUa", "pki");
|
|
|
|
var config = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = _options.SessionName,
|
|
ApplicationType = ApplicationType.Client,
|
|
ApplicationUri = _options.ApplicationUri,
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "own"),
|
|
SubjectName = $"CN={_options.SessionName}",
|
|
},
|
|
TrustedPeerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "trusted"),
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "issuers"),
|
|
},
|
|
RejectedCertificateStore = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "rejected"),
|
|
},
|
|
AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates,
|
|
},
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds },
|
|
ClientConfiguration = new ClientConfiguration
|
|
{
|
|
DefaultSessionTimeout = (int)_options.SessionTimeout.TotalMilliseconds,
|
|
},
|
|
DisableHiResClock = true,
|
|
};
|
|
|
|
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
|
|
|
// Attach a cert-validator handler that honours the AutoAccept flag. Without this,
|
|
// AutoAcceptUntrustedCertificates on the config alone isn't always enough in newer
|
|
// SDK versions — the validator raises an event the app has to handle.
|
|
if (_options.AutoAcceptCertificates)
|
|
{
|
|
config.CertificateValidator.CertificateValidation += (s, e) =>
|
|
{
|
|
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
|
|
e.Accept = true;
|
|
};
|
|
}
|
|
|
|
// Ensure an application certificate exists. The SDK auto-generates one if missing.
|
|
app.ApplicationConfiguration = config;
|
|
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
|
.ConfigureAwait(false);
|
|
|
|
return config;
|
|
}
|
|
|
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
|
{
|
|
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
|
|
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
|
|
// whether or not the wire-side delete succeeds since the local handles are useless
|
|
// after close anyway.
|
|
foreach (var rs in _subscriptions.Values)
|
|
{
|
|
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
|
catch { /* best-effort */ }
|
|
}
|
|
_subscriptions.Clear();
|
|
|
|
if (_keepAliveHandler is not null && Session is not null)
|
|
{
|
|
try { Session.KeepAlive -= _keepAliveHandler; } catch { }
|
|
}
|
|
_keepAliveHandler = null;
|
|
|
|
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
|
|
catch { /* best-effort */ }
|
|
try { Session?.Dispose(); } catch { }
|
|
Session = null;
|
|
|
|
TransitionTo(HostState.Unknown);
|
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
|
}
|
|
|
|
public DriverHealth GetHealth() => _health;
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
// ---- IReadable ----
|
|
|
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
var session = RequireSession();
|
|
var results = new DataValueSnapshot[fullReferences.Count];
|
|
var now = DateTime.UtcNow;
|
|
|
|
// Parse NodeIds up-front. Tags whose reference doesn't parse get BadNodeIdInvalid
|
|
// and are omitted from the wire request — saves a round-trip against the upstream
|
|
// server for a fault the driver can detect locally.
|
|
var toSend = new ReadValueIdCollection();
|
|
var indexMap = new List<int>(fullReferences.Count); // maps wire-index -> results-index
|
|
for (var i = 0; i < fullReferences.Count; i++)
|
|
{
|
|
if (!TryParseNodeId(session, fullReferences[i], out var nodeId))
|
|
{
|
|
results[i] = new DataValueSnapshot(null, StatusBadNodeIdInvalid, null, now);
|
|
continue;
|
|
}
|
|
toSend.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value });
|
|
indexMap.Add(i);
|
|
}
|
|
|
|
if (toSend.Count == 0) return results;
|
|
|
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
try
|
|
{
|
|
var resp = await session.ReadAsync(
|
|
requestHeader: null,
|
|
maxAge: 0,
|
|
timestampsToReturn: TimestampsToReturn.Both,
|
|
nodesToRead: toSend,
|
|
ct: cancellationToken).ConfigureAwait(false);
|
|
|
|
var values = resp.Results;
|
|
for (var w = 0; w < values.Count; w++)
|
|
{
|
|
var r = indexMap[w];
|
|
var dv = values[w];
|
|
// Preserve the upstream StatusCode verbatim — including Bad codes per
|
|
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
|
|
// clients can detect stale upstream data.
|
|
results[r] = new DataValueSnapshot(
|
|
Value: dv.Value,
|
|
StatusCode: dv.StatusCode.Code,
|
|
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
|
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
|
|
}
|
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Transport / timeout / session-dropped — fan out the same fault across every
|
|
// tag in this batch. Per-tag StatusCode stays BadCommunicationError (not
|
|
// BadInternalError) so operators distinguish "upstream unreachable" from
|
|
// "driver bug".
|
|
for (var w = 0; w < indexMap.Count; w++)
|
|
{
|
|
var r = indexMap[w];
|
|
results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
|
}
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
}
|
|
}
|
|
finally { _gate.Release(); }
|
|
return results;
|
|
}
|
|
|
|
// ---- IWritable ----
|
|
|
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<Core.Abstractions.WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
var session = RequireSession();
|
|
var results = new WriteResult[writes.Count];
|
|
|
|
var toSend = new WriteValueCollection();
|
|
var indexMap = new List<int>(writes.Count);
|
|
for (var i = 0; i < writes.Count; i++)
|
|
{
|
|
if (!TryParseNodeId(session, writes[i].FullReference, out var nodeId))
|
|
{
|
|
results[i] = new WriteResult(StatusBadNodeIdInvalid);
|
|
continue;
|
|
}
|
|
toSend.Add(new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant(writes[i].Value)),
|
|
});
|
|
indexMap.Add(i);
|
|
}
|
|
|
|
if (toSend.Count == 0) return results;
|
|
|
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
try
|
|
{
|
|
var resp = await session.WriteAsync(
|
|
requestHeader: null,
|
|
nodesToWrite: toSend,
|
|
ct: cancellationToken).ConfigureAwait(false);
|
|
|
|
var codes = resp.Results;
|
|
for (var w = 0; w < codes.Count; w++)
|
|
{
|
|
var r = indexMap[w];
|
|
// Pass upstream WriteResult StatusCode through verbatim. Success codes
|
|
// include Good (0) and any warning-level Good* variants; anything with
|
|
// the severity bits set is a Bad.
|
|
results[r] = new WriteResult(codes[w].Code);
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
for (var w = 0; w < indexMap.Count; w++)
|
|
results[indexMap[w]] = new WriteResult(StatusBadCommunicationError);
|
|
}
|
|
}
|
|
finally { _gate.Release(); }
|
|
return results;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse a tag's full-reference string as a NodeId. Accepts the standard OPC UA
|
|
/// serialized forms (<c>ns=2;s=…</c>, <c>i=2253</c>, <c>ns=4;g=…</c>, <c>ns=3;b=…</c>).
|
|
/// Empty + malformed strings return false; the driver surfaces that as
|
|
/// <see cref="StatusBadNodeIdInvalid"/> without a wire round-trip.
|
|
/// </summary>
|
|
internal static bool TryParseNodeId(ISession session, string fullReference, out NodeId nodeId)
|
|
{
|
|
nodeId = NodeId.Null;
|
|
if (string.IsNullOrWhiteSpace(fullReference)) return false;
|
|
try
|
|
{
|
|
nodeId = NodeId.Parse(session.MessageContext, fullReference);
|
|
return !NodeId.IsNull(nodeId);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private ISession RequireSession() =>
|
|
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
|
|
|
|
// ---- ITagDiscovery ----
|
|
|
|
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
var session = RequireSession();
|
|
|
|
var root = !string.IsNullOrEmpty(_options.BrowseRoot)
|
|
? NodeId.Parse(session.MessageContext, _options.BrowseRoot)
|
|
: ObjectIds.ObjectsFolder;
|
|
|
|
var rootFolder = builder.Folder("Remote", "Remote");
|
|
var visited = new HashSet<NodeId>();
|
|
var discovered = 0;
|
|
|
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await BrowseRecursiveAsync(session, root, rootFolder, visited,
|
|
depth: 0, discovered: () => discovered, increment: () => discovered++,
|
|
ct: cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally { _gate.Release(); }
|
|
}
|
|
|
|
private async Task BrowseRecursiveAsync(
|
|
ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet<NodeId> visited,
|
|
int depth, Func<int> discovered, Action increment, CancellationToken ct)
|
|
{
|
|
if (depth >= _options.MaxBrowseDepth) return;
|
|
if (discovered() >= _options.MaxDiscoveredNodes) return;
|
|
if (!visited.Add(node)) return;
|
|
|
|
var browseDescriptions = new BrowseDescriptionCollection
|
|
{
|
|
new()
|
|
{
|
|
NodeId = node,
|
|
BrowseDirection = BrowseDirection.Forward,
|
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
|
IncludeSubtypes = true,
|
|
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
|
|
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
|
|
| BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition),
|
|
}
|
|
};
|
|
|
|
BrowseResponse resp;
|
|
try
|
|
{
|
|
resp = await session.BrowseAsync(
|
|
requestHeader: null,
|
|
view: null,
|
|
requestedMaxReferencesPerNode: 0,
|
|
nodesToBrowse: browseDescriptions,
|
|
ct: ct).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// Transient browse failure on a sub-tree — don't kill the whole discovery, just
|
|
// skip this branch. The driver's health surface will reflect the cascade via the
|
|
// probe loop (PR 69).
|
|
return;
|
|
}
|
|
|
|
if (resp.Results.Count == 0) return;
|
|
var refs = resp.Results[0].References;
|
|
|
|
foreach (var rf in refs)
|
|
{
|
|
if (discovered() >= _options.MaxDiscoveredNodes) break;
|
|
|
|
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
|
|
if (NodeId.IsNull(childId)) continue;
|
|
|
|
var browseName = rf.BrowseName?.Name ?? childId.ToString();
|
|
var displayName = rf.DisplayName?.Text ?? browseName;
|
|
|
|
if (rf.NodeClass == NodeClass.Object)
|
|
{
|
|
var subFolder = folder.Folder(browseName, displayName);
|
|
increment();
|
|
await BrowseRecursiveAsync(session, childId, subFolder, visited,
|
|
depth + 1, discovered, increment, ct).ConfigureAwait(false);
|
|
}
|
|
else if (rf.NodeClass == NodeClass.Variable)
|
|
{
|
|
// Serialize the NodeId so the IReadable/IWritable surface receives a
|
|
// round-trippable string. Deferring the DataType + AccessLevel fetch to a
|
|
// follow-up PR — initial browse uses a conservative ViewOnly + Int32 default.
|
|
var nodeIdString = childId.ToString() ?? string.Empty;
|
|
folder.Variable(browseName, displayName, new DriverAttributeInfo(
|
|
FullName: nodeIdString,
|
|
DriverDataType: DriverDataType.Int32,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false));
|
|
increment();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- ISubscribable ----
|
|
|
|
public async Task<ISubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
|
{
|
|
var session = RequireSession();
|
|
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
|
var handle = new OpcUaSubscriptionHandle(id);
|
|
|
|
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
|
|
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
|
|
// bandwidth on every subscription create.
|
|
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
|
|
? 50
|
|
: (int)publishingInterval.TotalMilliseconds;
|
|
|
|
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
|
|
{
|
|
DisplayName = $"opcua-sub-{id}",
|
|
PublishingInterval = intervalMs,
|
|
KeepAliveCount = 10,
|
|
LifetimeCount = 1000,
|
|
MaxNotificationsPerPublish = 0,
|
|
PublishingEnabled = true,
|
|
Priority = 0,
|
|
TimestampsToReturn = TimestampsToReturn.Both,
|
|
});
|
|
|
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
session.AddSubscription(subscription);
|
|
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
foreach (var fullRef in fullReferences)
|
|
{
|
|
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
|
|
// The tag string is routed through MonitoredItem.Handle so the Notification
|
|
// handler can identify which tag changed without an extra lookup.
|
|
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
|
|
{
|
|
DisplayName = fullRef,
|
|
StartNodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
SamplingInterval = intervalMs,
|
|
QueueSize = 1,
|
|
DiscardOldest = true,
|
|
})
|
|
{
|
|
Handle = fullRef,
|
|
};
|
|
item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
|
|
subscription.AddItem(item);
|
|
}
|
|
|
|
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
|
|
_subscriptions[id] = new RemoteSubscription(subscription, handle);
|
|
}
|
|
finally { _gate.Release(); }
|
|
|
|
return handle;
|
|
}
|
|
|
|
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
if (handle is not OpcUaSubscriptionHandle h) return;
|
|
if (!_subscriptions.TryRemove(h.Id, out var rs)) return;
|
|
|
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
|
catch { /* best-effort — the subscription may already be gone on reconnect */ }
|
|
}
|
|
finally { _gate.Release(); }
|
|
}
|
|
|
|
private void OnMonitoredItemNotification(OpcUaSubscriptionHandle handle, MonitoredItem item, MonitoredItemNotificationEventArgs args)
|
|
{
|
|
// args.NotificationValue arrives as a MonitoredItemNotification for value-change
|
|
// subscriptions; extract its DataValue. The Handle property carries our tag string.
|
|
if (args.NotificationValue is not MonitoredItemNotification mn) return;
|
|
var dv = mn.Value;
|
|
if (dv is null) return;
|
|
var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty;
|
|
var snapshot = new DataValueSnapshot(
|
|
Value: dv.Value,
|
|
StatusCode: dv.StatusCode.Code,
|
|
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
|
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp);
|
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
|
|
}
|
|
|
|
private sealed record RemoteSubscription(Subscription Subscription, OpcUaSubscriptionHandle Handle);
|
|
|
|
private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle
|
|
{
|
|
public string DiagnosticId => $"opcua-sub-{Id}";
|
|
}
|
|
|
|
// ---- IHostConnectivityProbe ----
|
|
|
|
/// <summary>Endpoint-URL-keyed host identity for the Admin /hosts dashboard.</summary>
|
|
public string HostName => _options.EndpointUrl;
|
|
|
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
|
{
|
|
lock (_probeLock)
|
|
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
|
}
|
|
|
|
private void TransitionTo(HostState newState)
|
|
{
|
|
HostState old;
|
|
lock (_probeLock)
|
|
{
|
|
old = _hostState;
|
|
if (old == newState) return;
|
|
_hostState = newState;
|
|
_hostStateChangedUtc = DateTime.UtcNow;
|
|
}
|
|
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
|
}
|
|
|
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
|
|
catch { /* disposal is best-effort */ }
|
|
_gate.Dispose();
|
|
}
|
|
}
|