/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace Opc.Ua.Di.Server.Transfer { /// /// Default in-memory implementation. /// Holds active transfers in a /// keyed by an auto-incrementing transfer ID and exposes a /// pluggable export/import callback model for application logic /// to wire device-specific behaviour. /// /// /// /// Wire device-specific export/import via /// / /// . If no exporter is registered /// for an element, /// returns a transfer ID that will surface /// on the first /// . Same for importer. /// /// public sealed class DefaultTransferService : ITransferService { /// /// Creates a new service. /// /// /// Optional time source for testability (defaults to /// ). /// /// /// Maximum age of an idle transfer before it is auto-discarded /// on the next . /// Defaults to 5 minutes. /// public DefaultTransferService( TimeProvider? timeProvider = null, TimeSpan? transferTimeout = null) { m_time = timeProvider ?? TimeProvider.System; m_timeout = transferTimeout ?? TimeSpan.FromMinutes(5); } /// /// Registers a callback that produces the current parameter /// set for . Invoked once per /// /// call. /// public void RegisterExporter( NodeId elementId, Func> exporter) { if (elementId.IsNull) { throw new ArgumentNullException(nameof(elementId)); } if (exporter == null) { throw new ArgumentNullException(nameof(exporter)); } m_exporters[elementId] = exporter; } /// /// Registers a callback that applies a parameter set to /// . Receives the /// built by /// and /// returns the per-entry status codes (must match the input /// count and order). /// public void RegisterImporter( NodeId elementId, Func> importer) { if (elementId.IsNull) { throw new ArgumentNullException(nameof(elementId)); } if (importer == null) { throw new ArgumentNullException(nameof(importer)); } m_importers[elementId] = importer; } /// public async ValueTask TransferToDeviceAsync( ISystemContext context, NodeId elementId, ParameterSet parameters, CancellationToken ct = default) { if (context == null) { throw new ArgumentNullException(nameof(context)); } if (elementId.IsNull) { throw new ArgumentNullException(nameof(elementId)); } if (parameters == null) { throw new ArgumentNullException(nameof(parameters)); } int transferId = Interlocked.Increment(ref m_nextTransferId); ParameterEntry[] resultEntries; if (m_importers.TryGetValue(elementId, out var importer)) { StatusCode[] statuses; try { statuses = await importer(context, parameters, ct).ConfigureAwait(false); } catch (Exception ex) { m_transfers[transferId] = new TransferState( elementId, Array.Empty(), new ServiceResult(ex).StatusCode, m_time.GetUtcNow().UtcDateTime); return transferId; } resultEntries = new ParameterEntry[parameters.Entries.Count]; for (int i = 0; i < parameters.Entries.Count; i++) { StatusCode status = i < statuses.Length ? statuses[i] : StatusCodes.Bad; resultEntries[i] = parameters.Entries[i] with { StatusCode = status }; } } else { resultEntries = Array.Empty(); m_transfers[transferId] = new TransferState( elementId, resultEntries, StatusCodes.BadNotSupported, m_time.GetUtcNow().UtcDateTime); return transferId; } m_transfers[transferId] = new TransferState( elementId, resultEntries, StatusCodes.Good, m_time.GetUtcNow().UtcDateTime); return transferId; } /// public async ValueTask TransferFromDeviceAsync( ISystemContext context, NodeId elementId, CancellationToken ct = default) { if (context == null) { throw new ArgumentNullException(nameof(context)); } if (elementId.IsNull) { throw new ArgumentNullException(nameof(elementId)); } int transferId = Interlocked.Increment(ref m_nextTransferId); ParameterEntry[] entries; StatusCode error = StatusCodes.Good; if (m_exporters.TryGetValue(elementId, out var exporter)) { try { ParameterSet set = await exporter(context, ct).ConfigureAwait(false); entries = new ParameterEntry[set.Entries.Count]; for (int i = 0; i < set.Entries.Count; i++) { entries[i] = set.Entries[i]; } } catch (Exception ex) { entries = Array.Empty(); error = new ServiceResult(ex).StatusCode; } } else { entries = Array.Empty(); error = StatusCodes.BadNotSupported; } m_transfers[transferId] = new TransferState( elementId, entries, error, m_time.GetUtcNow().UtcDateTime); return transferId; } /// public ValueTask FetchAsync( ISystemContext context, int transferId, int sequenceNumber, int maxResults, bool omitGoodResults, CancellationToken ct = default) { if (context == null) { throw new ArgumentNullException(nameof(context)); } if (sequenceNumber < 0) { throw new ArgumentOutOfRangeException( nameof(sequenceNumber), sequenceNumber, "Sequence number must be non-negative."); } if (maxResults < 0) { throw new ArgumentOutOfRangeException( nameof(maxResults), maxResults, "MaxResults must be non-negative (0 = unlimited)."); } DiscardExpired(); if (!m_transfers.TryGetValue(transferId, out TransferState? state)) { return new ValueTask(new FetchResult( sequenceNumber, EndOfResults: true, Entries: Array.Empty(), TransferError: StatusCodes.BadNotFound)); } if (StatusCode.IsBad(state.TransferError)) { m_transfers.TryRemove(transferId, out _); return new ValueTask(new FetchResult( sequenceNumber, EndOfResults: true, Entries: Array.Empty(), TransferError: state.TransferError)); } int offset = sequenceNumber; if (offset >= state.Entries.Length) { m_transfers.TryRemove(transferId, out _); return new ValueTask(new FetchResult( sequenceNumber, EndOfResults: true, Entries: Array.Empty(), TransferError: StatusCodes.Good)); } int take = maxResults == 0 ? state.Entries.Length - offset : Math.Min(maxResults, state.Entries.Length - offset); var chunk = new List(take); for (int i = 0; i < take; i++) { ParameterEntry entry = state.Entries[offset + i]; if (omitGoodResults && StatusCode.IsGood(entry.StatusCode)) { continue; } chunk.Add(entry); } int nextOffset = offset + take; bool endOfResults = nextOffset >= state.Entries.Length; if (endOfResults) { m_transfers.TryRemove(transferId, out _); } return new ValueTask(new FetchResult( nextOffset, endOfResults, chunk.ToArray(), StatusCodes.Good)); } private void DiscardExpired() { DateTime cutoff = m_time.GetUtcNow().UtcDateTime - m_timeout; foreach (KeyValuePair kvp in m_transfers) { if (kvp.Value.CreatedAt < cutoff) { m_transfers.TryRemove(kvp.Key, out _); } } } private readonly TimeProvider m_time; private readonly TimeSpan m_timeout; private readonly ConcurrentDictionary>> m_exporters = new(); private readonly ConcurrentDictionary>> m_importers = new(); private readonly ConcurrentDictionary m_transfers = new(); private int m_nextTransferId; private sealed record TransferState( NodeId ElementId, ParameterEntry[] Entries, StatusCode TransferError, DateTime CreatedAt); } }