Skip to content

Conversation

@m-glz
Copy link

@m-glz m-glz commented Nov 11, 2025

Overview

This pull request introduces synthetic notifications as a new notification channel in the ADS library.
Unlike standard ADS notifications that originate from target ADS devices, synthetic notifications are generated within the library to inform users about key internal events and states.

Implemented Synthetic Notifications
Two new synthetic notification types have been added:

  1. NOTIFY_CONNECTION_LOST
    This synthetic notification type is emitted when the connection to a target ADS device is lost.
    Previously, client code could only detect a connection failure indirectly by sending a request and receiving an error response. For applications that rely mainly on ADS notifications to track symbol updates (without performing regular read/write operations), this made it impossible to detect connection loss at runtime.
    With NOTIFY_CONNECTION_LOST, the library now notifies registered users whenever any socket operation for a given connection fails, allowing client code to react immediately to connection issues.

  2. NOTIFY_NOTIFICATION_RCV
    This synthetic notification type is emitted whenever any ADS notification is received from a target device.
    It serves as a lightweight, data-free event that is triggered after the invocation of all ADS notifications contained within an AMS packet, enabling client code to receive a general “summary” notification when any subscribed ADS symbol is updated.
    This can be useful in scenarios where multiple symbols are updated via ADS notifications, and a single trigger is needed to perform follow-up actions, such as refreshing a GUI, aggregating data, or committing a database transaction.

@pbruenn
Copy link
Member

pbruenn commented Nov 12, 2025

Hi @m-glz,
interesting idea, I really like it, since it seems we can finally inform about conenction loss. Whats still unclear to me is how does this work in practice? Please add a short example to your description, which we can later copy into one of the commit messages.
Some additional notes:

  • Do we really need a new Notification type? On first look it seems we can use the normal one and we should even reuse the NotificationHeader, as a timestamp would be nice for these notifications, too. Overall the handling would be much simpler if we had only one type of notifications. And apart from a little overhead I don't see why our synthetic notification, shouldn't fit into the generic types.
  • I don't understand the last commit. It is very intrusive. As I said maybe an example to test the connection lost usecase might help to understand this
  • keep your commits clean. Don't add stuff to one commit which you immediately remove in the next one. Yes, sometimes this is necessary but I don't think thats the case for this feature.

@georg-emg
Copy link

Hi, @pbruenn
Thank you for your prompt response. In answer to your questions:

  • A new notication type is not really necessary. However, the existing notifications are identified by index group and index offset. Merging the notification types would require reserving special index group and index offset pairs for the synthetic notifications, which we didn't feel empowered to do. If Beckhoff is willing to reserve some addresses for these notifications, then the existing infrastructure can absolutely be used.
  • As for the second commit, the issue we were facing was the following: The AMS connection uses the ring buffer to communicate with the notification dispatcher. Once the semaphore is triggered, the notification dispatcher will inspect the ring buffer to check whether there is a notification in it or not. Previously, the semaphore was only ever triggered once the ring buffer had been fully updated, so there was no issue. Now however, AmsConnection::ReceiveNotification might be interrupted by a socket read error. This will cause the semaphore to be triggered from AmsConnection::TryRecv. At this point, however, there may already be a partial notification in the ring buffer, which will confuse the notification dispatcher when it wakes up. Our solution was to make updates to the ring buffer atomic by introducing a transaction object. The transaction object operates on a copy of the ring buffer's write pointer, and only updates the real write pointer once the entire notification is in the buffer. That way, we can ensure that the notification dispatcher only ever sees complete notifications.
  • As for the commits: When adding NOTIFY_NOTIFICATION_RCV, we needed access to the AMSAddr/Port associated with the notification, so we added code to NotificationDispatcher::Run to extract it from the ring buffer. When adding NOTIFY_CONNECTION_LOST, that no longer worked, because now the ring buffer was empty. So we replaced the mechanism we had implemented previously with a better and simpler one, that would work in both cases. If you prefer, we can rewrite the commit history to remove that intermediate step.

I will also try to prepare some code examples of use cases for the new notification types.

@pbruenn
Copy link
Member

pbruenn commented Nov 12, 2025

Thanks for clarification. Yes, the examples are important for me to understand this and get a better idea of how we can cleanly implement this. Don't put to much effort into cleanup just yet. I first want to try your example to see in which direction we should move this.

@georg-emg
Copy link

