/* ======================================================================== * 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; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Bindings; using Opc.Ua.Client; using Opc.Ua.Client.Discovery; using Opc.Ua.Configuration; using Opc.Ua.Server; using ClientSession = Opc.Ua.Client.ISession; namespace Quickstarts.ConsoleDataChannelAudio { /// /// Streams a looping melody from a Server to a Client over an OPC UA data /// channel, and plays it back. /// /// /// The point of the sample is the shape of the problem rather than the /// audio: a continuous, latency-sensitive stream of opaque bytes that OPC /// UA has no other primitive for. Polling it with Read would be /// nonsensical, a Subscription carrying ByteString values would pay the /// encoding and Publish round trip for every packet, and PubSub would carry /// it beside the SecureChannel rather than on it. A data channel carries it /// on the SecureChannel that is already open, alongside the Service traffic /// and without disturbing it. /// public static class Program { /// /// Runs the sample until Ctrl-C. /// /// Command line arguments. public static async Task Main(string[] args) { int frameMilliseconds = 20; if (args.Length > 0 && (!int.TryParse(args[0], out frameMilliseconds) || frameMilliseconds is < 1 or > 1000)) { Console.Error.WriteLine("Usage: ConsoleDataChannelAudio [frame-milliseconds]"); return 1; } using var stop = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); }; try { await RunAsync(frameMilliseconds, stop.Token).ConfigureAwait(false); return 0; } catch (OperationCanceledException) { return 0; } } private static async Task RunAsync(int frameMilliseconds, CancellationToken ct) { using var source = new AudioStreamingSource(frameMilliseconds); AudioSampleServer.PendingSource = source; string endpointUrl = $"opc.tcp://localhost:{EndpointPort}/ConsoleDataChannelAudio"; HostApplicationBuilder builder = Host.CreateApplicationBuilder(); builder.Logging.ClearProviders(); builder.Logging.AddConsole(); builder.Logging.SetMinimumLevel(LogLevel.Warning); ConfigureServer(builder, endpointUrl); ConfigureClient(builder, endpointUrl); using IHost host = builder.Build(); await host.StartAsync(ct).ConfigureAwait(false); try { await StreamAsync(host, source, endpointUrl, frameMilliseconds, ct) .ConfigureAwait(false); } finally { await source.StopAsync().ConfigureAwait(false); await host.StopAsync(CancellationToken.None).ConfigureAwait(false); } } private static async Task StreamAsync( IHost host, AudioStreamingSource source, string endpointUrl, int frameMilliseconds, CancellationToken ct) { ClientSession session = await ConnectWithRetryAsync(host.Services, endpointUrl, ct) .ConfigureAwait(false); await using (session.ConfigureAwait(false)) { var clientChannel = session.TransportChannel as UaSCUaBinaryTransportChannel ?? throw new InvalidOperationException("The client transport is not UASC binary."); // Ready to receive before asking for the channel: the Server may // send as soon as the response is dispatched. DataChannelManager channels = clientChannel.SecureChannel!.EnableDataChannels( isServer: false, host.Services.GetRequiredService()); OpenDataChannelResponse opened = await session.OpenDataChannelAsync( null, source.SourceNodeId, 0, 0, new DataChannelParametersDataType { Direction = DataChannelDirection.SourceToSink, DeliveryMode = DataChannelDeliveryMode.ReliableOrdered, ContentType = AudioFormat.ContentType, MaxFrameSize = (uint)AudioFormat.BytesForDuration(frameMilliseconds), InitialCredit = (uint)AudioFormat.BytesForDuration(frameMilliseconds * 32), Priority = 1 }, ct).ConfigureAwait(false); DataChannel sink = channels.Register( opened.ChannelId, source.SourceNodeId, DataChannelSettings.FromParameters(opened.RevisedParameters), isSource: false, opened.RevisedTransportChannelId); channels.MarkOpen(opened.ChannelId); string wavPath = Path.Combine( Path.GetTempPath(), "ConsoleDataChannelAudio.wav"); using IAudioSink player = AudioSink.Create(wavPath); Console.WriteLine(); Console.WriteLine("OPC UA data channel audio streaming"); Console.WriteLine($" endpoint {endpointUrl}"); Console.WriteLine($" content type {AudioFormat.ContentType} " + $"({AudioFormat.SampleRate} Hz, {AudioFormat.BitsPerSample}-bit, mono)"); Console.WriteLine($" frame {frameMilliseconds} ms " + $"({AudioFormat.BytesForDuration(frameMilliseconds)} bytes)"); Console.WriteLine($" loop {source.LoopDuration.TotalSeconds:F1} s, repeating"); Console.WriteLine($" sink {player.Description}"); Console.WriteLine(); Console.WriteLine("Press Ctrl-C to stop."); Console.WriteLine(); await ReceiveAsync(sink, player, ct).ConfigureAwait(false); await session.CloseDataChannelAsync( null, opened.ChannelId, StatusCodes.Good, deleteQueued: true, CancellationToken.None).ConfigureAwait(false); } } private static async Task ReceiveAsync( DataChannel sink, IAudioSink player, CancellationToken ct) { ulong frames = 0; ulong bytes = 0; DateTime nextReport = DateTime.UtcNow.AddSeconds(1); while (!ct.IsCancellationRequested) { using DataChannelMessage? message = await sink.ReadAsync(ct).ConfigureAwait(false); if (message == null) { break; } player.Write(message.Payload.Span); frames++; bytes += (ulong)message.Payload.Length; if (DateTime.UtcNow >= nextReport) { DataChannelDiagnosticsDataType diagnostics = sink.GetDiagnostics(); Console.Write( $"\r {frames} frames, {bytes / 1024} KiB, " + $"credit stalls {diagnostics.CreditStalls} "); // The line carries no newline so it overwrites itself on a // terminal; a redirected stream would otherwise hold it in // the buffer and show nothing at all. Console.Out.Flush(); nextReport = DateTime.UtcNow.AddSeconds(1); } } } private static void ConfigureServer(HostApplicationBuilder builder, string endpointUrl) { builder.Services.AddOpcUa() .AddServer(o => { const string applicationName = "ConsoleDataChannelAudioServer"; o.ApplicationName = applicationName; o.ApplicationUri = "urn:localhost:OPCFoundation:ConsoleDataChannelAudioServer"; o.ProductUri = "uri:opcfoundation.org:ConsoleDataChannelAudioServer"; o.AutoAcceptUntrustedCertificates = true; o.PkiRoot = PkiRoot(applicationName); o.RejectSHA1Certificates = true; o.MinCertificateKeySize = 2048; o.EndpointUrls.Add(endpointUrl); }); } private static void ConfigureClient(HostApplicationBuilder builder, string endpointUrl) { builder.Services.AddOpcUa() .AddClient(o => { const string applicationName = "ConsoleDataChannelAudioClient"; o.ApplicationName = applicationName; o.ApplicationUri = "urn:localhost:OPCFoundation:ConsoleDataChannelAudioClient"; o.ProductUri = "uri:opcfoundation.org:ConsoleDataChannelAudioClient"; o.PkiRoot = PkiRoot(applicationName); o.AutoAcceptUntrustedCertificates = true; o.RejectSHA1SignedCertificates = true; o.MinimumCertificateKeySize = 2048; }) .AddDiscoveryAndConnect(o => { o.DiscoveryUrl = endpointUrl; o.SecurityMode = MessageSecurityMode.SignAndEncrypt; o.SecurityPolicyUri = SecurityPolicies.Basic256Sha256; }); } private static string PkiRoot(string applicationName) { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OPC Foundation", applicationName, "pki"); } private static async Task ConnectWithRetryAsync( IServiceProvider services, string endpointUrl, CancellationToken ct) { Exception? last = null; for (int ii = 0; ii < 20; ii++) { try { return await ConnectAsync(services, endpointUrl, ct).ConfigureAwait(false); } catch (Exception ex) when (!ct.IsCancellationRequested) { last = ex; await Task.Delay(250, ct).ConfigureAwait(false); } } throw new InvalidOperationException("The in-process server did not become ready.", last); } private static async Task ConnectAsync( IServiceProvider services, string endpointUrl, CancellationToken ct) { ApplicationConfiguration configuration = await services .GetRequiredService() .GetAsync(ct) .ConfigureAwait(false); ArrayOf endpoints = await services .GetRequiredService() .GetEndpointsAsync(endpointUrl, ct: ct) .ConfigureAwait(false); EndpointDescription? description = null; foreach (EndpointDescription candidate in endpoints) { if (candidate.TransportProfileUri == Profiles.UaTcpTransport && candidate.SecurityMode == MessageSecurityMode.SignAndEncrypt && candidate.SecurityPolicyUri == SecurityPolicies.Basic256Sha256) { description = candidate; break; } } if (description == null) { throw new InvalidOperationException( "No SignAndEncrypt/Basic256Sha256 opc.tcp endpoint was returned."); } var endpoint = new ConfiguredEndpoint( null, description, EndpointConfiguration.Create(configuration)); return await services.GetRequiredService().CreateAsync( configuration, endpoint, updateBeforeConnect: false, checkDomain: false, "ConsoleDataChannelAudio", 60_000, new UserIdentity(), default, ct).ConfigureAwait(false); } private const int EndpointPort = 62560; } /// /// A Server that publishes the audio source and grants it to any activated /// Session on a signed and encrypted SecureChannel. /// internal sealed class AudioSampleServer : StandardServer { /// /// Creates the server. /// /// Telemetry context. /// Time provider. public AudioSampleServer(ITelemetryContext telemetry, TimeProvider timeProvider) : base(telemetry, timeProvider) { AudioStreamingSource source = PendingSource ?? throw new InvalidOperationException("No audio source was provided."); DataChannelSources.Register(source); // Registering a source states that it exists, not that any given // user may read it. The default authorizer resolves the source in // the AddressSpace and applies its RolePermissions; a source kept // outside the AddressSpace, as this one is, has no such metadata and // would be denied. DataChannelAuthorizer = new AudioSourceAuthorizer(source.SourceNodeId); DataChannelCapabilities = new DataChannelServerCapabilities { MaxDataChannels = 4, MaxFrameSize = 64 * 1024, MaxCreditPerChannel = 1024 * 1024, SupportedDeliveryModes = [DataChannelDeliveryMode.ReliableOrdered], SupportedTransportProfileUris = [Profiles.UaTcpTransport] }; } /// /// The source the next server instance publishes. /// public static AudioStreamingSource? PendingSource { get; set; } } /// /// Grants the one source this sample publishes. /// internal sealed class AudioSourceAuthorizer(NodeId sourceNodeId) : IDataChannelAuthorizer { /// public ValueTask IsAuthorizedAsync( DataChannelRequestContext context, NodeId requestedSourceNodeId, DataChannelDirection direction, CancellationToken ct) { // Part 4 errata ยง7.2: a channel carrying payload towards the source // is a write, so only the outbound direction this sample publishes // is granted. bool authorized = requestedSourceNodeId == sourceNodeId && direction == DataChannelDirection.SourceToSink && context.IsSessionActivated && context.SecurityMode == MessageSecurityMode.SignAndEncrypt; return new ValueTask(authorized); } } }