Files
Copilot d525bc3f28 Bound reconnect cleanup and organize samples by domain (#4276)
# Description

This PR fixes bounded ManagedSession reconnect cleanup and reorganizes
the sample applications into domain-focused folders.

## Reconnect cleanup

- Bounds reconnect integration cleanup with independent 30-second close
and dispose limits.
- Sends `CloseSession` through the awaited transport request path during
`Session.DisposeAsync`.
- Preserves explicit `Session.CloseAsync` behavior that waits for or
cancels outstanding publish requests.
- Covers the disposal request path with a regression test.

## Sample organization

- `samples/OpenUsd`: `GeneratorServer`, `SiteCompositionServer`
- `samples/Redundancy`: `RedundantClient`, `RedundantServer`,
`RedundantPubSub`
- `samples/Isa95`: `MinimalIsa95Server`
- `samples/MinimalApi`: `MinimalBoilerServer`, `MinimalCalcServer`,
`MinimalClient`
- `samples/DI`: `PumpDeviceIntegrationServer`
- `samples/Lds`: `ConsoleLdsServer`
- `samples/Reference`: `ConsoleReferenceClient`,
`ConsoleReferenceServer`
- `samples/PubSub`: `ConsoleReferencePubSubClient`

`UA.slnx` now exposes the same requested domain groups. Project
references, tests, runtime sample probes, CI workflows, Dockerfiles,
Compose files, helper scripts, and documentation were updated to the new
paths. `samples/Robotics/MinimalRobotServer` remains under `Robotics`.

## Validation

- `dotnet build UA.slnx`
- 3 Redundancy short-haul sample tests
- 70 DI sample tests
- 914 OpenUSD tests
- 1 WotCon model-materialization test
- 137 AOT tests
- `docker compose config` for all 6 moved Redundancy Compose files

## Related Issues

- Fixes #4273

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce new build or
analyzer warnings.
- [x] I built the complete `UA.slnx` solution locally.
- [ ] I ran all tests locally against at least .NET Framework and .NET
10.
- [ ] I fixed all failing and flaky tests in the CI pipelines and all
CodeQL warnings.
- [x] I have addressed all PR feedback received.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: Marc Schier <mschier@microsoft.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Copilot-Session: a2c2838f-74ae-4997-a1d4-50574d9e0636
2026-08-21 04:35:47 +00:00

999 lines
40 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.CommandLine;
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.PubSub.Adapter;
using Opc.Ua.PubSub.Adapter.DependencyInjection;
using Opc.Ua.PubSub.Application;
using Opc.Ua.PubSub.Configuration;
namespace Quickstarts.ConsoleReferencePubSubClient
{
/// <summary>
/// Unified OPC UA Part 14 PubSub reference sample built on the fluent
/// <see cref="PubSubApplicationBuilder"/> + DI + .NET Generic Host surface.
/// One executable exposes three command-line-selectable modes:
/// <list type="bullet">
/// <item><description>
/// <c>publisher</c> - publishes sample DataSets over UDP/UADP, Ethernet/UADP, MQTT, or Kafka (UADP/JSON).
/// </description></item>
/// <item><description>
/// <c>subscriber</c> - receives DataSets and logs each decoded message.
/// </description></item>
/// <item><description>
/// <c>external</c> - bridges an external OPC UA server to PubSub through the
/// <c>Opc.Ua.PubSub.Adapter</c> library (publisher / subscriber / responder).
/// </description></item>
/// </list>
/// The build publishes as a NativeAOT-ready single-file executable.
/// </summary>
internal static class Program
{
private const string DefaultExternalEndpoint =
"opc.tcp://localhost:62541/Quickstarts/ReferenceServer";
private const string ExternalPublisherOptionsName = "ExternalPublisher";
private const string ExternalSubscriberOptionsName = "ExternalSubscriber";
private const string ExternalResponderOptionsName = "ExternalResponder";
public static async Task<int> Main(string[] args)
{
int exitCode = 0;
var rootCommand = new RootCommand(
"OPC UA Part 14 PubSub Reference sample. " +
"Select a mode: publisher | subscriber | external.");
rootCommand.Subcommands.Add(BuildPublisherCommand(code => exitCode = code));
rootCommand.Subcommands.Add(BuildSubscriberCommand(code => exitCode = code));
rootCommand.Subcommands.Add(BuildExternalCommand(code => exitCode = code));
ParseResult parse = rootCommand.Parse(args);
await parse.InvokeAsync().ConfigureAwait(false);
return exitCode;
}
/// <summary>
/// Builds the <c>publisher</c> subcommand: a UDP/UADP, Ethernet/UADP, MQTT, or Kafka publisher
/// that publishes a built-in sample DataSet.
/// </summary>
private static Command BuildPublisherCommand(Action<int> setExitCode)
{
var profileOption = new Option<string>("--profile")
{
Description =
"Transport profile: udp-uadp | eth-uadp | mqtt-uadp | mqtt-json | " +
"kafka-uadp | kafka-json.",
DefaultValueFactory = _ => "udp-uadp"
};
var configFileOption = new Option<string?>("--config-file")
{
Description = "Optional path to a Part 14 XML PubSub configuration."
};
var publisherIdOption = new Option<ushort>("--publisher-id")
{
Description = "PublisherId published in every NetworkMessage header.",
DefaultValueFactory = _ => 1
};
var writerGroupIdOption = new Option<ushort>("--writer-group-id")
{
Description = "WriterGroupId for the single sample WriterGroup.",
DefaultValueFactory = _ => 100
};
var dataSetWriterIdOption = new Option<ushort>("--data-set-writer-id")
{
Description = "DataSetWriterId for the single sample writer.",
DefaultValueFactory = _ => 1
};
var endpointOption = new Option<string?>("--endpoint")
{
Description =
"Transport endpoint URL. Defaults: opc.udp://239.0.0.1:4840 (UDP), " +
"opc.eth://01-00-5E-7F-00-01 (Ethernet), mqtt://localhost:1883 (MQTT), " +
"kafka://localhost:9092 (Kafka)."
};
var intervalOption = new Option<int>("--interval")
{
Description = "Publishing interval in milliseconds.",
DefaultValueFactory = _ => 1000
};
var command = new Command(
"publisher",
"Publish a sample DataSet over UDP/UADP, Ethernet/UADP, MQTT, or Kafka (UADP/JSON).")
{
profileOption,
configFileOption,
publisherIdOption,
writerGroupIdOption,
dataSetWriterIdOption,
endpointOption,
intervalOption
};
command.SetAction(async (parseResult, cancellationToken) =>
{
string? profileArg = parseResult.GetValue(profileOption);
if (!TryParsePublisherProfile(profileArg, out PublisherProfile profile))
{
await Console.Error.WriteLineAsync(
$"Unknown --profile value '{profileArg}'. " +
"Expected one of: udp-uadp, eth-uadp, mqtt-uadp, mqtt-json, kafka-uadp, kafka-json.")
.ConfigureAwait(false);
setExitCode(2);
return;
}
setExitCode(await RunPublisherAsync(
profile,
parseResult.GetValue(configFileOption),
parseResult.GetValue(publisherIdOption),
parseResult.GetValue(writerGroupIdOption),
parseResult.GetValue(dataSetWriterIdOption),
parseResult.GetValue(endpointOption),
parseResult.GetValue(intervalOption),
cancellationToken).ConfigureAwait(false));
});
return command;
}
/// <summary>
/// Builds the <c>subscriber</c> subcommand: a UDP/UADP, Ethernet/UADP, MQTT, or Kafka subscriber
/// that logs each decoded DataSetMessage.
/// </summary>
private static Command BuildSubscriberCommand(Action<int> setExitCode)
{
var profileOption = new Option<string>("--profile")
{
Description =
"Transport profile: udp-uadp | eth-uadp | mqtt-uadp | mqtt-json | " +
"kafka-uadp | kafka-json.",
DefaultValueFactory = _ => "udp-uadp"
};
var configFileOption = new Option<string?>("--config-file")
{
Description = "Optional path to a Part 14 XML PubSub configuration."
};
var publisherFilterOption = new Option<ushort>("--publisher-id-filter")
{
Description = "PublisherId filter applied by the reader.",
DefaultValueFactory = _ => 1
};
var writerGroupFilterOption = new Option<ushort>("--writer-group-id-filter")
{
Description = "WriterGroupId filter applied by the reader.",
DefaultValueFactory = _ => 100
};
var dataSetWriterFilterOption = new Option<ushort>("--data-set-writer-id-filter")
{
Description = "DataSetWriterId filter applied by the reader.",
DefaultValueFactory = _ => 1
};
var endpointOption = new Option<string?>("--endpoint")
{
Description =
"Transport endpoint URL. Defaults: opc.udp://239.0.0.1:4840 (UDP), " +
"opc.eth://01-00-5E-7F-00-01 (Ethernet), mqtt://localhost:1883 (MQTT), " +
"kafka://localhost:9092 (Kafka)."
};
var command = new Command(
"subscriber",
"Receive DataSets and log each decoded message.")
{
profileOption,
configFileOption,
publisherFilterOption,
writerGroupFilterOption,
dataSetWriterFilterOption,
endpointOption
};
command.SetAction(async (parseResult, cancellationToken) =>
{
string? profileArg = parseResult.GetValue(profileOption);
if (!TryParseSubscriberProfile(profileArg, out SubscriberProfile profile))
{
await Console.Error.WriteLineAsync(
$"Unknown --profile value '{profileArg}'. " +
"Expected one of: udp-uadp, eth-uadp, mqtt-uadp, mqtt-json, kafka-uadp, kafka-json.")
.ConfigureAwait(false);
setExitCode(2);
return;
}
setExitCode(await RunSubscriberAsync(
profile,
parseResult.GetValue(configFileOption),
parseResult.GetValue(publisherFilterOption),
parseResult.GetValue(writerGroupFilterOption),
parseResult.GetValue(dataSetWriterFilterOption),
parseResult.GetValue(endpointOption),
cancellationToken).ConfigureAwait(false));
});
return command;
}
/// <summary>
/// Builds the <c>external</c> subcommand: bridges an external OPC UA server
/// to PubSub via the adapter library, in publisher / subscriber / responder
/// direction.
/// </summary>
private static Command BuildExternalCommand(Action<int> setExitCode)
{
var directionOption = new Option<string>("--mode")
{
Description =
"Adapter directions to run, comma- or plus-separated: publisher | subscriber | responder.",
DefaultValueFactory = _ => "publisher"
};
var readModeOption = new Option<string>("--read-mode")
{
Description =
"Publisher source strategy: cyclic (Read each cycle) | " +
"subscription (client Subscription cache).",
DefaultValueFactory = _ => "cyclic"
};
var affinityOption = new Option<string>("--affinity")
{
Description =
"Subscription grouping when --read-mode=subscription: " +
"writergroup | datasetwriter.",
DefaultValueFactory = _ => "writergroup"
};
var endpointOption = new Option<string?>("--endpoint")
{
Description =
"External OPC UA server endpoint URL. Defaults to " +
"OPCUA_EXTERNAL_ENDPOINT or " +
DefaultExternalEndpoint +
"."
};
var pubSubEndpointOption = new Option<string>("--pubsub-endpoint")
{
Description = "UDP/UADP PubSub transport endpoint URL.",
DefaultValueFactory = _ => ExternalServerPubSubConfiguration.DefaultPubSubEndpoint
};
var hotReloadOption = new Option<bool>("--hot-reload")
{
Description =
"Enable the external bridge hot-reload demo using appsettings.json and pubsub-config.xml."
};
var command = new Command(
"external",
"Bridge an external OPC UA server to PubSub (publisher | subscriber | responder).")
{
directionOption,
readModeOption,
affinityOption,
endpointOption,
pubSubEndpointOption,
hotReloadOption
};
command.SetAction(async (parseResult, cancellationToken) =>
{
if (!TryParseBridgeMode(parseResult.GetValue(directionOption), out BridgeMode mode))
{
await Console.Error.WriteLineAsync(
$"Unknown --mode value '{parseResult.GetValue(directionOption)}'. " +
"Expected one or more of: publisher, subscriber, responder.")
.ConfigureAwait(false);
setExitCode(2);
return;
}
if (!TryParseReadMode(parseResult.GetValue(readModeOption), out ReadMode readMode))
{
await Console.Error.WriteLineAsync(
$"Unknown --read-mode value '{parseResult.GetValue(readModeOption)}'. " +
"Expected one of: cyclic, subscription.")
.ConfigureAwait(false);
setExitCode(2);
return;
}
if (!TryParseAffinity(
parseResult.GetValue(affinityOption), out SubscriptionAffinity affinity))
{
await Console.Error.WriteLineAsync(
$"Unknown --affinity value '{parseResult.GetValue(affinityOption)}'. " +
"Expected one of: writergroup, datasetwriter.")
.ConfigureAwait(false);
setExitCode(2);
return;
}
string externalEndpoint = parseResult.GetValue(endpointOption)
?? Environment.GetEnvironmentVariable("OPCUA_EXTERNAL_ENDPOINT")
?? DefaultExternalEndpoint;
setExitCode(await RunExternalAsync(
mode,
readMode,
affinity,
externalEndpoint,
parseResult.GetValue(pubSubEndpointOption)
?? ExternalServerPubSubConfiguration.DefaultPubSubEndpoint,
parseResult.GetValue(hotReloadOption),
cancellationToken).ConfigureAwait(false));
});
return command;
}
private static async Task<int> RunPublisherAsync(
PublisherProfile profile,
string? configFile,
ushort publisherId,
ushort writerGroupId,
ushort dataSetWriterId,
string? endpoint,
int intervalMs,
CancellationToken cancellationToken)
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
string transportEndpoint = endpoint
?? PublisherConfigurationBuilder.DefaultEndpointFor(profile);
var sampleSource = new SampleDataSetSource();
builder.Services.AddOpcUa().AddPubSub(pubsub =>
{
IPubSubBuilder publisher = pubsub
.AddPublisher()
.AddUdpTransport()
.AddSecurityKeyProvider(SampleSecurity.CreateKeyProvider())
.AddDataSetSource(PublisherConfigurationBuilder.DataSetName, sampleSource);
switch (profile)
{
case PublisherProfile.UdpUadp:
break;
case PublisherProfile.EthUadp:
publisher.AddEthTransport();
break;
case PublisherProfile.MqttUadp:
case PublisherProfile.MqttJson:
publisher.AddMqttTransport();
break;
case PublisherProfile.KafkaUadp:
case PublisherProfile.KafkaJson:
publisher.AddKafkaTransport();
break;
default:
throw new ArgumentOutOfRangeException(nameof(profile), profile, null);
}
publisher.ConfigureApplication(app =>
{
app.WithApplicationId("urn:opcfoundation:ConsoleReferencePubSubClient:Publisher");
if (!string.IsNullOrEmpty(configFile))
{
app.UseConfigurationFile(configFile);
}
else
{
app.UseConfiguration(PublisherConfigurationBuilder.Build(
profile,
transportEndpoint,
publisherId,
writerGroupId,
dataSetWriterId,
intervalMs));
}
});
});
IHost host = builder.Build();
ILogger logger = host.Services
.GetRequiredService<ILoggerFactory>()
.CreateLogger("ConsoleReferencePubSubClient.Publisher");
logger.PublisherStarting(profile, transportEndpoint, intervalMs, publisherId, writerGroupId);
logger.PublisherStarted();
await host.RunAsync(cancellationToken).ConfigureAwait(false);
return 0;
}
private static async Task<int> RunSubscriberAsync(
SubscriberProfile profile,
string? configFile,
ushort publisherIdFilter,
ushort writerGroupIdFilter,
ushort dataSetWriterIdFilter,
string? endpoint,
CancellationToken cancellationToken)
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
string transportEndpoint = endpoint
?? SubscriberConfigurationBuilder.DefaultEndpointFor(profile);
builder.Services.AddOpcUa().AddPubSub(pubsub =>
{
IPubSubBuilder subscriber = pubsub
.AddSubscriber()
.AddUdpTransport()
.AddSecurityKeyProvider(SampleSecurity.CreateKeyProvider())
.AddSubscribedDataSetSink(
SubscriberConfigurationBuilder.ReaderName,
sp => new ConsoleLoggingSink(
sp.GetRequiredService<ILoggerFactory>()
.CreateLogger<ConsoleLoggingSink>()));
switch (profile)
{
case SubscriberProfile.UdpUadp:
break;
case SubscriberProfile.EthUadp:
subscriber.AddEthTransport();
break;
case SubscriberProfile.MqttUadp:
case SubscriberProfile.MqttJson:
subscriber.AddMqttTransport();
break;
case SubscriberProfile.KafkaUadp:
case SubscriberProfile.KafkaJson:
subscriber.AddKafkaTransport();
break;
default:
throw new ArgumentOutOfRangeException(nameof(profile), profile, null);
}
subscriber.ConfigureApplication(app =>
{
app.WithApplicationId("urn:opcfoundation:ConsoleReferencePubSubClient:Subscriber");
if (!string.IsNullOrEmpty(configFile))
{
app.UseConfigurationFile(configFile);
}
else
{
app.UseConfiguration(SubscriberConfigurationBuilder.Build(
profile,
transportEndpoint,
publisherIdFilter,
writerGroupIdFilter,
dataSetWriterIdFilter));
}
});
});
IHost host = builder.Build();
ILogger logger = host.Services
.GetRequiredService<ILoggerFactory>()
.CreateLogger("ConsoleReferencePubSubClient.Subscriber");
logger.SubscriberStarting(profile, transportEndpoint, publisherIdFilter, writerGroupIdFilter);
logger.SubscriberStarted();
await host.RunAsync(cancellationToken).ConfigureAwait(false);
return 0;
}
private static async Task<int> RunExternalAsync(
BridgeMode mode,
ReadMode readMode,
SubscriptionAffinity affinity,
string externalEndpoint,
string pubSubEndpoint,
bool hotReload,
CancellationToken cancellationToken)
{
HostApplicationBuilder builder = hotReload
? Host.CreateApplicationBuilder(
new HostApplicationBuilderSettings { ContentRootPath = AppContext.BaseDirectory })
: Host.CreateApplicationBuilder();
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
string? configFile = null;
XmlPubSubConfigurationStore? hotReloadStore = null;
string appSettingsFile = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (hotReload)
{
(configFile, hotReloadStore) = await ConfigureExternalBridgeHotReloadAsync(
builder,
mode,
pubSubEndpoint,
cancellationToken).ConfigureAwait(false);
}
else
{
ConfigureExternalBridge(builder, mode, readMode, affinity, externalEndpoint, pubSubEndpoint);
}
IHost host = builder.Build();
ILogger logger = host.Services
.GetRequiredService<ILoggerFactory>()
.CreateLogger("ConsoleReferencePubSubClient.External");
logger.ExternalServerPubSubBridgeStarting(mode, readMode, affinity, externalEndpoint, pubSubEndpoint);
if (hotReload)
{
logger.HotReloadEnabled(appSettingsFile, ExternalPublisherOptionsName, configFile);
}
logger.BridgeStarted();
try
{
await host.RunAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
hotReloadStore?.Dispose();
}
return 0;
}
/// <summary>
/// Wires the selected external bridge directions on one UDP/UADP PubSub
/// application and one host.
/// </summary>
private static void ConfigureExternalBridge(
HostApplicationBuilder builder,
BridgeMode modes,
ReadMode readMode,
SubscriptionAffinity affinity,
string externalEndpoint,
string pubSubEndpoint)
{
builder.Services.AddOpcUa().AddPubSub(pubsub =>
{
IPubSubBuilder bridge = pubsub;
if (modes.HasFlag(BridgeMode.Publisher))
{
bridge = bridge.AddPublisher();
}
if (modes.HasFlag(BridgeMode.Subscriber) || modes.HasFlag(BridgeMode.Responder))
{
bridge = bridge.AddSubscriber();
}
bridge = bridge
.AddUdpTransport()
.ConfigureApplication(app => app.WithApplicationId(
"urn:opcfoundation:ConsoleReferencePubSubClient:ExternalBridge"))
.UseConfiguration(
ExternalServerPubSubConfiguration.BuildConfiguration(modes, pubSubEndpoint));
if (modes.HasFlag(BridgeMode.Publisher))
{
bridge = bridge.AddServerAsPublisher(options =>
{
options.Connection.EndpointUrl = externalEndpoint;
// The demo connects unsecured for zero-config interop. A
// production bridge must use SignAndEncrypt with a provisioned
// application instance certificate.
options.Connection.SecurityMode = MessageSecurityMode.None;
options.ReadMode = readMode;
options.Affinity = affinity;
});
}
if (modes.HasFlag(BridgeMode.Subscriber))
{
bridge = bridge.AddServerAsSubscriber(options =>
{
options.Connection.EndpointUrl = externalEndpoint;
options.Connection.SecurityMode = MessageSecurityMode.None;
});
}
if (modes.HasFlag(BridgeMode.Responder))
{
bridge.AddServerAsActionResponder(options =>
{
options.Connection.EndpointUrl = externalEndpoint;
options.Connection.SecurityMode = MessageSecurityMode.None;
options.AllowUnsecured = true;
// Map the "ResetCounters" action to an external method call.
options.MethodMap.Add(
"ResetCounters",
NodeId.Parse("ns=2;s=Demo.External.Methods"),
NodeId.Parse("ns=2;s=Demo.External.ResetCounters"));
options.Targets.Add(new PubSubActionTarget
{
DataSetWriterId = 1,
ActionName = "ResetCounters"
});
});
}
});
}
private static async Task<(string ConfigFile, XmlPubSubConfigurationStore Store)> ConfigureExternalBridgeHotReloadAsync(
HostApplicationBuilder builder,
BridgeMode modes,
string pubSubEndpoint,
CancellationToken cancellationToken)
{
ITelemetryContext telemetry = DefaultTelemetry.Create(logging => logging.AddConsole());
string configFile = Path.Combine(AppContext.BaseDirectory, "pubsub-config.xml");
var store = new XmlPubSubConfigurationStore(configFile, telemetry, watchForChanges: true);
try
{
await store.SaveAsync(
ExternalServerPubSubConfiguration.BuildConfiguration(modes, pubSubEndpoint),
cancellationToken).ConfigureAwait(false);
builder.Services.AddOpcUa().AddPubSub(pubsub =>
{
IPubSubBuilder bridge = pubsub;
if (modes.HasFlag(BridgeMode.Publisher))
{
bridge = bridge.AddPublisher();
}
if (modes.HasFlag(BridgeMode.Subscriber) || modes.HasFlag(BridgeMode.Responder))
{
bridge = bridge.AddSubscriber();
}
bridge = bridge
.AddUdpTransport()
.ConfigureApplication(app => app.WithApplicationId(
"urn:opcfoundation:ConsoleReferencePubSubClient:ExternalBridge"))
// WithConfigurationStore registers this externally-created singleton instance.
// The sample disposes it after the host stops instead of relying on the container.
.WithConfigurationStore(store);
if (modes.HasFlag(BridgeMode.Publisher))
{
bridge = bridge.AddServerAsPublisher(
ExternalPublisherOptionsName,
builder.Configuration.GetSection(ExternalPublisherOptionsName));
}
if (modes.HasFlag(BridgeMode.Subscriber))
{
bridge = bridge.AddServerAsSubscriber(
ExternalSubscriberOptionsName,
builder.Configuration.GetSection(ExternalSubscriberOptionsName));
}
if (modes.HasFlag(BridgeMode.Responder))
{
bridge.AddServerAsActionResponder(
ExternalResponderOptionsName,
builder.Configuration.GetSection(ExternalResponderOptionsName));
}
});
if (modes.HasFlag(BridgeMode.Responder))
{
builder.Services.Configure<ServerActionResponderOptions>(
ExternalResponderOptionsName,
options =>
{
options.MethodMap.Add(
"ResetCounters",
NodeId.Parse("ns=2;s=Demo.External.Methods"),
NodeId.Parse("ns=2;s=Demo.External.ResetCounters"));
options.Targets.Add(new PubSubActionTarget
{
DataSetWriterId = 1,
ActionName = "ResetCounters"
});
});
}
return (configFile, store);
}
catch
{
store.Dispose();
throw;
}
}
private static bool TryParsePublisherProfile(string? text, out PublisherProfile profile)
{
switch (text)
{
case "udp-uadp":
profile = PublisherProfile.UdpUadp;
return true;
case "mqtt-uadp":
profile = PublisherProfile.MqttUadp;
return true;
case "mqtt-json":
profile = PublisherProfile.MqttJson;
return true;
case "eth-uadp":
profile = PublisherProfile.EthUadp;
return true;
case "kafka-uadp":
profile = PublisherProfile.KafkaUadp;
return true;
case "kafka-json":
profile = PublisherProfile.KafkaJson;
return true;
default:
profile = PublisherProfile.UdpUadp;
return false;
}
}
private static bool TryParseSubscriberProfile(string? text, out SubscriberProfile profile)
{
switch (text)
{
case "udp-uadp":
profile = SubscriberProfile.UdpUadp;
return true;
case "mqtt-uadp":
profile = SubscriberProfile.MqttUadp;
return true;
case "mqtt-json":
profile = SubscriberProfile.MqttJson;
return true;
case "eth-uadp":
profile = SubscriberProfile.EthUadp;
return true;
case "kafka-uadp":
profile = SubscriberProfile.KafkaUadp;
return true;
case "kafka-json":
profile = SubscriberProfile.KafkaJson;
return true;
default:
profile = SubscriberProfile.UdpUadp;
return false;
}
}
private static bool TryParseBridgeMode(string? text, out BridgeMode mode)
{
mode = BridgeMode.None;
if (string.IsNullOrWhiteSpace(text))
{
mode = BridgeMode.Publisher;
return true;
}
string[] tokens = text.Split(
[',', '+'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (tokens.Length == 0)
{
mode = BridgeMode.Publisher;
return true;
}
foreach (string token in tokens)
{
switch (token)
{
case "publisher":
mode |= BridgeMode.Publisher;
break;
case "subscriber":
mode |= BridgeMode.Subscriber;
break;
case "responder":
mode |= BridgeMode.Responder;
break;
default:
mode = BridgeMode.Publisher;
return false;
}
}
return mode != BridgeMode.None;
}
private static bool TryParseReadMode(string? text, out ReadMode readMode)
{
switch (text)
{
case "cyclic":
readMode = ReadMode.Cyclic;
return true;
case "subscription":
readMode = ReadMode.Subscription;
return true;
default:
readMode = ReadMode.Cyclic;
return false;
}
}
private static bool TryParseAffinity(string? text, out SubscriptionAffinity affinity)
{
switch (text)
{
case "writergroup":
affinity = SubscriptionAffinity.WriterGroup;
return true;
case "datasetwriter":
affinity = SubscriptionAffinity.DataSetWriter;
return true;
default:
affinity = SubscriptionAffinity.WriterGroup;
return false;
}
}
}
/// <summary>
/// Publisher transport/message profile selected via <c>publisher --profile</c>.
/// </summary>
public enum PublisherProfile
{
/// <summary>
/// UDP transport with UADP message mapping.
/// </summary>
UdpUadp = 0,
/// <summary>
/// MQTT broker transport with UADP message mapping.
/// </summary>
MqttUadp = 1,
/// <summary>
/// MQTT broker transport with JSON message mapping.
/// </summary>
MqttJson = 2,
/// <summary>
/// Ethernet (Layer 2) transport with UADP message mapping.
/// </summary>
EthUadp = 3,
/// <summary>
/// Kafka broker transport with UADP message mapping.
/// </summary>
KafkaUadp = 4,
/// <summary>
/// Kafka broker transport with JSON message mapping.
/// </summary>
KafkaJson = 5
}
/// <summary>
/// Subscriber transport/message profile selected via <c>subscriber --profile</c>.
/// </summary>
public enum SubscriberProfile
{
/// <summary>
/// UDP transport with UADP message mapping.
/// </summary>
UdpUadp = 0,
/// <summary>
/// MQTT broker transport with UADP message mapping.
/// </summary>
MqttUadp = 1,
/// <summary>
/// MQTT broker transport with JSON message mapping.
/// </summary>
MqttJson = 2,
/// <summary>
/// Ethernet (Layer 2) transport with UADP message mapping.
/// </summary>
EthUadp = 3,
/// <summary>
/// Kafka broker transport with UADP message mapping.
/// </summary>
KafkaUadp = 4,
/// <summary>
/// Kafka broker transport with JSON message mapping.
/// </summary>
KafkaJson = 5
}
/// <summary>
/// The external-server adapter direction selected via <c>external --mode</c>.
/// </summary>
[Flags]
public enum BridgeMode
{
/// <summary>
/// No external bridge direction selected.
/// </summary>
None = 0,
/// <summary>
/// Read an external server and publish its data over PubSub.
/// </summary>
Publisher = 1,
/// <summary>
/// Receive PubSub data and write it back to an external server.
/// </summary>
Subscriber = 2,
/// <summary>
/// Map an inbound PubSub Action to an external server method call.
/// </summary>
Responder = 4
}
internal static partial class ProgramLog
{
[LoggerMessage(EventId = ConsoleReferencePubSubClientEventIds.Program + 0, Level = LogLevel.Information,
Message = "Publisher starting: profile={Profile} endpoint={Endpoint} interval={Interval}ms " +
"publisherId={PublisherId} writerGroup={WriterGroupId}")]
public static partial void PublisherStarting(
this ILogger logger,
PublisherProfile profile,
string endpoint,
int interval,
ushort publisherId,
ushort writerGroupId);
[LoggerMessage(EventId = ConsoleReferencePubSubClientEventIds.Program + 1, Level = LogLevel.Information,
Message = "Publisher started. Press Ctrl-C to exit.")]
public static partial void PublisherStarted(this ILogger logger);
[LoggerMessage(EventId = ConsoleReferencePubSubClientEventIds.Program + 2, Level = LogLevel.Information,
Message = "Subscriber starting: profile={Profile} endpoint={Endpoint} " +
"publisherFilter={PublisherFilter} writerGroupFilter={WriterGroupFilter}")]
public static partial void SubscriberStarting(
this ILogger logger,
SubscriberProfile profile,
string endpoint,
ushort publisherFilter,
ushort writerGroupFilter);
[LoggerMessage(EventId = ConsoleReferencePubSubClientEventIds.Program + 3, Level = LogLevel.Information,
Message = "Subscriber started. Press Ctrl-C to exit.")]
public static partial void SubscriberStarted(this ILogger logger);
[LoggerMessage(EventId = ConsoleReferencePubSubClientEventIds.Program + 4, Level = LogLevel.Information,
Message = "External-server PubSub bridge starting: mode={Mode} readMode={ReadMode} " +
"affinity={Affinity} externalServer={ExternalEndpoint} pubSub={PubSubEndpoint}")]
public static partial void ExternalServerPubSubBridgeStarting(
this ILogger logger,
BridgeMode mode,
ReadMode readMode,
SubscriptionAffinity affinity,
string externalEndpoint,
string pubSubEndpoint);
[LoggerMessage(EventId = ConsoleReferencePubSubClientEventIds.Program + 5, Level = LogLevel.Information,
Message = "Hot reload enabled. Edit {AppSettingsFile} (for example, change " +
"{PublisherOptionsName}:ReadMode to Subscription) or {ConfigFile} (for example, add or remove " +
"a DataSetWriter) and save to reconfigure the running bridge.")]
public static partial void HotReloadEnabled(
this ILogger logger,
string appSettingsFile,
string publisherOptionsName,
string? configFile);
[LoggerMessage(EventId = ConsoleReferencePubSubClientEventIds.Program + 6, Level = LogLevel.Information,
Message = "Bridge started. Press Ctrl-C to exit.")]
public static partial void BridgeStarted(this ILogger logger);
}
}