Chapter 198: OPC UA Server Implementation
Chapter Objectives
Upon completing this chapter, you will be able to:
- Understand the role and architecture of an OPC UA server.
- Design and implement a custom OPC UA information model (Address Space) on an ESP32.
- Add objects, variables (with different data types), and methods to the server’s Address Space.
- Handle client read, write, and method call requests.
- Implement data sources for variables, allowing them to reflect real-time sensor data or system status.
- Manage subscriptions and monitored items from OPC UA clients.
- Configure basic security settings for an ESP32-based OPC UA server.
- Recognize resource considerations for running an OPC UA server on various ESP32 variants.
- Troubleshoot common issues in OPC UA server development on ESP32.
Introduction
In the previous chapter, we explored how an ESP32 can act as an OPC UA client, consuming data from industrial systems. However, the versatility of the ESP32 also allows it to function as an OPC UA server. This capability transforms the ESP32 into an intelligent data provider, capable of exposing its sensor readings, system status, or control parameters in a standardized, secure, and interoperable manner.
Imagine an ESP32-based environmental monitoring station. By implementing an OPC UA server on it, this station can directly serve its temperature, humidity, and air quality data to SCADA systems, HMIs, or cloud platforms without needing an intermediate gateway. Similarly, a small machine controlled by an ESP32 could expose its operational status, production counts, and fault conditions via OPC UA.
This chapter will guide you through the development of OPC UA server applications on ESP32 using the ESP-IDF v5.x framework and the open62541
library. We will delve into how to define your server’s information model, handle client interactions, and manage the server lifecycle, turning your ESP32 into a first-class citizen in an OPC UA ecosystem.
Theory
The OPC UA Server Role
An OPC UA server is an application that:
- Manages an Address Space: This is the core of the server. It contains a structured collection of Nodes representing the information and functionality the server exposes.
- Listens for Client Connections: It opens network endpoints (e.g.,
opc.tcp://<ip_address>:<port>
) and awaits connection requests from OPC UA clients. - Handles Client Service Requests: Once a client is connected and a session is established, the server processes various service requests from the client, such as:
- Reading the values of variables.
- Writing new values to variables.
- Browsing the Address Space.
- Calling methods.
- Creating subscriptions to monitor data changes or events.
- Manages Security: It enforces configured security policies, authenticates clients, and authorizes their access to specific parts of the Address Space or services.
- Provides Data: It makes data available, either by storing static values, dynamically generating values, or linking variables to underlying data sources (like sensor readings or system parameters).
%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans'}}}%% graph TD subgraph ESP32 as OPC UA Server direction LR A[<b>ESP32 Board</b><br>with Wi-Fi] --> B{"OPC UA Server<br><i>(open62541)</i>"}; subgraph Address Space direction TB V1(Temperature: Float) V2(Status: String) M1("ResetCounter()") end B --> V1; B --> V2; B --> M1; end subgraph OPC UA Clients direction TB C1["SCADA / HMI<br><i>(e.g., UaExpert on PC)</i>"] C2["Another ESP32<br><i>(OPC UA Client)</i>"] C3[" Cloud Platform"] end C1 -- "Read/Write/Subscribe" --> B; C2 -- "Read/Call Method" --> B; C3 -- "Read Data" --> B; style A fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6 style B fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF style V1 fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B style V2 fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B style M1 fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E style C1 fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46 style C2 fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46 style C3 fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46
Building the Server’s Address Space
The Address Space is the heart of an OPC UA server. It’s a structured collection of Nodes. When building a server, you are primarily defining this Address Space.
1. Default Server Nodes
Every OPC UA server, including one built with open62541
, comes with a set of standard nodes defined by the OPC UA specification (typically in Namespace 0). These include nodes for server status, server capabilities, data types, etc. You generally don’t need to modify these directly but should be aware of their existence.
2. Custom Information Model
The real power comes from adding your custom information model, usually in a separate namespace. This involves:
- Defining Namespaces: Namespaces help organize NodeIds and prevent collisions. Your custom nodes will typically reside in a namespace with an index greater than 0 (Namespace 0 is reserved for OPC UA standard definitions, Namespace 1 is often for server-specific diagnostics by the stack).
- Creating Objects: Objects are used to structure your data. For example, you might create an “EnvironmentSensor” object.
- Adding Variables to Objects: Variables represent data points. The “EnvironmentSensor” object might have child variables like “Temperature,” “Humidity,” and “Pressure.”
- Data Type: Each variable must have a data type (e.g.,
Float
,Int32
,Boolean
,String
,DateTime
). - Access Level: Defines whether the variable is readable, writable, or both.
- Value: The actual data held by the variable.
- Data Type: Each variable must have a data type (e.g.,
- Adding Methods to Objects: Methods represent functions that clients can call on the server. For example, the “EnvironmentSensor” object might have a “ResetReadings” method.
- Using References: References link nodes together, creating the structure (e.g.,
HasComponent
to link a variable to its parent object,Organizes
to group objects).
%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans'}}}%% graph TD subgraph "Namespace 2:" direction TB NS[myesp32device.example.com]-->O O["<b>Object: EnvironmentSensor</b><br>NodeId: ns=2, s='EnvironmentSensor'"] subgraph " " V1["<b>Variable: Temperature</b><br>NodeId: ns=2, s='Temperature'<br>DataType: Float<br>Access: Read-only<br><i>Linked to ValueSource</i>"] V2["<b>Variable: Humidity</b><br>NodeId: ns=2, s='Humidity'<br>DataType: Double<br>Access: Read/Write"] M1["<b>Method: ResetReadings</b><br>NodeId: ns=2, s='ResetReadings'"] end end O -- "HasComponent" --> V1; O -- "HasComponent" --> V2; O -- "HasComponent" --> M1; style O fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6 style V1 fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF style V2 fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF style M1 fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E
3. Data Sources for Variables
A variable’s value can be:
- Static: Set once and rarely changes.
- Managed by the Stack: The
open62541
stack stores the value directly. - Dynamic (ValueSource): Linked to an external data source via callbacks. This is crucial for exposing live data. When a client reads such a variable, the server calls a specific function you provide to get the current value. Similarly, when a client writes, another callback can update the underlying system.
UA_ValueSource
Structure: Inopen62541
, you can define aUA_ValueSource
for a variable. This structure contains function pointers forread
andwrite
operations.read(UA_Server *server, const UA_NodeId *sessionId, void *sessionContext, const UA_NodeId *nodeId, void *nodeContext, UA_Boolean sourceTimeStamp, const UA_NumericRange *range, UA_DataValue *dataValue)
: Called when a client reads the variable. Your implementation fetches the current value and populatesdataValue
.write(UA_Server *server, const UA_NodeId *sessionId, void *sessionContext, const UA_NodeId *nodeId, void *nodeContext, const UA_NumericRange *range, const UA_DataValue *dataValue)
: Called when a client writes to the variable. Your implementation updates the underlying data source.
4. Method Callbacks
When you add a method node to the server’s Address Space, you also provide a callback function. When a client calls this method, the open62541
stack invokes your callback.
- Method Callback Signature:UA_StatusCode (*method)(UA_Server *server, const UA_NodeId *sessionId, void *sessionHandle, const UA_NodeId *methodId, void *methodContext, const UA_NodeId *objectId, void *objectContext, size_t inputSize, const UA_Variant *input, size_t outputSize, UA_Variant *output)Your function receives input arguments from the client, performs the action, and returns output arguments.
Server Lifecycle with open62541
- Create Server Instance:
UA_Server_new()
creates a server instance. - Configure Server:
UA_Server_getConfig()
gets the default configuration, which you can then modify (e.g., set port number, security policies). - Add Custom Information Model: Use
UA_Server_addVariableNode()
,UA_Server_addObectNode()
,UA_Server_addMethodNode()
, etc., to build your Address Space. For dynamic variables, you’ll link them toUA_ValueSource
callbacks. For methods, you’ll link them to method callbacks. - Run the Server:
UA_Server_run()
starts the server. This function typically enters a loop, listening for client connections and processing requests.UA_Server_run_iterate(UA_Server *server, UA_Boolean waitInternal)
: A non-blocking alternative that processes pending events and returns. This is often used in embedded systems to integrate the OPC UA server loop with other tasks.
- Shutdown Server:
UA_Server_delete()
cleans up and stops the server.
%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans'}}}%% flowchart TD A(Start) --> B["UA_Server_new()<br><i>Create Server Instance</i>"]; B --> C["UA_Server_getConfig()<br><i>Modify Port, Security, etc.</i>"]; C --> D["add_custom_nodes()<br><i>Add Objects, Variables, Methods<br>Link DataSources & Callbacks</i>"]; D --> E{"UA_Server_run_startup()"}; E -- Success --> F["Loop: while(running)"]; E -- Failure --> G(Handle Error & Cleanup); subgraph Server Main Loop F --> H{"UA_Server_run_iterate(wait=true)<br><i>Process Client Requests</i>"}; H -- Returns --> F; end F -- Loop Exits --> I["UA_Server_run_shutdown()<br><i>Stop Server</i>"]; I --> J["UA_Server_delete()<br><i>Cleanup Resources</i>"]; J --> K(End); classDef primary fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6; classDef process fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF; classDef decision fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E; classDef check fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B; classDef success fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46; class A,K primary; class B,C,D,I,J process; class F,H,G decision; class E check;
Security Considerations for an ESP32 Server
Even on a resource-constrained device like an ESP32, security is vital for an OPC UA server:
- Certificates: The server needs its own X.509 instance certificate and private key.
- Trust Lists: The server maintains a trust list of client certificates it accepts.
- Security Policies: Define which cryptographic algorithms are supported (e.g.,
Basic256Sha256
). - Message Security Modes: Enforce
Sign
orSignAndEncrypt
. - User Authentication: Implement mechanisms for clients to authenticate (e.g., anonymous access, username/password).
- User Authorization: (More advanced) Define access rights for different users to specific nodes or services.
Implementing full security can be resource-intensive. open62541
allows for scalable security configurations.
Tip: Start with no security or basic security for initial development and testing in a controlled environment. However, plan for and implement appropriate security measures before deploying any OPC UA server in a production setting.
Practical Examples
This section demonstrates how to create a basic OPC UA server on an ESP32, add custom variables (including one linked to a simulated sensor), and a simple method.
Prerequisites:
Same as Chapter 197 (ESP-IDF v5.x, VS Code, ESP32 board, Wi-Fi). You will also need an OPC UA client tool (like UaExpert) to connect to and test your ESP32 server.
Step 1: Create and Configure Project
Follow the same project creation steps as in Chapter 197, Step 1. Name your project, for example, esp32_opcua_server
.
Step 2: Add open62541
Component and Configure
Follow Chapter 197, Step 2, to add open62541 as a dependency and configure the project.
Key menuconfig settings for a server:
Component config ---> open62541 --->
- Ensure
UA_ENABLE_SERVER
is enabled. - You might disable
UA_ENABLE_CLIENT
if this ESP32 will only be a server, to save space. UA_LOGLEVEL
toInfo
orDebug
.- Review
UA_MAX_NODES
,UA_MAX_REFERENCES
etc., if you plan a large address space, but defaults are often fine for simple ESP32 servers.
- Ensure
Component config ---> ESP System Settings ---> Main task stack size
: Increase to at least8192
or16384
. Server operations can also be stack-intensive.
Step 3: Write the OPC UA Server Code
Replace the content of main/your_main_file.c
with the following:
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_random.h" // For simulated sensor
#include "lwip/err.h"
#include "lwip/sys.h"
#include "lwip/sockets.h" // For getting IP address
// open62541
#include "open62541/server.h"
#include "open62541/server_config_default.h"
#include "open62541/plugin/log_stdout.h"
// Wi-Fi Configuration
#define EXAMPLE_ESP_WIFI_SSID CONFIG_EXAMPLE_WIFI_SSID
#define EXAMPLE_ESP_WIFI_PASS CONFIG_EXAMPLE_WIFI_PASSWORD
#define EXAMPLE_ESP_MAXIMUM_RETRY CONFIG_EXAMPLE_MAXIMUM_RETRY
// OPC UA Server Configuration
#define OPCUA_SERVER_PORT 4840 // Default OPC UA port
static const char *TAG = "OPCUA_SERVER";
static UA_Boolean running = true; // Global flag to control server loop
/* FreeRTOS event group to signal when we are connected*/
static EventGroupHandle_t s_wifi_event_group;
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
static int s_retry_num = 0;
// Simulated sensor value
static float simulated_temperature = 20.0;
static void event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data) {
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
if (s_retry_num < EXAMPLE_ESP_MAXIMUM_RETRY) {
esp_wifi_connect();
s_retry_num++;
ESP_LOGI(TAG, "retry to connect to the AP");
} else {
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
}
ESP_LOGI(TAG,"connect to the AP fail");
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
ESP_LOGI(TAG, "got ip:" IPSTR, IP2STR(&event->ip_info.ip));
s_retry_num = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
}
}
void wifi_init_sta(void) {
// (Identical to wifi_init_sta from Chapter 197 - omitted for brevity here,
// but ensure you have the full Wi-Fi initialization code from that chapter)
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_t* sta_netif = esp_netif_create_default_wifi_sta();
assert(sta_netif);
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
esp_event_handler_instance_t instance_any_id;
esp_event_handler_instance_t instance_got_ip;
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID,
&event_handler,
NULL,
&instance_any_id));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
IP_EVENT_STA_GOT_IP,
&event_handler,
NULL,
&instance_got_ip));
wifi_config_t wifi_config = {
.sta = {
.ssid = EXAMPLE_ESP_WIFI_SSID,
.password = EXAMPLE_ESP_WIFI_PASS,
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
},
};
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA) );
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config) );
ESP_ERROR_CHECK(esp_wifi_start() );
ESP_LOGI(TAG, "wifi_init_sta finished.");
EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
pdFALSE,
pdFALSE,
portMAX_DELAY);
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "connected to ap SSID:%s", EXAMPLE_ESP_WIFI_SSID);
esp_netif_ip_info_t ip_info;
esp_netif_get_ip_info(sta_netif, &ip_info);
ESP_LOGI(TAG, "OPC UA Server Endpoint will be: opc.tcp://" IPSTR ":%d",
IP2STR(&ip_info.ip), OPCUA_SERVER_PORT);
} else if (bits & WIFI_FAIL_BIT) {
ESP_LOGI(TAG, "Failed to connect to SSID:%s", EXAMPLE_ESP_WIFI_SSID);
} else {
ESP_LOGE(TAG, "UNEXPECTED WIFI EVENT");
}
}
// Callback for reading the dynamic temperature value
static UA_StatusCode readTemperatureDataSource(UA_Server *server,
const UA_NodeId *sessionId, void *sessionContext,
const UA_NodeId *nodeId, void *nodeContext,
UA_Boolean sourceTimeStamp, const UA_NumericRange *range,
UA_DataValue *dataValue) {
// Simulate temperature fluctuation
simulated_temperature += ((float)esp_random() / (float)UINT32_MAX - 0.5) * 0.2; // Small random change
if (simulated_temperature < 15.0) simulated_temperature = 15.0;
if (simulated_temperature > 35.0) simulated_temperature = 35.0;
UA_Variant_setScalar(&dataValue->value, &simulated_temperature, &UA_TYPES[UA_TYPES_FLOAT]);
dataValue->hasValue = true;
// Optionally set source timestamp
// dataValue->sourceTimestamp = UA_DateTime_now();
// dataValue->hasSourceTimestamp = true;
return UA_STATUSCODE_GOOD;
}
// Callback for writing to a writable variable (example)
static UA_StatusCode writeSetPointDataSource(UA_Server *server,
const UA_NodeId *sessionId, void *sessionContext,
const UA_NodeId *nodeId, void *nodeContext,
const UA_NumericRange *range, const UA_DataValue *dataValue) {
if (UA_Variant_hasScalarType(&dataValue->value, &UA_TYPES[UA_TYPES_DOUBLE])) {
UA_Double newSetPoint = *(UA_Double*)dataValue->value.data;
ESP_LOGI(TAG, "Client wrote new SetPoint: %f", newSetPoint);
// Here you would apply the setpoint to your system
// For this example, we just log it.
// Store it if you need to read it back from the same node.
// For simplicity, this example doesn't store it back to the node via ValueSource.
// If this node was also readable via ValueSource, the 'write' callback would update
// the backing store that the 'read' callback uses.
return UA_STATUSCODE_GOOD;
}
return UA_STATUSCODE_BADTYPEMISMATCH;
}
// Method callback for "ResetCounter"
static UA_StatusCode resetCounterMethodCallback(UA_Server *server,
const UA_NodeId *sessionId, void *sessionHandle,
const UA_NodeId *methodId, void *methodContext,
const UA_NodeId *objectId, void *objectContext,
size_t inputSize, const UA_Variant *input,
size_t outputSize, UA_Variant *output) {
ESP_LOGI(TAG, "ResetCounter method called by a client!");
// In a real application, you would reset some counter associated with objectId or methodContext.
// For this example, we don't have input or output arguments.
// If the method had output arguments, you would set them here:
// UA_String myString = UA_STRING("Counter Reset Successfully");
// UA_Variant_setScalar(output, &myString, &UA_TYPES[UA_TYPES_STRING]);
return UA_STATUSCODE_GOOD;
}
static void add_custom_nodes(UA_Server *server) {
// Add a new Namespace to the server (optional, but good practice)
UA_UInt16 nsIdx = UA_Server_addNamespace(server, "http://myesp32device.example.com");
ESP_LOGI(TAG, "Custom Namespace added with index %u", nsIdx);
// --- 1. Add an Object Node: "MyDevice" ---
UA_NodeId deviceObjectId; // Will be populated by the addNode function
UA_ObjectAttributes oAttr = UA_ObjectAttributes_default;
oAttr.displayName = UA_LOCALIZEDTEXT("en-US", "My ESP32 Device");
UA_Server_addObjectNode(server, UA_NODEID_NULL, // Assign new NodeId
UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER), // Parent: ObjectsFolder
UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), // ReferenceType: Organizes
UA_QUALIFIEDNAME(nsIdx, "MyDevice"), // BrowseName
UA_NODEID_NUMERIC(0, UA_NS0ID_BASEOBJECTTYPE),// TypeDefinition
oAttr, NULL, &deviceObjectId);
ESP_LOGI(TAG, "Added ObjectNode 'MyDevice' with NodeId: ns=%d, i=%d", deviceObjectId.namespaceIndex, deviceObjectId.identifier.numeric);
// --- 2. Add a Variable Node (static value): "DeviceStatus" under "MyDevice" ---
UA_VariableAttributes vAttr_status = UA_VariableAttributes_default;
UA_String deviceStatus = UA_STRING("Nominal");
UA_Variant_setScalar(&vAttr_status.value, &deviceStatus, &UA_TYPES[UA_TYPES_STRING]);
vAttr_status.displayName = UA_LOCALIZEDTEXT("en-US", "Device Status");
vAttr_status.accessLevel = UA_ACCESSLEVELMASK_READ; // Read-only
UA_NodeId statusNodeId;
UA_Server_addVariableNode(server, UA_NODEID_NULL, deviceObjectId,
UA_NODEID_NUMERIC(0, UA_NS0ID_HASCOMPONENT),
UA_QUALIFIEDNAME(nsIdx, "DeviceStatus"),
UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE),
vAttr_status, NULL, &statusNodeId);
ESP_LOGI(TAG, "Added VariableNode 'DeviceStatus' under 'MyDevice'");
// --- 3. Add a Variable Node with DataSource (dynamic value): "Temperature" under "MyDevice" ---
UA_VariableAttributes vAttr_temp = UA_VariableAttributes_default;
vAttr_temp.displayName = UA_LOCALIZEDTEXT("en-US", "Temperature");
vAttr_temp.accessLevel = UA_ACCESSLEVELMASK_READ; // Read-only for this example
vAttr_temp.dataType = UA_TYPES[UA_TYPES_FLOAT].typeId; // Set the data type
// Initial value (optional if ValueSource provides it immediately)
UA_Float initialTemp = simulated_temperature;
UA_Variant_setScalar(&vAttr_temp.value, &initialTemp, &UA_TYPES[UA_TYPES_FLOAT]);
UA_ValueSource tempDataSource;
tempDataSource.read = readTemperatureDataSource;
tempDataSource.write = NULL; // This variable is read-only by ValueSource
UA_NodeId tempNodeId;
UA_Server_addDataSourceVariableNode(server, UA_NODEID_NULL, deviceObjectId,
UA_NODEID_NUMERIC(0, UA_NS0ID_HASCOMPONENT),
UA_QUALIFIEDNAME(nsIdx, "Temperature"),
UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE),
vAttr_temp, tempDataSource, NULL, &tempNodeId);
ESP_LOGI(TAG, "Added DataSource VariableNode 'Temperature' under 'MyDevice'");
// --- 4. Add a Writable Variable Node: "TemperatureSetPoint" under "MyDevice" ---
UA_VariableAttributes vAttr_setpoint = UA_VariableAttributes_default;
vAttr_setpoint.displayName = UA_LOCALIZEDTEXT("en-US", "Temperature SetPoint");
vAttr_setpoint.accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE; // Readable & Writable
vAttr_setpoint.dataType = UA_TYPES[UA_TYPES_DOUBLE].typeId;
UA_Double initialSetPoint = 22.5; // Initial value
UA_Variant_setScalar(&vAttr_setpoint.value, &initialSetPoint, &UA_TYPES[UA_TYPES_DOUBLE]);
UA_ValueSource setPointDataSource;
setPointDataSource.read = NULL; // Can be read directly from stack if not set, or implement a read callback
setPointDataSource.write = writeSetPointDataSource; // Write callback
UA_NodeId setPointNodeId;
UA_Server_addDataSourceVariableNode(server, UA_NODEID_NULL, deviceObjectId,
UA_NODEID_NUMERIC(0, UA_NS0ID_HASCOMPONENT),
UA_QUALIFIEDNAME(nsIdx, "TemperatureSetPoint"),
UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE),
vAttr_setpoint, setPointDataSource, NULL, &setPointNodeId);
ESP_LOGI(TAG, "Added Writable DataSource VariableNode 'TemperatureSetPoint' under 'MyDevice'");
// --- 5. Add a Method Node: "ResetCounter" under "MyDevice" ---
UA_MethodAttributes mAttr = UA_MethodAttributes_default;
mAttr.displayName = UA_LOCALIZEDTEXT("en-US", "Reset Counter");
mAttr.executable = true;
mAttr.userExecutable = true;
UA_NodeId methodNodeId;
UA_Server_addMethodNode(server, UA_NODEID_NULL, deviceObjectId,
UA_NODEID_NUMERIC(0, UA_NS0ID_HASCOMPONENT),
UA_QUALIFIEDNAME(nsIdx, "ResetCounter"),
mAttr, &resetCounterMethodCallback,
0, NULL, 0, NULL, // No input/output arguments for this simple example
NULL, &methodNodeId);
ESP_LOGI(TAG, "Added MethodNode 'ResetCounter' under 'MyDevice'");
}
static void opcua_server_task(void *pvParameters) {
UA_Server *server = UA_Server_new();
UA_ServerConfig *config = UA_Server_getConfig(server);
UA_ServerConfig_setMinimal(config, OPCUA_SERVER_PORT, NULL); // NULL for no certificate
// For more advanced configuration, e.g., custom hostname:
// UA_String hostname;
// UA_String_init(&hostname);
// esp_netif_get_hostname(esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"), &hostname.data); // ESP-IDF specific way to get hostname
// if(hostname.data) hostname.length = strlen((char*)hostname.data);
// UA_ServerConfig_setCustomHostname(config, &hostname);
// UA_String_clear(&hostname);
// Add custom nodes to the server's address space
add_custom_nodes(server);
ESP_LOGI(TAG, "Starting OPC UA server...");
UA_StatusCode retval = UA_Server_run_startup(server);
if(retval != UA_STATUSCODE_GOOD) {
ESP_LOGE(TAG, "Failed to start OPC UA server: %s", UA_StatusCode_name(retval));
UA_Server_delete(server);
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "OPC UA server started. Listening on port %d.", OPCUA_SERVER_PORT);
ESP_LOGI(TAG, "Connect with an OPC UA Client (e.g., UaExpert).");
while (running) {
// UA_Server_run_iterate processes client requests, timeouts, etc.
// The second argument true means it will block for a short time waiting for events.
// For non-blocking, use false and call more frequently.
retval = UA_Server_run_iterate(server, true);
if(retval != UA_STATUSCODE_GOOD && retval != UA_STATUSCODE_BADTIMEOUT) {
ESP_LOGW(TAG, "Server iteration failed or interrupted: %s", UA_StatusCode_name(retval));
}
// Add a small delay to allow other tasks to run, especially if run_iterate is non-blocking
// or if there are many other tasks. If run_iterate blocks, this might be less critical.
vTaskDelay(pdMS_TO_TICKS(10));
}
ESP_LOGI(TAG, "Shutting down OPC UA server...");
UA_Server_run_shutdown(server);
UA_Server_delete(server);
ESP_LOGI(TAG, "OPC UA server shut down.");
vTaskDelete(NULL);
}
void app_main(void) {
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
ESP_LOGI(TAG, "ESP_WIFI_MODE_STA");
wifi_init_sta();
EventBits_t bits = xEventGroupGetBits(s_wifi_event_group);
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "Wi-Fi Connected. Starting OPC UA server task.");
xTaskCreate(&opcua_server_task, "opcua_server_task", 16384, NULL, 5, NULL);
} else {
ESP_LOGE(TAG, "Wi-Fi not connected. OPC UA server task will not start.");
}
}
Explanation of the Code:
- Includes and Wi-Fi: Similar to the client example. Wi-Fi connection is established first. The server’s IP address is logged to help clients connect.
simulated_temperature
: A global static variable to simulate a sensor value.readTemperatureDataSource
Callback:- This function is called by
open62541
whenever a client reads the “Temperature” variable. - It simulates a slight random fluctuation in
simulated_temperature
. UA_Variant_setScalar()
prepares the current temperature value.dataValue->hasValue = true;
indicates that a value is provided.
- This function is called by
writeSetPointDataSource
Callback:- Called when a client writes to the “TemperatureSetPoint” variable.
- It checks if the incoming data type is
Double
. - Logs the received setpoint. In a real application, this would update a system parameter.
resetCounterMethodCallback
:- Called when a client invokes the “ResetCounter” method.
- Logs the method call. For this example, it doesn’t take input or produce output arguments, but the structure shows where that would happen.
add_custom_nodes
Function: This is where the server’s Address Space is defined.- Namespace:
UA_Server_addNamespace()
creates a custom namespace for our nodes. - Object Node (“MyDevice”):
UA_Server_addObjectNode()
creates an object node. This acts as a folder for our device-specific data. It’s placed under the standard “ObjectsFolder”. - Static Variable (“DeviceStatus”):
UA_Server_addVariableNode()
adds a simple read-only string variable. Its value is set directly in the attributes. - DataSource Variable (“Temperature”):
UA_Server_addDataSourceVariableNode()
adds a variable whose value is provided by thereadTemperatureDataSource
callback. - Writable DataSource Variable (“TemperatureSetPoint”): Also uses
UA_Server_addDataSourceVariableNode()
, but this time awrite
callback (writeSetPointDataSource
) is provided. - Method Node (“ResetCounter”):
UA_Server_addMethodNode()
adds a method, linking it toresetCounterMethodCallback
.
- Namespace:
opcua_server_task
:- Server Creation & Config:
UA_Server_new()
andUA_Server_getConfig()
.UA_ServerConfig_setMinimal()
is a helper to set the port and disable security (no certificate specified withNULL
). - Add Custom Nodes: Calls
add_custom_nodes()
. - Run Server:
UA_Server_run_startup(server)
starts the server. The main loop then repeatedly callsUA_Server_run_iterate(server, true)
. Thetrue
argument makesrun_iterate
block for a short period waiting for network events, which is suitable for an embedded server that might not have other high-frequency tasks. Iffalse
were used,vTaskDelay
would be more critical to prevent starving other tasks. - Shutdown: When
running
becomes false (not implemented in this basic example, but could be triggered by a GPIO or another task), the server shuts down.
- Server Creation & Config:
app_main
: Initializes Wi-Fi and startsopcua_server_task
.
Step 4: Modify CMakeLists.txt
Ensure your main/CMakeLists.txt
correctly requires open62541
as in Chapter 197, Step 4.
# main/CMakeLists.txt
idf_component_register(SRCS "your_main_file.c" # or your main C file name
INCLUDE_DIRS "."
REQUIRES lwip esp_wifi nvs_flash esp_random open62541) # Added open62541 and esp_random
The esp_random
component is needed for esp_random()
used in the temperature simulation.
Step 5: Build, Flash, and Observe
- Build, Flash, and Monitor as described in Chapter 197, Step 5.
- Observe Initial Output: You should see Wi-Fi connection logs, the server’s IP address, and messages indicating the server has started and custom nodes have been added.
I (OPCUA_SERVER): connected to ap SSID:your_ssid
I (OPCUA_SERVER): OPC UA Server Endpoint will be: opc.tcp://<esp32_ip_address>:4840 ...
I (OPCUA_SERVER): Custom Namespace added with index 1
I (OPCUA_SERVER): Added ObjectNode 'MyDevice' with NodeId: ns=1, i=xxxx
I (OPCUA_SERVER): Added VariableNode 'DeviceStatus' under 'MyDevice'
I (OPCUA_SERVER): Added DataSource VariableNode 'Temperature' under 'MyDevice'
I (OPCUA_SERVER): Added Writable DataSource VariableNode 'TemperatureSetPoint' under 'MyDevice'
I (OPCUA_SERVER): Added MethodNode 'ResetCounter' under 'MyDevice'
I (OPCUA_SERVER): Starting OPC UA server...
I (OPCUA_SERVER): OPC UA server started. Listening on port 4840.
I (OPCUA_SERVER): Connect with an OPC UA Client (e.g., UaExpert).
- Connect with an OPC UA Client (e.g., UaExpert):
- Add a new server connection in UaExpert.
- Endpoint URL:
opc.tcp://<esp32_ip_address>:4840
(use the IP address shown in your ESP32’s log). - Security Policy:
None
. - Connect.
- Browse the Address Space:
- In UaExpert’s Address Space panel, navigate to
Objects
->MyDevice
. - You should see
DeviceStatus
,Temperature
,TemperatureSetPoint
(variables) andResetCounter
(method).
- In UaExpert’s Address Space panel, navigate to
- Read Variables:
- Drag
DeviceStatus
andTemperature
into UaExpert’s Data Access View. You should see their values. The “Temperature” value will change slightly over time due to the simulation.
- Drag
- Write Variable:
- In UaExpert, try writing a new value to
TemperatureSetPoint
(e.g., 25.5). - Observe the ESP32’s serial monitor. You should see the log:
I (OPCUA_SERVER): Client wrote new SetPoint: 25.500000
.
- In UaExpert, try writing a new value to
- Call Method:
- Right-click the
ResetCounter
method in UaExpert and select “Call”. - Observe the ESP32’s serial monitor. You should see:
I (OPCUA_SERVER): ResetCounter method called by a client!
.
- Right-click the
- Subscribe to Variables:
- Drag
Temperature
to the “Data Access View” in UaExpert. It will automatically create a subscription. You should see the value updating periodically.
- Drag
Tip: The
NodeId
s for your custom nodes (e.g.,ns=1, i=xxxx
orns=1, s="Temperature"
) are important. The numeric identifiers (i=xxxx
) are assigned by the stack if you passUA_NODEID_NULL
when adding nodes. Using string identifiers (s="Temperature"
) withUA_NODEID_STRING(nsIdx, "TemperatureNodeName")
can make NodeIds more predictable if needed, but requires careful management.
Variant Notes
Running an OPC UA server is generally more resource-intensive than a client, as the server needs to manage the Address Space, handle multiple client connections (potentially), and process various requests.
ESP32 Variant | Core(s) | RAM | Suitability as OPC UA Server | Key Considerations |
---|---|---|---|---|
ESP32 (Original) | Dual-Core | 520 KB | Good | RAM is the primary bottleneck. Best for basic servers with moderate Address Spaces and minimal security. Careful configuration is key. |
ESP32-S3 | Dual-Core | 512 KB (+PSRAM) | Excellent | Most capable variant. PSRAM support is ideal for large Address Spaces. AI instructions can offload other tasks. Best choice for demanding server applications. |
ESP32-S2 | Single-Core | 320 KB | Fair | Single core limits handling of concurrent client requests. Tighter RAM constraints. Suitable only for simple servers with low client load. |
ESP32-C3 | Single-Core RISC-V | 400 KB | Fair | Suitable for lightweight servers with a small number of nodes, minimal security, and few (ideally one) clients. RAM is the main constraint. |
ESP32-C6 | Single-Core RISC-V | 512 KB | Good | Better performance and more RAM than C3. Can handle more complex servers than C3, but the single core is still a consideration for high load. |
ESP32-H2 | Single-Core RISC-V | 320 KB | Limited | Technically possible but severely constrained by low RAM and processing power. Not recommended for OPC UA server roles; its strengths are in Thread/Zigbee. |
General Considerations for Servers on All Variants:
- RAM Usage: The size of the Address Space (number of nodes, string lengths for names/URIs) directly impacts RAM. Each active client session and subscription also consumes RAM.
- Flash Usage: The
open62541
library itself, especially with server and security features enabled, occupies flash space. - CPU Load: Handling client requests, subscriptions, and especially cryptographic operations for security, increases CPU load. Dual-core devices (ESP32, ESP32-S3) can better distribute this load if the OPC UA server task is pinned to one core and other application logic to another.
- Task Stack Size: The server task needs a generous stack (e.g., 16KB-32KB) as
open62541
can make deep calls, especially when parsing complex messages or handling security. - Number of Concurrent Clients:
open62541
(and the underlying LWIP stack) has limits on concurrent TCP connections. For an ESP32, expect to handle only a few (1-5) active clients reliably, especially if they have active subscriptions.
Common Mistakes & Troubleshooting Tips
Mistake / Issue | Symptom(s) | Troubleshooting / Solution |
---|---|---|
Server Not Reachable | Client (e.g., UaExpert) times out or gives a “could not connect” error. ESP32 cannot be pinged. |
|
Nodes Not Appearing | Client connects, but the custom objects/variables (e.g., “MyDevice”) are missing from the Address Space browser. |
|
Task Stack Overflow | ESP32 panics and reboots, with log messages mentioning “stack canary” or “stack overflow”. Often happens when a client connects or browses. |
|
DataSource Not Working | Variable value is static, doesn’t update, or is always zero. Writing to a variable has no effect. |
|
Server Unresponsive | Server connects initially but then stops responding to any requests (read, write, browse). |
|
Security/Certificate Errors | When security is enabled, client fails to connect with cryptographic errors. |
|
Exercises
- Expand the Information Model:Add another object to your server, for example, “SystemInfo”. Under this object, add variables for:
FirmwareVersion
(String, read-only, static value).FreeHeap
(UInt32, read-only, dynamic using ValueSource to reportesp_get_free_heap_size()
).UptimeSeconds
(UInt32, read-only, dynamic, reporting seconds since boot).
- Implement a Writable String Variable:Add a writable string variable named “DeviceLocation” to the “MyDevice” object. Implement a ValueSource with both read and write callbacks. The write callback should store the string (e.g., in a static buffer or NVS), and the read callback should return the stored string. Test with UaExpert.
- Create a Method with Input/Output Arguments:Add a method named “CalculateSum” to “MyDevice”. This method should:
- Accept two
Int32
input arguments. - Return one Int32 output argument, which is the sum of the inputs.Define the input and output argument structures using UA_Argument and update the UA_Server_addMethodNode call. Test by calling it from UaExpert and providing input values.
- Accept two
- Basic Server-Side Subscription Monitoring (Informational):While open62541 handles subscription mechanics, you can add logging within your ValueSource read callbacks to see how frequently they are polled when a client subscribes to a variable. For example, add an ESP_LOGI in readTemperatureDataSource every time it’s called. Observe the log frequency when UaExpert subscribes to the “Temperature” variable with different sampling/publishing intervals. (Note: True server-side subscription event handling is more advanced, often involving UA_Server_setVariableNode_valueUpdated if the value changes internally and needs to trigger notifications).
Summary
- The ESP32 can effectively function as an OPC UA server, exposing data and services in a standardized way.
- The core of an OPC UA server is its Address Space, which you define by adding objects, variables, and methods using
open62541
API calls. UA_ValueSource
callbacks are essential for linking variables to dynamic, real-time data (like sensor readings or system status).- Method callbacks allow clients to invoke functions on the ESP32 server.
- The
UA_Server_run_iterate()
function is crucial for the server’s main loop to process client requests and manage its state. - Resource limitations (RAM, CPU, stack size) on ESP32 variants must be carefully considered, especially for servers with large Address Spaces, many clients, or security enabled. ESP32-S3 is generally the most capable for server roles.
- Security is paramount for production servers but adds resource overhead.
- Thorough testing with an OPC UA client tool like UaExpert is vital during development.
Further Reading
- open62541 Documentation:https://open62541.org/doc/current/
- Server Tutorial: https://open62541.org/doc/current/tutorial_server.html
- Adding Variables, Objects, Methods: Consult API documentation for
UA_Server_add...Node
functions. - Information Modelling: https://open62541.org/doc/current/information_modeling.html
- Working with ValueSources (DataSources): Search documentation for
UA_ValueSource
.
- ESP-IDF Programming Guide: (Relevant sections for system information, Wi-Fi, FreeRTOS as in Chapter 197)
- OPC Foundation Website: https://opcfoundation.org/ (For understanding OPC UA specifications and concepts).