mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-24 19:13:02 +08:00
Address the PR review: sample layout, audio sample, docs, logging and net8.0
Nine review comments; two were answered by the previous commit (the generic extension seam and the file named after it are both gone). The rest: Sample layout and a second sample The data channel samples now live under samples/Core, and a new ConsoleDataChannelAudio joins the throughput benchmark. It stands up a Server and a Client in one process, synthesises a short melody as 16-bit PCM once at startup and streams it on repeat over a data channel while the Client plays it back. Nothing binary enters the repository. The source writes in real time rather than as fast as the channel will take it, because a media source is paced by its own clock and writing faster only adds latency; the progress line reports frames, bytes and credit stalls so a consumer that cannot keep up is visible rather than silently buffered. Measured over 31 seconds: 1582 frames, 2725 KiB, zero credit stalls, which is exactly real time for 20 ms frames. Playback uses NAudio 2.3.0 (MIT), referenced only by that sample - nothing under src/ gains a dependency. NAudio's output devices are Windows interfaces and 2.x has no ALSA or CoreAudio backend, so on Linux and macOS the sample writes the received stream to a WAV instead and says which it is doing on startup. The Windows-only paths sit behind OperatingSystem.IsWindows() rather than a CA1416 suppression. Documentation "Where the code lives" describes where the code is rather than where it moved from, and the same edit removes "rather than in ...", "remains" and the rest of the change narration. Two sections that were a development diary - the tables of defects found while writing tests and while running the sample - are gone entirely: they recorded history rather than the state of the implementation. Logging The response-dispatched callback logged through a message that reads "Failed to send fault response to client", which is not what happened, and on the HTTPS listener it invented a requestId of 0 to fit that signature. All three listeners now have a named message that says what actually failed. net8.0 for the QUIC binding Rather than excluding net8.0, the binding targets it and enables preview features, so a net8.0 consumer opts in the same way for System.Net.Quic. The three file-wide NET9_0_OR_GREATER guards are gone; only QuicServerConnectionOptions.HandshakeTimeout stays guarded because it is genuinely .NET 9+, and the listener's own admission expiry still bounds a stalled handshake there. Removing the #if around [Experimental] ExperimentalAttribute is a .NET 8 type, and the repo's rule is to polyfill rather than fork with #if, so it joins the existing polyfills in Opc.Ua.Types. Verified that this is a real opt-in and not just something that compiles: with the suppression removed, a net48 caller of a data channel Service fails the build with "error DataChannels", which is the same behaviour as net10.0. Validated with the matrix CI runs: all seven pinned UA.slnx builds (net10.0, net9.0, net8.0, net48, net472, netstandard2.0, netstandard2.1), 316 data channel tests on net10.0 and 242 on net48, Opc.Ua.Server.Tests 4072, Opc.Ua.Client.Tests 2126, Opc.Ua.Redundancy.Client.Tests 123, and the reference server Docker image end to end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d026b47-265d-4c97-8715-26e207dfa03c
This commit is contained in:
@@ -68,6 +68,12 @@
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.10" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers" Version="18.7.37220.1" />
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<!--
|
||||
NAudio (MIT) is referenced only by samples/Core/ConsoleDataChannelAudio,
|
||||
to play the streamed audio. Nothing under src/ depends on it. Playback in
|
||||
2.x is Windows-only, so the sample writes a .wav on other platforms.
|
||||
-->
|
||||
<PackageVersion Include="NAudio" Version="2.3.0" />
|
||||
<PackageVersion Include="TUnit" Version="1.64.6" />
|
||||
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.10.91" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<Project Path="samples/ConsoleReferenceClient/ConsoleReferenceClient.csproj" />
|
||||
<Project Path="samples/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj" />
|
||||
<Project Path="samples/ConsoleReferenceServer/ConsoleReferenceServer.csproj" />
|
||||
<Project Path="samples/ConsoleDataChannelStreaming/ConsoleDataChannelStreaming.csproj" />
|
||||
<Project Path="samples/Core/ConsoleDataChannelStreaming/ConsoleDataChannelStreaming.csproj" />
|
||||
<Project Path="samples/Core/ConsoleDataChannelAudio/ConsoleDataChannelAudio.csproj" />
|
||||
<Project Path="samples/RedundantPubSub/RedundantPubSub.csproj" />
|
||||
<Project Path="samples/RedundantServer/RedundantServer.csproj" />
|
||||
<Project Path="samples/MinimalBoilerServer/MinimalBoilerServer.csproj" />
|
||||
|
||||
+74
-94
@@ -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
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core.Channels" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);DataChannels</NoWarn>
|
||||
</PropertyGroup>
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// The PCM format the sample streams.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Signed 16-bit little-endian mono, which is what <c>audio/L16</c> 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.
|
||||
/// </remarks>
|
||||
internal static class AudioFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Samples per second.
|
||||
/// </summary>
|
||||
public const int SampleRate = 44100;
|
||||
|
||||
/// <summary>
|
||||
/// Bits in one sample.
|
||||
/// </summary>
|
||||
public const int BitsPerSample = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Channels in the stream.
|
||||
/// </summary>
|
||||
public const int Channels = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Bytes in one sample frame.
|
||||
/// </summary>
|
||||
public const int BytesPerSample = BitsPerSample / 8 * Channels;
|
||||
|
||||
/// <summary>
|
||||
/// The IANA media type the data channel advertises.
|
||||
/// </summary>
|
||||
public const string ContentType = "audio/L16";
|
||||
|
||||
/// <summary>
|
||||
/// The number of bytes that carry a given duration of audio.
|
||||
/// </summary>
|
||||
/// <param name="milliseconds">The duration.</param>
|
||||
public static int BytesForDuration(int milliseconds)
|
||||
{
|
||||
return SampleRate * BytesPerSample * milliseconds / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a short melody to PCM once, so the server has something to loop
|
||||
/// without a binary asset in the repository.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal static class MelodyGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders the melody as little-endian PCM.
|
||||
/// </summary>
|
||||
public static byte[] Render()
|
||||
{
|
||||
var samples = new List<short>();
|
||||
|
||||
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<short> 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)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Consumes the PCM a data channel delivers.
|
||||
/// </summary>
|
||||
internal interface IAudioSink : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// What the sink does with the audio, for the console banner.
|
||||
/// </summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one frame of audio.
|
||||
/// </summary>
|
||||
/// <param name="pcm">Little-endian 16-bit mono samples.</param>
|
||||
void Write(ReadOnlySpan<byte> pcm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chooses the sink that suits the platform.
|
||||
/// </summary>
|
||||
internal static class AudioSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a sink that plays the audio where the platform can, and
|
||||
/// writes it to a WAV file where it cannot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="wavPath">Where to write when playback is unavailable.</param>
|
||||
public static IAudioSink Create(string wavPath)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return new PlaybackAudioSink();
|
||||
}
|
||||
|
||||
return new WaveFileAudioSink(wavPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays the audio through the default output device.
|
||||
/// </summary>
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
internal sealed class PlaybackAudioSink : IAudioSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the sink and opens the output device.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Description => "playing through the default output device";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Write(ReadOnlySpan<byte> pcm)
|
||||
{
|
||||
m_buffer.AddSamples(pcm.ToArray(), 0, pcm.Length);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
m_output.Dispose();
|
||||
}
|
||||
|
||||
private readonly BufferedWaveProvider m_buffer;
|
||||
private readonly WaveOutEvent m_output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the audio to a WAV file, for platforms where NAudio cannot play.
|
||||
/// </summary>
|
||||
internal sealed class WaveFileAudioSink : IAudioSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the sink and opens the file.
|
||||
/// </summary>
|
||||
/// <param name="path">The file to write.</param>
|
||||
public WaveFileAudioSink(string path)
|
||||
{
|
||||
m_path = path;
|
||||
m_writer = new WaveFileWriter(
|
||||
path,
|
||||
new WaveFormat(
|
||||
AudioFormat.SampleRate,
|
||||
AudioFormat.BitsPerSample,
|
||||
AudioFormat.Channels));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Description => $"writing to {m_path}";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Write(ReadOnlySpan<byte> pcm)
|
||||
{
|
||||
m_writer.Write(pcm.ToArray(), 0, pcm.Length);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
m_writer.Dispose();
|
||||
}
|
||||
|
||||
private readonly string m_path;
|
||||
private readonly WaveFileWriter m_writer;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// A data channel source that plays a canned melody on repeat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal sealed class AudioStreamingSource : IDataChannelSource, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the source.
|
||||
/// </summary>
|
||||
/// <param name="frameMilliseconds">How much audio one frame carries.</param>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Node a Client opens the channel on.
|
||||
/// </summary>
|
||||
public NodeId SourceNodeId { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public NodeId NodeId => SourceNodeId;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DataChannelSourceCapabilities Capabilities { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int ActiveChannelCount => m_channel == null ? 0 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// The length of the rendered melody, for the console banner.
|
||||
/// </summary>
|
||||
public TimeSpan LoopDuration
|
||||
=> TimeSpan.FromSeconds(
|
||||
(double)m_pcm.Length / (AudioFormat.SampleRate * AudioFormat.BytesPerSample));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void OnChannelOpened(DataChannel channel)
|
||||
{
|
||||
m_channel = channel;
|
||||
m_stop = new CancellationTokenSource();
|
||||
m_pump = Task.Run(() => PumpAsync(channel, m_stop.Token));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void OnChannelClosed(DataChannel channel, StatusCode reason)
|
||||
{
|
||||
m_channel = null;
|
||||
m_stop?.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the pump and waits for it, so the sample shuts down cleanly.
|
||||
/// </summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
m_stop?.Cancel();
|
||||
|
||||
if (m_pump != null)
|
||||
{
|
||||
await m_pump.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
<AssemblyName>ConsoleDataChannelAudio</AssemblyName>
|
||||
<PackageId>ConsoleDataChannelAudio</PackageId>
|
||||
<Company>OPC Foundation</Company>
|
||||
<Description>Experimental console sample that streams looping audio over an OPC UA data channel and plays it back.</Description>
|
||||
<Copyright>Copyright © 2004-2026 OPC Foundation, Inc</Copyright>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- CA1812/CA1822: a console sample does not need instantiation analysis
|
||||
or static members. DataChannels: the sample exists to demonstrate the
|
||||
experimental surface, so it opts in deliberately. -->
|
||||
<NoWarn>$(NoWarn);CA1812;CA1822;DataChannels</NoWarn>
|
||||
<!-- Opt into the root Directory.Build.targets no-op shape on per-TFM CI
|
||||
matrix entries that pin CustomTestTarget to a TFM this app cannot
|
||||
target. -->
|
||||
<RestrictForLegacyTfm>true</RestrictForLegacyTfm>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Opc.Ua.Core\Opc.Ua.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Opc.Ua.Client\Opc.Ua.Client.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Opc.Ua.Server\Opc.Ua.Server.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<!-- NAudio (MIT) plays the streamed audio. Nothing under src/ references
|
||||
it: audio output is the sample's concern, not the stack's. -->
|
||||
<PackageReference Include="NAudio" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Streams a looping melody from a Server to a Client over an OPC UA data
|
||||
/// channel, and plays it back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The point of the sample is the shape of the problem rather than the
|
||||
/// audio: a continuous, latency-sensitive stream of opaque bytes that OPC
|
||||
/// UA has no other primitive for. Polling it with Read would be
|
||||
/// nonsensical, a Subscription carrying ByteString values would pay the
|
||||
/// encoding and Publish round trip for every packet, and PubSub would carry
|
||||
/// it beside the SecureChannel rather than on it. A data channel carries it
|
||||
/// on the SecureChannel that is already open, alongside the Service traffic
|
||||
/// and without disturbing it.
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the sample until Ctrl-C.
|
||||
/// </summary>
|
||||
/// <param name="args">Command line arguments.</param>
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
int frameMilliseconds = 20;
|
||||
|
||||
if (args.Length > 0 &&
|
||||
(!int.TryParse(args[0], out frameMilliseconds) ||
|
||||
frameMilliseconds is < 1 or > 1000))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: ConsoleDataChannelAudio [frame-milliseconds]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var stop = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
stop.Cancel();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await RunAsync(frameMilliseconds, stop.Token).ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunAsync(int frameMilliseconds, CancellationToken ct)
|
||||
{
|
||||
using var source = new AudioStreamingSource(frameMilliseconds);
|
||||
AudioSampleServer.PendingSource = source;
|
||||
|
||||
string endpointUrl = $"opc.tcp://localhost:{EndpointPort}/ConsoleDataChannelAudio";
|
||||
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
builder.Logging.SetMinimumLevel(LogLevel.Warning);
|
||||
|
||||
ConfigureServer(builder, endpointUrl);
|
||||
ConfigureClient(builder, endpointUrl);
|
||||
|
||||
using IHost host = builder.Build();
|
||||
await host.StartAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await StreamAsync(host, source, endpointUrl, frameMilliseconds, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await source.StopAsync().ConfigureAwait(false);
|
||||
await host.StopAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task StreamAsync(
|
||||
IHost host,
|
||||
AudioStreamingSource source,
|
||||
string endpointUrl,
|
||||
int frameMilliseconds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ClientSession session = await ConnectWithRetryAsync(host.Services, endpointUrl, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using (session.ConfigureAwait(false))
|
||||
{
|
||||
var clientChannel = session.TransportChannel as UaSCUaBinaryTransportChannel
|
||||
?? throw new InvalidOperationException("The client transport is not UASC binary.");
|
||||
|
||||
// Ready to receive before asking for the channel: the Server may
|
||||
// send as soon as the response is dispatched.
|
||||
DataChannelManager channels = clientChannel.SecureChannel!.EnableDataChannels(
|
||||
isServer: false,
|
||||
host.Services.GetRequiredService<ITelemetryContext>());
|
||||
|
||||
OpenDataChannelResponse opened = await session.OpenDataChannelAsync(
|
||||
null,
|
||||
source.SourceNodeId,
|
||||
0,
|
||||
0,
|
||||
new DataChannelParametersDataType
|
||||
{
|
||||
Direction = DataChannelDirection.SourceToSink,
|
||||
DeliveryMode = DataChannelDeliveryMode.ReliableOrdered,
|
||||
ContentType = AudioFormat.ContentType,
|
||||
MaxFrameSize = (uint)AudioFormat.BytesForDuration(frameMilliseconds),
|
||||
InitialCredit = (uint)AudioFormat.BytesForDuration(frameMilliseconds * 32),
|
||||
Priority = 1
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
DataChannel sink = channels.Register(
|
||||
opened.ChannelId,
|
||||
source.SourceNodeId,
|
||||
DataChannelSettings.FromParameters(opened.RevisedParameters),
|
||||
isSource: false,
|
||||
opened.RevisedTransportChannelId);
|
||||
channels.MarkOpen(opened.ChannelId);
|
||||
|
||||
string wavPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"ConsoleDataChannelAudio.wav");
|
||||
|
||||
using IAudioSink player = AudioSink.Create(wavPath);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("OPC UA data channel audio streaming");
|
||||
Console.WriteLine($" endpoint {endpointUrl}");
|
||||
Console.WriteLine($" content type {AudioFormat.ContentType} " +
|
||||
$"({AudioFormat.SampleRate} Hz, {AudioFormat.BitsPerSample}-bit, mono)");
|
||||
Console.WriteLine($" frame {frameMilliseconds} ms " +
|
||||
$"({AudioFormat.BytesForDuration(frameMilliseconds)} bytes)");
|
||||
Console.WriteLine($" loop {source.LoopDuration.TotalSeconds:F1} s, repeating");
|
||||
Console.WriteLine($" sink {player.Description}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Press Ctrl-C to stop.");
|
||||
Console.WriteLine();
|
||||
|
||||
await ReceiveAsync(sink, player, ct).ConfigureAwait(false);
|
||||
|
||||
await session.CloseDataChannelAsync(
|
||||
null,
|
||||
opened.ChannelId,
|
||||
StatusCodes.Good,
|
||||
deleteQueued: true,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReceiveAsync(
|
||||
DataChannel sink,
|
||||
IAudioSink player,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ulong frames = 0;
|
||||
ulong bytes = 0;
|
||||
DateTime nextReport = DateTime.UtcNow.AddSeconds(1);
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
using DataChannelMessage? message = await sink.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (message == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
player.Write(message.Payload.Span);
|
||||
frames++;
|
||||
bytes += (ulong)message.Payload.Length;
|
||||
|
||||
if (DateTime.UtcNow >= nextReport)
|
||||
{
|
||||
DataChannelDiagnosticsDataType diagnostics = sink.GetDiagnostics();
|
||||
Console.Write(
|
||||
$"\r {frames} frames, {bytes / 1024} KiB, " +
|
||||
$"credit stalls {diagnostics.CreditStalls} ");
|
||||
|
||||
// The line carries no newline so it overwrites itself on a
|
||||
// terminal; a redirected stream would otherwise hold it in
|
||||
// the buffer and show nothing at all.
|
||||
Console.Out.Flush();
|
||||
nextReport = DateTime.UtcNow.AddSeconds(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureServer(HostApplicationBuilder builder, string endpointUrl)
|
||||
{
|
||||
builder.Services.AddOpcUa()
|
||||
.AddServer<AudioSampleServer>(o =>
|
||||
{
|
||||
const string applicationName = "ConsoleDataChannelAudioServer";
|
||||
o.ApplicationName = applicationName;
|
||||
o.ApplicationUri = "urn:localhost:OPCFoundation:ConsoleDataChannelAudioServer";
|
||||
o.ProductUri = "uri:opcfoundation.org:ConsoleDataChannelAudioServer";
|
||||
o.AutoAcceptUntrustedCertificates = true;
|
||||
o.PkiRoot = PkiRoot(applicationName);
|
||||
o.RejectSHA1Certificates = true;
|
||||
o.MinCertificateKeySize = 2048;
|
||||
o.EndpointUrls.Add(endpointUrl);
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureClient(HostApplicationBuilder builder, string endpointUrl)
|
||||
{
|
||||
builder.Services.AddOpcUa()
|
||||
.AddClient(o =>
|
||||
{
|
||||
const string applicationName = "ConsoleDataChannelAudioClient";
|
||||
o.ApplicationName = applicationName;
|
||||
o.ApplicationUri = "urn:localhost:OPCFoundation:ConsoleDataChannelAudioClient";
|
||||
o.ProductUri = "uri:opcfoundation.org:ConsoleDataChannelAudioClient";
|
||||
o.PkiRoot = PkiRoot(applicationName);
|
||||
o.AutoAcceptUntrustedCertificates = true;
|
||||
o.RejectSHA1SignedCertificates = true;
|
||||
o.MinimumCertificateKeySize = 2048;
|
||||
})
|
||||
.AddDiscoveryAndConnect(o =>
|
||||
{
|
||||
o.DiscoveryUrl = endpointUrl;
|
||||
o.SecurityMode = MessageSecurityMode.SignAndEncrypt;
|
||||
o.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
|
||||
});
|
||||
}
|
||||
|
||||
private static string PkiRoot(string applicationName)
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation",
|
||||
applicationName,
|
||||
"pki");
|
||||
}
|
||||
|
||||
private static async Task<ClientSession> ConnectWithRetryAsync(
|
||||
IServiceProvider services,
|
||||
string endpointUrl,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Exception? last = null;
|
||||
|
||||
for (int ii = 0; ii < 20; ii++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ConnectAsync(services, endpointUrl, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
last = ex;
|
||||
await Task.Delay(250, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("The in-process server did not become ready.", last);
|
||||
}
|
||||
|
||||
private static async Task<ClientSession> ConnectAsync(
|
||||
IServiceProvider services,
|
||||
string endpointUrl,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ApplicationConfiguration configuration = await services
|
||||
.GetRequiredService<IOpcUaApplicationConfigurationProvider>()
|
||||
.GetAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
ArrayOf<EndpointDescription> endpoints = await services
|
||||
.GetRequiredService<IOpcUaDiscoveryService>()
|
||||
.GetEndpointsAsync(endpointUrl, ct: ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
EndpointDescription? description = null;
|
||||
|
||||
foreach (EndpointDescription candidate in endpoints)
|
||||
{
|
||||
if (candidate.TransportProfileUri == Profiles.UaTcpTransport &&
|
||||
candidate.SecurityMode == MessageSecurityMode.SignAndEncrypt &&
|
||||
candidate.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
|
||||
{
|
||||
description = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (description == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"No SignAndEncrypt/Basic256Sha256 opc.tcp endpoint was returned.");
|
||||
}
|
||||
|
||||
var endpoint = new ConfiguredEndpoint(
|
||||
null,
|
||||
description,
|
||||
EndpointConfiguration.Create(configuration));
|
||||
|
||||
return await services.GetRequiredService<ISessionFactory>().CreateAsync(
|
||||
configuration,
|
||||
endpoint,
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
"ConsoleDataChannelAudio",
|
||||
60_000,
|
||||
new UserIdentity(),
|
||||
default,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private const int EndpointPort = 62560;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Server that publishes the audio source and grants it to any activated
|
||||
/// Session on a signed and encrypted SecureChannel.
|
||||
/// </summary>
|
||||
internal sealed class AudioSampleServer : StandardServer
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the server.
|
||||
/// </summary>
|
||||
/// <param name="telemetry">Telemetry context.</param>
|
||||
/// <param name="timeProvider">Time provider.</param>
|
||||
public AudioSampleServer(ITelemetryContext telemetry, TimeProvider timeProvider)
|
||||
: base(telemetry, timeProvider)
|
||||
{
|
||||
AudioStreamingSource source = PendingSource
|
||||
?? throw new InvalidOperationException("No audio source was provided.");
|
||||
|
||||
DataChannelSources.Register(source);
|
||||
|
||||
// Registering a source states that it exists, not that any given
|
||||
// user may read it. The default authorizer resolves the source in
|
||||
// the AddressSpace and applies its RolePermissions; a source kept
|
||||
// outside the AddressSpace, as this one is, has no such metadata and
|
||||
// would be denied.
|
||||
DataChannelAuthorizer = new AudioSourceAuthorizer(source.SourceNodeId);
|
||||
DataChannelCapabilities = new DataChannelServerCapabilities
|
||||
{
|
||||
MaxDataChannels = 4,
|
||||
MaxFrameSize = 64 * 1024,
|
||||
MaxCreditPerChannel = 1024 * 1024,
|
||||
SupportedDeliveryModes = [DataChannelDeliveryMode.ReliableOrdered],
|
||||
SupportedTransportProfileUris = [Profiles.UaTcpTransport]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The source the next server instance publishes.
|
||||
/// </summary>
|
||||
public static AudioStreamingSource? PendingSource { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grants the one source this sample publishes.
|
||||
/// </summary>
|
||||
internal sealed class AudioSourceAuthorizer(NodeId sourceNodeId) : IDataChannelAuthorizer
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<bool> IsAuthorizedAsync(
|
||||
DataChannelRequestContext context,
|
||||
NodeId requestedSourceNodeId,
|
||||
DataChannelDirection direction,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Part 4 errata §7.2: a channel carrying payload towards the source
|
||||
// is a write, so only the outbound direction this sample publishes
|
||||
// is granted.
|
||||
bool authorized =
|
||||
requestedSourceNodeId == sourceNodeId &&
|
||||
direction == DataChannelDirection.SourceToSink &&
|
||||
context.IsSessionActivated &&
|
||||
context.SecurityMode == MessageSecurityMode.SignAndEncrypt;
|
||||
|
||||
return new ValueTask<bool>(authorized);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -19,11 +19,11 @@
|
||||
<RestrictForLegacyTfm>true</RestrictForLegacyTfm>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Opc.Ua.Core\Opc.Ua.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Opc.Ua.Bindings.Quic\Opc.Ua.Bindings.Quic.csproj" />
|
||||
<ProjectReference Include="..\..\src\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\src\Opc.Ua.Client\Opc.Ua.Client.csproj" />
|
||||
<ProjectReference Include="..\..\src\Opc.Ua.Server\Opc.Ua.Server.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Opc.Ua.Core\Opc.Ua.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Opc.Ua.Bindings.Quic\Opc.Ua.Bindings.Quic.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Opc.Ua.Client\Opc.Ua.Client.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Opc.Ua.Server\Opc.Ua.Server.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
@@ -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)]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -609,10 +609,8 @@ namespace Opc.Ua.Client
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
#if NET8_0_OR_GREATER
|
||||
[Experimental(DataChannelFeature.ExperimentalDiagnosticId,
|
||||
UrlFormat = DataChannelFeature.ExperimentalUrlFormat)]
|
||||
#endif
|
||||
public async ValueTask<OpenDataChannelResponse> OpenDataChannelAsync(
|
||||
RequestHeader? requestHeader,
|
||||
NodeId sourceNodeId,
|
||||
@@ -634,10 +632,8 @@ namespace Opc.Ua.Client
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
#if NET8_0_OR_GREATER
|
||||
[Experimental(DataChannelFeature.ExperimentalDiagnosticId,
|
||||
UrlFormat = DataChannelFeature.ExperimentalUrlFormat)]
|
||||
#endif
|
||||
public async ValueTask<ModifyDataChannelResponse> ModifyDataChannelAsync(
|
||||
RequestHeader? requestHeader,
|
||||
uint channelId,
|
||||
@@ -655,10 +651,8 @@ namespace Opc.Ua.Client
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
#if NET8_0_OR_GREATER
|
||||
[Experimental(DataChannelFeature.ExperimentalDiagnosticId,
|
||||
UrlFormat = DataChannelFeature.ExperimentalUrlFormat)]
|
||||
#endif
|
||||
public async ValueTask<CloseDataChannelResponse> CloseDataChannelAsync(
|
||||
RequestHeader? requestHeader,
|
||||
uint channelId,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1235,11 +1235,9 @@ namespace Opc.Ua.Redundancy.Client
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
#if NET8_0_OR_GREATER
|
||||
[System.Diagnostics.CodeAnalysis.Experimental(
|
||||
Opc.Ua.DataChannelFeature.ExperimentalDiagnosticId,
|
||||
UrlFormat = Opc.Ua.DataChannelFeature.ExperimentalUrlFormat)]
|
||||
#endif
|
||||
public async ValueTask<OpenDataChannelResponse> OpenDataChannelAsync(
|
||||
RequestHeader? requestHeader,
|
||||
NodeId sourceNodeId,
|
||||
@@ -1260,11 +1258,9 @@ namespace Opc.Ua.Redundancy.Client
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
#if NET8_0_OR_GREATER
|
||||
[System.Diagnostics.CodeAnalysis.Experimental(
|
||||
Opc.Ua.DataChannelFeature.ExperimentalDiagnosticId,
|
||||
UrlFormat = Opc.Ua.DataChannelFeature.ExperimentalUrlFormat)]
|
||||
#endif
|
||||
public async ValueTask<ModifyDataChannelResponse> ModifyDataChannelAsync(
|
||||
RequestHeader? requestHeader,
|
||||
uint channelId,
|
||||
@@ -1281,11 +1277,9 @@ namespace Opc.Ua.Redundancy.Client
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
#if NET8_0_OR_GREATER
|
||||
[System.Diagnostics.CodeAnalysis.Experimental(
|
||||
Opc.Ua.DataChannelFeature.ExperimentalDiagnosticId,
|
||||
UrlFormat = Opc.Ua.DataChannelFeature.ExperimentalUrlFormat)]
|
||||
#endif
|
||||
public async ValueTask<CloseDataChannelResponse> CloseDataChannelAsync(
|
||||
RequestHeader? requestHeader,
|
||||
uint channelId,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates that an API is experimental and subject to change or removal,
|
||||
/// so a caller has to opt in deliberately.
|
||||
/// </summary>
|
||||
[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
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the
|
||||
/// <see cref="ExperimentalAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="diagnosticId">The diagnostic the compiler reports for a
|
||||
/// use of the marked API.</param>
|
||||
public ExperimentalAttribute(string diagnosticId)
|
||||
{
|
||||
DiagnosticId = diagnosticId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the diagnostic the compiler reports for a use of the marked
|
||||
/// API.
|
||||
/// </summary>
|
||||
public string DiagnosticId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the format of the URL naming the documentation for the
|
||||
/// diagnostic, where <c>{0}</c> is the diagnostic identifier.
|
||||
/// </summary>
|
||||
public string? UrlFormat { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user