/* ======================================================================== * Copyright (c) 2005-2026 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.IO; using System.Threading; using System.Threading.Tasks; namespace Opc.Ua.Di.Client { /// /// Client-side helpers that drive the OPC 10000-5 §11.4 /// TemporaryFileTransferType upload pipeline exposed by the /// DI software-update facet's PackageLoadingType.FileTransfer /// slot. The server-side wiring is delivered by /// SoftwareUpdateFileTransferManager. /// public partial class SoftwareUpdateClient { /// /// Default chunk size when the server does not advertise one. /// public const int DefaultUploadChunkSizeBytes = 8 * 1024; /// OPC 10000-5 §11.3.3 — Open mode Write|EraseExisting. private const byte OpenModeWriteEraseExisting = 6; private NodeId? m_cachedFileTransferNodeId; private NodeId? m_cachedGenerateFileForWriteNodeId; private NodeId? m_cachedCloseAndCommitNodeId; /// /// Uploads through the SU FileTransfer /// pipeline. The bytes are streamed in /// -sized chunks via the standard /// FileType.Write method; on completion /// CloseAndCommit is called, which commits the payload to /// the server-side ISoftwarePackageStore. /// /// /// Source stream. Must be readable; position is not reset by this /// method. /// /// /// Optional package id to advertise to the server via the /// vendor-defined generateOptions argument. When non-empty /// the default SoftwareUpdateFileTransferManager uses it /// as the resulting package's id. /// /// /// Chunk size in bytes. Defaults to /// when /// . /// /// Cancellation token. /// /// The number of payload bytes uploaded. /// public async ValueTask UploadPackageAsync( Stream payload, string? suggestedPackageId = null, int? chunkSizeBytes = null, CancellationToken ct = default) { if (payload is null) { throw new ArgumentNullException(nameof(payload)); } if (!payload.CanRead) { throw new ArgumentException("Payload stream must be readable.", nameof(payload)); } int chunk = chunkSizeBytes ?? DefaultUploadChunkSizeBytes; if (chunk <= 0) { throw new ArgumentOutOfRangeException( nameof(chunkSizeBytes), "Chunk size must be positive."); } (NodeId fileTransferId, NodeId generateForWriteId, NodeId closeAndCommitId) = await ResolveFileTransferMethodsAsync(ct).ConfigureAwait(false); // 1) GenerateFileForWrite(generateOptions) → (fileNodeId, fileHandle) Variant generateOptions = string.IsNullOrEmpty(suggestedPackageId) ? Variant.Null : new Variant(suggestedPackageId); (NodeId fileNodeId, uint commitHandle) = await CallGenerateFileForWriteAsync( fileTransferId, generateForWriteId, generateOptions, ct) .ConfigureAwait(false); // 2) Resolve Open / Write / Close on the transient FileState. (NodeId openId, NodeId writeId, NodeId closeId) = await ResolveFileMethodsAsync(fileNodeId, ct).ConfigureAwait(false); // 3) Open(mode=6) → openHandle uint openHandle = await CallOpenAsync( fileNodeId, openId, OpenModeWriteEraseExisting, ct) .ConfigureAwait(false); // 4) Chunked Write long totalWritten = 0; byte[] buffer = new byte[chunk]; try { while (true) { ct.ThrowIfCancellationRequested(); #if NET6_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER int read = await payload.ReadAsync( buffer.AsMemory(0, chunk), ct).ConfigureAwait(false); #else int read = await payload.ReadAsync( buffer, 0, chunk, ct).ConfigureAwait(false); #endif if (read <= 0) { break; } byte[] toSend = read == chunk ? buffer : buffer.AsSpan(0, read).ToArray(); await CallWriteAsync( fileNodeId, writeId, openHandle, toSend, ct) .ConfigureAwait(false); totalWritten += read; } // 5) Close(openHandle) await CallCloseAsync( fileNodeId, closeId, openHandle, ct).ConfigureAwait(false); } catch { // Best-effort close on failure; ignore secondary errors. try { await CallCloseAsync( fileNodeId, closeId, openHandle, ct).ConfigureAwait(false); } catch { // swallowed } throw; } // 6) CloseAndCommit(commitHandle) _ = await CallCloseAndCommitAsync( fileTransferId, closeAndCommitId, commitHandle, ct) .ConfigureAwait(false); return totalWritten; } /// /// Convenience overload that uploads a byte buffer. /// public ValueTask UploadPackageAsync( byte[] payload, string? suggestedPackageId = null, int? chunkSizeBytes = null, CancellationToken ct = default) { if (payload is null) { throw new ArgumentNullException(nameof(payload)); } return UploadPackageAsync( new MemoryStream(payload, writable: false), suggestedPackageId, chunkSizeBytes, ct); } private async ValueTask<(NodeId FileTransfer, NodeId GenerateForWrite, NodeId CloseAndCommit)> ResolveFileTransferMethodsAsync(CancellationToken ct) { if (m_cachedFileTransferNodeId is { } cachedFt && m_cachedGenerateFileForWriteNodeId is { } cachedGen && m_cachedCloseAndCommitNodeId is { } cachedCmt) { return (cachedFt, cachedGen, cachedCmt); } ushort diNs = Session.NamespaceUris.GetIndexOrAppend( Opc.Ua.Di.Namespaces.OpcUaDi); var paths = ArrayOf.Wrapped(new[] { BuildBrowsePath( new RelativePathElement { ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("Loading", diNs) }, new RelativePathElement { ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("FileTransfer", diNs) }), BuildBrowsePath( new RelativePathElement { ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("Loading", diNs) }, new RelativePathElement { ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("FileTransfer", diNs) }, new RelativePathElement { ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("GenerateFileForWrite") }), BuildBrowsePath( new RelativePathElement { ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("Loading", diNs) }, new RelativePathElement { ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("FileTransfer", diNs) }, new RelativePathElement { ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("CloseAndCommit") }), }); TranslateBrowsePathsToNodeIdsResponse response = await Session .TranslateBrowsePathsToNodeIdsAsync(null, paths, ct) .ConfigureAwait(false); NodeId ftId = ExpectSingleTarget(response.Results, 0, "Loading.FileTransfer"); NodeId genId = ExpectSingleTarget(response.Results, 1, "Loading.FileTransfer.GenerateFileForWrite"); NodeId cmtId = ExpectSingleTarget(response.Results, 2, "Loading.FileTransfer.CloseAndCommit"); m_cachedFileTransferNodeId = ftId; m_cachedGenerateFileForWriteNodeId = genId; m_cachedCloseAndCommitNodeId = cmtId; return (ftId, genId, cmtId); } private async ValueTask<(NodeId OpenId, NodeId WriteId, NodeId CloseId)> ResolveFileMethodsAsync(NodeId fileNodeId, CancellationToken ct) { var paths = ArrayOf.Wrapped(new[] { MakeBrowsePathFromFile(fileNodeId, "Open"), MakeBrowsePathFromFile(fileNodeId, "Write"), MakeBrowsePathFromFile(fileNodeId, "Close"), }); TranslateBrowsePathsToNodeIdsResponse response = await Session .TranslateBrowsePathsToNodeIdsAsync(null, paths, ct) .ConfigureAwait(false); return ( ExpectSingleTarget(response.Results, 0, "FileState.Open"), ExpectSingleTarget(response.Results, 1, "FileState.Write"), ExpectSingleTarget(response.Results, 2, "FileState.Close") ); } private async ValueTask<(NodeId FileNodeId, uint Handle)> CallGenerateFileForWriteAsync( NodeId fileTransferId, NodeId methodId, Variant generateOptions, CancellationToken ct) { CallResponse response = await Session.CallAsync( null, ArrayOf.Wrapped(new[] { new CallMethodRequest { ObjectId = fileTransferId, MethodId = methodId, InputArguments = ArrayOf.Wrapped(new[] { generateOptions }), } }), ct).ConfigureAwait(false); CallMethodResult result = ExpectSingleCallResult(response, "GenerateFileForWrite"); if (result.OutputArguments.Count < 2) { throw new ServiceResultException( StatusCodes.BadDecodingError, "GenerateFileForWrite did not return (fileNodeId, fileHandle)."); } NodeId fileNodeId = result.OutputArguments[0].TryGetValue(out NodeId nid) ? nid : NodeId.Null; uint handle = result.OutputArguments[1].TryGetValue(out uint h) ? h : 0u; if (fileNodeId.IsNull || handle == 0) { throw new ServiceResultException( StatusCodes.BadDecodingError, "GenerateFileForWrite returned an invalid file NodeId or handle."); } return (fileNodeId, handle); } private async ValueTask CallOpenAsync( NodeId fileNodeId, NodeId openMethodId, byte mode, CancellationToken ct) { CallResponse response = await Session.CallAsync( null, ArrayOf.Wrapped(new[] { new CallMethodRequest { ObjectId = fileNodeId, MethodId = openMethodId, InputArguments = ArrayOf.Wrapped(new[] { new Variant(mode) }), } }), ct).ConfigureAwait(false); CallMethodResult result = ExpectSingleCallResult(response, "FileState.Open"); if (result.OutputArguments.Count < 1 || !result.OutputArguments[0].TryGetValue(out uint handle)) { throw new ServiceResultException( StatusCodes.BadDecodingError, "FileState.Open did not return a uint file handle."); } return handle; } private async ValueTask CallWriteAsync( NodeId fileNodeId, NodeId writeMethodId, uint fileHandle, byte[] data, CancellationToken ct) { CallResponse response = await Session.CallAsync( null, ArrayOf.Wrapped(new[] { new CallMethodRequest { ObjectId = fileNodeId, MethodId = writeMethodId, InputArguments = ArrayOf.Wrapped(new[] { new Variant(fileHandle), new Variant(ByteString.From(data)), }), } }), ct).ConfigureAwait(false); _ = ExpectSingleCallResult(response, "FileState.Write"); } private async ValueTask CallCloseAsync( NodeId fileNodeId, NodeId closeMethodId, uint fileHandle, CancellationToken ct) { CallResponse response = await Session.CallAsync( null, ArrayOf.Wrapped(new[] { new CallMethodRequest { ObjectId = fileNodeId, MethodId = closeMethodId, InputArguments = ArrayOf.Wrapped(new[] { new Variant(fileHandle) }), } }), ct).ConfigureAwait(false); _ = ExpectSingleCallResult(response, "FileState.Close"); } private async ValueTask CallCloseAndCommitAsync( NodeId fileTransferId, NodeId methodId, uint commitHandle, CancellationToken ct) { CallResponse response = await Session.CallAsync( null, ArrayOf.Wrapped(new[] { new CallMethodRequest { ObjectId = fileTransferId, MethodId = methodId, InputArguments = ArrayOf.Wrapped(new[] { new Variant(commitHandle) }), } }), ct).ConfigureAwait(false); CallMethodResult result = ExpectSingleCallResult(response, "CloseAndCommit"); if (result.OutputArguments.Count < 1 || !result.OutputArguments[0].TryGetValue(out NodeId completion)) { return NodeId.Null; } return completion; } private BrowsePath BuildBrowsePath(params RelativePathElement[] elements) { return new BrowsePath { StartingNode = SoftwareUpdateNodeId, RelativePath = new RelativePath { Elements = ArrayOf.Wrapped(elements) } }; } private static BrowsePath MakeBrowsePathFromFile( NodeId fileNodeId, string browseName) { return new BrowsePath { StartingNode = fileNodeId, RelativePath = new RelativePath { Elements = ArrayOf.Wrapped(new[] { new RelativePathElement { ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName(browseName) } }) } }; } private NodeId ExpectSingleTarget( ArrayOf results, int index, string description) { if (results.Count <= index) { throw new ServiceResultException( StatusCodes.BadInternalError, $"TranslateBrowsePaths returned fewer results than expected resolving {description}."); } BrowsePathResult result = results[index]; if (!StatusCode.IsGood(result.StatusCode) || result.Targets.Count == 0) { throw new ServiceResultException( (uint)result.StatusCode, $"Could not resolve {description}."); } return ExpandedNodeId.ToNodeId( result.Targets[0].TargetId, Session.NamespaceUris); } private static CallMethodResult ExpectSingleCallResult( CallResponse response, string description) { if (response.Results.Count == 0) { throw new ServiceResultException( StatusCodes.BadInternalError, $"Call({description}) returned no results."); } CallMethodResult result = response.Results[0]; if (!StatusCode.IsGood(result.StatusCode)) { throw new ServiceResultException( (uint)result.StatusCode, $"Call({description}) failed."); } return result; } } }