COAP.TwinCAT.CommandProtocol 0.3.0

dotnet add package COAP.TwinCAT.CommandProtocol --version 0.3.0
                    
NuGet\Install-Package COAP.TwinCAT.CommandProtocol -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="COAP.TwinCAT.CommandProtocol" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="COAP.TwinCAT.CommandProtocol" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="COAP.TwinCAT.CommandProtocol" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add COAP.TwinCAT.CommandProtocol --version 0.3.0
                    
#r "nuget: COAP.TwinCAT.CommandProtocol, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package COAP.TwinCAT.CommandProtocol@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=COAP.TwinCAT.CommandProtocol&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=COAP.TwinCAT.CommandProtocol&version=0.3.0
                    
Install as a Cake Tool

CoapClient

English | 中文

COAP means Command Oriented Ads Protocol.

CoapClient is a command-oriented C# middleware for Beckhoff TwinCAT ADS.

It turns the common PLC interaction pattern of "write DO/AO, then poll DI/AI" into an asynchronous command lifecycle based on ADS notifications.

Note: this project is not an implementation of the IETF CoAP protocol. In this project, COAP stands for Command Oriented Ads Protocol.

Command Sequence

The sequence below shows how CoapClient prepares the ACK observation path before writing the command variable, then completes the command when the PLC publishes the expected ACK.

CoapClient command sequence

Why This Project Exists

In many upper-level automation applications, one complete action is modeled like this:

  1. The C# application writes a DO/AO variable to start an action.
  2. A PLC task executes the action.
  3. The PLC updates a DI/AI variable to expose the result.
  4. The C# application repeatedly reads the DI/AI variable until it reaches the expected value or times out.

That works, but it pushes polling, timeout checks, command collision handling, and notification timing issues into every caller.

CoapClient wraps this into a command model:

Create command
-> register DI/AI notification
-> complete notification handshake
-> write DO/AO
-> receive ADS notification
-> validate ACK
-> clean up notification handle
-> complete the command task

The caller describes what should be written and what ACK condition should be observed. CoapClient manages the lifecycle.

Design Goal

CoapClient is designed for PLC workflows that can be expressed as:

DO/AO starts a command.
DI/AI represents the command result.

It is especially useful when upper-level software should issue commands without manually implementing DI/AI polling loops.

Communication Flow

For a command with ACK, CoapClient follows this flow:

Step Behavior Purpose
1 Register a notification handle for the ACK DI/AI variable Observe the result before sending the command
2 Wait for the first notification frame or actively read the value Avoid losing the initial handshake under high load
3 Write the DO/AO command variable Start the PLC action only after the ACK path is ready
4 PLC executes the action Handled by the PLC task
5 PLC changes the DI/AI result variable Triggers ADS OnChange notification
6 CoapClient validates the ACK predicate Decide whether the command succeeded
7 Remove the notification handle End the command lifecycle
8 If timeout occurs, actively read the ACK once more Reduce false timeouts caused by notification timing edges

The handshake step is important. Under high load, the first notification frame after registering a handle may be missed. CoapClient actively reads the ACK variable during registration and again after timeout to keep the command lifecycle as complete as possible.

Quick Start

using CoapClient;

var client = new SimpleCoapClient();
client.Connect("YOUR_AMS_NET_ID", 851);

var write = new VariableWriteItem("PC_GVAR.DO_TO_POS", true);
var ack = new AckPredicate<bool>(
    "PC_GVAR.DI_AT_POS",
    PredicateType.Equals,
    true);

var command = new CoapCommandItem(write, ack);

object result = await client.SendAndWaitAck(
    command,
    TimeSpan.FromSeconds(3));

This command means:

Write PC_GVAR.DO_TO_POS = true.
Listen to PC_GVAR.DI_AT_POS.
Complete the command when PC_GVAR.DI_AT_POS == true.
Timeout after 3 seconds.

Timeouts

Every ACK-required command has a timeout. The value is resolved in this order:

1. the timeSpan argument passed to SendAndWaitAck
2. ICoapCommand.Timeout, when the command supplies one
3. SimpleCoapClient.DefaultAckTimeout  — currently 1 minute

The third step matters: CoapCommandItem and CoapSumCommandItem do not populate ICoapCommand.Timeout, so omitting timeSpan falls through to DefaultAckTimeout. If you need a different bound, either pass timeSpan explicitly or derive from AbstractCoapCommand and set Timeout in your own constructor.

Commands without an ACK (AckRequired == false) are never scheduled for timeout — they complete as soon as the write is dispatched.

Cancellation

SendAndWaitAck accepts an optional CancellationToken. When the token is signalled, the command task completes as cancelled instead of waiting for the ACK or the timeout.

