aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-02-19 06:19:23 +0100
committerThomas Vanbesien <tvanbesi@proton.me>2026-02-19 06:19:23 +0100
commit2b632bd229edaa9999be5043f9a8ae2ac7d17e41 (patch)
tree5071ef9fa36a898fbe009f477441fd2f34a4cb2d
parent37c0fee672afd3701ea3ed87958da4d548bf1be3 (diff)
downloadBobinkCOpcUa-2b632bd229edaa9999be5043f9a8ae2ac7d17e41.tar.gz
BobinkCOpcUa-2b632bd229edaa9999be5043f9a8ae2ac7d17e41.zip
Add configurable variable node initialization for server_registerHEADmaster
New optional CLI argument [nodes-config] lets the server populate its address space from a dot-indexed config file (node.N.name/type/value/ accessLevel/description). Supports 10 scalar types plus 1D arrays.
-rw-r--r--CMakeLists.txt2
-rw-r--r--readme.md66
-rw-r--r--src/nodes_config.c738
-rw-r--r--src/nodes_config.h126
-rw-r--r--src/server_register.c39
5 files changed, 966 insertions, 5 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9be6ab9..d38f0bb 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -25,7 +25,7 @@ include(cmake/BuildDeps.cmake)
# Shared helpers (file loading, security factories, config parser) used by all
# three programs.
-add_library(common STATIC src/common.c src/config.c)
+add_library(common STATIC src/common.c src/config.c src/nodes_config.c)
target_link_libraries(common open62541::open62541)
# Unified client: find-servers, get-endpoints, read-time.
diff --git a/readme.md b/readme.md
index 29ee7a7..b334fb2 100644
--- a/readme.md
+++ b/readme.md
@@ -59,7 +59,8 @@ build/bobink_opcua_discovery_server tests/secure_user/server_lds.conf
# 2. Register Server (connects to the LDS on port 14840)
build/bobink_opcua_server tests/secure_user/server_register.conf \
- tests/secure_user/server_register_client.conf opc.tcp://localhost:14840
+ tests/secure_user/server_register_client.conf opc.tcp://localhost:14840 \
+ [nodes.conf]
# 3. Find registered servers via the LDS
build/bobink_opcua_client tests/secure_user/client.conf find-servers opc.tcp://localhost:14840
@@ -123,3 +124,66 @@ Three authentication modes are supported via the `authMode` key:
- **anonymous** — no user identity
- **user** — username and password (requires `username` and `password` keys)
- **cert** — X509 certificate identity token (reuses the application certificate; requires encryption to be configured)
+
+## Nodes Configuration
+
+`bobink_opcua_server` accepts an optional nodes config file that populates
+the server's address space with variable nodes. Pass it as the fourth
+positional argument (before the optional log level):
+
+```sh
+build/bobink_opcua_server server.conf client.conf opc.tcp://localhost:14840 nodes.conf
+```
+
+The file uses the same `key = value` format with dot-indexed keys:
+
+```
+node.0.name = Temperature
+node.0.description = Current temperature reading
+node.0.type = double
+node.0.value = 23.5
+node.0.accessLevel = read
+
+node.1.name = DeviceName
+node.1.type = string
+node.1.value = Sensor-01
+node.1.accessLevel = readwrite
+
+node.2.name = Measurements
+node.2.description = Recent measurements
+node.2.type = double[]
+node.2.value = 1.5, 2.3, 3.7, 4.1
+node.2.accessLevel = read
+```
+
+Each node gets a string NodeId in namespace 1 (`ns=1;s=<name>`). The `name`
+field is also used as the display name and browse name.
+
+### Fields
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `name` | yes | Display name, browse name, and string NodeId |
+| `description` | no | Human-readable description |
+| `type` | yes | Data type (see table below); append `[]` for a 1D array |
+| `value` | yes | Initial value; comma-separated for arrays |
+| `accessLevel` | yes | `read` or `readwrite` |
+
+### Supported Types
+
+| Type name | OPC UA type |
+|-----------|-------------|
+| `bool` | Boolean (`true` / `false`) |
+| `int16` | Int16 |
+| `uint16` | UInt16 |
+| `int32` | Int32 |
+| `uint32` | UInt32 |
+| `int64` | Int64 |
+| `uint64` | UInt64 |
+| `float` | Float |
+| `double` | Double |
+| `string` | String |
+
+Append `[]` to any type name for a 1D array (e.g. `double[]`, `string[]`).
+Array values are comma-separated. String values in arrays cannot contain
+literal commas.
diff --git a/src/nodes_config.c b/src/nodes_config.c
new file mode 100644
index 0000000..79d5919
--- /dev/null
+++ b/src/nodes_config.c
@@ -0,0 +1,738 @@
+/**
+ * @file nodes_config.c
+ * @brief Dot-indexed node configuration parser and server-side node creator.
+ */
+
+#include "nodes_config.h"
+#include "config.h"
+
+#include <open62541/plugin/log_stdout.h>
+#include <open62541/server.h>
+#include <open62541/types.h>
+#include <open62541/types_generated.h>
+
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ========================================================================
+ * Type Map
+ * ======================================================================== */
+
+typedef struct
+{
+ const char *name;
+ node_type type;
+ int ua_type_index;
+} type_map_entry;
+
+static const type_map_entry _s_type_map[] = {
+ { "bool", NODE_TYPE_BOOL, UA_TYPES_BOOLEAN },
+ { "int16", NODE_TYPE_INT16, UA_TYPES_INT16 },
+ { "uint16", NODE_TYPE_UINT16, UA_TYPES_UINT16 },
+ { "int32", NODE_TYPE_INT32, UA_TYPES_INT32 },
+ { "uint32", NODE_TYPE_UINT32, UA_TYPES_UINT32 },
+ { "int64", NODE_TYPE_INT64, UA_TYPES_INT64 },
+ { "uint64", NODE_TYPE_UINT64, UA_TYPES_UINT64 },
+ { "float", NODE_TYPE_FLOAT, UA_TYPES_FLOAT },
+ { "double", NODE_TYPE_DOUBLE, UA_TYPES_DOUBLE },
+ { "string", NODE_TYPE_STRING, UA_TYPES_STRING },
+};
+
+#define TYPE_MAP_SIZE (sizeof (_s_type_map) / sizeof (_s_type_map[0]))
+
+/* ========================================================================
+ * Static Helpers
+ * ======================================================================== */
+
+/**
+ * @brief Trims leading and trailing whitespace from a string in place.
+ *
+ * Returns a pointer past any leading whitespace and writes a NUL
+ * terminator after the last non-whitespace character.
+ */
+static char *
+_s_trim (char *s)
+{
+ while (*s == ' ' || *s == '\t')
+ s++;
+ size_t len = strlen (s);
+ while (len > 0 && (s[len - 1] == ' ' || s[len - 1] == '\t'))
+ len--;
+ s[len] = '\0';
+ return s;
+}
+
+/**
+ * @brief Resolves a type string to a node_type and array flag.
+ *
+ * Strips a trailing "[]" suffix if present (setting is_array to true),
+ * then looks up the base name in _s_type_map.
+ *
+ * @return 0 on success, -1 if the type name is unknown.
+ */
+static int
+_s_resolve_type (const char *str, node_type *out_type,
+ UA_Boolean *out_is_array)
+{
+ char buf[64];
+ size_t len = strlen (str);
+ if (len >= sizeof (buf))
+ return -1;
+ memcpy (buf, str, len + 1);
+
+ *out_is_array = false;
+ if (len >= 2 && buf[len - 2] == '[' && buf[len - 1] == ']')
+ {
+ buf[len - 2] = '\0';
+ *out_is_array = true;
+ }
+
+ for (size_t i = 0; i < TYPE_MAP_SIZE; i++)
+ {
+ if (strcmp (buf, _s_type_map[i].name) == 0)
+ {
+ *out_type = _s_type_map[i].type;
+ return 0;
+ }
+ }
+ return -1;
+}
+
+/**
+ * @brief Resolves an access level string to a node_access_level enum.
+ *
+ * @return 0 on success, -1 if the string is unknown.
+ */
+static int
+_s_resolve_access (const char *str, node_access_level *out)
+{
+ if (strcmp (str, "read") == 0)
+ {
+ *out = NODE_ACCESS_READ;
+ return 0;
+ }
+ if (strcmp (str, "readwrite") == 0)
+ {
+ *out = NODE_ACCESS_READWRITE;
+ return 0;
+ }
+ return -1;
+}
+
+/**
+ * @brief Returns the UA_TYPES index for a node_type enum value.
+ */
+static int
+_s_ua_type_index (node_type t)
+{
+ for (size_t i = 0; i < TYPE_MAP_SIZE; i++)
+ {
+ if (_s_type_map[i].type == t)
+ return _s_type_map[i].ua_type_index;
+ }
+ return -1;
+}
+
+/**
+ * @brief Scans config entries for the highest N in node.N.* keys.
+ *
+ * @return The maximum index found, or -1 if no node.* keys exist.
+ */
+static int
+_s_find_max_node_index (const config *cfg)
+{
+ int max_idx = -1;
+ for (size_t i = 0; i < cfg->count; i++)
+ {
+ int idx;
+ int consumed;
+ if (sscanf (cfg->entries[i].key, "node.%d.%n", &idx, &consumed) >= 1
+ && consumed > 0 && idx >= 0)
+ {
+ if (idx > max_idx)
+ max_idx = idx;
+ }
+ }
+ return max_idx;
+}
+
+/**
+ * @brief Parses a single scalar value string into a typed buffer.
+ *
+ * For numeric types uses strtol/strtod family with range checks.
+ * For bool accepts "true"/"false". For string creates a UA_String
+ * via UA_String_fromChars (written to out_str).
+ *
+ * @param str The value string to parse.
+ * @param type The target data type.
+ * @param out Buffer to write the parsed value into (must be large
+ * enough for the target type; unused for string).
+ * @param out_str Output for UA_String when type == NODE_TYPE_STRING.
+ * @return 0 on success, -1 on parse error.
+ */
+static int
+_s_parse_scalar_value (const char *str, node_type type, void *out,
+ UA_String *out_str)
+{
+ char *endptr;
+ errno = 0;
+
+ switch (type)
+ {
+ case NODE_TYPE_BOOL:
+ if (strcmp (str, "true") == 0)
+ *(UA_Boolean *)out = true;
+ else if (strcmp (str, "false") == 0)
+ *(UA_Boolean *)out = false;
+ else
+ return -1;
+ return 0;
+
+ case NODE_TYPE_INT16:
+ {
+ long v = strtol (str, &endptr, 10);
+ if (*endptr != '\0' || errno == ERANGE || v < INT16_MIN
+ || v > INT16_MAX)
+ return -1;
+ *(UA_Int16 *)out = (UA_Int16)v;
+ return 0;
+ }
+
+ case NODE_TYPE_UINT16:
+ {
+ unsigned long v = strtoul (str, &endptr, 10);
+ if (*endptr != '\0' || errno == ERANGE || v > UINT16_MAX)
+ return -1;
+ *(UA_UInt16 *)out = (UA_UInt16)v;
+ return 0;
+ }
+
+ case NODE_TYPE_INT32:
+ {
+ long v = strtol (str, &endptr, 10);
+ if (*endptr != '\0' || errno == ERANGE || v < INT32_MIN
+ || v > INT32_MAX)
+ return -1;
+ *(UA_Int32 *)out = (UA_Int32)v;
+ return 0;
+ }
+
+ case NODE_TYPE_UINT32:
+ {
+ unsigned long v = strtoul (str, &endptr, 10);
+ if (*endptr != '\0' || errno == ERANGE || v > UINT32_MAX)
+ return -1;
+ *(UA_UInt32 *)out = (UA_UInt32)v;
+ return 0;
+ }
+
+ case NODE_TYPE_INT64:
+ {
+ long long v = strtoll (str, &endptr, 10);
+ if (*endptr != '\0' || errno == ERANGE)
+ return -1;
+ *(UA_Int64 *)out = (UA_Int64)v;
+ return 0;
+ }
+
+ case NODE_TYPE_UINT64:
+ {
+ unsigned long long v = strtoull (str, &endptr, 10);
+ if (*endptr != '\0' || errno == ERANGE)
+ return -1;
+ *(UA_UInt64 *)out = (UA_UInt64)v;
+ return 0;
+ }
+
+ case NODE_TYPE_FLOAT:
+ {
+ float v = strtof (str, &endptr);
+ if (*endptr != '\0' || errno == ERANGE)
+ return -1;
+ *(UA_Float *)out = (UA_Float)v;
+ return 0;
+ }
+
+ case NODE_TYPE_DOUBLE:
+ {
+ double v = strtod (str, &endptr);
+ if (*endptr != '\0' || errno == ERANGE)
+ return -1;
+ *(UA_Double *)out = (UA_Double)v;
+ return 0;
+ }
+
+ case NODE_TYPE_STRING:
+ *out_str = UA_String_fromChars (str);
+ return 0;
+ }
+
+ return -1;
+}
+
+/**
+ * @brief Counts the number of comma-separated elements in a string.
+ */
+static size_t
+_s_count_elements (const char *str)
+{
+ if (*str == '\0')
+ return 0;
+ size_t count = 1;
+ for (const char *p = str; *p; p++)
+ {
+ if (*p == ',')
+ count++;
+ }
+ return count;
+}
+
+/**
+ * @brief Parses a comma-separated value string into a UA array.
+ *
+ * Tokenizes by comma, trims whitespace, parses each element with
+ * _s_parse_scalar_value, and writes into a heap-allocated array.
+ *
+ * @param str The comma-separated value string.
+ * @param type The node data type.
+ * @param ua_type_index The UA_TYPES index for the data type.
+ * @param out_array Output: heap-allocated array (free with
+ * UA_Array_delete).
+ * @param out_size Output: number of elements.
+ * @return 0 on success, -1 on error.
+ */
+static int
+_s_parse_array_value (const char *str, node_type type, int ua_type_index,
+ void **out_array, size_t *out_size)
+{
+ size_t count = _s_count_elements (str);
+ if (count == 0)
+ {
+ *out_array = NULL;
+ *out_size = 0;
+ return 0;
+ }
+
+ const UA_DataType *dt = &UA_TYPES[ua_type_index];
+ void *array = UA_Array_new (count, dt);
+ if (!array)
+ return -1;
+
+ char *tmp = strdup (str);
+ if (!tmp)
+ {
+ UA_Array_delete (array, count, dt);
+ return -1;
+ }
+
+ size_t i = 0;
+ char *saveptr;
+ char *token = strtok_r (tmp, ",", &saveptr);
+
+ while (token && i < count)
+ {
+ char *trimmed = _s_trim (token);
+ if (*trimmed == '\0')
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: empty element at position %zu", i);
+ goto fail;
+ }
+
+ /* Parse into the array element at offset i. */
+ void *elem = (char *)array + i * dt->memSize;
+ UA_String str_val = UA_STRING_NULL;
+
+ if (_s_parse_scalar_value (trimmed, type, elem, &str_val) != 0)
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: failed to parse array element '%s'",
+ trimmed);
+ goto fail;
+ }
+
+ /* For strings, copy into the array slot. */
+ if (type == NODE_TYPE_STRING)
+ *(UA_String *)elem = str_val;
+
+ i++;
+ token = strtok_r (NULL, ",", &saveptr);
+ }
+
+ free (tmp);
+ *out_array = array;
+ *out_size = count;
+ return 0;
+
+fail:
+ free (tmp);
+ UA_Array_delete (array, count, dt);
+ return -1;
+}
+
+/* ========================================================================
+ * Parsing: nodes_config_load
+ * ======================================================================== */
+
+/**
+ * @brief Frees a partially populated raw node array.
+ */
+static void
+_s_free_raw_nodes (node_config *raw, size_t count)
+{
+ for (size_t i = 0; i < count; i++)
+ {
+ free (raw[i].name);
+ free (raw[i].description);
+ free (raw[i].value_str);
+ }
+ free (raw);
+}
+
+int
+nodes_config_load (const char *path, nodes_config *out)
+{
+ memset (out, 0, sizeof (*out));
+
+ config cfg = { 0 };
+ if (config_load (path, &cfg) != 0)
+ return -1;
+
+ int max_idx = _s_find_max_node_index (&cfg);
+ if (max_idx < 0)
+ {
+ /* No node.* keys — empty config, not an error. */
+ config_free (&cfg);
+ return 0;
+ }
+
+ size_t raw_count = (size_t)(max_idx + 1);
+ node_config *raw = calloc (raw_count, sizeof (node_config));
+ if (!raw)
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: out of memory");
+ config_free (&cfg);
+ return -1;
+ }
+
+ /* Track which fields have been set to detect unknown fields and
+ distinguish "not set" from "set to default enum value 0". */
+ UA_Boolean *type_set = calloc (raw_count, sizeof (UA_Boolean));
+ UA_Boolean *access_set = calloc (raw_count, sizeof (UA_Boolean));
+ if (!type_set || !access_set)
+ {
+ free (type_set);
+ free (access_set);
+ _s_free_raw_nodes (raw, raw_count);
+ config_free (&cfg);
+ return -1;
+ }
+
+ int rc = 0;
+
+ for (size_t i = 0; i < cfg.count; i++)
+ {
+ int idx;
+ int consumed = 0;
+ if (sscanf (cfg.entries[i].key, "node.%d.%n", &idx, &consumed) < 1
+ || consumed == 0 || idx < 0 || (size_t)idx >= raw_count)
+ continue;
+
+ const char *field = cfg.entries[i].key + consumed;
+ const char *value = cfg.entries[i].value;
+
+ if (strcmp (field, "name") == 0)
+ {
+ free (raw[idx].name);
+ raw[idx].name = strdup (value);
+ }
+ else if (strcmp (field, "description") == 0)
+ {
+ free (raw[idx].description);
+ raw[idx].description = strdup (value);
+ }
+ else if (strcmp (field, "type") == 0)
+ {
+ if (_s_resolve_type (value, &raw[idx].type, &raw[idx].is_array) != 0)
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: unknown type '%s' for node.%d",
+ value, idx);
+ rc = -1;
+ goto done;
+ }
+ type_set[idx] = true;
+ }
+ else if (strcmp (field, "value") == 0)
+ {
+ free (raw[idx].value_str);
+ raw[idx].value_str = strdup (value);
+ }
+ else if (strcmp (field, "accessLevel") == 0)
+ {
+ if (_s_resolve_access (value, &raw[idx].access) != 0)
+ {
+ UA_LOG_ERROR (
+ UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: unknown accessLevel '%s' for node.%d "
+ "(expected 'read' or 'readwrite')",
+ value, idx);
+ rc = -1;
+ goto done;
+ }
+ access_set[idx] = true;
+ }
+ else
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: unknown field '%s' for node.%d", field,
+ idx);
+ rc = -1;
+ goto done;
+ }
+ }
+
+ /* Compact: count populated entries (those with a name). */
+ size_t populated = 0;
+ for (size_t i = 0; i < raw_count; i++)
+ {
+ if (raw[i].name)
+ populated++;
+ }
+
+ if (populated == 0)
+ {
+ /* All indices were gaps. */
+ goto done;
+ }
+
+ /* Validate required fields for each populated node. */
+ for (size_t i = 0; i < raw_count; i++)
+ {
+ if (!raw[i].name)
+ continue;
+
+ if (!type_set[i])
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: node.%zu missing required key 'type'",
+ i);
+ rc = -1;
+ goto done;
+ }
+ if (!raw[i].value_str)
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: node.%zu missing required key 'value'",
+ i);
+ rc = -1;
+ goto done;
+ }
+ if (!access_set[i])
+ {
+ UA_LOG_ERROR (
+ UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: node.%zu missing required key 'accessLevel'", i);
+ rc = -1;
+ goto done;
+ }
+ }
+
+ /* Build the compacted output array. */
+ out->nodes = calloc (populated, sizeof (node_config));
+ if (!out->nodes)
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
+ "NodesConfig: out of memory");
+ rc = -1;
+ goto done;
+ }
+
+ size_t j = 0;
+ for (size_t i = 0; i < raw_count; i++)
+ {
+ if (!raw[i].name)
+ continue;
+ out->nodes[j] = raw[i];
+ /* Transfer ownership: clear the raw slot so _s_free_raw_nodes
+ does not double-free these strings. */
+ memset (&raw[i], 0, sizeof (node_config));
+ j++;
+ }
+ out->count = populated;
+
+done:
+ free (type_set);
+ free (access_set);
+ _s_free_raw_nodes (raw, raw_count);
+ config_free (&cfg);
+ if (rc != 0)
+ nodes_config_free (out);
+ return rc;
+}
+
+/* ========================================================================
+ * Node Creation: nodes_config_add
+ * ======================================================================== */
+
+int
+nodes_config_add (UA_Server *server, const nodes_config *nc)
+{
+ int failures = 0;
+
+ for (size_t i = 0; i < nc->count; i++)
+ {
+ const node_config *n = &nc->nodes[i];
+ int ua_idx = _s_ua_type_index (n->type);
+ if (ua_idx < 0)
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_SERVER,
+ "NodesConfig: internal error resolving type for '%s'",
+ n->name);
+ failures++;
+ continue;
+ }
+
+ const UA_DataType *dt = &UA_TYPES[ua_idx];
+
+ UA_VariableAttributes attr = UA_VariableAttributes_default;
+ attr.displayName = UA_LOCALIZEDTEXT ("en-US", n->name);
+ if (n->description)
+ attr.description = UA_LOCALIZEDTEXT ("en-US", n->description);
+ attr.dataType = dt->typeId;
+
+ if (n->access == NODE_ACCESS_READ)
+ attr.accessLevel = UA_ACCESSLEVELMASK_READ;
+ else
+ attr.accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE;
+
+ /* Parse value and set the variant. */
+ UA_Boolean value_ok = false;
+
+ /* Union large enough for any scalar type. */
+ union
+ {
+ UA_Boolean b;
+ UA_Int16 i16;
+ UA_UInt16 u16;
+ UA_Int32 i32;
+ UA_UInt32 u32;
+ UA_Int64 i64;
+ UA_UInt64 u64;
+ UA_Float f;
+ UA_Double d;
+ } scalar_buf;
+ UA_String string_buf = UA_STRING_NULL;
+ void *array_buf = NULL;
+ size_t array_size = 0;
+ UA_UInt32 array_dim = 0;
+
+ if (!n->is_array)
+ {
+ /* Scalar */
+ if (_s_parse_scalar_value (n->value_str, n->type, &scalar_buf,
+ &string_buf)
+ != 0)
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_SERVER,
+ "NodesConfig: failed to parse value '%s' "
+ "for node '%s'",
+ n->value_str, n->name);
+ failures++;
+ continue;
+ }
+
+ if (n->type == NODE_TYPE_STRING)
+ UA_Variant_setScalar (&attr.value, &string_buf, dt);
+ else
+ UA_Variant_setScalar (&attr.value, &scalar_buf, dt);
+
+ attr.valueRank = UA_VALUERANK_SCALAR;
+ value_ok = true;
+ }
+ else
+ {
+ /* Array */
+ if (_s_parse_array_value (n->value_str, n->type, ua_idx, &array_buf,
+ &array_size)
+ != 0)
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_SERVER,
+ "NodesConfig: failed to parse array value "
+ "for node '%s'",
+ n->name);
+ failures++;
+ continue;
+ }
+
+ UA_Variant_setArray (&attr.value, array_buf, array_size, dt);
+ attr.valueRank = UA_VALUERANK_ONE_DIMENSION;
+ array_dim = (UA_UInt32)array_size;
+ attr.arrayDimensions = &array_dim;
+ attr.arrayDimensionsSize = 1;
+ value_ok = true;
+ }
+
+ if (!value_ok)
+ {
+ failures++;
+ continue;
+ }
+
+ UA_NodeId node_id = UA_NODEID_STRING (1, n->name);
+ UA_QualifiedName browse_name = UA_QUALIFIEDNAME (1, n->name);
+
+ UA_StatusCode rv = UA_Server_addVariableNode (
+ server, node_id, UA_NODEID_NUMERIC (0, UA_NS0ID_OBJECTSFOLDER),
+ UA_NODEID_NUMERIC (0, UA_NS0ID_ORGANIZES), browse_name,
+ UA_NODEID_NUMERIC (0, UA_NS0ID_BASEDATAVARIABLETYPE), attr, NULL,
+ NULL);
+
+ if (rv != UA_STATUSCODE_GOOD)
+ {
+ UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_SERVER,
+ "NodesConfig: failed to add node '%s': %s", n->name,
+ UA_StatusCode_name (rv));
+ failures++;
+ }
+ else
+ {
+ UA_LOG_INFO (UA_Log_Stdout, UA_LOGCATEGORY_SERVER,
+ "NodesConfig: added node '%s' "
+ "(ns=1;s=%s, %s%s, %s)",
+ n->name, n->name, _s_type_map[n->type].name,
+ n->is_array ? "[]" : "",
+ n->access == NODE_ACCESS_READ ? "read" : "readwrite");
+ }
+
+ /* Clean up parsed values. UA_Server_addVariableNode deep-copies
+ everything, so our local buffers can be freed now. */
+ if (n->type == NODE_TYPE_STRING && !n->is_array)
+ UA_String_clear (&string_buf);
+ if (array_buf)
+ UA_Array_delete (array_buf, array_size, dt);
+ }
+
+ return failures > 0 ? -1 : 0;
+}
+
+/* ========================================================================
+ * Cleanup
+ * ======================================================================== */
+
+void
+nodes_config_free (nodes_config *nc)
+{
+ for (size_t i = 0; i < nc->count; i++)
+ {
+ free (nc->nodes[i].name);
+ free (nc->nodes[i].description);
+ free (nc->nodes[i].value_str);
+ }
+ free (nc->nodes);
+ memset (nc, 0, sizeof (*nc));
+}
diff --git a/src/nodes_config.h b/src/nodes_config.h
new file mode 100644
index 0000000..0207a55
--- /dev/null
+++ b/src/nodes_config.h
@@ -0,0 +1,126 @@
+#ifndef DISCOVERY_NODES_CONFIG_H
+#define DISCOVERY_NODES_CONFIG_H
+
+/**
+ * @file nodes_config.h
+ * @brief Dot-indexed node configuration parser and server-side node creator.
+ *
+ * Reads a config file containing node.N.field entries, parses them into
+ * an array of node descriptors, and creates corresponding OPC UA variable
+ * nodes in the server address space. Each node gets a string NodeId in
+ * namespace 1 (ns=1;s=<name>).
+ *
+ * Supported types (scalar and 1D array via "type[]" suffix):
+ * bool, int16, uint16, int32, uint32, int64, uint64, float, double, string
+ *
+ * Access levels: read, readwrite.
+ */
+
+#include <open62541/server.h>
+
+#include <stddef.h>
+
+/* ========================================================================
+ * Types
+ * ======================================================================== */
+
+/**
+ * @brief OPC UA data types supported by the nodes config parser.
+ */
+typedef enum
+{
+ NODE_TYPE_BOOL,
+ NODE_TYPE_INT16,
+ NODE_TYPE_UINT16,
+ NODE_TYPE_INT32,
+ NODE_TYPE_UINT32,
+ NODE_TYPE_INT64,
+ NODE_TYPE_UINT64,
+ NODE_TYPE_FLOAT,
+ NODE_TYPE_DOUBLE,
+ NODE_TYPE_STRING
+} node_type;
+
+/**
+ * @brief Access level flags for a variable node.
+ */
+typedef enum
+{
+ NODE_ACCESS_READ,
+ NODE_ACCESS_READWRITE
+} node_access_level;
+
+/**
+ * @brief Describes a single variable node to be created on the server.
+ *
+ * All string pointers are heap-allocated copies owned by this struct.
+ * Call nodes_config_free() to release them.
+ */
+typedef struct
+{
+ char *name; /**< DisplayName, browseName, and string NodeId. */
+ char *description; /**< Optional description text (NULL when absent). */
+ node_type type; /**< OPC UA data type. */
+ UA_Boolean is_array; /**< true when the type name ended with "[]". */
+ char *value_str; /**< Raw value string (comma-separated for arrays). */
+ node_access_level access; /**< Read or read-write. */
+} node_config;
+
+/**
+ * @brief A parsed set of variable node definitions.
+ */
+typedef struct
+{
+ node_config *nodes;
+ size_t count;
+} nodes_config;
+
+/* ========================================================================
+ * Public API
+ * ======================================================================== */
+
+/**
+ * @brief Parses a nodes configuration file.
+ *
+ * Reads dot-indexed keys (node.N.name, node.N.type, etc.) from the
+ * file at @p path, groups them by index, validates required fields,
+ * and populates a nodes_config struct. The caller must free the
+ * result with nodes_config_free().
+ *
+ * When the file contains no node.* keys the function succeeds with
+ * out->count == 0 and out->nodes == NULL.
+ *
+ * @param path Path to the nodes configuration file.
+ * @param out Output: parsed nodes configuration.
+ * @return 0 on success, -1 on error (logged via UA_LOG_ERROR/FATAL).
+ */
+int nodes_config_load (const char *path, nodes_config *out);
+
+/**
+ * @brief Adds all configured variable nodes to a server.
+ *
+ * For each node_config entry, parses the value string into the
+ * appropriate UA type, constructs a UA_VariableAttributes, and calls
+ * UA_Server_addVariableNode with a string NodeId in namespace 1
+ * under the Objects folder.
+ *
+ * Individual node failures are logged but do not stop processing of
+ * remaining nodes.
+ *
+ * @param server The UA_Server to add nodes to.
+ * @param nc The parsed nodes configuration.
+ * @return 0 when all nodes were added, -1 if any node failed.
+ */
+int nodes_config_add (UA_Server *server, const nodes_config *nc);
+
+/**
+ * @brief Frees all memory owned by a nodes_config structure.
+ *
+ * After this call the structure is zeroed and must not be used
+ * unless nodes_config_load() is called again.
+ *
+ * @param nc The nodes configuration to free.
+ */
+void nodes_config_free (nodes_config *nc);
+
+#endif /* DISCOVERY_NODES_CONFIG_H */
diff --git a/src/server_register.c b/src/server_register.c
index b1d87fd..b938ca4 100644
--- a/src/server_register.c
+++ b/src/server_register.c
@@ -10,6 +10,7 @@
#include "common.h"
#include "config.h"
+#include "nodes_config.h"
#include <open62541/client.h>
#include <open62541/client_config_default.h>
@@ -110,18 +111,36 @@ main (int argc, char **argv)
signal (SIGINT, _s_stop_handler);
signal (SIGTERM, _s_stop_handler);
- if (argc < 4 || argc > 5)
+ if (argc < 4 || argc > 6)
{
UA_LOG_FATAL (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND,
"Usage: %s <server-config> <client-config> "
- "<discovery-url> [log-level]",
+ "<discovery-url> [nodes-config] [log-level]",
argv[0]);
return EXIT_FAILURE;
}
const char *discovery_endpoint = argv[3];
- const char *log_level_str = (argc == 5) ? argv[4] : "info";
+ /* Parse the optional [nodes-config] and [log-level] arguments.
+ When only one optional arg is given, disambiguate by checking
+ whether it is a valid log-level name. */
+ const char *nodes_config_path = NULL;
+ const char *log_level_str = "info";
+
+ if (argc == 6)
+ {
+ nodes_config_path = argv[4];
+ log_level_str = argv[5];
+ }
+ else if (argc == 5)
+ {
+ if (parse_log_level (argv[4]) >= 0)
+ log_level_str = argv[4];
+ else
+ nodes_config_path = argv[4];
+ }
+
int log_level = parse_log_level (log_level_str);
if (log_level < 0)
{
@@ -139,6 +158,7 @@ main (int argc, char **argv)
config client_cfg = { 0 };
security_config server_sec = { 0 };
security_config client_sec = { 0 };
+ nodes_config nodes_cfg = { 0 };
UA_Server *server = NULL;
if (config_load (argv[1], &server_cfg) != 0)
@@ -198,6 +218,18 @@ main (int argc, char **argv)
server_config->applicationDescription.applicationType
= UA_APPLICATIONTYPE_SERVER;
+ /* ── Load and create variable nodes (optional) ───────────── */
+
+ if (nodes_config_path)
+ {
+ if (nodes_config_load (nodes_config_path, &nodes_cfg) != 0)
+ goto cleanup;
+
+ if (nodes_cfg.count > 0 && nodes_config_add (server, &nodes_cfg) != 0)
+ UA_LOG_WARNING (UA_Log_Stdout, UA_LOGCATEGORY_SERVER,
+ "Some nodes failed to initialize");
+ }
+
lds_client_params lds_params = {
.app_uri = client_app_uri,
.sec = client_sec,
@@ -250,6 +282,7 @@ main (int argc, char **argv)
cleanup:
if (server)
UA_Server_delete (server);
+ nodes_config_free (&nodes_cfg);
free_trust_store (client_sec.trust_paths, client_sec.trust_size);
free_trust_store (server_sec.trust_paths, server_sec.trust_size);
config_free (&client_cfg);