mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-25 03:43:05 +08:00
Nine review comments; two were answered by the previous commit (the generic extension seam and the file named after it are both gone). The rest: Sample layout and a second sample The data channel samples now live under samples/Core, and a new ConsoleDataChannelAudio joins the throughput benchmark. It stands up a Server and a Client in one process, synthesises a short melody as 16-bit PCM once at startup and streams it on repeat over a data channel while the Client plays it back. Nothing binary enters the repository. The source writes in real time rather than as fast as the channel will take it, because a media source is paced by its own clock and writing faster only adds latency; the progress line reports frames, bytes and credit stalls so a consumer that cannot keep up is visible rather than silently buffered. Measured over 31 seconds: 1582 frames, 2725 KiB, zero credit stalls, which is exactly real time for 20 ms frames. Playback uses NAudio 2.3.0 (MIT), referenced only by that sample - nothing under src/ gains a dependency. NAudio's output devices are Windows interfaces and 2.x has no ALSA or CoreAudio backend, so on Linux and macOS the sample writes the received stream to a WAV instead and says which it is doing on startup. The Windows-only paths sit behind OperatingSystem.IsWindows() rather than a CA1416 suppression. Documentation "Where the code lives" describes where the code is rather than where it moved from, and the same edit removes "rather than in ...", "remains" and the rest of the change narration. Two sections that were a development diary - the tables of defects found while writing tests and while running the sample - are gone entirely: they recorded history rather than the state of the implementation. Logging The response-dispatched callback logged through a message that reads "Failed to send fault response to client", which is not what happened, and on the HTTPS listener it invented a requestId of 0 to fit that signature. All three listeners now have a named message that says what actually failed. net8.0 for the QUIC binding Rather than excluding net8.0, the binding targets it and enables preview features, so a net8.0 consumer opts in the same way for System.Net.Quic. The three file-wide NET9_0_OR_GREATER guards are gone; only QuicServerConnectionOptions.HandshakeTimeout stays guarded because it is genuinely .NET 9+, and the listener's own admission expiry still bounds a stalled handshake there. Removing the #if around [Experimental] ExperimentalAttribute is a .NET 8 type, and the repo's rule is to polyfill rather than fork with #if, so it joins the existing polyfills in Opc.Ua.Types. Verified that this is a real opt-in and not just something that compiles: with the suppression removed, a net48 caller of a data channel Service fails the build with "error DataChannels", which is the same behaviour as net10.0. Validated with the matrix CI runs: all seven pinned UA.slnx builds (net10.0, net9.0, net8.0, net48, net472, netstandard2.0, netstandard2.1), 316 data channel tests on net10.0 and 242 on net48, Opc.Ua.Server.Tests 4072, Opc.Ua.Client.Tests 2126, Opc.Ua.Redundancy.Client.Tests 123, and the reference server Docker image end to end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d026b47-265d-4c97-8715-26e207dfa03c
430 lines
17 KiB
C#
430 lines
17 KiB
C#
/* ========================================================================
|
|
* 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
|
|
{
|
|
/// <summary>
|
|
/// Streams a looping melody from a Server to a Client over an OPC UA data
|
|
/// channel, and plays it back.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public static class Program
|
|
{
|
|
/// <summary>
|
|
/// Runs the sample until Ctrl-C.
|
|
/// </summary>
|
|
/// <param name="args">Command line arguments.</param>
|
|
public static async Task<int> 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<ITelemetryContext>());
|
|
|
|
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<AudioSampleServer>(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<ClientSession> 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<ClientSession> ConnectAsync(
|
|
IServiceProvider services,
|
|
string endpointUrl,
|
|
CancellationToken ct)
|
|
{
|
|
ApplicationConfiguration configuration = await services
|
|
.GetRequiredService<IOpcUaApplicationConfigurationProvider>()
|
|
.GetAsync(ct)
|
|
.ConfigureAwait(false);
|
|
|
|
ArrayOf<EndpointDescription> endpoints = await services
|
|
.GetRequiredService<IOpcUaDiscoveryService>()
|
|
.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<ISessionFactory>().CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
updateBeforeConnect: false,
|
|
checkDomain: false,
|
|
"ConsoleDataChannelAudio",
|
|
60_000,
|
|
new UserIdentity(),
|
|
default,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private const int EndpointPort = 62560;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Server that publishes the audio source and grants it to any activated
|
|
/// Session on a signed and encrypted SecureChannel.
|
|
/// </summary>
|
|
internal sealed class AudioSampleServer : StandardServer
|
|
{
|
|
/// <summary>
|
|
/// Creates the server.
|
|
/// </summary>
|
|
/// <param name="telemetry">Telemetry context.</param>
|
|
/// <param name="timeProvider">Time provider.</param>
|
|
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]
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// The source the next server instance publishes.
|
|
/// </summary>
|
|
public static AudioStreamingSource? PendingSource { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grants the one source this sample publishes.
|
|
/// </summary>
|
|
internal sealed class AudioSourceAuthorizer(NodeId sourceNodeId) : IDataChannelAuthorizer
|
|
{
|
|
/// <inheritdoc/>
|
|
public ValueTask<bool> 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<bool>(authorized);
|
|
}
|
|
}
|
|
}
|