diff --git a/Directory.Packages.props b/Directory.Packages.props index 46c364bf09..93c1f02068 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -68,6 +68,12 @@ + + diff --git a/UA.slnx b/UA.slnx index f8c03fd2fa..e7c1b150d3 100644 --- a/UA.slnx +++ b/UA.slnx @@ -6,7 +6,8 @@ - + + diff --git a/docs/DataChannels.md b/docs/DataChannels.md index 2c0a321ae5..85e2ac4822 100644 --- a/docs/DataChannels.md +++ b/docs/DataChannels.md @@ -36,12 +36,12 @@ it, and a peer that does use it never speaks first: it may not transmit a frame because an unrecognized `MessageType` is a protocol error that closes the SecureChannel, taking every Session, Subscription and Service call with it. -Consumers opt in by referencing the package and suppressing the experimental diagnostic: +Consumers opt in by suppressing the experimental diagnostic. The engine is part of +`OPCFoundation.NetStandard.Opc.Ua.Core`, so no extra package is needed for inline +framing; `opc.quic` is a separate package because it carries channels on native QUIC +streams: ```xml - - - $(NoWarn);DataChannels @@ -49,32 +49,32 @@ Consumers opt in by referencing the package and suppressing the experimental dia ## Where the code lives -The engine ships in `OPCFoundation.NetStandard.Opc.Ua.Core.Channels` rather than in -`Opc.Ua.Core`. It is a consumer of the message-extension seam in Core: it registers as the -owner of the `STR` MessageType, and the SecureChannel decrypts, verifies and -sequence-checks every chunk before the engine sees it. Core itself carries no data channel -vocabulary — `ISecureChannelMessageExtension` and `ISecureChannelMessageHost` name only "a -MessageType that is neither a Service call nor part of establishing the SecureChannel". +The engine is part of `Opc.Ua.Core`, under `Stack/DataChannels`. `UaSCUaBinaryChannel` +owns the `DataChannelManager` for its SecureChannel and dispatches `STR` chunks to it +after decrypting, verifying and sequence-checking them, so the engine only ever sees +authenticated content. `EnableDataChannels` turns the feature on for a channel; until it +is called an incoming `STR` chunk closes the SecureChannel, which is what the +interoperability rule of §5.16 requires of a peer that does not implement this +specification. -Both are `internal`, reached through the `InternalsVisibleTo` that Core already grants its -sibling first-party assemblies (`Opc.Ua.Client`, `Opc.Ua.Bindings.Quic`, -`Opc.Ua.Bindings.Https`). The seam exists to keep the engine out of Core, not to invite -other implementers — there is exactly one, and the `opc.quic` binding does not use any of -it, because a QUIC data channel rides its own stream and never becomes a UASC chunk. -Publishing it would commit Core to an abstraction with one implementer for ever. +`SequenceNumberBudget` tracks the sequence space every MessageType on the channel draws +on. The channel claims a SequenceNumber while it secures a frame and refuses the send with +`Bad_SecureChannelTokenUnknown` when the space under the current token is exhausted, so a +sender stalls rather than reuse a number (§5.1.1). Assigning the number and applying +message security are serialized against Service traffic — both draw on the same keys and +the same counter — while the write itself is awaited outside that serialization, so a slow +peer on a data channel cannot stall `Publish`. -Two things stay in Core because they are the SecureChannel's own concerns rather than the -feature's: `SequenceNumberBudget`, which tracks the sequence space every MessageType draws -on, and `UaSCSecureChannelRegistry`, which maps a SecureChannel identifier to the channel -that owns it. The budget is internal for the same reason as the seam; the registry is -public because an application writing its own `IServerDataChannelTransport` for inline -framing needs it to resolve the channel behind a request, which -`samples/ConsoleDataChannelStreaming` demonstrates. Because the budget is Core's, the -channel claims the SequenceNumber itself while it secures the chunk and refuses the send -with `Bad_SecureChannelTokenUnknown` when the space under the current token is exhausted — -an extension stalls rather than reuse a number, and never has to reason about the -serialization to do so. The `opc.quic` transport remains its own package, -`OPCFoundation.NetStandard.Opc.Ua.Bindings.Quic`. +`UaSCSecureChannelRegistry` maps a SecureChannel identifier to the channel that owns it. +It is public because an application writing its own `IServerDataChannelTransport` for +inline framing needs it to resolve the channel behind a request, which +`samples/Core/ConsoleDataChannelStreaming` demonstrates. + +The server-side Service surface — `IServerDataChannelTransport` and +`InlineServerDataChannelTransport` — is part of `Opc.Ua.Server`. The `opc.quic` transport +is its own package, `OPCFoundation.NetStandard.Opc.Ua.Bindings.Quic`, because it carries +data channels on native QUIC streams rather than as UASC chunks and needs no part of the +inline framing. ## Server side @@ -154,29 +154,55 @@ if (message != null) | `OpenDataChannel`, `ModifyDataChannel`, `CloseDataChannel` | Complete, and served by `StandardServer`: a real Client opens a channel through a real Session | | Inline framing (`opc.tcp`, `opc.wss`) on the Server | Complete on the raw-socket `opc.tcp` listener. `InlineServerDataChannelTransport` resolves the UASC channel behind the request and enables the engine on it, so a Server with no transport configured still carries channels over the connection the Client already holds. A SecureChannel that can carry no frames is refused with `Bad_DataChannelTransportUnsupported` rather than accepted and then drained silently, which §5.16 requires | | Authorization | Complete, and **direction-aware**: `SourceToSink` requires Read on the source, `SinkToSource` requires Write, `Bidirectional` requires both (Part 4 errata §7.2, DCS-023). Re-evaluated on an interval, on `ActivateSession` and on role change | -| Parameter negotiation, Session scoping, authorization recheck, audit | Complete, driven from the Server rather than only callable. **Server-initiated offers (Part 4 §6) are not implemented**: the registry exists but nothing creates an offer or raises `DataChannelOfferedEventType`, so `TryRedeem` can only fail | -| `opc.quic` — url scheme, ALPN negotiation and enforcement, control stream, client channel and factory | Complete (`Opc.Ua.Bindings.Quic`, **net9.0+**) | +| Parameter negotiation, Session scoping, authorization recheck, audit | Complete, and driven from the Server. **Server-initiated offers (Part 4 §6) are not implemented**: the registry exists but nothing creates an offer or raises `DataChannelOfferedEventType`, so `TryRedeem` can only fail | +| `opc.quic` — url scheme, ALPN negotiation and enforcement, control stream, client channel and factory | Complete (`Opc.Ua.Bindings.Quic`, **net8.0+**) | | `opc.quic` — listener, service host, endpoint discovery, reverse connect, certificate rotation | Complete | | `opc.quic` — data channels bound to per-channel streams, `RESET_STREAM` carrying the StatusCode | Complete. The stream is released when the channel reaches a terminal state, so an orderly close completes the writes and a `RESET` becomes a `RESET_STREAM` carrying the StatusCode | | `opc.quic` — direction to stream type and initiator (§7.4), `revisedTransportChannelId` | Complete; `SourceToSink` gets a server-initiated unidirectional stream whose id is returned to the Client | -| `opc.quic` — TLS-to-OPC-UA key binding (§7.6.1) | Complete, and **invoked on the connect path** — it previously existed only as tested, uncalled code, which left the profile unbound | +| `opc.quic` — TLS-to-OPC-UA key binding (§7.6.1) | Complete. The binding is verified on the connect path, comparing the TLS peer's subjectPublicKeyInfo against the OPN senderCertificate in constant time | | `DataChannelCapabilities` model projection | **Not wired.** `DataChannelModel` builds the values, but the Object is never instantiated under `ServerCapabilities`, so a Client cannot read the capabilities or discover the feature through the address space (Part 3 §6, Part 4 §10) | -| Worked sample | `samples/ConsoleDataChannelStreaming` | +| Worked samples | `samples/Core/ConsoleDataChannelStreaming` (throughput benchmark) and `samples/Core/ConsoleDataChannelAudio` (looping audio, played back by the Client) | | Unreliable datagrams (§7.5) | **Not implementable on .NET.** `QuicConnection` exposes no RFC 9221 datagram API through .NET 10, so `SupportsUnreliableDatagrams` is `False` and the Server refuses `Unreliable` and `PartiallyReliable` with `Bad_DeliveryModeUnsupported` — which is what the errata requires rather than silently carrying them on the stream | -| DI / fluent builder extension | Complete. `AddQuicTransport()` registers the listener and channel factories **and** the server-side data channel transport, so a DI-built Server carries channels on `opc.quic` streams with no further wiring. `UseQuicDataChannelTransport()` remains the direct-construction fallback | +| DI / fluent builder extension | Complete. `AddQuicTransport()` registers the listener and channel factories **and** the server-side data channel transport, so a DI-built Server carries channels on `opc.quic` streams with no further wiring. `UseQuicDataChannelTransport()` is the direct-construction fallback | | Connection loss and SecureChannel close | Complete. A closed SecureChannel or lost transport faults every data channel riding on it (§5.13), on both the inline and `opc.quic` paths | -## Why the QUIC binding is net9.0+ +## Why the QUIC binding is net8.0+ -`System.Net.Quic` is still behind `[RequiresPreviewFeatures]` on net8.0. Opting in -would emit a `RequiresPreviewFeatures` assembly attribute that every consumer would -then have to opt into as well, so the binding targets net9.0 and net10.0, where the -API is stable. `Opc.Ua.Core` itself is unaffected and still builds for all six TFMs. +`System.Net.Quic` carries `[RequiresPreviewFeatures]` on net8.0, so the binding enables +preview features for that target and a net8.0 consumer sets `EnablePreviewFeatures` in +its own project to use it. On net9.0 and net10.0 the API is stable and no opt-in applies. +`QuicServerConnectionOptions.HandshakeTimeout` is .NET 9+, so on net8.0 the platform +default bounds the handshake and the listener's own admission expiry still releases a +stalled peer's slot. `Opc.Ua.Core` is unaffected and builds for all six TFMs. ## Running the sample +### Audio streaming + +`samples/Core/ConsoleDataChannelAudio` is the shortest path to seeing the feature do +something a Subscription cannot. It stands up a Server and a Client in one process, +synthesises a short melody as 16-bit PCM, and streams it on repeat over a data channel +while the Client plays it: + ```sh -cd samples/ConsoleDataChannelStreaming +cd samples/Core/ConsoleDataChannelAudio +dotnet run # 20 ms frames +dotnet run -- 60 # 60 ms frames +``` + +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 would only add latency. The +progress line reports frames, bytes and credit stalls, so a consumer that cannot keep up +is visible rather than silently buffered. + +Playback uses NAudio, whose output devices are Windows interfaces; on Linux and macOS the +sample writes the received stream to a WAV in the temp directory instead and says so on +startup. + +### Throughput benchmark + +```sh +cd samples/Core/ConsoleDataChannelStreaming dotnet run -- --transport tcp --mode server --frames 2000 --size 1200 dotnet run -- --transport quic --mode server --frames 2000 --size 1200 ``` @@ -288,12 +314,9 @@ second. The sample measured 0.5 Mbit/s before the fix and 1.3 Gbit/s after it, a ## Test coverage -The suite is 311 tests on `net10.0` and 237 tests on `net48` over -`tests/Opc.Ua.Core.DataChannels.Tests`, covering -`Opc.Ua.Core.Channels`, `Stack/Tcp/UaSCBinaryChannel.MessageExtensions.cs` -and `Opc.Ua.Bindings.Quic`. By assembly that is **92.9% line / 82.5% branch** -for `Opc.Ua.Core.Channels` and **80.7% line / 68.3% branch** for -`Opc.Ua.Bindings.Quic`. +The suite is 316 tests on `net10.0` and 242 tests on `net48` over +`tests/Opc.Ua.Core.DataChannels.Tests`, covering `Stack/DataChannels`, +`Stack/Tcp/UaSCBinaryChannel.DataChannels.cs` and `Opc.Ua.Bindings.Quic`. `DataChannelIntegrationTests` is the end-to-end leg: a real Client Session drives `OpenDataChannel`, `ModifyDataChannel` and `CloseDataChannel` against a @@ -303,62 +326,19 @@ SecureChannel inline alongside the Service traffic. It is the only place the per-SecureChannel state and the authorization chain — is exercised by a request that actually arrived off a socket. -The paths still at zero are not evenly distributed, which matters more than the +The paths at zero are not evenly distributed, which matters more than the percentage does: `opc.quic` reverse connect and the retired-key teardown of -§7.6.1 are the largest remaining gaps. Re-measure with the repo's own settings: +§7.6.1 are the largest gaps. Re-measure with the repo's own settings: ```sh dotnet test tests/Opc.Ua.Core.DataChannels.Tests/Opc.Ua.Core.DataChannels.Tests.csproj \ -f net10.0 -c Release --collect:"XPlat Code Coverage" --settings tests/coverlet.runsettings.xml ``` -QUIC tests are guarded by `QuicConnection.IsSupported` and `#if NET9_0_OR_GREATER`, so -the net472 and net8.0 legs and any agent without msquic run 237 of them and skip the -rest rather than failing. There is deliberately **no build-time coverage gate**: a gate -would fail exactly those agents. - -Three defects were found by writing these tests, all in code that the pre-existing -end-to-end tests had executed without asserting: - -| Defect | Consequence | -| --- | --- | -| `TryPing()` had no channel-state guard, though `Write()` guards `Closed`/`Faulted` | On a dead channel it took a sequence number, enqueued a PING, re-woke the scheduler and latched `m_pingOutstanding`, so the channel could later be declared dead by a ping that should never have been sent. `TryPing` is public API. | -| `QuicConnectionBuilder.ConnectAsync` caught only `QuicException` | An ALPN or certificate rejection surfaces from the TLS handshake as `AuthenticationException` and escaped as a raw platform exception, so callers using the stack's `catch (ServiceResultException)` idiom missed it entirely. Now mapped to `Bad_SecurityChecksFailed`. | -| `QuicTransportListener` captured the TLS certificate in the accept callback's closure | `CertificateUpdate` moved the UASC layer to the rotated certificate while TLS kept presenting the retired one — breaking the very key-equality check of §7.6.1 that the errata exists to enforce. The callback now reads a field, endpoint descriptions are refreshed, and retired certificates are held until close so an in-flight handshake is never pulled out from under. | - -The one lesson worth carrying forward: none of these were caught by coverage of the -*happy path*. The scheduler bug in particular had every line executed and still shipped, -because nothing asserted the rate. - -## What running it found that tests did not - -A later conformance review showed the components were right and the **wiring** was -missing: `QuicPeerBinding.Verify` had eight test references and no production callers, -`DataChannelServiceHandler` was never constructed, `DataChannelManager.Remove` was never -called. Wiring them, and then driving a real Client against a real Server, found four -more defects that the suite could not reach: - -| Defect | Why no test caught it | -| --- | --- | -| `SendDataChannelFrameAsync` secured a `STR` chunk without holding `DataLock`, so the scheduler thread and the Service path reached the same HMAC concurrently — a `CryptographicException` from the CNG provider on Windows, and duplicate `SequenceNumber`s where it did not throw | Data channels are the first thing to write to a SecureChannel off the Service thread; no unit test runs both writers at once. Now a normative rule with conformance unit DCF-038 | -| `Session` rejected the DataChannel request types as an unexpected `RequestType` | Unit tests construct the handler directly and never traverse Session validation | -| `StandardServer` created listeners only for schemes in the hardcoded `Utils.DefaultUriSchemes`, so registering any out-of-tree binding silently did nothing | Listener tests construct the listener directly rather than going through server startup | -| `ServerBase` did not map `opc.quic` to a transport profile, so its endpoints advertised none | Same | - -`MaxDataChannels` also counted channels that had already ended, so a SecureChannel -refused every new channel after sixteen open-close cycles with none open — reachable -only because the connection-level limit had no test at all, unlike the source limit. - -A later specification-compliance review found the same pattern four more times, and every -regression test added for these goes through the production entry point rather than the -component, because that is what the component-level tests kept missing: - -| Defect | Why no test caught it | -| --- | --- | -| `QuicServerDataChannelTransport.BindClientStreamAsync` discarded the task carrying the §7.4 `transportChannelId` checks, so a Client could name a stream it did not own and the Server answered `Good` and echoed it | The validation had tests, but they called `BindChannelAsync` directly; nothing exercised the Service path that consumes it | -| The per-channel delivery queue was bounded in frames but derived from a byte credit, and blocked the shared receive loop when full — one unread channel stalled `MSG`, `OPN` and `CLO` for the whole SecureChannel | No test enqueued more small frames than the queue held while nothing consumed | -| `OnResponseSent` ran before the response object was even encoded, so the scheduler could emit a frame for a ChannelId the peer had not been told about | The state model was right and unit-tested; only the call site was wrong | -| `StandardServer` fell back to a transport whose `SendFrameAsync` returned without doing anything, so `opc.tcp` was advertised, accepted, and then silently dropped every frame | No test opened a data channel through `StandardServer` at all | +QUIC tests are guarded by `QuicConnection.IsSupported`, so the net472 and net48 +legs and any agent without msquic skip them rather than failing. There is +deliberately **no build-time coverage gate**: a gate would fail exactly those +agents. ## Deviation from the errata diff --git a/docs/README.md b/docs/README.md index 2ba0ebedf3..54fd4b8a5d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,7 @@ Here is a list of available documentation for different topics: * Pluggable [Identity Providers](IdentityProviders.md) — interfaces (`IClientIdentityProvider`, `IUserTokenAuthenticator`, `IAccessTokenProvider`, `ITokenIssuer`, `IIdentityClaims`) plus the OPC 10000-6 §6.5.2.2 `IssuerEndpointUrl` JSON parser for OAuth2 / OIDC / Entra / JWT flows. * Support for [ECC Certificates](EccProfiles.md). * Working with [ComplexTypes](ComplexTypes.md) - Custom structures and enumerations. -* [Data Channels](DataChannels.md) - **Experimental.** Inline streaming of opaque bytes over a SecureChannel: the `STR` frame, per-channel flow control and scheduling, and the `OpenDataChannel` / `ModifyDataChannel` / `CloseDataChannel` Service Set. Ships as `OPCFoundation.NetStandard.Opc.Ua.Core.Channels`, attached to the SecureChannel through the message-extension seam in `Opc.Ua.Core`. Identifiers are provisional and the errata is not endorsed by the OPC Foundation. +* [Data Channels](DataChannels.md) - **Experimental.** Inline streaming of opaque bytes over a SecureChannel: the `STR` frame, per-channel flow control and scheduling, and the `OpenDataChannel` / `ModifyDataChannel` / `CloseDataChannel` Service Set. The engine is part of `Opc.Ua.Core` and carried by the UASC channel itself; `opc.quic` ships separately as `OPCFoundation.NetStandard.Opc.Ua.Bindings.Quic`. Identifiers are provisional and the errata is not endorsed by the OPC Foundation. * Client-based [NodeSet Export](NodeSetExport.md) - Export server address space to NodeSet2 XML. * Source generated [DataTypes] - How to annotate POCO classes and let the source generator generate the `IEncodeable` implementation. * Runtime [Schema Generation](SchemaGeneration.md) - Produce XSD, OPC Binary (BSD) and JSON Schema (Part 6 Annex C, compact + verbose) for generated encodeable types and dynamically added complex types via the injectable `ISchemaProvider`; schemas are built as object models in code (trimmable, NativeAOT compatible). diff --git a/samples/Core/ConsoleDataChannelAudio/AudioFormat.cs b/samples/Core/ConsoleDataChannelAudio/AudioFormat.cs new file mode 100644 index 0000000000..9a7201728a --- /dev/null +++ b/samples/Core/ConsoleDataChannelAudio/AudioFormat.cs @@ -0,0 +1,180 @@ +/* ======================================================================== + * 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.Collections.Generic; + +namespace Quickstarts.ConsoleDataChannelAudio +{ + /// + /// The PCM format the sample streams. + /// + /// + /// Signed 16-bit little-endian mono, which is what audio/L16 names + /// in the IANA registry and what the data channel advertises as its + /// ContentType. A data channel is content agnostic: the payload is opaque + /// octets and the media type is what tells the sink how to read them. + /// + internal static class AudioFormat + { + /// + /// Samples per second. + /// + public const int SampleRate = 44100; + + /// + /// Bits in one sample. + /// + public const int BitsPerSample = 16; + + /// + /// Channels in the stream. + /// + public const int Channels = 1; + + /// + /// Bytes in one sample frame. + /// + public const int BytesPerSample = BitsPerSample / 8 * Channels; + + /// + /// The IANA media type the data channel advertises. + /// + public const string ContentType = "audio/L16"; + + /// + /// The number of bytes that carry a given duration of audio. + /// + /// The duration. + public static int BytesForDuration(int milliseconds) + { + return SampleRate * BytesPerSample * milliseconds / 1000; + } + } + + /// + /// Renders a short melody to PCM once, so the server has something to loop + /// without a binary asset in the repository. + /// + /// + /// The waveform is deliberately plain: a sine at the note's frequency with + /// a short attack and release so consecutive notes do not click, mixed with + /// a quieter octave above so it is audibly musical rather than a test tone. + /// What matters for the sample is that it is a continuous, latency + /// sensitive stream of opaque bytes, not that it sounds good. + /// + internal static class MelodyGenerator + { + /// + /// Renders the melody as little-endian PCM. + /// + public static byte[] Render() + { + var samples = new List(); + + foreach ((double frequency, int milliseconds) in s_melody) + { + AppendNote(samples, frequency, milliseconds); + } + + byte[] pcm = new byte[samples.Count * sizeof(short)]; + + for (int ii = 0; ii < samples.Count; ii++) + { + pcm[(ii * 2) + 0] = (byte)(samples[ii] & 0xFF); + pcm[(ii * 2) + 1] = (byte)((samples[ii] >> 8) & 0xFF); + } + + return pcm; + } + + private static void AppendNote(List samples, double frequency, int milliseconds) + { + int count = AudioFormat.SampleRate * milliseconds / 1000; + + // A rest carries silence rather than nothing, so the loop keeps a + // constant sample rate and the sink's clock stays honest. + if (frequency <= 0) + { + for (int ii = 0; ii < count; ii++) + { + samples.Add(0); + } + + return; + } + + int envelope = Math.Min(count / 8, AudioFormat.SampleRate / 100); + + for (int ii = 0; ii < count; ii++) + { + double t = (double)ii / AudioFormat.SampleRate; + double value = + Math.Sin(2.0 * Math.PI * frequency * t) + + (0.35 * Math.Sin(2.0 * Math.PI * frequency * 2.0 * t)); + + double gain = 1.0; + + if (envelope > 0) + { + if (ii < envelope) + { + gain = (double)ii / envelope; + } + else if (ii > count - envelope) + { + gain = (double)(count - ii) / envelope; + } + } + + samples.Add((short)(value * gain * 0.28 * short.MaxValue)); + } + } + + // Frequencies in Hz, durations in milliseconds. Zero is a rest. + private static readonly (double Frequency, int Milliseconds)[] s_melody = + [ + (587.33, 260), + (587.33, 260), + (880.00, 260), + (880.00, 260), + (987.77, 260), + (987.77, 260), + (880.00, 520), + (783.99, 260), + (783.99, 260), + (739.99, 260), + (739.99, 260), + (659.25, 260), + (659.25, 260), + (587.33, 520), + (0, 240) + ]; + } +} diff --git a/samples/Core/ConsoleDataChannelAudio/AudioSink.cs b/samples/Core/ConsoleDataChannelAudio/AudioSink.cs new file mode 100644 index 0000000000..3256d4aa2c --- /dev/null +++ b/samples/Core/ConsoleDataChannelAudio/AudioSink.cs @@ -0,0 +1,167 @@ +/* ======================================================================== + * 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 NAudio.Wave; + +namespace Quickstarts.ConsoleDataChannelAudio +{ + /// + /// Consumes the PCM a data channel delivers. + /// + internal interface IAudioSink : IDisposable + { + /// + /// What the sink does with the audio, for the console banner. + /// + string Description { get; } + + /// + /// Accepts one frame of audio. + /// + /// Little-endian 16-bit mono samples. + void Write(ReadOnlySpan pcm); + } + + /// + /// Chooses the sink that suits the platform. + /// + internal static class AudioSink + { + /// + /// Creates a sink that plays the audio where the platform can, and + /// writes it to a WAV file where it cannot. + /// + /// + /// NAudio's output devices wrap WASAPI, WaveOut, DirectSound and ASIO, + /// all of which are Windows interfaces; NAudio 2.x has no ALSA or + /// CoreAudio backend. Rather than fail on Linux and macOS, the sample + /// writes a WAV there, which still demonstrates that the bytes arrived + /// intact and in order. + /// + /// Where to write when playback is unavailable. + public static IAudioSink Create(string wavPath) + { + if (OperatingSystem.IsWindows()) + { + return new PlaybackAudioSink(); + } + + return new WaveFileAudioSink(wavPath); + } + } + + /// + /// Plays the audio through the default output device. + /// + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + internal sealed class PlaybackAudioSink : IAudioSink + { + /// + /// Creates the sink and opens the output device. + /// + public PlaybackAudioSink() + { + var format = new WaveFormat( + AudioFormat.SampleRate, + AudioFormat.BitsPerSample, + AudioFormat.Channels); + + m_buffer = new BufferedWaveProvider(format) + { + // Two seconds is far more than the stream needs and is there to + // absorb a scheduling hiccup, not to hide a slow consumer. + BufferDuration = TimeSpan.FromSeconds(2), + DiscardOnBufferOverflow = true + }; + + m_output = new WaveOutEvent(); + m_output.Init(m_buffer); + m_output.Play(); + } + + /// + public string Description => "playing through the default output device"; + + /// + public void Write(ReadOnlySpan pcm) + { + m_buffer.AddSamples(pcm.ToArray(), 0, pcm.Length); + } + + /// + public void Dispose() + { + m_output.Dispose(); + } + + private readonly BufferedWaveProvider m_buffer; + private readonly WaveOutEvent m_output; + } + + /// + /// Writes the audio to a WAV file, for platforms where NAudio cannot play. + /// + internal sealed class WaveFileAudioSink : IAudioSink + { + /// + /// Creates the sink and opens the file. + /// + /// The file to write. + public WaveFileAudioSink(string path) + { + m_path = path; + m_writer = new WaveFileWriter( + path, + new WaveFormat( + AudioFormat.SampleRate, + AudioFormat.BitsPerSample, + AudioFormat.Channels)); + } + + /// + public string Description => $"writing to {m_path}"; + + /// + public void Write(ReadOnlySpan pcm) + { + m_writer.Write(pcm.ToArray(), 0, pcm.Length); + } + + /// + public void Dispose() + { + m_writer.Dispose(); + } + + private readonly string m_path; + private readonly WaveFileWriter m_writer; + } +} diff --git a/samples/Core/ConsoleDataChannelAudio/AudioStreamingSource.cs b/samples/Core/ConsoleDataChannelAudio/AudioStreamingSource.cs new file mode 100644 index 0000000000..ed23b2e8ff --- /dev/null +++ b/samples/Core/ConsoleDataChannelAudio/AudioStreamingSource.cs @@ -0,0 +1,205 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua; +using Opc.Ua.Bindings; + +namespace Quickstarts.ConsoleDataChannelAudio +{ + /// + /// A data channel source that plays a canned melody on repeat. + /// + /// + /// The source writes in real time rather than as fast as the channel will + /// take it: a media source is paced by its own clock, and writing faster + /// would only fill the receiver's buffer and add latency. Credit still + /// bounds it if the consumer falls behind, and the channel reports the + /// stall through its diagnostics rather than growing without limit. + /// + internal sealed class AudioStreamingSource : IDataChannelSource, IDisposable + { + /// + /// Creates the source. + /// + /// How much audio one frame carries. + public AudioStreamingSource(int frameMilliseconds) + { + m_pcm = MelodyGenerator.Render(); + m_frameBytes = AudioFormat.BytesForDuration(frameMilliseconds); + m_frameMilliseconds = frameMilliseconds; + + SourceNodeId = new NodeId("Speaker1", 1); + Capabilities = new DataChannelSourceCapabilities + { + Direction = DataChannelDirection.SourceToSink, + SupportedDeliveryModes = [DataChannelDeliveryMode.ReliableOrdered], + ContentType = AudioFormat.ContentType, + MaxFrameSize = (uint)m_frameBytes, + MaxChannels = 1, + Priority = 1 + }; + } + + /// + /// The Node a Client opens the channel on. + /// + public NodeId SourceNodeId { get; } + + /// + public NodeId NodeId => SourceNodeId; + + /// + public DataChannelSourceCapabilities Capabilities { get; } + + /// + public int ActiveChannelCount => m_channel == null ? 0 : 1; + + /// + /// The length of the rendered melody, for the console banner. + /// + public TimeSpan LoopDuration + => TimeSpan.FromSeconds( + (double)m_pcm.Length / (AudioFormat.SampleRate * AudioFormat.BytesPerSample)); + + /// + public void OnChannelOpened(DataChannel channel) + { + m_channel = channel; + m_stop = new CancellationTokenSource(); + m_pump = Task.Run(() => PumpAsync(channel, m_stop.Token)); + } + + /// + public void OnChannelClosed(DataChannel channel, StatusCode reason) + { + m_channel = null; + m_stop?.Cancel(); + } + + /// + /// Stops the pump and waits for it, so the sample shuts down cleanly. + /// + public async Task StopAsync() + { + m_stop?.Cancel(); + + if (m_pump != null) + { + await m_pump.ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + m_stop?.Dispose(); + m_stop = null; + } + + private async Task PumpAsync(DataChannel channel, CancellationToken ct) + { + int offset = 0; + var next = DateTime.UtcNow; + + try + { + while (!ct.IsCancellationRequested && + channel.State is DataChannelState.Open or DataChannelState.Opening) + { + if (channel.State == DataChannelState.Opening) + { + await Task.Delay(5, ct).ConfigureAwait(false); + continue; + } + + int count = Math.Min(m_frameBytes, m_pcm.Length - offset); + + // MessageStart and MessageEnd on every frame: each one is a + // self contained unit of audio, so a sink can play what it + // has without waiting for a boundary. + channel.Write( + m_pcm.AsSpan(offset, count), + DataChannelFrameFlags.MessageStart | DataChannelFrameFlags.MessageEnd); + + offset += count; + + if (offset >= m_pcm.Length) + { + offset = 0; + } + + // Paced against a fixed schedule rather than by sleeping for + // the frame duration, so the send rate does not drift by the + // time each iteration takes. + next = next.AddMilliseconds(m_frameMilliseconds); + TimeSpan delay = next - DateTime.UtcNow; + + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, ct).ConfigureAwait(false); + } + else + { + next = DateTime.UtcNow; + } + } + } + catch (OperationCanceledException) + { + // Shutting down. + } + catch (ServiceResultException e) + { + // The channel closed underneath the pump, which is how a + // CloseDataChannel while writing surfaces. Anything else is + // worth seeing rather than losing in an unobserved task. + if (e.StatusCode != StatusCodes.BadDataChannelClosed) + { + Console.Error.WriteLine($"audio pump stopped: {e.Message}"); + } + } +#pragma warning disable CA1031 // A sample must not die silently. + catch (Exception e) +#pragma warning restore CA1031 + { + Console.Error.WriteLine($"audio pump failed: {e}"); + } + } + + private readonly byte[] m_pcm; + private readonly int m_frameBytes; + private readonly int m_frameMilliseconds; + private DataChannel? m_channel; + private CancellationTokenSource? m_stop; + private Task? m_pump; + } +} diff --git a/samples/Core/ConsoleDataChannelAudio/ConsoleDataChannelAudio.csproj b/samples/Core/ConsoleDataChannelAudio/ConsoleDataChannelAudio.csproj new file mode 100644 index 0000000000..2d5d8e7f85 --- /dev/null +++ b/samples/Core/ConsoleDataChannelAudio/ConsoleDataChannelAudio.csproj @@ -0,0 +1,35 @@ + + + net10.0 + Exe + false + ConsoleDataChannelAudio + ConsoleDataChannelAudio + OPC Foundation + Experimental console sample that streams looping audio over an OPC UA data channel and plays it back. + Copyright © 2004-2026 OPC Foundation, Inc + enable + + $(NoWarn);CA1812;CA1822;DataChannels + + true + + + + + + + + + + + + + + + diff --git a/samples/Core/ConsoleDataChannelAudio/Program.cs b/samples/Core/ConsoleDataChannelAudio/Program.cs new file mode 100644 index 0000000000..a8bbe724d3 --- /dev/null +++ b/samples/Core/ConsoleDataChannelAudio/Program.cs @@ -0,0 +1,429 @@ +/* ======================================================================== + * 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); + } + } +} diff --git a/samples/ConsoleDataChannelStreaming/Properties/AssemblyInfo.cs b/samples/Core/ConsoleDataChannelAudio/Properties/AssemblyInfo.cs similarity index 100% rename from samples/ConsoleDataChannelStreaming/Properties/AssemblyInfo.cs rename to samples/Core/ConsoleDataChannelAudio/Properties/AssemblyInfo.cs diff --git a/samples/ConsoleDataChannelStreaming/Benchmark.cs b/samples/Core/ConsoleDataChannelStreaming/Benchmark.cs similarity index 100% rename from samples/ConsoleDataChannelStreaming/Benchmark.cs rename to samples/Core/ConsoleDataChannelStreaming/Benchmark.cs diff --git a/samples/ConsoleDataChannelStreaming/ConsoleDataChannelStreaming.csproj b/samples/Core/ConsoleDataChannelStreaming/ConsoleDataChannelStreaming.csproj similarity index 76% rename from samples/ConsoleDataChannelStreaming/ConsoleDataChannelStreaming.csproj rename to samples/Core/ConsoleDataChannelStreaming/ConsoleDataChannelStreaming.csproj index 435078bee1..2ebc667d88 100644 --- a/samples/ConsoleDataChannelStreaming/ConsoleDataChannelStreaming.csproj +++ b/samples/Core/ConsoleDataChannelStreaming/ConsoleDataChannelStreaming.csproj @@ -19,11 +19,11 @@ true - - - - - + + + + + diff --git a/samples/ConsoleDataChannelStreaming/Program.cs b/samples/Core/ConsoleDataChannelStreaming/Program.cs similarity index 100% rename from samples/ConsoleDataChannelStreaming/Program.cs rename to samples/Core/ConsoleDataChannelStreaming/Program.cs diff --git a/samples/Core/ConsoleDataChannelStreaming/Properties/AssemblyInfo.cs b/samples/Core/ConsoleDataChannelStreaming/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..2b9848014c --- /dev/null +++ b/samples/Core/ConsoleDataChannelStreaming/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * 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; + +[assembly: CLSCompliant(false)] diff --git a/samples/ConsoleDataChannelStreaming/SampleOptions.cs b/samples/Core/ConsoleDataChannelStreaming/SampleOptions.cs similarity index 100% rename from samples/ConsoleDataChannelStreaming/SampleOptions.cs rename to samples/Core/ConsoleDataChannelStreaming/SampleOptions.cs diff --git a/samples/ConsoleDataChannelStreaming/StreamingHarness.cs b/samples/Core/ConsoleDataChannelStreaming/StreamingHarness.cs similarity index 100% rename from samples/ConsoleDataChannelStreaming/StreamingHarness.cs rename to samples/Core/ConsoleDataChannelStreaming/StreamingHarness.cs diff --git a/src/Opc.Ua.Bindings.Https/Https/HttpsTransportListener.cs b/src/Opc.Ua.Bindings.Https/Https/HttpsTransportListener.cs index 1717bc18ec..a80416d4d9 100644 --- a/src/Opc.Ua.Bindings.Https/Https/HttpsTransportListener.cs +++ b/src/Opc.Ua.Bindings.Https/Https/HttpsTransportListener.cs @@ -1715,7 +1715,7 @@ namespace Opc.Ua.Bindings { // A callback that throws must not fault the request loop; the // response has already been handed to the transport. - m_logger.ErrorProcessingRequest(ex, 0); + m_logger.HttpsResponseDispatchedCallbackFailed(ex); } } @@ -2558,5 +2558,12 @@ namespace Opc.Ua.Bindings [LoggerMessage(EventId = BindingsHttpsEventIds.HttpsTransportListener + 13, Level = LogLevel.Error, Message = "WSSLISTENER - unexpected OpenAPI WebSocket error.")] public static partial void UnexpectedOpenApiWebSocketError(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = BindingsHttpsEventIds.HttpsTransportListener + 14, Level = LogLevel.Error, + Message = "HTTPSLISTENER - A response-dispatched callback threw. " + + "The response itself has already been written.")] + public static partial void HttpsResponseDispatchedCallbackFailed( + this ILogger logger, + Exception exception); } } diff --git a/src/Opc.Ua.Bindings.Https/Tcp/KestrelTcpTransportListener.cs b/src/Opc.Ua.Bindings.Https/Tcp/KestrelTcpTransportListener.cs index 0789065b8d..b02ff39fa8 100644 --- a/src/Opc.Ua.Bindings.Https/Tcp/KestrelTcpTransportListener.cs +++ b/src/Opc.Ua.Bindings.Https/Tcp/KestrelTcpTransportListener.cs @@ -648,7 +648,7 @@ namespace Opc.Ua.Bindings { // A callback that throws must not fault the request loop; the // response has already been handed to the transport. - Logger.RequestProcessingFailed(ex); + Logger.KestrelResponseDispatchedCallbackFailed(ex); } } @@ -805,6 +805,13 @@ namespace Opc.Ua.Bindings [LoggerMessage(EventId = BindingsHttpsEventIds.KestrelTcpTransportListener + 6, Level = LogLevel.Information, Message = "KestrelTcp closed {Count} SecureChannel(s) whose peer certificate is no longer trusted.")] public static partial void KestrelClosedUntrustedPeerSecureChannels(this ILogger logger, int count); + + [LoggerMessage(EventId = BindingsHttpsEventIds.KestrelTcpTransportListener + 7, Level = LogLevel.Error, + Message = "KestrelTcp - A response-dispatched callback threw. " + + "The response itself has already been written.")] + public static partial void KestrelResponseDispatchedCallbackFailed( + this ILogger logger, + global::System.Exception? exception); } } #endif // NET8_0_OR_GREATER diff --git a/src/Opc.Ua.Client/Session/ManagedSession.Services.cs b/src/Opc.Ua.Client/Session/ManagedSession.Services.cs index 8de69e38fe..00e6be0fa6 100644 --- a/src/Opc.Ua.Client/Session/ManagedSession.Services.cs +++ b/src/Opc.Ua.Client/Session/ManagedSession.Services.cs @@ -609,10 +609,8 @@ namespace Opc.Ua.Client } /// -#if NET8_0_OR_GREATER [Experimental(DataChannelFeature.ExperimentalDiagnosticId, UrlFormat = DataChannelFeature.ExperimentalUrlFormat)] -#endif public async ValueTask OpenDataChannelAsync( RequestHeader? requestHeader, NodeId sourceNodeId, @@ -634,10 +632,8 @@ namespace Opc.Ua.Client } /// -#if NET8_0_OR_GREATER [Experimental(DataChannelFeature.ExperimentalDiagnosticId, UrlFormat = DataChannelFeature.ExperimentalUrlFormat)] -#endif public async ValueTask ModifyDataChannelAsync( RequestHeader? requestHeader, uint channelId, @@ -655,10 +651,8 @@ namespace Opc.Ua.Client } /// -#if NET8_0_OR_GREATER [Experimental(DataChannelFeature.ExperimentalDiagnosticId, UrlFormat = DataChannelFeature.ExperimentalUrlFormat)] -#endif public async ValueTask CloseDataChannelAsync( RequestHeader? requestHeader, uint channelId, diff --git a/src/Opc.Ua.Core/Stack/Tcp/TcpTransportListener.cs b/src/Opc.Ua.Core/Stack/Tcp/TcpTransportListener.cs index 29ac335895..29649e3ac6 100644 --- a/src/Opc.Ua.Core/Stack/Tcp/TcpTransportListener.cs +++ b/src/Opc.Ua.Core/Stack/Tcp/TcpTransportListener.cs @@ -1479,7 +1479,7 @@ namespace Opc.Ua.Bindings { // A callback that throws must not fault the request loop; the // response itself has already been handed to the transport. - m_logger.TcpTransportLog24(e); + m_logger.TcpTransportResponseDispatchedCallbackFailed(e); } } @@ -1788,6 +1788,13 @@ namespace Opc.Ua.Bindings public static partial void TcpTransportLog30( this ILogger logger, int count); + + [LoggerMessage(EventId = CoreEventIds.TcpTransportListener + 31, Level = LogLevel.Error, + Message = "TCPLISTENER - A response-dispatched callback threw. " + + "The response itself has already been written.")] + public static partial void TcpTransportResponseDispatchedCallbackFailed( + this ILogger logger, + global::System.Exception? exception); } } diff --git a/src/Opc.Ua.Redundancy.Client/RedundantClientSession.cs b/src/Opc.Ua.Redundancy.Client/RedundantClientSession.cs index dce50c7e66..8a6229da23 100644 --- a/src/Opc.Ua.Redundancy.Client/RedundantClientSession.cs +++ b/src/Opc.Ua.Redundancy.Client/RedundantClientSession.cs @@ -1235,11 +1235,9 @@ namespace Opc.Ua.Redundancy.Client } /// -#if NET8_0_OR_GREATER [System.Diagnostics.CodeAnalysis.Experimental( Opc.Ua.DataChannelFeature.ExperimentalDiagnosticId, UrlFormat = Opc.Ua.DataChannelFeature.ExperimentalUrlFormat)] -#endif public async ValueTask OpenDataChannelAsync( RequestHeader? requestHeader, NodeId sourceNodeId, @@ -1260,11 +1258,9 @@ namespace Opc.Ua.Redundancy.Client } /// -#if NET8_0_OR_GREATER [System.Diagnostics.CodeAnalysis.Experimental( Opc.Ua.DataChannelFeature.ExperimentalDiagnosticId, UrlFormat = Opc.Ua.DataChannelFeature.ExperimentalUrlFormat)] -#endif public async ValueTask ModifyDataChannelAsync( RequestHeader? requestHeader, uint channelId, @@ -1281,11 +1277,9 @@ namespace Opc.Ua.Redundancy.Client } /// -#if NET8_0_OR_GREATER [System.Diagnostics.CodeAnalysis.Experimental( Opc.Ua.DataChannelFeature.ExperimentalDiagnosticId, UrlFormat = Opc.Ua.DataChannelFeature.ExperimentalUrlFormat)] -#endif public async ValueTask CloseDataChannelAsync( RequestHeader? requestHeader, uint channelId, diff --git a/src/Opc.Ua.Types/Polyfills/System.Diagnostics.CodeAnalysis.cs b/src/Opc.Ua.Types/Polyfills/System.Diagnostics.CodeAnalysis.cs index 64f6ded173..cac0127ce4 100644 --- a/src/Opc.Ua.Types/Polyfills/System.Diagnostics.CodeAnalysis.cs +++ b/src/Opc.Ua.Types/Polyfills/System.Diagnostics.CodeAnalysis.cs @@ -278,3 +278,57 @@ namespace System.Diagnostics.CodeAnalysis } #endif + +// ExperimentalAttribute shipped in .NET 8. The compiler resolves it by full +// name, so a source definition drives the same EXPxxxx diagnostics on the older +// frameworks and callers opt in identically everywhere. +#if !NET8_0_OR_GREATER + +namespace System.Diagnostics.CodeAnalysis +{ + /// + /// Indicates that an API is experimental and subject to change or removal, + /// so a caller has to opt in deliberately. + /// + [AttributeUsage( + AttributeTargets.Assembly | + AttributeTargets.Module | + AttributeTargets.Class | + AttributeTargets.Struct | + AttributeTargets.Enum | + AttributeTargets.Constructor | + AttributeTargets.Method | + AttributeTargets.Property | + AttributeTargets.Field | + AttributeTargets.Event | + AttributeTargets.Interface | + AttributeTargets.Delegate, + Inherited = false)] + public sealed class ExperimentalAttribute : Attribute + { + /// + /// Initializes a new instance of the + /// class. + /// + /// The diagnostic the compiler reports for a + /// use of the marked API. + public ExperimentalAttribute(string diagnosticId) + { + DiagnosticId = diagnosticId; + } + + /// + /// Gets the diagnostic the compiler reports for a use of the marked + /// API. + /// + public string DiagnosticId { get; } + + /// + /// Gets or sets the format of the URL naming the documentation for the + /// diagnostic, where {0} is the diagnostic identifier. + /// + public string? UrlFormat { get; set; } + } +} + +#endif