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
<PackageReference Include="COAP.TwinCAT.CommandProtocol" Version="0.3.0" />
<PackageVersion Include="COAP.TwinCAT.CommandProtocol" Version="0.3.0" />
<PackageReference Include="COAP.TwinCAT.CommandProtocol" />
paket add COAP.TwinCAT.CommandProtocol --version 0.3.0
#r "nuget: COAP.TwinCAT.CommandProtocol, 0.3.0"
#:package COAP.TwinCAT.CommandProtocol@0.3.0
#addin nuget:?package=COAP.TwinCAT.CommandProtocol&version=0.3.0
#tool nuget:?package=COAP.TwinCAT.CommandProtocol&version=0.3.0
CoapClient
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.
Why This Project Exists
In many upper-level automation applications, one complete action is modeled like this:
- The C# application writes a DO/AO variable to start an action.
- A PLC task executes the action.
- The PLC updates a DI/AI variable to expose the result.
- 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. |
CycleTimeandMaxDelayTimeare milliseconds here, because they bind toNotificationSettings(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:
SendAndWaitAckwith anullcommand →ArgumentNullException.SendAndWaitAckwith a command that requires an ACK but has an emptyAckVarName→ArgumentException.Connectrethrows 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
IAckimplementations.
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 | Versions 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. |
-
net8.0
- Beckhoff.TwinCAT.Ads (>= 7.0.172)
- log4net (>= 3.3.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.