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