If the token is already cancelled when SendAndWaitAck is called, the returned task is cancelled immediately without touching the PLC.

using System.Threading;
using CoapClient;

var client = new SimpleCoapClient();
client.Connect("YOUR_AMS_NET_ID", 851);

var write = new VariableWriteItem("PC_GVAR.DO_TO_POS", true);
var ack = new AckPredicate<bool>("PC_GVAR.DI_AT_POS", PredicateType.Equals, true);
var command = new CoapCommandItem(write, ack);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
try
{
    object result = await client.SendAndWaitAck(command, cancellationToken: cts.Token);
    Console.WriteLine($"Command completed: {result}");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Command was cancelled.");
}

Cancellation is cooperative: CoapClient stops waiting and unregisters the notification handle, but it does not undo the DO/AO write already sent to the PLC.

Note this example relies on the CancellationTokenSource to bound the wait — it does not pass timeSpan. Without the token, the command would time out after DefaultAckTimeout instead.

Public API

SimpleCoapClient

Main entry point for callers.

Member Description
Connect(string amsNetId, int amsPort) Connects to the TwinCAT ADS server and loads the symbol table.
Task<object> SendAndWaitAck(ICoapCommand command, TimeSpan? timeSpan = null, CancellationToken cancellationToken = default) Sends a command and returns a task that completes when the ACK is satisfied, fails, collides, is cancelled, or times out.
bool VariableExists(string varName) Checks whether a PLC variable exists, using the locally cached symbol table. No ADS round trip.
object GetVariableValue(string varName) Reads a PLC variable value directly.
object[] GetSumVariableValue(string[] varNames) Reads several PLC variable values in a single ADS sum read; the result aligns with varNames.
string GetServerStatusString() Reads the ADS server state as a string.
static readonly TimeSpan DefaultAckTimeout Fallback ACK timeout, currently 1 minute. See Timeouts.

Parameter validation: SendAndWaitAck throws ArgumentNullException when command is null, and ArgumentException when the command requires an ACK but its AckVarName is null or empty. Both are thrown before the cancellation check, so a malformed command fails loudly even when the token is already cancelled.

Batch read in one ADS round-trip:

object[] values = client.GetSumVariableValue(
    new[] { "PC_GVAR.DI_AT_POS", "PC_GVAR.AI_PRESSURE" });
bool atPos = (bool)values[0];

Checking whether a variable exists

VariableExists answers whether a variable name is known to the PLC, using only the locally cached symbol table — no ADS round trip per call. Check as many names as needed by calling it repeatedly.

if (!client.VariableExists("PC_GVAR.DO_TO_POS"))
{
    Console.WriteLine("Variable is not part of the current PLC project.");
}

Behavior and constraints:

Case Result
Variable exists in the loaded symbol table true
Variable does not exist false
varName is null, empty, or whitespace throws ArgumentException
Connect has not been called throws CoapNotConnectedException

The symbol table is loaded during Connect and is not refreshed automatically. After the PLC application is re-downloaded, call Connect again before relying on this check — otherwise a stale table can report a variable that no longer exists.

VariableWriteItem

Represents one PLC variable write.

var item = new VariableWriteItem("PC_GVAR.DO_TO_POS", true);
Member Description
VarName PLC variable name to write.
Value Value to write.

ICoapCommand

The contract SendAndWaitAck operates on. Implement it directly, or derive from AbstractCoapCommand (recommended — see below).

Member Description
bool AckRequired false makes the command fire-and-forget: the write is dispatched and the task completes immediately, with no notification, no ACK check and no timeout.
string AckVarName The DI/AI variable observed for the result. Required when AckRequired is true.
int CycleTime Interval, in milliseconds, at which the ADS server samples the ACK variable for changes. Passed to NotificationSettings.
int MaxDelayTime Maximum delay, in milliseconds, passed to NotificationSettings. 0 disables the mechanism.
TimeSpan? Timeout Optional per-command ACK timeout. Used when SendAndWaitAck is called without timeSpan.
Type ValueType Declared for type information. Not currently populated or read by the library — treat it as unused.
bool ValidateAck(byte[] rawAckData) Returns whether the raw ACK bytes satisfy the command's condition.
object GetResult(byte[] rawData) Converts the raw ACK bytes into the object returned by SendAndWaitAck.

CycleTime and MaxDelayTime are milliseconds here, because they bind to NotificationSettings(AdsTransMode, int, int). The raw ADS protocol (AdsNotificationAttrib) uses 100 ns units instead — do not copy values between the two.

AbstractCoapCommand

Base class for custom commands. It implements ICoapCommand and delegates ACK handling to an IAck instance, so a derived class only has to supply the payload and the properties.