Here is a small sample for the use of the NOTIFY_CONNECTION_LOST notification. It's a little application that establishes a connection and logs ADSIOFFS_DEVDATA_ADSSTATE. It uses NOTIFY_CONNECTION_LOST to detect a connection loss and reestablished the connection automatically.

I will add a sample for NOTIFY_NOTIFICATION_RCV later.

// This is a sample application demonstarting the use of the proposed NOTIFY_CONNECTION_LOST synthetic notification
// 
// This sample requires C++23
//
// This application will connect to a TwinCAT PLC and output the PLC state. If the connection drops, the application
// will attempt to reconnect once a until successful.
// 
// To simplify the code, error handling has been largely omitted.
//
// Without the NOTIFY_CONNECTION_LOST the application would never detect the connection loss, because it does not
// send any commands, and thus never gets any errors. With the NOTIFY_CONNECTION_LOST notification, the connection
// loss is detected, and the connection reestablished.
//
// To test:
//
// - Start the application and connect to a TwinCAT PLC instance. The application will start logging the device state.
// - Start and stop the PLC to verify that the device state is logged
// - Put TwinCAT into config mode. This will cause TwinCAT to drop the connection.
// - Put TwinCAT back into run mode. The application will reestablish the connection and resume logging the device state.
// - Start and stop the PLC to verify that the device state is logged again
//
// Without the NOTIFY_CONNECTION_LOST notification, the connection will never be reestablished, and logging of the PLC state
// will never resume even after TwinCAT has been put back into run mode.

#include <AdsLib.h>

#include <chrono>
#include <cstddef>
#include <cstdlib>
#include <iostream>
#include <semaphore>
#include <span>
#include <thread>

using namespace std::literals;

// A semaphore used to communicate with the main thread
std::binary_semaphore connectionLostSemaphore { 0 };

// Callback for connection loss notifications
void connectionLostCallback(const AmsAddr *amsAddress, std::uint32_t type, std::uint32_t userValue)
{
	// Log the connection loss
	std::cout << "Connection loss detected." << std::endl;
	// Notify the main thread
	connectionLostSemaphore.release();
}

// Callback for device state notifications
void deviceStateCallback(const AmsAddr *amsAddress, const AdsNotificationHeader *header, std::uint32_t userValue)
{
	// Sanity check
	if (header->cbSampleSize != sizeof(std::uint16_t))
	{
		std::cout << "Received invalid device state with size " << header->cbSampleSize << std::endl;
		return;
	}
	
	// Get the value
	std::uint16_t state;
	std::memcpy(&state, header + 1, sizeof(state));
	if constexpr (std::endian::native != std::endian::little)
	{
		state = std::byteswap(state);
	}
	
	// Log the state
	std::cout << "Device state: " << state << std::endl;
}

auto main(int argc, char *argv[]) -> int
{
	// Decode the parameters
	if (argc < 4)
	{
		std::cerr << "Usage: <AMS Net-ID> <AMS Port> <IP address>" << std::endl;
		return EXIT_FAILURE;
	}
	AmsAddr amsAddress { .netId { argv[1] }, .port { std::uint16_t(std::atoi(argv[2])) } };
	const char *ipAddress { argv[3] };

	// Keep reconnecting forever
	for (;;)
	{
		//////////////////////////////////////////////////
		// Establish the connection
		
		// Try to connect
		std::cout << "Initiating connection..." << std::endl;
		auto connectionError = AdsAddRoute(amsAddress.netId, ipAddress);

		// Check for error
		if (connectionError != ADSERR_NOERR)
		{
			std::cout << "Connection failed: 0x" << std::hex << connectionError << std::dec << std::endl;

			// Sleep for 1s and retry
			std::this_thread::sleep_for(1s);
			continue;
		}

		// Open a port
		auto port = AdsPortOpenEx();

		//////////////////////////////////////////////////
		// Add handlers and service the connection
		
		// Add a handler for the connection loss notification
		std::uint32_t connectionLossNotification;
		AdsAddSyntheticDeviceNotificationReqEx(
			port, &amsAddress, NOTIFY_CONNECTION_LOST, connectionLostCallback, 0, &connectionLossNotification);

		// Add a handler for the PLC state
		const AdsNotificationAttrib deviceStateNotificationAttrib { .cbLength = sizeof(std::uint16_t), .nTransMode = ADSTRANS_SERVERONCHA };
		std::uint32_t deviceStateNotification;
		auto addNotificationError = AdsSyncAddDeviceNotificationReqEx(port,
			&amsAddress,
			ADSIGRP_DEVICE_DATA,
			ADSIOFFS_DEVDATA_ADSSTATE,
			&deviceStateNotificationAttrib,
			deviceStateCallback,
			0,
			&deviceStateNotification);

		// The connection might have failed before the call to AdsAddSyntheticDeviceNotificationReqEx, so check for an error here as well
		if (addNotificationError == ADSERR_NOERR)
		{
			std::cout << "Connected." << std::endl;

			// Wait for the connection to terminate
			connectionLostSemaphore.acquire();
		}
		else
		{
			std::cout << "AdsSyncAddDeviceNotificationReqEx failed: 0x" << std::hex << addNotificationError << std::dec << std::endl;
		}

		//////////////////////////////////////////////////
		// Shut down the connection and clean up

		std::cout << "Shutting down connection." << std::endl;

		// Clean everything up before trying to re-add the route
		AdsSyncDelDeviceNotificationReqEx(port, &amsAddress, deviceStateNotification);
		AdsDelSyntheticDeviceNotificationReqEx(port, &amsAddress, connectionLossNotification);
		AdsPortCloseEx(port);
		AdsDelRoute(amsAddress.netId);

		// Clean up any dangling connection loss notifications
		void(connectionLostSemaphore.try_acquire());
			
		// Sleep for 1s before reconnecting
		std::this_thread::sleep_for(1s);
	}
}

