OPC UA PLC server simulator for Azure IoT Edge. Generates random data, anomalies, alarms, and supports user-defined nodes. Built with C# 12 / .NET 10.0, using the OPC Foundation UA SDK and ASP.NET Core for web hosting.
# Build solution (Release)
dotnet build opcplc.sln -c Release
# Build solution (Debug)
dotnet build opcplc.sln -c Debug
# Run all tests
dotnet test tests/opc-plc-tests.csproj -c Release
# Run a single test by fully-qualified name
dotnet test tests/opc-plc-tests.csproj -c Release --filter "FullyQualifiedName~OpcPlc.Tests.SimulatorNodesTests.Telemetry_StepUp"
# Run a single test by name
dotnet test tests/opc-plc-tests.csproj -c Release --filter "Name=Telemetry_StepUp"
# Run all tests in a single test class
dotnet test tests/opc-plc-tests.csproj -c Release --filter "FullyQualifiedName~OpcPlc.Tests.SimulatorNodesTests"
# Run the server locally
dotnet run --project src/opc-plc.csproj -- --pn=50000 --autoaccept
# Docker build (release)
docker build -f Dockerfile.release -t iotedge/opc-plc .Important: TreatWarningsAsErrors is enabled globally. All warnings must be fixed.
src/ # Main application
opc-plc.csproj # Target: net10.0, LangVersion: Preview
Program.cs # Entry point
OpcPlcServer.cs # Server orchestration, plugin loading
PlcServer.cs # OPC UA StandardServer override
PlcNodeManager.cs # OPC UA node manager
PlcSimulation.cs # Simulation engine
TimeService.cs # Virtual time (mocked in tests)
PluginNodes/ # Plugin node implementations (IPluginNodes)
Models/IPluginNodes.cs # Plugin interface
PluginNodeBase.cs # Base class with primary constructor
Configuration/ # CLI options, OPC UA config
Helpers/ # Metrics, OTEL, CLI helpers
DeterministicAlarms/ # Deterministic alarm system
Boilers/ # Boiler simulation models
tests/ # Integration tests (NUnit)
opc-plc-tests.csproj # Test project
PlcSimulatorFixture.cs # Starts real OPC PLC server with mocked time
SimulatorTestsBase.cs # Base class for read/write tests
MonitoringTestsBase.cs # Base class for subscription/event tests
tools/scripts/ # PowerShell CI/build scripts
- Indent: 4 spaces for C#, 2 spaces for XML/JSON/YAML
- Braces: Allman style (opening brace on new line for types, methods, control blocks)
- Line length: 120 characters max
- Line endings: LF (
end_of_line = lf) - Final newline: Required
- Trim trailing whitespace: Yes
- Charset: UTF-8
- File-scoped namespaces:
namespace OpcPlc.PluginNodes; - Usings go inside the namespace, after the file-scoped namespace declaration
- Sort alphabetically as a single group (no
System.*first, no group separation)
namespace OpcPlc.PluginNodes;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using OpcPlc.Helpers;
using System.Collections.Generic;| Element | Convention | Example |
|---|---|---|
| Private instance fields | _camelCase (underscore prefix) |
_cancellationTokenSource |
| Private constants | PascalCase | DefaultMinThreads |
| Public constants | PascalCase | PlcShutdownWaitSeconds |
| Local variables / parameters | camelCase | nodeCount, cancellationToken |
| Methods, Properties, Classes | PascalCase | StartAsync, NodeCount |
| Async methods | Must end in Async |
CreateSessionAsync |
| Interfaces | I prefix |
IPluginNodes, ITimer |
var: Use when type is apparent from the right side; use explicit types for built-in types (string,int,uint)- Pattern matching: Prefer
is not nullover!= null; use switch expressions - C# 12 features in use: Primary constructors, collection expressions
[...], file-scoped namespaces - Nullable reference types: NOT globally enabled; null checks are manual
- Always use
.ConfigureAwait(false)on everyawait(CA2007 is a warning) - Always suffix async methods with
Async - Pass
CancellationTokenthrough async call chains
await StartPlcServerAsync(cancellationToken).ConfigureAwait(false);- Use exception filters (
catch ... when) for specific status codes:
catch (ServiceResultException ex) when (ex.StatusCode == StatusCodes.BadServerHalted)
{
LogCreateSessionWhileHalted();
return new ResponseHeader { ServiceResult = StatusCodes.BadServerHalted };
}- Pattern: metrics + log + rethrow for unknown exceptions
- Bare
catchblocks are acceptable for non-critical paths (shutdown, IP resolution) with a comment - Guard clauses:
_field = param ?? throw new ArgumentNullException(nameof(param));
- Use
[LoggerMessage]source-generated logging for performance-critical paths (partial methods on partial classes):
[LoggerMessage(Level = LogLevel.Error, Message = "{Function} error")]
partial void LogError(string function, Exception exception);- Use
ILogger.LogX(template, args)with structured message templates elsewhere - Use named placeholders in message templates:
"Starting on {Endpoint}"not"Starting on {0}" CA1848(use LoggerMessage delegates) is disabled; string interpolation in log calls is tolerated in non-hot paths
Plugin nodes implement IPluginNodes and extend PluginNodeBase using primary constructors:
public class MyPluginNode(TimeService timeService, ILogger logger)
: PluginNodeBase(timeService, logger), IPluginNodes
{
public void AddOptions(Mono.Options.OptionSet optionSet) { ... }
public void AddToAddressSpace(FolderState telemetry, FolderState methods, PlcNodeManager mgr) { ... }
public void StartSimulation() { ... }
public void StopSimulation() { ... }
}Plugins are discovered via reflection at runtime -- any non-abstract class implementing IPluginNodes is instantiated with (TimeService, ILogger) constructor arguments.
- Manual constructor injection (no DI container for core domain objects)
TimeServiceprovides testability seam via virtual methods (mocked with Moq in tests)ImmutableList<IPluginNodes>for thread-safe plugin collection
- NUnit 4.5 (
[Test],[TestCase],[OneTimeSetUp],[OneTimeTearDown]) - FluentAssertions 7.2 for all assertions
- Moq 4.20 for mocking (
TimeService,ITimer)
Tests are integration tests that start a real OPC PLC server in-process:
SimulatorTestsBase-- starts server per test class, providesReadValueAsync<T>,WriteValueAsync,FindNodeAsyncSubscriptionTestsBase(extends above) -- adds OPC UA subscription/monitoring helpers- Time is controlled via mocked
TimeService; useFireTimersWithPeriod()to advance simulation
Use PascalCase with underscores: Subject_Behavior or Subject_ExpectedOutcome:
[Test] public async Task Telemetry_StepUp() { ... }
[Test] public async Task BadNode_HasAlternatingStatusCode() { ... }
[Test] public async Task LimitNumberOfUpdates_StopsUpdatingAfterLimit() { ... }Always use FluentAssertions method chains:
value.Should().Be(expectedValue);
values.Should().NotBeEmpty().And.HaveCount(10);
maxValue.Should().BeInRange(90, 100, "data should have a ceiling around 100");Use [TestCase] for parameterized tests:
[TestCase("FastUInt1", typeof(uint), 1000u, 1, 0)]
[TestCase("SlowUInt1", typeof(uint), 10000u, 1, 0)]
public async Task Telemetry_ChangesWithPeriod(string id, Type type, uint period, int invocations, int rampUp)Tests also use .ConfigureAwait(false) on all awaits, matching production code.
- Azure DevOps Pipelines (
azure-pipelines.yml): builds solution, runs tests (30-min timeout), builds Docker images - GitHub Actions: CodeQL security scanning on push/PR to
main - Versioning: Nerdbank.GitVersioning (
version.json, current: 2.12.x)