public class MyCommand : AbstractCoapCommand
{
    public MyCommand(VariableWriteItem write, IAck ack)
    {
        this.WriteItem = write;
        this.AckPredicate = ack;
        this.AckRequired = true;
        this.AckVarName = ack.AckVarName;
        this.CycleTime = 2;
        this.MaxDelayTime = 0;
        this.Timeout = TimeSpan.FromSeconds(5);   // protected set
    }

    public VariableWriteItem WriteItem { get; }
}
Member Description
AckPredicate The IAck used by ValidateAck / GetResult.
AckRequired, CycleTime, MaxDelayTime, AckVarName, Timeout protected set, so a derived class can populate them.
ValidateAck(byte[]) Returns true when AckRequired is false; otherwise delegates to AckPredicate.IsMatched.
GetResult(byte[]) Returns true when AckRequired is false; otherwise delegates to AckPredicate.GetResultWithType.

CoapCommandItem

Single-variable command, derived from AbstractCoapCommand.

// ack omitted (or null) => AckRequired stays false => fire-and-forget
var command = new CoapCommandItem(writeItem);

// with ACK; cycleTime defaults to 2 ms
var command = new CoapCommandItem(writeItem, ack);
var command = new CoapCommandItem(writeItem, ack, cycleTime: 10);

Passing null for ack is the supported way to issue a write without waiting for a result.

CoapSumCommandItem

Multi-variable write command, dispatched via an ADS sum write.

var command = new CoapSumCommandItem(
    new[]
    {
        new VariableWriteItem("PC_GVAR.DO_A", true),
        new VariableWriteItem("PC_GVAR.DO_B", false),
    },
    ack);

Same three constructor forms as CoapCommandItem, including the optional cycleTime.

The written variables must all be distinct — a repeated variable raises InvalidOperationException at dispatch time.

IAck

Defines how a command result is checked.

Member Description
string AckVarName PLC DI/AI variable used as the command result.
bool IsMatched(byte[] rawData) Returns whether the raw ACK value satisfies the condition.
object GetResultWithType(byte[] rawData) Converts the raw ACK bytes into a typed result.

Implement IAck when the built-in predicate model is not enough.

AckPredicate<T>

Built-in ACK implementation for unmanaged value types.

IAck ack = new AckPredicate<bool>(
    "PC_GVAR.DI_AT_POS",
    PredicateType.Equals,
    true);

You can also provide a custom predicate:

IAck ack = new AckPredicate<int>(
    "PC_GVAR.AI_PRESSURE",
    value => value >= 10 && value <= 20,
    "pressure in expected range");

PredicateType

PredicateType Meaning Arguments
Equals ACK value equals the expected value arg1
NotEquals ACK value does not equal the expected value arg1
GreaterOrEqual ACK value is greater than or equal to the expected value arg1
LessOrEqual ACK value is less than or equal to the expected value arg1
InBetween ACK value is within a closed interval arg1, arg2

The three ordering predicates require a comparable ACK value type; using them with a value type that does not support comparison raises InvalidOperationException.

Command Lifecycle

Internally, a command moves through these states:

State Meaning
Queued The command has been accepted but not dispatched.
HandShakeSent The ACK notification path is being established.
Dispatched The DO/AO write has been sent to the PLC.
Success The ACK predicate matched.
Timeout The ACK did not match before timeout, including the final active read.
Collided Another command is already using the same ACK variable.
Cancelled The command was cancelled via a CancellationToken.

Only one in-flight command can use the same ACK variable at the same time. This avoids ambiguous notification ownership.

Exceptions

SendAndWaitAck reports every terminal state through the returned task — either the ACK value, or one of the exceptions below. Note that a few failures are thrown synchronously from the call itself rather than through the task; see the notes.

Exception Meaning
TimeoutException The ACK predicate was not satisfied before the timeout. The message carries the last known value.
CoapCommandCollisionException Another command already owns this ACK variable. Carries AckVarName.
OperationCanceledException (as TaskCanceledException) The command was cancelled through the CancellationToken.
CoapResultConversionException The command succeeded but the task still faulted. See below.
CoapSymbolNotFoundException The variable is not in the loaded symbol table. Carries VarName.
CoapNotConnectedException Connect has not been called.
ADS exceptions (AdsErrorException and friends) Propagated from the underlying Beckhoff.TwinCAT.Ads layer.

Synchronous throws:

  • SendAndWaitAck with a null command → ArgumentNullException.
  • SendAndWaitAck with a command that requires an ACK but has an empty AckVarName → ArgumentException.
  • Connect rethrows whatever the ADS layer reports, unchanged.

CoapResultConversionException — do not retry blindly

This is the one exception that means the command succeeded. The ACK predicate was satisfied, so the PLC-side action has already taken effect — but ICoapCommand.GetResult then threw while turning the raw ACK bytes into a result object.