@georg-emg
Copy link

Ok, here is a sample for the use NOTIFY_NOTIFICATION_RCV. This application just logs all the notifications from a single AoE frame to standard out as a block.

// This is a sample application demonstarting the use of the proposed NOTIFY_NOTIFICATION_RCV synthetic notification
// 
// This sample requires C++23
//
// This application will connect to a TwinCAT PLC and register notifications for symbols specified on the command
// line. It will then group change notifications into transactions, where each transaction contains all the changes
// from a single DEVICE_NOTIFICATION AoE frame.
// 
// To simplify the code, error handling has been largely omitted.
//
// Without the NOTIFY_NOTIFICATION_RCV, it would not be possible to group the change notifications together. This
// could introduce a critical bottleneck in situations where a lot of PLC variables change at the same time.
//
// To test:
//
// - Start the application and connect to a TwinCAT PLC instance.
// - Perform an action in the PLC that will change multiple of the watched symbols within the same cycle
// - Verify that the changes are grouped into a single transaction.

#include <AdsLib.h>

#include <compare>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <span>
#include <string>
#include <utility>
#include <vector>

// The symbol names, in the order they were specified on the command line
std::vector<std::string> symbolNames;

// Information about a symbol change
struct ChangeInfo
{
	std::size_t symbolIndex;
	std::vector<std::byte> data;
};

// The list of changes since the last transaction
std::vector<ChangeInfo> changeLog;

// Callback for change notifications
void symbolChangeCallback(const AmsAddr *amsAddress, const AdsNotificationHeader *header, std::uint32_t index)
{
	// Sanity check
	if (std::cmp_greater_equal(index, symbolNames.size()))
	{
		std::cout << "Received invalid notification with index " << index << std::endl;
		return;
	}
	
	// Extract the data
	std::span data { reinterpret_cast<const std::byte *>(header + 1), std::size_t(header->cbSampleSize) };
	// Add an entry to the log
	changeLog.push_back({ .symbolIndex { std::size_t(index) }, .data { std::from_range, data } });
}

// Callback for notification received notifications
void notificationReceivedCallback(const AmsAddr *amsAddress, std::uint32_t type, std::uint32_t userValue)
{
	// This callback just outputs the list of changes to std::cout. A real-life application could do
	// any of the following action, e.g.:
	// 
	// - Write the changes to a database using a single database transaction
	// - Send the changes to a REST interface using a single HTTP POST request
	// - Send the changes to a UI thread using a single event
	// - etc. etc. etc.
	//
	// Each of these actions benefit heavily from batching together multiple changes. When dealing with a
	// large number of symbols, having to process each change individually could introduce a critical
	// performance bottleneck.

	std::cout << "BEGIN NOTIFICATION FRAME\n[";
	for (bool isFirst = true; auto &&[symbolIndex, data] : changeLog)
	{
		if (!std::exchange(isFirst, false))
		{
			std::cout << ",";
		}
		std::cout << "\n  {\n    \"symbol\": \"" << symbolNames[symbolIndex] << "\",\n    \"data\": ";
		char prefix { '"' };
		for (auto &&byte : data)
		{
			std::cout << std::exchange(prefix, ':') << std::hex << std::setfill('0') << std::setw(2) << static_cast<unsigned int>(byte) << std::dec << std::setfill(' ');
		}
		std::cout << "\"\n  }";
	}
	std::cout <<"\n]\nEND NOTIFICATION FRAME" << std::endl;

	changeLog.clear();
}

