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:

  1. 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.
  2. Listens for Client Connections: It opens network endpoints (e.g., opc.tcp://<ip_address>:<port>) and awaits connection requests from OPC UA clients.
  3. 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.
  4. Manages Security: It enforces configured security policies, authenticates clients, and authorizes their access to specific parts of the Address Space or services.
  5. 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.
  • 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: In open62541, you can define a UA_ValueSource for a variable. This structure contains function pointers for read and write 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 populates dataValue.
      • 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

  1. Create Server Instance: UA_Server_new() creates a server instance.
  2. Configure Server: UA_Server_getConfig() gets the default configuration, which you can then modify (e.g., set port number, security policies).
  3. 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 to UA_ValueSource callbacks. For methods, you’ll link them to method callbacks.
  4. 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.
  5. 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 or SignAndEncrypt.
  • 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 to Info or Debug.
    • Review UA_MAX_NODES, UA_MAX_REFERENCES etc., if you plan a large address space, but defaults are often fine for simple ESP32 servers.
  • Component config ---> ESP System Settings ---> Main task stack size: Increase to at least 8192 or 16384. 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:

C
#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:

  1. 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.
  2. simulated_temperature: A global static variable to simulate a sensor value.
  3. 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.
  4. 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.
  5. 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.
  6. 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 the readTemperatureDataSource callback.
    • Writable DataSource Variable (“TemperatureSetPoint”): Also uses UA_Server_addDataSourceVariableNode(), but this time a write callback (writeSetPointDataSource) is provided.
    • Method Node (“ResetCounter”): UA_Server_addMethodNode() adds a method, linking it to resetCounterMethodCallback.
  7. opcua_server_task:
    • Server Creation & Config: UA_Server_new() and UA_Server_getConfig(). UA_ServerConfig_setMinimal() is a helper to set the port and disable security (no certificate specified with NULL).
    • Add Custom Nodes: Calls add_custom_nodes().
    • Run Server: UA_Server_run_startup(server) starts the server. The main loop then repeatedly calls UA_Server_run_iterate(server, true). The true argument makes run_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. If false 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.
  8. app_main: Initializes Wi-Fi and starts opcua_server_task.

Step 4: Modify CMakeLists.txt

Ensure your main/CMakeLists.txt correctly requires open62541 as in Chapter 197, Step 4.

Plaintext
# 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

  1. Build, Flash, and Monitor as described in Chapter 197, Step 5.
  2. 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).
  3. 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.
  4. Browse the Address Space:
    • In UaExpert’s Address Space panel, navigate to Objects -> MyDevice.
    • You should see DeviceStatus, Temperature, TemperatureSetPoint (variables) and ResetCounter (method).
  5. Read Variables:
    • Drag DeviceStatus and Temperature into UaExpert’s Data Access View. You should see their values. The “Temperature” value will change slightly over time due to the simulation.
  6. 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.
  7. 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!.
  8. 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.

Tip: The NodeIds for your custom nodes (e.g., ns=1, i=xxxx or ns=1, s="Temperature") are important. The numeric identifiers (i=xxxx) are assigned by the stack if you pass UA_NODEID_NULL when adding nodes. Using string identifiers (s="Temperature") with UA_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.
  • Verify ESP32’s IP address from its serial monitor logs.
  • Ensure the client device and ESP32 are on the same Wi-Fi network.
  • Check for firewalls on the client PC blocking port 4840.
  • Confirm the opcua_server_task started and didn’t crash.
Nodes Not Appearing Client connects, but the custom objects/variables (e.g., “MyDevice”) are missing from the Address Space browser.
  • Check for errors in add_custom_nodes function logs.
  • Ensure parent NodeId is correct, e.g., UA_NS0ID_OBJECTSFOLDER for top-level objects.
  • Verify the correct namespace index (nsIdx) is used in UA_QUALIFIEDNAME.
Task Stack Overflow ESP32 panics and reboots, with log messages mentioning “stack canary” or “stack overflow”. Often happens when a client connects or browses.
  • The server task requires significant stack. Increase the size in xTaskCreate.
  • Start with 16384 and increase to 32768 if needed.
  • Monitor stack high-water mark using ESP-IDF tools to find optimal size.
DataSource Not Working Variable value is static, doesn’t update, or is always zero. Writing to a variable has no effect.
  • Ensure UA_ValueSource callbacks (read/write) are assigned before calling addDataSourceVariableNode.
  • Add ESP_LOGI calls inside your callbacks to see if they are being triggered.
  • Check for data type mismatches. Ensure the callback populates the UA_DataValue* correctly.
Server Unresponsive Server connects initially but then stops responding to any requests (read, write, browse).
  • Ensure the UA_Server_run_iterate() loop is running continuously.
  • CRITICAL: Callbacks (DataSource, Method) must be non-blocking and return quickly.
  • Do not use long delays (vTaskDelay) or blocking I/O inside a callback. Offload long jobs to a separate task.
Security/Certificate Errors When security is enabled, client fails to connect with cryptographic errors.
  • Start with no security to verify basic functionality.
  • Ensure server and client trust each other’s certificates.
  • Verify that the Security Policy and Message Security Mode are supported by both client and server.

Exercises

  1. 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 report esp_get_free_heap_size()).
    • UptimeSeconds (UInt32, read-only, dynamic, reporting seconds since boot).
  2. 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.
  3. 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.
  4. 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

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top