A caller that treats this as a command failure and re-issues the write will execute the PLC action a second time. Inspect RawAckData (the bytes the PLC actually reported) and the InnerException (whatever GetResult threw) instead.

try
{
    object result = await client.SendAndWaitAck(command);
}
catch (CoapResultConversionException ex)
{
    // The PLC action already happened. Investigate, do not blindly retry.
    Console.WriteLine($"ACK was satisfied ({ex.AckVarName}) but result parsing failed.");
    Console.WriteLine($"Raw ACK bytes: {Convert.ToHexString(ex.RawAckData ?? Array.Empty<byte>())}");
    Console.WriteLine($"Cause: {ex.InnerException?.Message}");
}
catch (CoapCommandCollisionException ex)
{
    Console.WriteLine($"{ex.AckVarName} already has a command in flight.");
}
catch (CoapSymbolNotFoundException ex)
{
    Console.WriteLine($"{ex.VarName} is not part of the current PLC project.");
}
catch (CoapNotConnectedException)
{
    Console.WriteLine("Call Connect() first.");
}

Backward compatibility

CoapCommandCollisionException, CoapSymbolNotFoundException and CoapNotConnectedException all derive from InvalidOperationException, so callers written against earlier versions that caught InvalidOperationException keep working. Prefer the specific types in new code — the base type alone cannot tell these conditions apart.

Logging

The library logs through log4net using its own repository and logger:

repository : CoapProjectRepository
logger     : [COAP]

The repository is configured internally, so you do not need to add a log4net configuration to see library output. Connect, command dispatch, ACK validation, handle cleanup and timeout handling all write log entries; failures are logged before the exception is propagated.

Concurrency

  • Different ACK variables may be used by concurrent commands. Each command owns its own notification handle, and the notification callback thread hands work to a single consumer queue, so ACK processing for different commands is serialized rather than interleaved.
  • The same ACK variable cannot have two in-flight commands. The second one fails immediately with CoapCommandCollisionException.
  • Commands without an ACK are dispatched through a serialized sender.

Lifetime

SimpleCoapClient does not implement IDisposable yet, and there is no Disconnect. The underlying ADS connection is therefore held for the lifetime of the instance. Create one client per application and reuse it rather than constructing a new one per command — repeatedly creating clients leaks ADS connections and handles, and TwinCAT allows roughly 8191 handles in total.

See CoapTestConsole/Issue.md for the tracked work item.

Project Structure

Paths are relative to the repository root.

Path Purpose
CoapClient/CoapClient/ Library source code.
CoapClient/CoapTestConsole/ Small console project for manual experiments.
CoapClient/BRIEF.md Maintainer notes describing the project intent.
CoapClient/CoapTestConsole/Issue.md Known open defects.
CoapClient/CoapTestConsole/CoapClient_KnownIssues.md Exception-semantics traps, for downstream consumers.

Important internal components:

Component Responsibility
ThinAdsServer Wraps TwinCAT ADS connection, symbol lookup, read, write, and notifications.
CommandContext Holds one command lifecycle and completion state.
CommandSender Serializes command dispatch.
CommandHandler Handles ADS notifications and routes them to command contexts.
CommandInFlightManager Prevents ACK variable collisions.
TimeoutScheduler Schedules command timeout callbacks.

SingleConsumerQueue<T> and ITimeoutItem are implementation details that are currently declared public; do not depend on them. See Issue.md.

Requirements

  • .NET 8 SDK
  • Beckhoff TwinCAT ADS environment
  • Reachable ADS route from the machine running the C# application

The library currently targets net8.0.

Build

dotnet restore
dotnet build CoapClient.sln

Roadmap

Planned directions:

  • Observation mode: long-lived variable observation outside a single command lifecycle.
  • Multi-ACK commands: one command can depend on multiple ACK variables.
  • Stronger examples and integration tests around real TwinCAT setups.
  • More complete documentation for custom IAck implementations.

Contributing

This project is looking for contributors who have experience with:

  • TwinCAT ADS and Beckhoff PLC workflows
  • C# async programming
  • Industrial automation command models
  • Robust timeout and notification handling
  • Documentation and examples for real machine-control scenarios

Useful contribution areas:

  • Improve the public API ergonomics.
  • Add examples for common DO/DI and AO/AI workflows.
  • Implement Observation mode.
  • Design and implement multi-ACK command support.
  • Add tests for command lifecycle edge cases.
  • Improve error messages and logging.

Before changing behavior, please keep the core design rule in mind:

The ACK observation path should be ready before the DO/AO command is sent.

This rule protects command lifecycle integrity under notification delay or high-load scenarios.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0 48 9/29/2026
0.2.0 95 9/11/2026
0.1.0 131 7/6/2026