auto main(int argc, char *argv[]) -> int
{
	// Decode the parameters
	if (argc < 5)
	{
		std::cerr << "Usage: <AMS Net-ID> <AMS Port> <IP address> <symbol name>..." << std::endl;
		return EXIT_FAILURE;
	}
	AmsAddr amsAddress { .netId { argv[1] }, .port { std::uint16_t(std::atoi(argv[2])) } };
	const char *ipAddress { argv[3] };
	for (int index = 4; index < argc; ++index)
	{
		symbolNames.emplace_back(argv[index]);
	}

	std::cout << "Press Enter to exit.\nLog:" << std::endl;

	// Establish the connection
	AdsAddRoute(amsAddress.netId, ipAddress);

	// Open a port and add the notification received callback
	auto port = AdsPortOpenEx();
	// Add a handler for the notification received notification
	std::uint32_t notificationReceivedNotification;
	AdsAddSyntheticDeviceNotificationReqEx(
		port, &amsAddress, NOTIFY_NOTIFICATION_RCV, notificationReceivedCallback, 0, &notificationReceivedNotification);

	// Remember all the notifications and handles, so we can release them
	struct SymbolInfo
	{
		std::uint32_t handle;
		std::uint32_t notification;
	};
	std::vector<SymbolInfo> symbolInfos;
	
	// Add notifications for all the symbols
	for (std::size_t index = 0; index < symbolNames.size(); ++index)
	{
		SymbolInfo symbolInfo;

		// Get a handle
		auto &&symbolName { symbolNames[index] };
		std::uint32_t bytesRead;
		auto lookupError = AdsSyncReadWriteReqEx2(port,
			&amsAddress,
			ADSIGRP_SYM_HNDBYNAME,
			0,
			sizeof(symbolInfo.handle),
			&symbolInfo.handle,
			std::uint32_t(symbolName.size()),
			symbolName.data(),
			&bytesRead);
		if (lookupError != ADSERR_NOERR)
		{
			std::cerr << "Error looking up symbol \"" << symbolName << "\": 0x" << std::hex << lookupError << std::dec << std::endl;
			continue;
		}

		// Add a notification for it
		const AdsNotificationAttrib attrib { .cbLength = sizeof(std::uint16_t), .nTransMode = ADSTRANS_SERVERONCHA };
		AdsSyncAddDeviceNotificationReqEx(port,
			&amsAddress,
			ADSIGRP_SYM_VALBYHND,
			symbolInfo.handle,
			&attrib,
			symbolChangeCallback,
			std::uint32_t(index),
			&symbolInfo.notification);

		symbolInfos.push_back(symbolInfo);
	}

	// Wait for a key press
	std::cin.ignore();

	// Clean everything up before exiting
	for (auto &&[handle, notification] : symbolInfos)
	{
		AdsSyncDelDeviceNotificationReqEx(port, &amsAddress, notification);
		AdsSyncWriteReqEx(port,
			&amsAddress,
			ADSIGRP_SYM_RELEASEHND,
			0,
			sizeof(handle),
			&handle);
	}
	AdsDelSyntheticDeviceNotificationReqEx(port, &amsAddress, notificationReceivedNotification);
	AdsPortCloseEx(port);
	AdsDelRoute(amsAddress.netId);

	return EXIT_SUCCESS;
}

@pbruenn
Copy link
Member

pbruenn commented Nov 21, 2025

Thanks a lot. Sorry for not responding more early. It was a short and busy week :-(.

I struggle with the second usecase RCV. If I understand that correctly: The PLC sends a large notification with many different symbols changed. When we receive the notification and processing it we create a synthetic notification for each symbol and then process each of the symbol changes individually. How is this faster then just splitting the processing in the normal notification handler? We only delay it by the time we need too complete the entire notification. Again, iam not an ADS expert but I have a feeling your problem could be mitigated by tuning AdsNotificationAttrib https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_adsdll2/117553803.html&id= But I might be wrong.

I still like the LOST notification very much. I will try to integrate this within the next few days.

@georg-emg
Copy link

Hi Patrick,

There is only one single synthetic notification per frame. TwinCAT will take all the variables that changed in one single PLC cycle and put them into a single Network packet with a single AoE header. So, If variables MAIN.var1, MAIN.var2 and MAIN.var3 change in the same cycle, only a single network packet is sent, that looks something like this (simplified):

AoE header with command ID AoEHeader::DEVICE_NOTIFICATION
Sample for MAIN.var1
Sample for MAIN.var2
Sample for MAIN.var3

This is nothing new, that is how TwinCAT works, and that is how the ADS protocol is designed. This is done, of course, because placing all the change notification together into a single packet greatly reduces network traffic.

The existing library, however, will generate only the following notifications for that:

  • Notification for MAIN.var1
  • Notification for MAIN.var2
  • Notification for MAIN.var3

Now, if we want to do some batch processing on the notifications, there currently is no way for us to know that MAIN.var3 is the last notification, and that we can now go ahead and process the changes. For all we know, there could be a fourth change notification in the AoE packet. So all we can do is wait around for a bit to see if maybe the callback gets called a fourth time, and do our processing only after nothing has happened for a certain amount of time.

Our changes merely add a final synthetic notification that tells the caller that notifications for all the samples in the packet have been sent, and that it can go ahead and process them now. So the notifications for the same AoE packet in the updated library would look like this:

  • Notification for MAIN.var1
  • Notification for MAIN.var2
  • Notification for MAIN.var3
  • Synthetic notification NOTIFY_NOTIFICATION_RCV

Now, when we get NOTIFY_NOTIFICATION_RCV, we know that MAIN.var3 was the last sample in the AoE packet, and that we can now go ahead and process the changes.

It is important to understand that this changes nothing in the ADS protocol, or in the way TwinCAT or the notifications work. It is merely an extra callback in the client library to tell the caller that all the samples in the received frame have been processed.

AdsNotificationAttrib is not really related to that problem, it only influences the way TwinCAT packages the notifications together. But the packaging is not the issue, TwinCAT already does the exact thing we need. It's just that the library currently hides that from us, and only sends individual notifications, as if no packaging was done. The more aggressively we use AdsNotificationAttrib, the more aggressively TwinCAT will batch the notifications, and the more important it becomes for us to recognize the packet boundaries.

From an implemetation standpoint, NotificationDispatcher::Run() has an outer and two inner loops: The outer loop (for (;;)) handles individual AoE packets. The inner loops decode the individual samples, grouped by time stamp. The synthetic notification is sent from the outer loop, meaning that there is only one NOTIFY_NOTIFICATION_RCV per AoE frame.

Batch processing of changes is very important for applications that need to log or forward changes. Without the possibility of handling changes as a batch, many applications are simply not possible. One of our use-cases, for example, is sending data to an InfluxDB time series database using their HTTP write_lp API. Using AdsNotificationAttrib we can instruct TwinCAT to send us all changed variables, say once every 200ms. If there were 500 changes in that time, sending 500 separate HTTP requests to InfluxDB simply would not work. Instead, we need to send one single request containing all 500 changes. For that, we need to know when all the changes have been logged, so that we can construct the request and send it out. Right now, there is really no good way of doing that.

@pbruenn
Copy link
Member

pbruenn commented Nov 26, 2025

Okay, I think I now understand your usecase. But I have more questions. Wouldn't it be more efficient to fill the InfluxDB directly from the PLC? Like its done here:
https://infosys.beckhoff.com/english.php?content=../content/1033/tf6420_tc3_database_server/8117583755.html&id=

Or even use the TwinCAT Analytics Storage Provider:
https://infosys.beckhoff.com/english.php?content=../content/1033/tf3520_tc3_analytics_storage_provider/5759734539.html&id=

Did you consider these TwinCAT functions?

@georg-emg
Copy link

That was just an example usecase, demonstrating why this functionality is important, and how it would be applied. I'm not suggesting that we are stuck on this particular issue, and that we are in search of a specific solution for that specific problem. What we want to be able to do more generally, is get data from the PLC in an efficient way, and then process it in a number of different ways, only one of which is sending it to InfluxDB.

For us, and for anyone else who has to deal with large amounts of data, it is crucial to be able to eliminate data bottlenecks, and batch processing is the first and most important step in accomplishing that. That is why ADS itself sends change notifications in batches, and that is why the ADS protocol offers things like AdsNotificationAttrib that allow us to fine-tune the batch sizes and spacing. But we need that one little extra step to be able to take full advantage of the potential performance benefits that those features offer, and that is a notification of when the batch is complete. Otherwise, it is very difficult for us to do any batch processing inside our application, which makes it more difficult for us to meet our performance goals.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants