diff --git a/Dockerfile b/Dockerfile index 26fef27..6d49bde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,8 @@ # The ARCH can be overridden by providing it a value in the command line # with the "--build-arg=XXX" argument fed to 'docker build'. ARG ARCH=aarch64 -ARG VERSION=1.14 -ARG UBUNTU_VERSION=22.04 +ARG VERSION=12.7.0 +ARG UBUNTU_VERSION=24.04 ARG REPO=axisecp ARG SDK=acap-native-sdk ARG BUILD_DIR=/opt/app diff --git a/README.md b/README.md index 9291bb4..4132d44 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ # OPC-UA Plugin Server ACAP application This repository contains the source code for building an OPC-UA Server ACAP application, -along with a guide on developing and building custom modules/plugins. You can -create your own plugins to address specific use cases. Two example plugins -are provided and described below. Additional plugins will be published in -the future designed to enhance production value. +along with a guide on developing and building custom modules/plugins. +You can create your own plugins to address specific use cases. Example +plugins are provided and briefly described below, for more information about a +plugin see its readme. The OPC-UA Server is based on the [**open62541**](https://www.open62541.org/) library. For an introduction to OPC-UA, see @@ -26,6 +26,7 @@ library. For an introduction to OPC-UA, see - [Install](#install) - [Setup](#setup) - [Usage](#usage) + - [Available plugins](#available-plugins) - [Create your own plugin](#create-your-own-plugin) - [Limitations](#limitations) - [Security](#security) @@ -256,16 +257,21 @@ options are not available. To connect to the server right click on the name and click **Connect** or click on the **Connect Server** icon in the tool bar. If the connection is -established, the server will present its OPC-UA information as shown in the +established, the server will present its OPC-UA information similar to the picture below. ![UAExpert](assets/UAExpert_InfoModel.png) -There is one *object node* called **BasicDeviceInfo** with *property nodes* -presenting different information about the active device. This is implemented in -the **plugins/bdi** module. The **plugins/hello_world** module is responsible -for creating a *variable node*, called **HelloWorldNode** with the string value -"Hello World!". +#### Available Plugins + +This application includes the source code for several plugins, which serve both as functional components and as examples for custom development. The currently available plugins are: + +- The `bdi` plugin (`plugins/bdi`) provides a **BasicDeviceInfo** object node with device-specific properties (See [readme](app/plugins/bdi/README.md) for details). +- The `hello_world` plugin (`plugins/hello_world`) creates a **HelloWorldNode** variable (See [readme](app/plugins/hello_world/README.md) for details). +- The `ioports` plugin (`plugins/ioports`) exposes I/O port status and control (See [readme](app/plugins/ioports/README.md) for details). +- The `simple_event` plugin (`plugins/simple_event`) demonstrates Axis event integration via OPC-UA events (See [readme](app/plugins/simple_event/README.md) for details). +- The `thermal` plugin (`plugins/thermal`) exposes thermal camera data and controls (See [readme](app/plugins/thermal/README.md) for details). +- The `vinput` plugin (`plugins/vinput`) provides control over virtual inputs (See [readme](app/plugins/vinput/README.md) for details). ## Create your own plugin @@ -276,13 +282,18 @@ GModule APIs. Create a new directory under *app/plugins/* and copy a Makefile from the example plugin. Each plugin will need its own Makefile. +The directory structure looks like this, +where **`your_plugin`** represents the new plugin you would create: + ```text opc-ua-plugin-server ├── app │   ├── include │   │   ├── error.h │   │   ├── log.h -│   │   └── plugin.h +│   │   ├── plugin.h +│   │   ├── ua_utils.h +│   │   └── vapix_utils.h │   ├── LICENSE │   ├── Makefile │   ├── manifest.json @@ -293,19 +304,54 @@ opc-ua-plugin-server │   ├── opcua_server.c │   ├── opcua_server.h │   ├── plugin.c -│   └── plugins -│   ├── bdi -│   │   ├── bdi_plugin.c -│   │   ├── bdi_plugin.h -│   │   └── Makefile -│   ├── hello_world -│   │ ├── hello_world_plugin.c -│   │ ├── hello_world_plugin.h -│   │ └── Makefile -│   └── your_plugin -│   ├── your_plugin.c -│   ├── your_plugin.h -│   └── Makefile +│   ├── plugins +│   │   ├── bdi +│   │   │   ├── bdi_plugin.c +│   │   │   ├── bdi_plugin.h +│   │   │   ├── Makefile +│   │   │   └── README.md +│   │   ├── hello_world +│   │   │   ├── hello_world_plugin.c +│   │   │   ├── hello_world_plugin.h +│   │   │   ├── Makefile +│   │   │   └── README.md +│   │   ├── ioports +│   │   │   ├── ioports_nodeids.h +│   │   │   ├── ioports_ns.c +│   │   │   ├── ioports_ns.h +│   │   │   ├── ioports_plugin.c +│   │   │   ├── ioports_plugin.h +│   │   │   ├── ioports_types.c +│   │   │   ├── ioports_types.h +│   │   │   ├── ioports_vapix.c +│   │   │   ├── ioports_vapix.h +│   │   │   ├── Makefile +│   │   │   └── README.md +│   │   ├── simple_event +│   │   │   ├── Makefile +│   │   │   ├── README.md +│   │   │   ├── simple_event_plugin.c +│   │   │   └── simple_event_plugin.h +│   │   ├── thermal +│   │   │   ├── Makefile +│   │   │   ├── README.md +│   │   │   ├── thermal_plugin.c +│   │   │   ├── thermal_plugin.h +│   │   │   ├── thermal_vapix.c +│   │   │   └── thermal_vapix.h +│   │   ├── vinput +│   │   │   ├── Makefile +│   │   │   ├── README.md +│   │   │   ├── vinput_plugin.c +│   │   │   ├── vinput_plugin.h +│   │   │   ├── vinput_vapix.c +│   │   │   └── vinput_vapix.h +│   │   └── your_plugin +│   │   ├── Makefile +│   │   ├── your_plugin.c +│   │   └── your_plugin.h +│   ├── ua_utils.c +│   └── vapix_utils.c ├── assets ├── CODEOWNERS ├── CONTRIBUTING.md diff --git a/app/Makefile b/app/Makefile index 8bad5d3..bb25ad8 100644 --- a/app/Makefile +++ b/app/Makefile @@ -3,7 +3,7 @@ SRCS = $(wildcard *.c) OBJS = $(SRCS:.c=.o) DEPS = $(patsubst %.c,%.d,$(SRCS)) -PKGS = gio-2.0 glib-2.0 gio-unix-2.0 gmodule-2.0 axparameter +PKGS = gio-2.0 glib-2.0 gio-unix-2.0 gmodule-2.0 libcurl axparameter CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) diff --git a/app/include/ua_utils.h b/app/include/ua_utils.h new file mode 100644 index 0000000..71185d1 --- /dev/null +++ b/app/include/ua_utils.h @@ -0,0 +1,132 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __UA_UTILS_H__ +#define __UA_UTILS_H__ + +#include +#include + +typedef struct rollback_data { + /* used to save the existing 'customDataTypes' of the server configuration + * (struct UA_ServerConfig) */ + const UA_DataTypeArray *saved_cdt; + + /* a list of 'struct UA_NodeId' that have been added to the server */ + GList *node_ids; +} rollback_data_t; + +/* Performs a 'deep' free() to deallocate the 'rollback_data_t' structure with + * its associated 'node_ids' list */ +void +ua_utils_clear_rbd(rollback_data_t **rbd); + +/* Loops over the rbd->node_ids list in reverse order and deletes the nodes + * from the information model. + * NOTE: the list is populated by pre-pending, traversing it in forward + * direction will visit the nodes in the reverse order of their addition. + * IMPORTANT: This can only be called before the server thread gets started + * as it can change the server configuration. */ +gboolean +ua_utils_do_rollback(UA_Server *server, rollback_data_t *rbd, GError **err); + +/* wrapper around the open62541 UA_Server_addObjectNode() + * If underlying UA_Server_addObjectNode() succeeds it also adds the nodeId to + * the rbd (rollback data) */ +UA_StatusCode +UA_Server_addObjectNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_NodeId typeDefinition, + const UA_ObjectAttributes attr, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId); + +/* wrapper around the open62541 UA_Server_addDataTypeNode() + * If underlying UA_Server_addDataTypeNode() succeeds it also adds the nodeId to + * the rbd (rollback data) */ +UA_StatusCode +UA_Server_addDataTypeNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_DataTypeAttributes attr, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId); + +/* wrapper around the open62541 UA_Server_addVariableNode() + * If underlying UA_Server_addVariableNode() succeeds it also adds the nodeId to + * the rbd (rollback data) */ +UA_StatusCode +UA_Server_addVariableNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_NodeId typeDefinition, + const UA_VariableAttributes attr, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId); + +/* wrapper around the open62541 UA_Server_addObjectTypeNode() + * If underlying UA_Server_addObjectTypeNode() succeeds it also adds the nodeId + * to the rbd (rollback data) */ +UA_StatusCode +UA_Server_addObjectTypeNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_ObjectTypeAttributes attr, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId); + +/* wrapper around the open62541 UA_Server_addMethodNode() + * If underlying UA_Server_addMethodNode() succeeds it also adds the nodeId + * to the rbd (rollback data) */ +UA_StatusCode +UA_Server_addMethodNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_MethodAttributes attr, + UA_MethodCallback method, + size_t inputArgumentsSize, + const UA_Argument *inputArguments, + size_t outputArgumentsSize, + const UA_Argument *outputArguments, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId); + +#endif /* __UA_UTILS_H__ */ diff --git a/app/include/vapix_utils.h b/app/include/vapix_utils.h new file mode 100644 index 0000000..a573248 --- /dev/null +++ b/app/include/vapix_utils.h @@ -0,0 +1,78 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __VAPIX_UTILS_H__ +#define __VAPIX_UTILS_H__ + +#include +#include + +/* HTTP request methods we use for VAPIX APIs */ +typedef enum { + HTTP_GET, + HTTP_POST, +} HTTP_req_method_t; + +typedef enum { NONE_data, XML_data, JSON_data } HTTP_media_t; + +/** + * vapix_get_credentials: + * @username: a user name which will be used to get credentials for to perform + * VAPIX calls + * @err: return location for a #GError + * + * Returns the required credentials for @username to perform VAPIX calls. + * + * Returns: a newly-allocated string holding the credentials on success, NULL + * if @err is set. + */ +gchar * +vapix_get_credentials(const gchar *username, GError **err); + +/** + * vapix_request: + * @handle: a cURL handle obtained with curl_easy_init() + * @credentials: credentials string obtained via vapix_get_credentials() + * @endpoint: the endpoint part of the VAPIX API + * @req_type: HTTP_GET or HTTP_POST + * @post_req: NULL if @req_type is HTTP_GET or a string holding the POST data + * if @req_type is HTTP_POST + * @err: return location for a #GError + * + * Performs a VAPIX API request. + * + * Returns: a newly-allocated string holding the VAPIX response on success, NULL + * if @err is set. + */ +gchar * +vapix_request(CURL *handle, + const gchar *credentials, + const gchar *endpoint, + HTTP_req_method_t req_type, + HTTP_media_t media_type, + const gchar *post_req, + GError **err); + +#endif /* __VAPIX_UTILS_H__ */ diff --git a/app/manifest.json b/app/manifest.json index 27e9224..d0bfddb 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -28,6 +28,11 @@ "name": "Port", "type": "int:min=1024,max=65535", "default": "4840" + }, + { + "name": "ExtendLogs", + "type": "bool:no,yes", + "default": "no" } ] } diff --git a/app/opcua_open62541.c b/app/opcua_open62541.c index 5ac0f9c..df09b9a 100644 --- a/app/opcua_open62541.c +++ b/app/opcua_open62541.c @@ -95,7 +95,9 @@ ua_server_init(app_context_t *ctx, /* Adjust the logging level for the server thread to be in sync with the * logging level set for the ACAP via the 'LogLevel' configuration parameter. */ - config->logging->context = (void *) log_level; + if (ctx->extend_logs) { + config->logging->context = (void *) log_level; + } /* Name of the server */ UA_String_clear(&config->applicationDescription.applicationName.text); diff --git a/app/opcua_parameter.c b/app/opcua_parameter.c index 0bfacc1..da80e57 100644 --- a/app/opcua_parameter.c +++ b/app/opcua_parameter.c @@ -82,6 +82,25 @@ handle_port(app_context_t *ctx, gint val, GError **err) return TRUE; } +static gboolean +handle_extend_logs(app_context_t *ctx, const gchar *val, GError **err) +{ + g_assert(ctx != NULL); + g_assert(val != NULL); + g_assert(err == NULL || *err == NULL); + + if (g_strcmp0(val, "no") == 0) { + ctx->extend_logs = FALSE; + } else if (g_strcmp0(val, "yes") == 0) { + ctx->extend_logs = TRUE; + } else { + SET_ERROR(err, -1, "Invalid values for \"extended logs\""); + return FALSE; + } + + return TRUE; +} + static gboolean handle_param(app_context_t *ctx, const gchar *name, @@ -107,6 +126,11 @@ handle_param(app_context_t *ctx, g_prefix_error(err, "handle_port() failed: "); return FALSE; } + } else if (g_strcmp0(name, "ExtendLogs") == 0) { + if (!handle_extend_logs(ctx, value, err)) { + g_prefix_error(err, "handle_port() failed: "); + return FALSE; + } } else { SET_ERROR(err, -1, "Axparam: %s is not supported", name); return FALSE; @@ -167,5 +191,10 @@ init_ua_parameters(app_context_t *ctx, const gchar *app_name, GError **err) return FALSE; } + if (!setup_param(ctx, "ExtendLogs", ctx->axparam, err)) { + g_prefix_error(err, "setup_param() failed: "); + return FALSE; + } + return TRUE; } diff --git a/app/opcua_server.h b/app/opcua_server.h index 936f748..8def3f0 100644 --- a/app/opcua_server.h +++ b/app/opcua_server.h @@ -52,6 +52,8 @@ typedef struct { volatile UA_Boolean ua_server_running; /* a GLib thread handle for the OPC-UA thread */ GThread *ua_server_thread_id; + /* flag to extend the logs or not */ + gboolean extend_logs; } app_context_t; #endif /* __OPCUA_SERVER_H__ */ diff --git a/app/plugin.c b/app/plugin.c index 90f2839..712f18c 100644 --- a/app/plugin.c +++ b/app/plugin.c @@ -116,7 +116,7 @@ plugin_load(const gchar *plugin_name, UA_Logger *logger, GError **err) return NULL; } - p = g_slice_new0(opc_plugin_t); + p = g_new0(opc_plugin_t, 1); p->module = module; p->filename = filename; @@ -136,7 +136,7 @@ plugin_load(const gchar *plugin_name, UA_Logger *logger, GError **err) g_clear_pointer(&p->filename, g_free); p->module = NULL; - g_slice_free(opc_plugin_t, p); + g_clear_pointer(&p, g_free); return NULL; } @@ -162,5 +162,5 @@ plugin_unload(opc_plugin_t *plugin, UA_Logger *logger) } g_clear_pointer(&plugin->filename, g_free); - g_slice_free(opc_plugin_t, plugin); + g_clear_pointer(&plugin, g_free); } diff --git a/app/plugins/bdi/Makefile b/app/plugins/bdi/Makefile index bcda6eb..638b106 100644 --- a/app/plugins/bdi/Makefile +++ b/app/plugins/bdi/Makefile @@ -7,7 +7,7 @@ SRCS = $(wildcard *.c) OBJS = $(SRCS:.c=.o) DEPS = $(patsubst %.c,%.d,$(SRCS)) -PKGS = gio-2.0 glib-2.0 gmodule-2.0 libcurl jansson +PKGS = gio-2.0 glib-2.0 gmodule-2.0 jansson CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) diff --git a/app/plugins/bdi/README.md b/app/plugins/bdi/README.md new file mode 100644 index 0000000..1f180e6 --- /dev/null +++ b/app/plugins/bdi/README.md @@ -0,0 +1,29 @@ +# Basic Device Information Plugin + +## Description + +This plugin provides basic device information through OPC-UA nodes. It exposes +various properties of the Axis device such as model name, firmware version, +serial number, and other device-specific information. + +## Features + +- **OPC-UA Nodes:** + - The plugin creates an object node called **BasicDeviceInfo** under the + Objects folder. + - This object contains multiple variable nodes representing different + device properties. + - Example properties include: ModelName, FirmwareVersion, SerialNumber, + HardwareID. + +- **Interacting with the Plugin:** + - Connect to the OPC-UA server using an OPC-UA client like UaExpert + (as described in the main ACAP readme). + - Navigate to the **BasicDeviceInfo** object node to view all available + device properties. + - Read operations can be performed on individual property nodes to + retrieve their values. + +## License + +**[MIT License](../../../LICENSE)** diff --git a/app/plugins/bdi/bdi_plugin.c b/app/plugins/bdi/bdi_plugin.c index ca102f6..1631608 100644 --- a/app/plugins/bdi/bdi_plugin.c +++ b/app/plugins/bdi/bdi_plugin.c @@ -32,6 +32,8 @@ #include "bdi_plugin.h" #include "error.h" #include "log.h" +#include "ua_utils.h" +#include "vapix_utils.h" #define UA_PLUGIN_NAMESPACE "http://www.axis.com/OpcUA/BasicDeviceInformation/" #define UA_PLUGIN_NAME "opc-bdi-plugin" @@ -41,14 +43,8 @@ #define ERR_NOT_INITIALIZED "The " UA_PLUGIN_NAME " is not initialized" #define ERR_NO_NAME "The " UA_PLUGIN_NAME " was not given a name" -#define VAPIX_URL "http://127.0.0.12/axis-cgi/%s" - #define BASIC_DEVICE_INFO_CGI_ENDPOINT "basicdeviceinfo.cgi" -#define CONF1_DBUS_SERVICE "com.axis.HTTPConf1" -#define CONF1_DBUS_OBJECT_PATH "/com/axis/HTTPConf1/VAPIXServiceAccounts1" -#define CONF1_DBUS_INTERFACE "com.axis.HTTPConf1.VAPIXServiceAccounts1" - DEFINE_GQUARK(UA_PLUGIN_NAME) typedef struct plugin { @@ -58,228 +54,12 @@ typedef struct plugin { UA_UInt16 ns; /* an open62541 logger */ UA_Logger *logger; + /* keep track of data that needs to be rolled back in case of failure */ + rollback_data_t *rbd; } plugin_t; static plugin_t *plugin; -/* Local functions */ -static size_t -post_write_cb(gchar *ptr, size_t size, size_t nmemb, void *userdata) -{ - size_t processed_bytes; - - g_assert(ptr != NULL); - g_assert(userdata != NULL); - - processed_bytes = size * nmemb; - - g_string_append_len((GString *) userdata, ptr, processed_bytes); - - return processed_bytes; -} - -static void -set_curl_setopt_error(CURLcode res, GError **err) -{ - g_assert(err == NULL || *err == NULL); - - SET_ERROR(err, - -1, - "curl_easy_setopt error %d: '%s'", - res, - curl_easy_strerror(res)); -} - -static gchar * -post_full(CURL *handle, - const gchar *credentials, - const gchar *endpoint, - const gchar *request, - GError **err) -{ - glong code; - GString *response; - gchar *url = NULL; - CURLcode res; - - g_assert(handle != NULL); - g_assert(credentials != NULL); - g_assert(endpoint != NULL); - g_assert(request != NULL); - g_assert(err == NULL || *err == NULL); - - url = g_strdup_printf(VAPIX_URL, endpoint); - response = g_string_new(NULL); - - res = curl_easy_setopt(handle, CURLOPT_URL, url); - - if (res != CURLE_OK) { - set_curl_setopt_error(res, err); - goto err_out; - } - - res = curl_easy_setopt(handle, CURLOPT_USERPWD, credentials); - - if (res != CURLE_OK) { - set_curl_setopt_error(res, err); - goto err_out; - } - - res = curl_easy_setopt(handle, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); - - if (res != CURLE_OK) { - set_curl_setopt_error(res, err); - goto err_out; - } - - res = curl_easy_setopt(handle, CURLOPT_POSTFIELDS, request); - - if (res != CURLE_OK) { - set_curl_setopt_error(res, err); - goto err_out; - } - - res = curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, post_write_cb); - - if (res != CURLE_OK) { - set_curl_setopt_error(res, err); - goto err_out; - } - - res = curl_easy_setopt(handle, CURLOPT_WRITEDATA, response); - - if (res != CURLE_OK) { - set_curl_setopt_error(res, err); - goto err_out; - } - - res = curl_easy_perform(handle); - - if (res != CURLE_OK) { - SET_ERROR(err, - -1, - "curl_easy_perform error %d: '%s'", - res, - curl_easy_strerror(res)); - - goto err_out; - } - - res = curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &code); - - if (res != CURLE_OK) { - SET_ERROR(err, - -1, - "curl_easy_getinfo error %d: '%s'", - res, - curl_easy_strerror(res)); - - goto err_out; - } - - if (code != 200) { - SET_ERROR(err, - -1, - "Got response code %ld from request to %s with response '%s'", - code, - request, - response->str); - g_string_free(response, TRUE); - g_clear_pointer(&url, g_free); - return NULL; - } - -err_out: - g_clear_pointer(&url, g_free); - - return g_string_free(response, FALSE); -} - -static gchar * -parse_credentials(GVariant *result, GError **err) -{ - gchar *v_creds = NULL; - gchar *credentials = NULL; - gchar **split = NULL; - guint len; - - g_assert(result != NULL); - g_assert(err == NULL || *err == NULL); - - g_variant_get(result, "(&s)", &v_creds); - - split = g_strsplit(v_creds, ":", -1); - if (split == NULL) { - SET_ERROR(err, -1, "Error parsing credential string: '%s'", v_creds); - goto out; - } - - len = g_strv_length(split); - if (len != 2) { - SET_ERROR(err, - -1, - "Invalid credential string length (%u): '%s'", - len, - v_creds); - goto out; - } - - credentials = g_strdup_printf("%s:%s", split[0], split[1]); - -out: - if (split != NULL) { - g_strfreev(split); - } - - return credentials; -} - -static gchar * -get_vapix_credentials(const gchar *username, GError **err) -{ - GDBusConnection *con = NULL; - GVariant *result = NULL; - gchar *credentials = NULL; - - g_assert(username != NULL); - g_assert(err == NULL || *err == NULL); - - con = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, err); - if (con == NULL) { - g_prefix_error(err, "Error connecting to D-Bus: "); - return NULL; - } - - result = g_dbus_connection_call_sync(con, - CONF1_DBUS_SERVICE, - CONF1_DBUS_OBJECT_PATH, - CONF1_DBUS_INTERFACE, - "GetCredentials", - g_variant_new("(s)", username), - NULL, - G_DBUS_CALL_FLAGS_NONE, - -1, - NULL, - err); - if (result == NULL) { - g_prefix_error(err, "Failed to get credentials: "); - goto out; - } - - credentials = parse_credentials(result, err); - - if (credentials == NULL) { - g_prefix_error(err, "parse_credentials() failed: "); - } - - g_variant_unref(result); - -out: - g_object_unref(con); - - return credentials; -} - static gboolean vapix_get_basic_device_information(GHashTable **bdi_hashtable, GError **err) { @@ -307,17 +87,19 @@ vapix_get_basic_device_information(GHashTable **bdi_hashtable, GError **err) goto out; } - credentials = get_vapix_credentials("vapix-basicdeviceinfo-user", err); + credentials = vapix_get_credentials("vapix-basicdeviceinfo-user", err); if (credentials == NULL) { g_prefix_error(err, "Failed to get the VAPIX credentials: "); goto out; } - response = post_full(curl_h, - credentials, - BASIC_DEVICE_INFO_CGI_ENDPOINT, - request, - err); + response = vapix_request(curl_h, + credentials, + BASIC_DEVICE_INFO_CGI_ENDPOINT, + HTTP_POST, + JSON_data, + request, + err); if (response == NULL) { g_prefix_error(err, "Failed to get the basic device information: "); goto out; @@ -327,7 +109,6 @@ vapix_get_basic_device_information(GHashTable **bdi_hashtable, GError **err) if (json_response == NULL) { SET_ERROR(err, -1, "Invalid JSON response: %s", parse_error.text); - retval = FALSE; goto out; } @@ -389,6 +170,8 @@ add_variable_to_object(UA_Server *server, g_assert(name != NULL); g_assert(value != NULL); g_assert(err == NULL || *err == NULL); + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); attr.accessLevel = UA_ACCESSLEVELMASK_READ; ua_value = UA_STRING(value); @@ -398,19 +181,22 @@ add_variable_to_object(UA_Server *server, attr.description = UA_LOCALIZEDTEXT("en-US", name); retval = - UA_Server_addVariableNode(server, - UA_NODEID_NUMERIC(plugin->ns, 0), - parent, - UA_NODEID_NUMERIC(0, UA_NS0ID_HASPROPERTY), - UA_QUALIFIEDNAME(plugin->ns, name), - UA_NODEID_NUMERIC(0, UA_NS0ID_PROPERTYTYPE), - attr, - NULL, - NULL); + UA_Server_addVariableNode_rb(server, + UA_NODEID_NUMERIC(plugin->ns, 0), + parent, + UA_NODEID_NUMERIC(0, + UA_NS0ID_HASPROPERTY), + UA_QUALIFIEDNAME(plugin->ns, name), + UA_NODEID_NUMERIC(0, + UA_NS0ID_PROPERTYTYPE), + attr, + NULL, + plugin->rbd, + NULL); if (retval != UA_STATUSCODE_GOOD) { SET_ERROR(err, -1, - "UA_Server_addVariableNode() failed: %s", + "UA_Server_addVariableNode_rb() failed: %s", UA_StatusCode_name(retval)); return FALSE; } @@ -425,6 +211,7 @@ add_bdi_object(UA_Server *server, UA_NodeId *outId, GError **err) UA_ObjectAttributes attr = UA_ObjectAttributes_default; g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); g_assert(server != NULL); g_assert(outId != NULL); g_assert(err == NULL || *err == NULL); @@ -433,20 +220,24 @@ add_bdi_object(UA_Server *server, UA_NodeId *outId, GError **err) attr.description = UA_LOCALIZEDTEXT("en-US", UA_BDI_OBJ_DESCRIPTION); status = - UA_Server_addObjectNode(server, - UA_NODEID_NUMERIC(plugin->ns, 0), - UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER), - UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), - UA_QUALIFIEDNAME(plugin->ns, - UA_BDI_OBJ_DISPLAY_NAME), - UA_NODEID_NUMERIC(0, UA_NS0ID_BASEOBJECTTYPE), - attr, - NULL, - outId); + UA_Server_addObjectNode_rb(server, + UA_NODEID_NUMERIC(plugin->ns, 0), + UA_NODEID_NUMERIC(0, + UA_NS0ID_OBJECTSFOLDER), + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + UA_QUALIFIEDNAME(plugin->ns, + UA_BDI_OBJ_DISPLAY_NAME), + UA_NODEID_NUMERIC(0, + UA_NS0ID_BASEOBJECTTYPE), + attr, + NULL, + plugin->rbd, + outId); + if (status != UA_STATUSCODE_GOOD) { SET_ERROR(err, -1, - "Failed to add variable node BasicDeviceInfo: %s", + "Failed to add object node BasicDeviceInfo: %s", UA_StatusCode_name(status)); return FALSE; } @@ -461,7 +252,6 @@ add_basic_device_info_data(UA_Server *server, UA_NodeId bdiNode, GError **err) GHashTableIter ht_iter; gpointer key; gpointer value; - GError *lerr = NULL; gboolean retval = FALSE; g_assert(server != NULL); @@ -492,10 +282,8 @@ add_basic_device_info_data(UA_Server *server, UA_NodeId bdiNode, GError **err) bdiNode, (gchar *) key, (gchar *) value, - &lerr)) { - LOG_W(plugin->logger, - "add_variable_to_object() failed: %s", - GERROR_MSG(lerr)); + err)) { + g_prefix_error(err, "add_variable_to_object() failed: "); goto err_out; } } @@ -504,7 +292,6 @@ add_basic_device_info_data(UA_Server *server, UA_NodeId bdiNode, GError **err) err_out: g_hash_table_unref(bdi_hashtable); - g_clear_error(&lerr); return retval; } @@ -516,6 +303,10 @@ plugin_cleanup(void) plugin->logger = NULL; g_clear_pointer(&plugin->name, g_free); + + /* free up allocated rollback data, if any */ + ua_utils_clear_rbd(&plugin->rbd); + g_clear_pointer(&plugin, g_free); } @@ -526,6 +317,7 @@ opc_ua_create(UA_Server *server, G_GNUC_UNUSED gpointer *params, GError **err) { + GError *lerr = NULL; UA_NodeId bdi_node; g_return_val_if_fail(server != NULL, FALSE); @@ -540,6 +332,8 @@ opc_ua_create(UA_Server *server, plugin->name = g_strdup(UA_PLUGIN_NAME); plugin->logger = logger; + plugin->rbd = g_new0(rollback_data_t, 1); + plugin->ns = UA_Server_addNamespace(server, UA_PLUGIN_NAMESPACE); /* add a bdi object to the opc-ua server */ @@ -554,11 +348,20 @@ opc_ua_create(UA_Server *server, goto err_out; } + /* the information model was successfully populated so now we can free up our + * rollback data since we no longer need it */ + ua_utils_clear_rbd(&plugin->rbd); + return TRUE; err_out: - /* Remove the added nodes from the server if something fails */ - UA_Server_deleteNode(server, bdi_node, TRUE); + if (!ua_utils_do_rollback(server, plugin->rbd, &lerr)) { + LOG_E(plugin->logger, + "ua_utils_do_rollback() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + plugin_cleanup(); return FALSE; diff --git a/app/plugins/hello_world/README.md b/app/plugins/hello_world/README.md new file mode 100644 index 0000000..e3897fa --- /dev/null +++ b/app/plugins/hello_world/README.md @@ -0,0 +1,44 @@ +# Hello World Plugin + +## Description + +This is a simple example plugin that demonstrates how to create a basic OPC-UA +variable node. It creates a single read/write variable node called +"HelloWorldNode" and serves as a template for developing custom plugins. + +## Features + +- Creates a single OPC-UA variable node called **HelloWorldNode** +under the Objects folder. +- The node supports both read and write operations. +- Its default value is "Hello World!". + +## Development + +To create your own plugin based on this example: + +- **Create a new plugin directory:** + + ```bash + cd app/plugins + mkdir my_new_plugin + cd my_new_plugin + ``` + +- **Copy the contents of hello_world plugin:** + + ```bash + cp ../hello_world/Makefile . + cp ../hello_world/hello_world_plugin.h my_plugin.h + cp ../hello_world/hello_world_plugin.c my_plugin.c + ``` + +- **Mandatory changes:** + - Update the namespace in `my_plugin.c` (change `UA_PLUGIN_NAMESPACE`). + This ensures your plugin has a unique identifier within the OPC-UA server. + - Update the plugin name in `my_plugin.c` (change `UA_PLUGIN_NAME`). + This defines the name by which the plugin is identified. + +## License + +**[MIT License](../../../LICENSE)** diff --git a/app/plugins/hello_world/hello_world_plugin.c b/app/plugins/hello_world/hello_world_plugin.c index fde6432..8f2acd6 100644 --- a/app/plugins/hello_world/hello_world_plugin.c +++ b/app/plugins/hello_world/hello_world_plugin.c @@ -29,6 +29,7 @@ #include "hello_world_plugin.h" #include "error.h" #include "log.h" +#include "ua_utils.h" #define UA_PLUGIN_NAMESPACE "http://www.axis.com/OpcUA/HelloWorld/" #define UA_PLUGIN_NAME "opc-hello-world-plugin" @@ -48,6 +49,8 @@ typedef struct plugin { UA_UInt16 ns; /* an open62541 logger */ UA_Logger *logger; + /* keep track of data that needs to be rolled back in case of failure */ + rollback_data_t *rbd; } plugin_t; static plugin_t *plugin; @@ -60,6 +63,8 @@ add_hello_world_node(UA_Server *server, GError **err) UA_String value = UA_STRING(UA_VALUE); UA_VariableAttributes attr = UA_VariableAttributes_default; + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); g_assert(server != NULL); g_assert(err == NULL || *err == NULL); @@ -69,7 +74,7 @@ add_hello_world_node(UA_Server *server, GError **err) attr.displayName = UA_LOCALIZEDTEXT("en-US", UA_DISPLAY_NAME); attr.description = UA_LOCALIZEDTEXT("en-US", UA_DESCRIPTION); - status = UA_Server_addVariableNode( + status = UA_Server_addVariableNode_rb( server, UA_NODEID_STRING(plugin->ns, UA_DISPLAY_NAME), UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER), @@ -78,7 +83,9 @@ add_hello_world_node(UA_Server *server, GError **err) UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE), attr, NULL, + plugin->rbd, NULL); + if (status != UA_STATUSCODE_GOOD) { SET_ERROR(err, -1, @@ -97,7 +104,11 @@ plugin_cleanup(void) plugin->logger = NULL; g_clear_pointer(&plugin->name, g_free); - g_slice_free(plugin_t, plugin); + + /* free up allocated rollback data, if any */ + ua_utils_clear_rbd(&plugin->rbd); + + g_clear_pointer(&plugin, g_free); } /* Exported functions */ @@ -107,6 +118,8 @@ opc_ua_create(UA_Server *server, G_GNUC_UNUSED gpointer *params, GError **err) { + GError *lerr = NULL; + g_return_val_if_fail(server != NULL, FALSE); g_return_val_if_fail(logger != NULL, FALSE); g_return_val_if_fail(err == NULL || *err == NULL, FALSE); @@ -115,20 +128,36 @@ opc_ua_create(UA_Server *server, return TRUE; } - plugin = g_slice_new0(plugin_t); + plugin = g_new0(plugin_t, 1); plugin->name = g_strdup(UA_PLUGIN_NAME); plugin->logger = logger; + plugin->rbd = g_new0(rollback_data_t, 1); plugin->ns = UA_Server_addNamespace(server, UA_PLUGIN_NAMESPACE); if (!add_hello_world_node(server, err)) { g_prefix_error(err, "add_hello_world_node() failed: "); - plugin_cleanup(); - return FALSE; + goto err_out; } + /* the information model was successfully populated so now we can free up our + * rollback data since we no longer need it */ + ua_utils_clear_rbd(&plugin->rbd); + return TRUE; + +err_out: + if (!ua_utils_do_rollback(server, plugin->rbd, &lerr)) { + LOG_E(plugin->logger, + "ua_utils_do_rollback() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + + plugin_cleanup(); + + return FALSE; } void diff --git a/app/plugins/ioports/Makefile b/app/plugins/ioports/Makefile new file mode 100644 index 0000000..cdd03ed --- /dev/null +++ b/app/plugins/ioports/Makefile @@ -0,0 +1,59 @@ +# The name of the plugin module (shared object) is built up by concatenating: +# * the prefix: 'libopcua_' +# * the directory/plugin name under 'plugins' +# * the suffix: '.so' +TARGET_LIB = libopcua_$(notdir $(CURDIR)).so +SRCS = $(wildcard *.c) +OBJS = $(SRCS:.c=.o) +DEPS = $(patsubst %.c,%.d,$(SRCS)) + +PKGS = gio-2.0 glib-2.0 gmodule-2.0 axevent jansson +CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) +LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) + +LIB_OPEN62541 = $(SDKTARGETSYSROOT)/usr/lib/libopen62541.a +LIBS += $(LIB_OPEN62541) + +# app/include +INCLUDE = ../../include + +# All the plugin modules are to be placed in 'app/lib'. 'eap-create.sh' will +# automatically pick up the 'lib' folder and add it to the final .eap file. +TARGET_LIB_DIR = ../../lib + +CFLAGS += -I. +CFLAGS += $(addprefix -I, $(INCLUDE)) + +CFLAGS += -Wall \ + -Wextra \ + -Wformat=2 \ + -Wpointer-arith \ + -Wbad-function-cast \ + -Wstrict-prototypes \ + -Wmissing-prototypes \ + -Winline \ + -Wdisabled-optimization \ + -Wfloat-equal \ + -W \ + -Werror \ + -Wno-maybe-uninitialized + +# preprocessor flags to generate Makefile dependencies +CPPFLAGS = -MMD -MP + +CFLAGS_MODULES = $(CFLAGS) -fPIC + +all: $(TARGET_LIB_DIR)/$(TARGET_LIB) + +$(TARGET_LIB_DIR)/$(TARGET_LIB): $(TARGET_LIB) + mkdir -p $(TARGET_LIB_DIR) + cp $(TARGET_LIB) $(TARGET_LIB_DIR) + +$(TARGET_LIB): $(OBJS) + $(CC) $(CFLAGS_MODULES) $(CPPFLAGS) $(LDFLAGS) -shared $^ $(LIBS) $(LDLIBS) \ + -o $@ + +-include $(DEPS) + +clean: + rm -f $(DEPS) *.o core *.so $(TARGET_LIB_DIR)/$(TARGET_LIB) diff --git a/app/plugins/ioports/README.md b/app/plugins/ioports/README.md new file mode 100644 index 0000000..6c10f96 --- /dev/null +++ b/app/plugins/ioports/README.md @@ -0,0 +1,41 @@ +# Input/Output Plugin + +## Description + +This plugin exposes the Input/Output Ports of the Axis device over the OPC-UA +protocol. It aims at providing the OPC-UA equivalent of the [I/O port management API](https://developer.axis.com/vapix/network-video/io-port-management). + +## Features + +The plugin presents the I/O Ports as OPC-UA objects in a tree structure as +follows: + +- **I/O Ports** (objects folder) + - **I/O Port \<#\>** (object with following properties:) + - `Configurable`: true/false + - `Direction`: 0 (Input)/1 (Output) + - `Disabled`: true/false + - `Index`: integer + - `Name`: string + - `NormalState`: 0 (Open)/1 (Closed) + - `State`: 0 (Open)/1 (Closed) + - `Usage`: string + +The plugin also implements an OPC-UA event type: `IOPStateEventType` (a subtype of +`IOPEventType`). This OPC-UA event is sent out when the current state of an I/O +Port changes. + +| Property | Access | Type | Description | +|--------------|-----------|---------------------|-----------------------------| +| Configurable | R/O | Boolean | Indicates if the direction of the I/O port is user configurable or not | +| Direction | R/O - R/W | IOPortDirectionType | Determines if the port is an input or an output. R/O if `Configurable` is FALSE. | +| Disabled | R/O | Boolean | If TRUE no port properties can be changed by the user | +| Index | R/O | Int32 | The port number/index | +| Name | R/W | String | User configurable name assigned to the port | +| NormalState | R/W | IOPortStateType | The desired normal state of the port: `OPEN` or `CLOSED` | +| State | R/O - R/W | IOPortStateType | The current state of the port: `OPEN` or `CLOSED` | +| Usage | R/W | String | User configurable usage description for the port | + +## License + +**[MIT License](../../../LICENSE)** diff --git a/app/plugins/ioports/ioports_nodeids.h b/app/plugins/ioports/ioports_nodeids.h new file mode 100644 index 0000000..f49c462 --- /dev/null +++ b/app/plugins/ioports/ioports_nodeids.h @@ -0,0 +1,50 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __IOPORTS_NODEIDS_H__ +#define __IOPORTS_NODEIDS_H__ + +/* clang-format off */ +#define UA_IOPID_IOPORTOBJTYPE 1004 /* ObjectType */ +#define UA_IOPID_IOPEVENTTYPE 1005 /* ObjectType */ +#define UA_IOPID_IOPSTATEEVENTTYPE 1008 /* ObjectType */ +#define UA_IOPID_IOPDIRECTIONEVENTTYPE 1011 /* ObjectType */ +#define UA_IOPID_IOPNORMALSTATEEVENTTYPE 1014 /* ObjectType */ +#define UA_IOPID_IOPORTDIRECTIONTYPE 3004 /* DataType */ +#define UA_IOPID_IOPORTSTATETYPE 3005 /* DataType */ +#define UA_IOPID_IOPORTS 5006 /* Object */ +#define UA_IOPID_IOPORTOBJTYPE_CONFIGURABLE 6007 /* Variable */ +#define UA_IOPID_IOPORTOBJTYPE_DIRECTION 6008 /* Variable */ +#define UA_IOPID_IOPORTOBJTYPE_DISABLED 6009 /* Variable */ +#define UA_IOPID_IOPORTOBJTYPE_INDEX 6010 /* Variable */ +#define UA_IOPID_IOPORTOBJTYPE_NAME 6011 /* Variable */ +#define UA_IOPID_IOPORTOBJTYPE_NORMALSTATE 6012 /* Variable */ +#define UA_IOPID_IOPORTOBJTYPE_STATE 6013 /* Variable */ +#define UA_IOPID_IOPORTOBJTYPE_USAGE 6014 /* Variable */ +#define UA_IOPID_IOPORTDIRECTIONTYPE_ENUMSTRINGS 6026 /* Variable */ +#define UA_IOPID_IOPORTSTATETYPE_ENUMSTRINGS 6042 /* Variable */ +/* clang-format on */ + +#endif /* __IOPORTS_NODEIDS_H__ */ diff --git a/app/plugins/ioports/ioports_ns.c b/app/plugins/ioports/ioports_ns.c new file mode 100644 index 0000000..7544bce --- /dev/null +++ b/app/plugins/ioports/ioports_ns.c @@ -0,0 +1,512 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include + +#include "error.h" +#include "ioports_nodeids.h" +#include "ioports_ns.h" +#include "ua_utils.h" + +#define UA_NS0_NAMESPACE "http://opcfoundation.org/UA/" + +/* I/O port: object type */ +#define IOP_OBJECT_TYPE_BNAME "IOPortObjType" + +/* I/O port: event types */ +#define IOP_EVENT_TYPE_BNAME "IOPEventType" +#define IOP_DIR_EVENT_BNAME "IOPDirectionEventType" +#define IOP_NORMALSTATE_EVENT_BNAME "IOPNormalStateEventType" +#define IOP_STATE_EVENT_BNAME "IOPStateEventType" + +/* I/O port: root object */ +#define IOP_ROOT_BNAME "I/O Ports" + +/* I/O port: direction values */ +#define IOP_DIR_INPUT "Input" +#define IOP_DIR_OUTPUT "Output" +/* I/O port: state value */ +#define IOP_STATE_OPEN "Open" +#define IOP_STATE_CLOSED "Closed" + +#define UA_ENUM_STRINGS "EnumStrings" +#define IOP_NR_EVENTTYPES 3 + +typedef struct IOP_property_node { + UA_NodeId node_id; + UA_Byte access_level; + UA_NodeId data_type; + UA_LocalizedText d_name; /* display name */ + UA_QualifiedName q_name; /* qualified name */ +} IOP_property_node_t; + +typedef struct IOP_eventtype_node { + UA_NodeId node_id; + UA_LocalizedText d_name; /* display name */ + UA_QualifiedName q_name; /* qualified name */ +} IOP_eventtype_node_t; + +/* clang-format off */ +static UA_DataTypeArray customUA_TYPES_IOP = { + NULL, + UA_TYPES_IOP_COUNT, + UA_TYPES_IOP, + UA_FALSE +}; +/* clang-format on */ + +static UA_StatusCode +ioports_add_port_state_type(UA_Server *server, + UA_UInt16 *ns, + rollback_data_t *rbd) +{ + UA_StatusCode retv = UA_STATUSCODE_GOOD; + UA_DataTypeAttributes attr = UA_DataTypeAttributes_default; + UA_VariableAttributes vattr = UA_VariableAttributes_default; + UA_UInt32 array_dimensions[1]; + UA_LocalizedText enum_strings[2]; + + g_assert(server != NULL); + g_assert(ns != NULL); + g_assert(rbd != NULL); + + attr.displayName = UA_LOCALIZEDTEXT("", UA_TYPE_IOP_STATETYPE_NAME); + retv = UA_Server_addDataTypeNode_rb( + server, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTSTATETYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_ENUMERATION), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_HASSUBTYPE), + UA_QUALIFIEDNAME(ns[1], UA_TYPE_IOP_STATETYPE_NAME), + attr, + NULL, + rbd, + NULL); + + if (retv != UA_STATUSCODE_GOOD) { + return retv; + } + + /* add the children nodes */ + vattr.userAccessLevel = 1; + vattr.accessLevel = UA_ACCESSLEVELMASK_READ; + vattr.valueRank = UA_VALUERANK_ONE_DIMENSION; + vattr.arrayDimensionsSize = 1; + + array_dimensions[0] = 2; + vattr.arrayDimensions = &array_dimensions[0]; + vattr.dataType = UA_NODEID_NUMERIC(ns[0], UA_NS0ID_LOCALIZEDTEXT); + + enum_strings[0] = UA_LOCALIZEDTEXT("", IOP_STATE_OPEN); + enum_strings[1] = UA_LOCALIZEDTEXT("", IOP_STATE_CLOSED); + + UA_Variant_setArray(&vattr.value, + &enum_strings, + (UA_Int32) 2, + &UA_TYPES[UA_TYPES_LOCALIZEDTEXT]); + vattr.displayName = UA_LOCALIZEDTEXT("", UA_ENUM_STRINGS); + + retv = UA_Server_addVariableNode_rb( + server, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTSTATETYPE_ENUMSTRINGS), + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTSTATETYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_HASPROPERTY), + UA_QUALIFIEDNAME(ns[0], UA_ENUM_STRINGS), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_PROPERTYTYPE), + vattr, + NULL, + rbd, + NULL); + + return retv; +} + +static UA_StatusCode +ioports_add_port_dir_type(UA_Server *server, + UA_UInt16 *ns, + rollback_data_t *rbd) +{ + UA_StatusCode retv = UA_STATUSCODE_GOOD; + UA_DataTypeAttributes attr = UA_DataTypeAttributes_default; + UA_VariableAttributes vattr = UA_VariableAttributes_default; + UA_UInt32 array_dimensions[1]; + UA_LocalizedText enum_strings[2]; + + g_assert(server != NULL); + g_assert(ns != NULL); + + attr.displayName = UA_LOCALIZEDTEXT("", UA_TYPE_IOP_DIRTYPE_NAME); + retv = UA_Server_addDataTypeNode_rb( + server, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTDIRECTIONTYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_ENUMERATION), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_HASSUBTYPE), + UA_QUALIFIEDNAME(ns[1], UA_TYPE_IOP_DIRTYPE_NAME), + attr, + NULL, + rbd, + NULL); + + if (retv != UA_STATUSCODE_GOOD) { + return retv; + } + + /* add the children nodes */ + vattr.userAccessLevel = 1; + vattr.accessLevel = UA_ACCESSLEVELMASK_READ; + vattr.valueRank = UA_VALUERANK_ONE_DIMENSION; + vattr.arrayDimensionsSize = 1; + + array_dimensions[0] = 2; + vattr.arrayDimensions = &array_dimensions[0]; + vattr.dataType = UA_NODEID_NUMERIC(ns[0], UA_NS0ID_LOCALIZEDTEXT); + + enum_strings[0] = UA_LOCALIZEDTEXT("", IOP_DIR_INPUT); + enum_strings[1] = UA_LOCALIZEDTEXT("", IOP_DIR_OUTPUT); + + UA_Variant_setArray(&vattr.value, + &enum_strings, + (UA_Int32) 2, + &UA_TYPES[UA_TYPES_LOCALIZEDTEXT]); + vattr.displayName = UA_LOCALIZEDTEXT("", UA_ENUM_STRINGS); + + retv = UA_Server_addVariableNode_rb( + server, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTDIRECTIONTYPE_ENUMSTRINGS), + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTDIRECTIONTYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_HASPROPERTY), + UA_QUALIFIEDNAME(ns[0], UA_ENUM_STRINGS), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_PROPERTYTYPE), + vattr, + NULL, + rbd, + NULL); + + return retv; +} + +/** + * Add definition nodes for our OPC-UA port events: + * - state change (open/closed) + * - normal state change (open/closed) + * - direction change (input/output) + **/ +static UA_StatusCode +ioports_add_port_event_type(UA_Server *server, + UA_UInt16 *ns, + rollback_data_t *rbd) +{ + guint i; + UA_StatusCode retv = UA_STATUSCODE_GOOD; + UA_ObjectTypeAttributes oattr = UA_ObjectTypeAttributes_default; + /* clang-format off */ + const IOP_eventtype_node_t IOP_eventtypes[IOP_NR_EVENTTYPES] = { + { + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPDIRECTIONEVENTTYPE), + .d_name = UA_LOCALIZEDTEXT("", IOP_DIR_EVENT_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], IOP_DIR_EVENT_BNAME), + }, + { + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPNORMALSTATEEVENTTYPE), + .d_name = UA_LOCALIZEDTEXT("", IOP_NORMALSTATE_EVENT_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], IOP_NORMALSTATE_EVENT_BNAME), + }, + { + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPSTATEEVENTTYPE), + .d_name = UA_LOCALIZEDTEXT("", IOP_STATE_EVENT_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], IOP_STATE_EVENT_BNAME), + }, + }; + /* clang-format on */ + + g_assert(server != NULL); + g_assert(ns != NULL); + + oattr.isAbstract = TRUE; + oattr.displayName = UA_LOCALIZEDTEXT("", IOP_EVENT_TYPE_BNAME); + + /* add object type (event types are object types) */ + retv |= UA_Server_addObjectTypeNode_rb( + server, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPEVENTTYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_BASEEVENTTYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_HASSUBTYPE), + UA_QUALIFIEDNAME(ns[1], IOP_EVENT_TYPE_BNAME), + oattr, + NULL, + rbd, + NULL); + + if (retv != UA_STATUSCODE_GOOD) { + return retv; + } + + /* add reference: an I/O port object generates I/O port event types */ + retv |= UA_Server_addReference( + server, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPEVENTTYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_GENERATESEVENT), + UA_EXPANDEDNODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE), + FALSE); + if (retv != UA_STATUSCODE_GOOD) { + return retv; + } + + /* add the particular I/O port event subtypes */ + for (i = 0; i < IOP_NR_EVENTTYPES; i++) { + oattr.displayName = IOP_eventtypes[i].d_name; + retv |= UA_Server_addObjectTypeNode_rb( + server, + IOP_eventtypes[i].node_id, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPEVENTTYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_HASSUBTYPE), + IOP_eventtypes[i].q_name, + oattr, + NULL, + rbd, + NULL); + if (retv != UA_STATUSCODE_GOOD) { + return retv; + } + } + + return retv; +} + +static UA_StatusCode +ioports_add_port_obj_type(UA_Server *server, + UA_UInt16 *ns, + rollback_data_t *rbd) +{ + UA_StatusCode retv = UA_STATUSCODE_GOOD; + UA_ObjectTypeAttributes oattr = UA_ObjectTypeAttributes_default; + UA_VariableAttributes vattr = UA_VariableAttributes_default; + guint i; + + /* clang-format off */ + const IOP_property_node_t IOP_properties[IOP_OBJ_NR_PROPS] = { + { /* "Configurable" property node */ + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE_CONFIGURABLE), + .access_level = UA_ACCESSLEVELMASK_READ, + .data_type = UA_NODEID_NUMERIC(ns[0], UA_NS0ID_BOOLEAN), + .d_name = UA_LOCALIZEDTEXT("", CONFIGURABLE_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], CONFIGURABLE_BNAME), + }, + { /* "Direction" property node */ + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE_DIRECTION), + .access_level = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE, + .data_type = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTDIRECTIONTYPE), + .d_name = UA_LOCALIZEDTEXT("", DIRECTION_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], DIRECTION_BNAME), + }, + { /* "Disabled" property node */ + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE_DISABLED), + .access_level = UA_ACCESSLEVELMASK_READ, + .data_type = UA_NODEID_NUMERIC(ns[0], UA_NS0ID_BOOLEAN), + .d_name = UA_LOCALIZEDTEXT("", DISABLED_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], DISABLED_BNAME), + }, + { /* Index property node */ + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE_INDEX), + .access_level = UA_ACCESSLEVELMASK_READ, + .data_type = UA_NODEID_NUMERIC(ns[0], UA_NS0ID_INT32), + .d_name = UA_LOCALIZEDTEXT("", INDEX_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], INDEX_BNAME), + }, + { /* Name property node */ + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE_NAME), + .access_level = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE, + .data_type = UA_NODEID_NUMERIC(ns[0], UA_NS0ID_STRING), + .d_name = UA_LOCALIZEDTEXT("", NAME_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], NAME_BNAME), + }, + { /* NormalState property node */ + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE_NORMALSTATE), + .access_level = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE, + .data_type = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTSTATETYPE), + .d_name = UA_LOCALIZEDTEXT("", NORMALSTATE_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], NORMALSTATE_BNAME), + }, + { /* State property node */ + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE_STATE), + .access_level = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE, + .data_type = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTSTATETYPE), + .d_name = UA_LOCALIZEDTEXT("", STATE_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], STATE_BNAME), + }, + { /* Usage property node */ + .node_id = UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE_USAGE), + .access_level = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE, + .data_type = UA_NODEID_NUMERIC(ns[0], UA_NS0ID_STRING), + .d_name = UA_LOCALIZEDTEXT("", USAGE_BNAME), + .q_name = UA_QUALIFIEDNAME(ns[1], USAGE_BNAME), + }, + }; + /* clang-format on */ + + g_assert(server != NULL); + g_assert(ns != NULL); + + oattr.displayName = UA_LOCALIZEDTEXT("", IOP_OBJECT_TYPE_BNAME); + retv = UA_Server_addObjectTypeNode_rb( + server, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_BASEOBJECTTYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_HASSUBTYPE), + UA_QUALIFIEDNAME(ns[1], IOP_OBJECT_TYPE_BNAME), + oattr, + NULL, + rbd, + NULL); + if (retv != UA_STATUSCODE_GOOD) { + return retv; + } + + /* add I/O port object type properties: */ + for (i = 0; i < IOP_OBJ_NR_PROPS; i++) { + vattr.accessLevel = IOP_properties[i].access_level; + vattr.dataType = IOP_properties[i].data_type; + vattr.displayName = IOP_properties[i].d_name; + retv = UA_Server_addVariableNode_rb( + server, + IOP_properties[i].node_id, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTOBJTYPE), + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_HASPROPERTY), + IOP_properties[i].q_name, + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_PROPERTYTYPE), + vattr, + NULL, + rbd, + NULL); + + if (retv != UA_STATUSCODE_GOOD) { + return retv; + } + + retv = UA_Server_addReference( + server, + IOP_properties[i].node_id, + UA_NODEID_NUMERIC(ns[0], UA_NS0ID_HASMODELLINGRULE), + UA_EXPANDEDNODEID_NUMERIC(ns[0], UA_NS0ID_MODELLINGRULE_MANDATORY), + TRUE); + if (retv != UA_STATUSCODE_GOOD) { + return retv; + } + } /* for i: 0..IOP_OBJ_NR_PROPS */ + + return retv; +} + +static UA_StatusCode +ioports_add_ioports_root(UA_Server *server, UA_UInt16 *ns, rollback_data_t *rbd) +{ + UA_StatusCode retv = UA_STATUSCODE_GOOD; + UA_ObjectAttributes oattr = UA_ObjectAttributes_default; + + g_assert(server != NULL); + g_assert(ns != NULL); + + oattr.displayName = UA_LOCALIZEDTEXT("", IOP_ROOT_BNAME); + oattr.description = UA_LOCALIZEDTEXT("", IOP_ROOT_BNAME); + + retv = UA_Server_addObjectNode_rb(server, + UA_NODEID_NUMERIC(ns[1], UA_IOPID_IOPORTS), + UA_NODEID_NUMERIC(0, + UA_NS0ID_OBJECTSFOLDER), + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + UA_QUALIFIEDNAME(ns[1], IOP_ROOT_BNAME), + UA_NODEID_NUMERIC(ns[0], + UA_NS0ID_BASEOBJECTTYPE), + oattr, + NULL, + rbd, + NULL); + + if (retv != UA_STATUSCODE_GOOD) { + return retv; + } + + /* set the event notifier attribute for 'I/O Ports' object node */ + retv = UA_Server_writeEventNotifier(server, + UA_NODEID_NUMERIC(ns[1], + UA_IOPID_IOPORTS), + UA_EVENTNOTIFIER_SUBSCRIBE_TO_EVENT); + return retv; +} + +UA_StatusCode +ioports_ns(UA_Server *server, rollback_data_t *rbd) +{ + UA_StatusCode retVal = UA_STATUSCODE_GOOD; + UA_UInt16 ns[2]; + gint i; + + g_return_val_if_fail(server != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(rbd != NULL, UA_STATUSCODE_BAD); + + ns[0] = UA_Server_addNamespace(server, UA_NS0_NAMESPACE); + ns[1] = UA_Server_addNamespace(server, UA_PLUGIN_NAMESPACE); + +#if UA_TYPES_IOP_COUNT > 0 + for (i = 0; i < UA_TYPES_IOP_COUNT; i++) { + UA_TYPES_IOP[i].typeId.namespaceIndex = ns[1]; + UA_TYPES_IOP[i].binaryEncodingId.namespaceIndex = ns[1]; + } +#endif + + /* Load custom datatype definitions into the server */ + if (UA_TYPES_IOP_COUNT > 0) { + customUA_TYPES_IOP.next = UA_Server_getConfig(server)->customDataTypes; + + /* also save the original pointer value in case we need to roll back */ + rbd->saved_cdt = UA_Server_getConfig(server)->customDataTypes; + + UA_Server_getConfig(server)->customDataTypes = &customUA_TYPES_IOP; + } + + if ((retVal = ioports_add_port_state_type(server, ns, rbd)) != + UA_STATUSCODE_GOOD) { + return retVal; + } + if ((retVal = ioports_add_port_dir_type(server, ns, rbd)) != + UA_STATUSCODE_GOOD) { + return retVal; + } + if ((retVal = ioports_add_port_obj_type(server, ns, rbd)) != + UA_STATUSCODE_GOOD) { + return retVal; + } + if ((retVal = ioports_add_port_event_type(server, ns, rbd)) != + UA_STATUSCODE_GOOD) { + return retVal; + } + + /* Root "I/O Ports" object */ + if ((retVal = ioports_add_ioports_root(server, ns, rbd)) != + UA_STATUSCODE_GOOD) { + return retVal; + } + + return retVal; +} diff --git a/app/plugins/ioports/ioports_ns.h b/app/plugins/ioports/ioports_ns.h new file mode 100644 index 0000000..f3e2977 --- /dev/null +++ b/app/plugins/ioports/ioports_ns.h @@ -0,0 +1,51 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __IOPORTS_NS_H__ +#define __IOPORTS_NS_H__ + +#include + +#include "ioports_types.h" +#include "ua_utils.h" + +#define UA_PLUGIN_NAMESPACE "http://www.axis.com/OpcUA/IOPorts/" + +#define IOP_OBJ_NR_PROPS 8 +/* I/O port: object properties */ +#define CONFIGURABLE_BNAME "Configurable" +#define DIRECTION_BNAME "Direction" +#define DISABLED_BNAME "Disabled" +#define INDEX_BNAME "Index" +#define NAME_BNAME "Name" +#define NORMALSTATE_BNAME "NormalState" +#define STATE_BNAME "State" +#define USAGE_BNAME "Usage" + +/* adds the ioports namespace to the information model */ +UA_StatusCode +ioports_ns(UA_Server *server, rollback_data_t *rbd); + +#endif /* __IOPORTS_NS_H__ */ diff --git a/app/plugins/ioports/ioports_plugin.c b/app/plugins/ioports/ioports_plugin.c new file mode 100644 index 0000000..397d546 --- /dev/null +++ b/app/plugins/ioports/ioports_plugin.c @@ -0,0 +1,2264 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include +#include +#include +#include + +#include "error.h" +#include "ioports_nodeids.h" +#include "ioports_ns.h" +#include "ioports_plugin.h" +#include "ioports_vapix.h" +#include "log.h" +#include "ua_utils.h" +#include "vapix_utils.h" + +#define UA_PLUGIN_NAME "opc-ioports-plugin" + +#define ERR_NOT_INITIALIZED "The " UA_PLUGIN_NAME " is not initialized" +#define ERR_NO_NAME "The " UA_PLUGIN_NAME " was not given a name" + +#define IOP_HT_LOCK(mtx) (g_mutex_lock(&plugin->mtx)) +#define IOP_HT_UNLOCK(mtx) (g_mutex_unlock(&plugin->mtx)) + +#define IOP_DBUS_CFG_SERVICE "com.axis.Configuration.Legacy.IOControl1.IOPort" +#define IOP_LABEL_FMT "I/O Port %d" + +#define IOP_STATE_CHANGE 0 +#define IOP_CFG_CHANGE 1 +#define IOP_STATE_CHANGE_EV_SEVERITY 100 + +/* parameters monitored for value changes via AxEvent */ +#define IOP_CFG_CHANGE_NAME "Name" +#define IOP_CFG_CHANGE_USAGE "Usage" +#define IOP_CFG_CHANGE_DIR "Direction" +/* normal state changes have have different names depending on direction */ +#define IOP_CFG_CHANGE_NS_IN "Trig" +#define IOP_CFG_CHANGE_NS_OUT "Active" + +#define NAME_PROP 0 +#define USAGE_PROP 1 +#define STATE_PROP 0 +#define NSTATE_PROP 1 + +DEFINE_GQUARK(UA_PLUGIN_NAME) + +typedef struct plugin { + /* pointer to the server instance */ + UA_Server *server; + /* user-friendly name of the plugin */ + gchar *name; + /* OPC-UA namespace index */ + UA_UInt16 ns; + /* keep track of data that needs to be rolled back in case of failure */ + rollback_data_t *rbd; + + /* an open62541 logger */ + UA_Logger *logger; + /* hash table with the I/O Ports returned by VAPIX 'getPorts' */ + GHashTable *iop_ht; + GMutex iop_mtx; + + /* AxEvent handlers */ + /* monitor I/O port state changes */ + AXEventHandler *iopstate_evh; + /* monitor I/O port configuration changes */ + AXEventHandler *iopcfg_evh; + /* event subscriptions for state and configuration changes respectively */ + guint event_subs[2]; + + gchar *vapix_credentials; + CURL *curl_h; +} plugin_t; + +typedef struct ioport_proptype_map { + gchar *browse_name; + UA_DataType *data_type_array; + guint16 data_type_index; +} ioport_proptype_map_t; + +/* structure passed in as nodeContext to the I/O port object node contructor */ +typedef struct ua_ioport_obj { + UA_Boolean configurable; + UA_IOPortDirectionType direction; + UA_Boolean disabled; + UA_UInt32 index; + UA_String name; + UA_IOPortStateType normalState; + UA_IOPortStateType state; + UA_String usage; +} ua_ioport_obj_t; + +static plugin_t *plugin; + +static const ioport_proptype_map_t IOPort_obj_type_map[] = { + { CONFIGURABLE_BNAME, UA_TYPES, UA_TYPES_BOOLEAN }, + { DIRECTION_BNAME, UA_TYPES_IOP, UA_TYPES_IOP_IOPORTDIRECTIONTYPE }, + { DISABLED_BNAME, UA_TYPES, UA_TYPES_BOOLEAN }, + { INDEX_BNAME, UA_TYPES, UA_TYPES_INT32 }, + { NAME_BNAME, UA_TYPES, UA_TYPES_STRING }, + { NORMALSTATE_BNAME, UA_TYPES_IOP, UA_TYPES_IOP_IOPORTSTATETYPE }, + { STATE_BNAME, UA_TYPES_IOP, UA_TYPES_IOP_IOPORTSTATETYPE }, + { USAGE_BNAME, UA_TYPES, UA_TYPES_STRING }, + { NULL, NULL, 0 } +}; + +/* Local functions */ +/* Wrapper around g_ascii_strtoll() using decimal base & with error handling */ +static gint64 +ascii_strtoll_dec(const gchar *nptr, GError **error) +{ + gchar *endptr = NULL; + gint64 value = 0; + + g_assert(nptr != NULL); + g_assert(error == NULL || *error == NULL); + + if (*nptr == '\0') { + SET_ERROR(error, -1, "Empty string"); + return 0; + } + + errno = 0; + value = g_ascii_strtoll(nptr, &endptr, 10); + + /* out of range */ + if (errno == ERANGE) { + SET_ERROR(error, -1, "String '%s' out of gint64 range", nptr); + return 0; + } + + /* no characters consumed */ + if (endptr == nptr) { + SET_ERROR(error, -1, "Failed converting '%s': no valid digits", nptr); + return 0; + } + + /* invalid trailing characters */ + if (*endptr != '\0') { + SET_ERROR(error, + -1, + "Failed converting '%s': trailing junk at: '%s'", + nptr, + endptr); + return 0; + } + + return value; +} + +static gpointer +get_member_from_browsename(const ua_ioport_obj_t *ioport, const gchar *bname) +{ + g_assert(ioport != NULL); + g_assert(bname != NULL); + + if (g_strcmp0(bname, CONFIGURABLE_BNAME) == 0) { + return (gpointer) &ioport->configurable; + } else if (g_strcmp0(bname, DIRECTION_BNAME) == 0) { + return (gpointer) &ioport->direction; + } else if (g_strcmp0(bname, DISABLED_BNAME) == 0) { + return (gpointer) &ioport->disabled; + } else if (g_strcmp0(bname, INDEX_BNAME) == 0) { + return (gpointer) &ioport->index; + } else if (g_strcmp0(bname, NAME_BNAME) == 0) { + return (gpointer) &ioport->name; + } else if (g_strcmp0(bname, NORMALSTATE_BNAME) == 0) { + return (gpointer) &ioport->normalState; + } else if (g_strcmp0(bname, STATE_BNAME) == 0) { + return (gpointer) &ioport->state; + } else if (g_strcmp0(bname, USAGE_BNAME) == 0) { + return (gpointer) &ioport->usage; + } + + return NULL; +} + +/* Find out and return the nodeId of an ioport object property node given its + * browseName. + * + * Input parameters: + * * server + * * start_node - the parent nodeId to start descending from + * * referenceTypeId - UA_NS0ID_ORGANIZES | UA_NS0ID_HASPROPERTY + * * browse_name - the browse name of the child node (property) we are looking + * for + * + * Output parameters: + * * out_nodeId - the nodeId of the child node + * * error - a GError in case of error */ +static gboolean +iop_ua_get_nodeid_from_browsename(UA_Server *server, + const UA_NodeId *start_node, + UA_UInt32 referenceTypeId, + gchar *browse_name, + UA_NodeId *out_nodeId, + GError **error) +{ + UA_RelativePathElement rpe; + UA_BrowsePath bp; + UA_BrowsePathResult bpr; + UA_StatusCode status; + + gboolean found = FALSE; + + g_assert(server != NULL); + g_assert(start_node != NULL); + g_assert(referenceTypeId == UA_NS0ID_HASPROPERTY || + referenceTypeId == UA_NS0ID_ORGANIZES); + g_assert(browse_name != NULL); + g_assert(out_nodeId != NULL); + g_assert(error == NULL || *error == NULL); + + g_assert(plugin != NULL); + + UA_RelativePathElement_init(&rpe); + rpe.referenceTypeId = UA_NODEID_NUMERIC(0, referenceTypeId); + rpe.isInverse = FALSE; + rpe.includeSubtypes = FALSE; + rpe.targetName = UA_QUALIFIEDNAME(plugin->ns, browse_name); + + UA_BrowsePath_init(&bp); + bp.startingNode = *start_node; + bp.relativePath.elementsSize = 1; + bp.relativePath.elements = &rpe; + + UA_BrowsePathResult_init(&bpr); + bpr = UA_Server_translateBrowsePathToNodeIds(server, &bp); + if (bpr.statusCode != UA_STATUSCODE_GOOD || bpr.targetsSize < 1) { + SET_ERROR(error, + -1, + "Unable to find nodeId of obj property '%s', err: %s", + browse_name, + UA_StatusCode_name(bpr.statusCode)); + goto bail_out; + + } else { + status = UA_NodeId_copy(&bpr.targets[0].targetId.nodeId, out_nodeId); + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(error, + -1, + "UA_NodeId_copy() failed: %s", + UA_StatusCode_name(status)); + goto bail_out; + } + + found = TRUE; + } + +bail_out: + UA_BrowsePathResult_clear(&bpr); + + return found; +} + +/* Get the parent nodeId (the ioport object node id) of an ioport object + * property node. + * + * Input parameters: + * * server + * * nodeId - the browse name of the property node + * + * Output parameters: + * * parent_nodeId - the nodeId of the parent node (the ioport object node the + * property belongs to) + * * error - a GError in case of an error */ +static gboolean +iop_ua_get_iop_prop_parent(UA_Server *server, + const UA_NodeId *nodeId, + UA_NodeId *parent_nodeId, + GError **error) +{ + gboolean retval = FALSE; + UA_BrowseDescription bd; + UA_BrowseResult br; + UA_StatusCode status; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(parent_nodeId != NULL); + g_assert(error == NULL || *error == NULL); + + /* find our parent node (the I/O port object we belong to) */ + UA_BrowseDescription_init(&bd); + bd.nodeId = *nodeId; + bd.browseDirection = UA_BROWSEDIRECTION_INVERSE; + bd.resultMask = + UA_BROWSERESULTMASK_REFERENCETYPEID | UA_BROWSERESULTMASK_ISFORWARD; + + br = UA_Server_browse(server, 0, &bd); + if (br.statusCode != UA_STATUSCODE_GOOD) { + SET_ERROR(error, + -1, + "UA_Server_browse() failed: %s", + UA_StatusCode_name(br.statusCode)); + goto err_out; + } + + status = UA_NodeId_copy(&br.references[0].nodeId.nodeId, parent_nodeId); + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(error, + -1, + "UA_NodeId_copy() failed: %s", + UA_StatusCode_name(status)); + goto err_out; + } + + retval = TRUE; + +err_out: + UA_BrowseResult_clear(&br); + + return retval; +} + +/* Given the nodeId of any ioport object property, find out and return the + * nodeId of another property (sibling node) of the same object. + * + * Input parameters: + * * server + * * nodeId - the nodeId of any ioport object property + * * browseName - the Browse Name of the other property node for which we want + * to get the nodeId + * + * Output parameters: + * * out_nodeId - the nodeId of the targeted 'browseName' property + * * error - a GError in case an error occurs */ +static gboolean +iop_ua_get_iop_prop_nodeid(UA_Server *server, + const UA_NodeId *nodeId, + gchar *browse_name, + UA_NodeId *out_nodeId, + GError **error) +{ + gboolean retval = FALSE; + UA_NodeId parent = UA_NODEID_NULL; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(browse_name != NULL); + g_assert(out_nodeId != NULL); + g_assert(error == NULL || *error == NULL); + + g_assert(plugin != NULL); + + /* find our parent node (the I/O port object we belong to) */ + if (!iop_ua_get_iop_prop_parent(server, nodeId, &parent, error)) { + g_prefix_error(error, "iop_ua_get_iop_prop_parent() failed: "); + goto err_out; + } + + /* having now the starting point, browse for 'browse_name' + * to find its nodeId */ + if (!iop_ua_get_nodeid_from_browsename(server, + &parent, + UA_NS0ID_HASPROPERTY, + browse_name, + out_nodeId, + error)) { + g_prefix_error(error, + "iop_ua_get_nodeid_from_browsename() failed nodeId lookup " + "of '%s': ", + browse_name); + goto err_out; + } + + retval = TRUE; + +err_out: + UA_NodeId_clear(&parent); + + return retval; +} + +/* Given the nodeId of any ioport object property, return the value of any other + * property node given its browseName. + * + * Input parameters: + * * server + * * nodeId - the nodeId of any ioport object property + * * browse_name - the Browse Name of the other property for which we want to + * fetch the value + * + * Output parameters: + * * ua_value - the value (UA_Variant) of the requested property node + * * error - a GError in case an error occurs */ +static gboolean +iop_ua_get_iop_prop_value(UA_Server *server, + const UA_NodeId *nodeId, + gchar *browse_name, + UA_Variant *ua_value, + GError **error) +{ + gboolean retval = FALSE; + UA_StatusCode ua_status = UA_STATUSCODE_BAD; + UA_NodeId parent = UA_NODEID_NULL; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(browse_name != NULL); + g_assert(ua_value != NULL); + g_assert(error == NULL || *error == NULL); + + g_assert(plugin != NULL); + + /* find our parent node (the I/O port object we belong to) */ + if (!iop_ua_get_iop_prop_parent(server, nodeId, &parent, error)) { + g_prefix_error(error, "iop_ua_get_iop_prop_parent() failed: "); + goto err_out; + } + + /* read the value of the 'browse_name' property */ + ua_status = UA_Server_readObjectProperty(server, + parent, + UA_QUALIFIEDNAME(plugin->ns, + browse_name), + ua_value); + if (ua_status != UA_STATUSCODE_GOOD) { + SET_ERROR(error, + -1, + "UA_Server_readObjectProperty(...'%s'...) failed: %s", + browse_name, + UA_StatusCode_name(ua_status)); + goto err_out; + } + + retval = TRUE; + +err_out: + UA_NodeId_clear(&parent); + + return retval; +} + +/* Given any property node of an ioport object, returns the 'Index' value of + * the respective ioport object. + * + * Input parameters: + * * server - + * * nodeId - the nodeId of any ioport object property + * + * Output parameters: + * * iop_index - the value of the 'Index' property of the same ioport object + * * error - a GError if an error occurs */ +static gboolean +iop_ua_get_iop_index(UA_Server *server, + const UA_NodeId *nodeId, + UA_UInt32 *iop_index, + GError **error) +{ + UA_Variant ua_value; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(iop_index != NULL); + g_assert(error == NULL || *error == NULL); + + g_assert(plugin != NULL); + + if (!iop_ua_get_iop_prop_value(server, + nodeId, + INDEX_BNAME, + &ua_value, + error)) { + g_prefix_error(error, "Could not fetch 'Index' property: "); + return FALSE; + } + *iop_index = *(UA_UInt32 *) ua_value.data; + UA_Variant_clear(&ua_value); + + return TRUE; +} + +/** + * Adds a new I/O Port object to the server. + * + * Input parameters: + * * server - OPC-UA server instance + * * port_nr - port index (0-based indexing) + * * port_data - a structure describing the properties of the I/O Port object + * + * Output parameters: + * * error - a GError if an error occurs + * + * Return value: + * TRUE if successful, FALSE otherwise + */ +static gboolean +iop_add_ioport_object(UA_Server *server, + guint32 port_nr, + const ioport_obj_t *port_data, + GError **err) +{ + gboolean ret = TRUE; + UA_ObjectAttributes oAttr = UA_ObjectAttributes_default; + UA_StatusCode status; + ua_ioport_obj_t node_ctx; + gchar *label; + + g_assert(server != NULL); + g_assert(port_data != NULL); + g_assert(err == NULL || *err == NULL); + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); + + /* NOTE: the web GUI uses 1-based indexing */ + label = g_strdup_printf(IOP_LABEL_FMT, port_nr + 1); + oAttr.displayName = UA_LOCALIZEDTEXT("", label); + oAttr.description = UA_LOCALIZEDTEXT("", "I/O port"); + + /* node context to be passed on to the iop_ua_obj_constructor() callback */ + node_ctx.index = port_nr; + node_ctx.configurable = port_data->configurable; + node_ctx.direction = port_data->direction; + node_ctx.disabled = port_data->readonly; + node_ctx.name = UA_STRING(port_data->name); + node_ctx.normalState = port_data->normal_state; + node_ctx.state = port_data->state; + node_ctx.usage = UA_STRING(port_data->usage); + + /* create an object instance for the given IO port and add it to the + * "I/O Ports" parent object */ + status = UA_Server_addObjectNode_rb(server, + UA_NODEID_NUMERIC(plugin->ns, 0), + UA_NODEID_NUMERIC(plugin->ns, + UA_IOPID_IOPORTS), + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + UA_QUALIFIEDNAME(plugin->ns, label), + UA_NODEID_NUMERIC(plugin->ns, + UA_IOPID_IOPORTOBJTYPE), + oAttr, + &node_ctx, + plugin->rbd, + NULL); + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_addObjectNode_rb('%s') failed: %s", + label, + UA_StatusCode_name(status)); + ret = FALSE; + } + + g_clear_pointer(&label, g_free); + + return ret; +} + +/* callback backend - gets the string value of the 'Name' or 'Usage' + * property of an IO port */ +static UA_StatusCode +iop_ua_get_string(UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionContext, + const UA_NodeId *nodeId, + G_GNUC_UNUSED void *nodeContext, + G_GNUC_UNUSED UA_Boolean includeSourceTimeStamp, + G_GNUC_UNUSED const UA_NumericRange *range, + UA_DataValue *dataValue, + gint property) +{ + GError *lerr = NULL; + ioport_obj_t *iop = NULL; + UA_StatusCode ua_status; + UA_UInt32 iop_index; + UA_String ua_string; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(dataValue != NULL); + g_assert(plugin != NULL); + g_assert(plugin->iop_ht != NULL); + g_assert(plugin->logger != NULL); + + /* property can only be one of 'Name' or 'Usage' */ + if ((property != NAME_PROP) && (property != USAGE_PROP)) { + LOG_E(plugin->logger, "Invalid property: %d in read request!", property); + return UA_STATUSCODE_BAD; + } + + /* open62541 'false' (flags if dataValue holds any data) */ + dataValue->hasValue = FALSE; + + /* find the index (port number) of the I/O port node we belong to */ + if (!iop_ua_get_iop_index(server, nodeId, &iop_index, &lerr)) { + LOG_E(plugin->logger, + "iop_ua_get_iop_index() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + return UA_STATUSCODE_BADNOTFOUND; + } + + IOP_HT_LOCK(iop_mtx); + iop = g_hash_table_lookup(plugin->iop_ht, &iop_index); + if (!iop) { + LOG_E(plugin->logger, "g_hash_table_lookup(port: %d) failed", iop_index); + IOP_HT_UNLOCK(iop_mtx); + return UA_STATUSCODE_BADINTERNALERROR; + } + + ua_string = (property == NAME_PROP) ? UA_STRING(iop->name) : + UA_STRING(iop->usage); + IOP_HT_UNLOCK(iop_mtx); + + ua_status = UA_Variant_setScalarCopy(&dataValue->value, + &ua_string, + &UA_TYPES[UA_TYPES_STRING]); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Variant_setScalarCopy() failed: %s", + UA_StatusCode_name(ua_status)); + + return ua_status; + } + + dataValue->hasValue = TRUE; + + return UA_STATUSCODE_GOOD; +} + +/* callback backend - sets the 'Name' or 'Usage' property of an IO port */ +static UA_StatusCode +iop_ua_set_string(UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionContext, + const UA_NodeId *nodeId, + G_GNUC_UNUSED void *nodeContext, + G_GNUC_UNUSED const UA_NumericRange *range, + const UA_DataValue *dataValue, + gint property) +{ + gchar *new_string = NULL; + GError *lerr = NULL; + UA_UInt32 iop_index; + UA_String *ua_str; + UA_StatusCode ret = UA_STATUSCODE_GOOD; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(dataValue != NULL); + g_assert(plugin != NULL); + g_assert(plugin->curl_h != NULL); + g_assert(plugin->vapix_credentials != NULL); + g_assert(plugin->logger != NULL); + + /* property can only be one of 'Name' or 'Usage' */ + if ((property != NAME_PROP) && (property != USAGE_PROP)) { + LOG_E(plugin->logger, "Invalid property: %d in write request!", property); + return UA_STATUSCODE_BAD; + } + + /* get the new 'Name' or 'Usage' value from the OPC-UA write node request + * (dataValue container) */ + ua_str = (UA_String *) dataValue->value.data; + new_string = g_strndup((gchar *) ua_str->data, ua_str->length); + if (new_string == NULL) { + ret = UA_STATUSCODE_BAD; + goto err_out; + } + + if (!iop_ua_get_iop_index(server, nodeId, &iop_index, &lerr)) { + LOG_E(plugin->logger, + "iop_ua_get_iop_index() failed: %s", + GERROR_MSG(lerr)); + + ret = UA_STATUSCODE_BADNOTFOUND; + goto err_out; + } + + if (!iop_vapix_set_port(plugin->curl_h, + plugin->vapix_credentials, + iop_index, + (property == NAME_PROP) ? IO_VAPIX_JSON_NAME : + IO_VAPIX_JSON_USAGE, + new_string, + &lerr)) { + LOG_E(plugin->logger, "iop_vapix_set_port() failed: %s", GERROR_MSG(lerr)); + + ret = UA_STATUSCODE_BADINTERNALERROR; + goto err_out; + } + +err_out: + g_clear_error(&lerr); + g_clear_pointer(&new_string, g_free); + + return ret; +} + +/* callback backend - gets the value of the 'State' or 'NormalState' property of + * an IO port */ +static UA_StatusCode +iop_ua_get_state(UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionContext, + const UA_NodeId *nodeId, + G_GNUC_UNUSED void *nodeContext, + G_GNUC_UNUSED UA_Boolean includeSourceTimeStamp, + G_GNUC_UNUSED const UA_NumericRange *range, + UA_DataValue *dataValue, + gint property) +{ + GError *lerr = NULL; + ioport_obj_t *iop = NULL; + UA_IOPortStateType ua_state; + UA_StatusCode ua_status; + UA_UInt32 iop_index; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(dataValue != NULL); + g_assert(plugin != NULL); + g_assert(plugin->iop_ht != NULL); + g_assert(plugin->logger != NULL); + + /* property can only be one of 'State' or 'NormalState' */ + if ((property != STATE_PROP) && (property != NSTATE_PROP)) { + LOG_E(plugin->logger, "Invalid property: %d in read request!", property); + return UA_STATUSCODE_BAD; + } + + /* open62541 'false' (flags if dataValue holds any data) */ + dataValue->hasValue = FALSE; + + if (!iop_ua_get_iop_index(server, nodeId, &iop_index, &lerr)) { + LOG_E(plugin->logger, + "iop_ua_get_iop_index() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + return UA_STATUSCODE_BADNOTFOUND; + } + + IOP_HT_LOCK(iop_mtx); + iop = g_hash_table_lookup(plugin->iop_ht, &iop_index); + if (!iop) { + LOG_E(plugin->logger, "g_hash_table_lookup(port: %d) failed", iop_index); + IOP_HT_UNLOCK(iop_mtx); + return UA_STATUSCODE_BADINTERNALERROR; + } + + ua_state = (property == STATE_PROP) ? iop->state : iop->normal_state; + IOP_HT_UNLOCK(iop_mtx); + + ua_status = + UA_Variant_setScalarCopy(&dataValue->value, + &ua_state, + &UA_TYPES_IOP[UA_TYPES_IOP_IOPORTSTATETYPE]); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Variant_setScalarCopy() failed: %s", + UA_StatusCode_name(ua_status)); + return ua_status; + } + + dataValue->hasValue = TRUE; + + return UA_STATUSCODE_GOOD; +} + +/* callback backend - sets the value of the 'State' or 'NormalState' property of + * an IO port */ +static UA_StatusCode +iop_ua_set_state(UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionContext, + const UA_NodeId *nodeId, + G_GNUC_UNUSED void *nodeContext, + G_GNUC_UNUSED const UA_NumericRange *range, + const UA_DataValue *dataValue, + gint property) +{ + GError *lerr = NULL; + const gchar *new_state = NULL; + UA_UInt32 iop_index; + UA_IOPortStateType ua_newstate; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(dataValue != NULL); + g_assert(plugin != NULL); + g_assert(plugin->curl_h != NULL); + g_assert(plugin->vapix_credentials != NULL); + g_assert(plugin->logger != NULL); + + /* property can only be one of 'State' or 'NormalState' */ + if ((property != STATE_PROP) && (property != NSTATE_PROP)) { + LOG_E(plugin->logger, "Invalid property: %d in write request!", property); + return UA_STATUSCODE_BAD; + } + + /* get the requested state from the OPC-UA write node request */ + ua_newstate = *(UA_IOPortStateType *) dataValue->value.data; + switch (ua_newstate) { + case UA_IOPORTSTATETYPE_OPEN: + new_state = IO_VAPIX_STATE_OPEN; + break; + + case UA_IOPORTSTATETYPE_CLOSED: + new_state = IO_VAPIX_STATE_CLOSED; + break; + + default: + LOG_E(plugin->logger, + "Invalid port state value: %d in node write request!", + ua_newstate); + return UA_STATUSCODE_BAD; + } + + /* find the index (port number) of the I/O port node we belong to */ + if (!iop_ua_get_iop_index(server, nodeId, &iop_index, &lerr)) { + LOG_E(plugin->logger, + "iop_ua_get_iop_index() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + return UA_STATUSCODE_BADNOTFOUND; + } + + if (!iop_vapix_set_port(plugin->curl_h, + plugin->vapix_credentials, + iop_index, + (property == STATE_PROP) ? IO_VAPIX_JSON_STATE : + IO_VAPIX_JSON_NSTATE, + new_state, + &lerr)) { + LOG_E(plugin->logger, "iop_vapix_set_port() failed: %s", GERROR_MSG(lerr)); + g_clear_error(&lerr); + return UA_STATUSCODE_BADINTERNALERROR; + } + + return UA_STATUSCODE_GOOD; +} + +/* callback executed when the 'Name' property of an IO port is read */ +static UA_StatusCode +iop_ua_read_name_cb(UA_Server *server, + const UA_NodeId *sessionId, + void *sessionContext, + const UA_NodeId *nodeId, + void *nodeContext, + UA_Boolean includeSourceTimeStamp, + const UA_NumericRange *range, + UA_DataValue *dataValue) +{ + /* call backend function to get the value of the 'Name' property */ + return iop_ua_get_string(server, + sessionId, + sessionContext, + nodeId, + nodeContext, + includeSourceTimeStamp, + range, + dataValue, + NAME_PROP); +} + +/* callback executed when the 'Name' property of an IO port is written */ +static UA_StatusCode +iop_ua_write_name_cb(UA_Server *server, + const UA_NodeId *sessionId, + void *sessionContext, + const UA_NodeId *nodeId, + void *nodeContext, + const UA_NumericRange *range, + const UA_DataValue *dataValue) +{ + /* call backend function to set a new value for the 'Name' property */ + return iop_ua_set_string(server, + sessionId, + sessionContext, + nodeId, + nodeContext, + range, + dataValue, + NAME_PROP); +} + +/* callback executed when the 'Usage' property of an IO port is read */ +static UA_StatusCode +iop_ua_read_usage_cb(UA_Server *server, + const UA_NodeId *sessionId, + void *sessionContext, + const UA_NodeId *nodeId, + void *nodeContext, + UA_Boolean includeSourceTimeStamp, + const UA_NumericRange *range, + UA_DataValue *dataValue) +{ + /* call backend function to get the value of the 'Usage' property */ + return iop_ua_get_string(server, + sessionId, + sessionContext, + nodeId, + nodeContext, + includeSourceTimeStamp, + range, + dataValue, + USAGE_PROP); +} + +/* callback executed when the 'Usage' property of an IO port is written */ +static UA_StatusCode +iop_ua_write_usage_cb(UA_Server *server, + const UA_NodeId *sessionId, + void *sessionContext, + const UA_NodeId *nodeId, + void *nodeContext, + const UA_NumericRange *range, + const UA_DataValue *dataValue) +{ + /* call backend function to set the value of the 'Usage' property */ + return iop_ua_set_string(server, + sessionId, + sessionContext, + nodeId, + nodeContext, + range, + dataValue, + USAGE_PROP); +} + +/* callback executed when the 'Direction' property of an IO port is read */ +static UA_StatusCode +iop_ua_read_dir_cb(UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionContext, + const UA_NodeId *nodeId, + G_GNUC_UNUSED void *nodeContext, + G_GNUC_UNUSED UA_Boolean includeSourceTimeStamp, + G_GNUC_UNUSED const UA_NumericRange *range, + UA_DataValue *dataValue) +{ + GError *lerr = NULL; + ioport_obj_t *iop = NULL; + UA_IOPortDirectionType ua_dir; + UA_StatusCode ua_status; + UA_UInt32 iop_index; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(dataValue != NULL); + g_assert(plugin != NULL); + g_assert(plugin->iop_ht != NULL); + g_assert(plugin->logger != NULL); + + /* open62541 'false' (flags if dataValue holds any data) */ + dataValue->hasValue = FALSE; + + if (!iop_ua_get_iop_index(server, nodeId, &iop_index, &lerr)) { + LOG_E(plugin->logger, + "iop_ua_get_iop_index() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + return UA_STATUSCODE_BADNOTFOUND; + } + + IOP_HT_LOCK(iop_mtx); + iop = g_hash_table_lookup(plugin->iop_ht, &iop_index); + if (!iop) { + LOG_E(plugin->logger, "g_hash_table_lookup(port: %d) failed", iop_index); + IOP_HT_UNLOCK(iop_mtx); + return UA_STATUSCODE_BADINTERNALERROR; + } + ua_dir = iop->direction; + IOP_HT_UNLOCK(iop_mtx); + + ua_status = UA_Variant_setScalarCopy( + &dataValue->value, + &ua_dir, + &UA_TYPES_IOP[UA_TYPES_IOP_IOPORTDIRECTIONTYPE]); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Variant_setScalarCopy() failed: %s", + UA_StatusCode_name(ua_status)); + + return ua_status; + } + + dataValue->hasValue = TRUE; + + return UA_STATUSCODE_GOOD; +} + +/* callback executed when the 'Direction' property of an IO port is written */ +static UA_StatusCode +iop_ua_write_dir_cb(UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionContext, + const UA_NodeId *nodeId, + G_GNUC_UNUSED void *nodeContext, + G_GNUC_UNUSED const UA_NumericRange *range, + const UA_DataValue *dataValue) +{ + GError *lerr = NULL; + const gchar *newdir = NULL; + UA_IOPortDirectionType ua_newdir; + UA_Byte access_level; + UA_StatusCode ua_status; + UA_UInt32 iop_index; + UA_NodeId state_nodeId = UA_NODEID_NULL; + UA_StatusCode ret = UA_STATUSCODE_GOOD; + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(dataValue != NULL); + g_assert(plugin != NULL); + g_assert(plugin->curl_h != NULL); + g_assert(plugin->vapix_credentials != NULL); + g_assert(plugin->logger != NULL); + + /* get the requested 'direction' from the OPC-UA write node request */ + ua_newdir = *(UA_IOPortDirectionType *) dataValue->value.data; + switch (ua_newdir) { + case UA_IOPORTDIRECTIONTYPE_INPUT: + newdir = IO_VAPIX_DIR_INPUT; + break; + + case UA_IOPORTDIRECTIONTYPE_OUTPUT: + newdir = IO_VAPIX_DIR_OUTPUT; + break; + + default: + LOG_E(plugin->logger, + "Invalid 'Direction' value: %d in node write request!", + ua_newdir); + ret = UA_STATUSCODE_BAD; + goto err_out; + } + + if (!iop_ua_get_iop_index(server, nodeId, &iop_index, &lerr)) { + LOG_E(plugin->logger, + "iop_ua_get_iop_index() failed: %s", + GERROR_MSG(lerr)); + + ret = UA_STATUSCODE_BADNOTFOUND; + goto err_out; + } + + if (!iop_vapix_set_port(plugin->curl_h, + plugin->vapix_credentials, + iop_index, + IO_VAPIX_JSON_DIR, + newdir, + &lerr)) { + LOG_E(plugin->logger, "iop_vapix_set_port() failed: %s", GERROR_MSG(lerr)); + + ret = UA_STATUSCODE_BADINTERNALERROR; + goto err_out; + } + + if (ua_newdir == UA_IOPORTDIRECTIONTYPE_OUTPUT) { + /* port was configured as output, make the 'State' property r/w*/ + access_level = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE; + } else { + /* port was configured as input, make the 'State' property r/o*/ + access_level = UA_ACCESSLEVELMASK_READ; + } + + /* we need to find the nodeid of the 'State' property */ + if (!iop_ua_get_iop_prop_nodeid(server, + nodeId, + "State", + &state_nodeId, + &lerr)) { + LOG_E(plugin->logger, + "iop_ua_get_iop_prop_nodeid() failed: %s", + GERROR_MSG(lerr)); + + ret = UA_STATUSCODE_BADINTERNALERROR; + goto err_out; + } + + ua_status = UA_Server_writeAccessLevel(server, state_nodeId, access_level); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "Failed to set the access level for port-%d - 'State' node: %s", + iop_index, + UA_StatusCode_name(ua_status)); + + ret = ua_status; + goto err_out; + } + +err_out: + g_clear_error(&lerr); + UA_NodeId_clear(&state_nodeId); + + return ret; +} + +/* callback executed when the 'NormalState' property of an IO port is read */ +static UA_StatusCode +iop_ua_read_normalstate_cb(UA_Server *server, + const UA_NodeId *sessionId, + void *sessionContext, + const UA_NodeId *nodeId, + void *nodeContext, + UA_Boolean includeSourceTimeStamp, + const UA_NumericRange *range, + UA_DataValue *dataValue) +{ + /* call backend function to get the value of the 'NormalState' property */ + return iop_ua_get_state(server, + sessionId, + sessionContext, + nodeId, + nodeContext, + includeSourceTimeStamp, + range, + dataValue, + NSTATE_PROP); +} + +/* callback executed when the 'NormalState' property of an IO port is written */ +static UA_StatusCode +iop_ua_write_normalstate_cb(UA_Server *server, + const UA_NodeId *sessionId, + void *sessionContext, + const UA_NodeId *nodeId, + void *nodeContext, + const UA_NumericRange *range, + const UA_DataValue *dataValue) +{ + /* call backend function to set the value of the 'NormalState' property */ + return iop_ua_set_state(server, + sessionId, + sessionContext, + nodeId, + nodeContext, + range, + dataValue, + NSTATE_PROP); +} + +/* callback executed when the 'State' property of an IO port is read */ +static UA_StatusCode +iop_ua_read_state_cb(UA_Server *server, + const UA_NodeId *sessionId, + void *sessionContext, + const UA_NodeId *nodeId, + void *nodeContext, + UA_Boolean includeSourceTimeStamp, + const UA_NumericRange *range, + UA_DataValue *dataValue) +{ + /* call backend function to get the value of the 'State' property */ + return iop_ua_get_state(server, + sessionId, + sessionContext, + nodeId, + nodeContext, + includeSourceTimeStamp, + range, + dataValue, + STATE_PROP); +} + +/* callback executed when the 'State' property of an IO port is written */ +static UA_StatusCode +iop_ua_write_state_cb(UA_Server *server, + const UA_NodeId *sessionId, + void *sessionContext, + const UA_NodeId *nodeId, + void *nodeContext, + const UA_NumericRange *range, + const UA_DataValue *dataValue) +{ + /* call backend function to set the value of the 'State' property */ + return iop_ua_set_state(server, + sessionId, + sessionContext, + nodeId, + nodeContext, + range, + dataValue, + STATE_PROP); +} + +/* Constructor callback for IOPortObjType object nodes. + * It loops over all the object property nodes and: + * - initializes each node with the appropriate value from the data provided + * via the nodeContext + * - sets the access level (R/O or R/W) for the node according to applicable + * conditions + * - attaches UA_DataSource callbacks (to handle read/write access) to + * applicable property nodes */ +static UA_StatusCode +iop_ua_obj_constructor(UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionContext, + G_GNUC_UNUSED const UA_NodeId *typeNodeId, + G_GNUC_UNUSED void *typeNodeContext, + const UA_NodeId *nodeId, + void **nodeContext) +{ + const ioport_proptype_map_t *iop_obj = &IOPort_obj_type_map[0]; + const ua_ioport_obj_t *node_ctx = (ua_ioport_obj_t *) *nodeContext; + GError *lerr = NULL; + UA_Byte access_level; + UA_StatusCode ua_status; + UA_NodeId prop_nodeId = UA_NODEID_NULL; + + /* clang-format off */ + UA_DataSource iop_dir_cb = { + .read = iop_ua_read_dir_cb, + .write = iop_ua_write_dir_cb + }; + UA_DataSource iop_name_cb = { + .read = iop_ua_read_name_cb, + .write = iop_ua_write_name_cb + }; + UA_DataSource iop_usage_cb = { + .read = iop_ua_read_usage_cb, + .write = iop_ua_write_usage_cb + }; + UA_DataSource iop_state_cb = { + .read = iop_ua_read_state_cb, + .write = iop_ua_write_state_cb + }; + UA_DataSource iop_normalstate_cb = { + .read = iop_ua_read_normalstate_cb, + .write = iop_ua_write_normalstate_cb + }; + /* clang-format on */ + + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(nodeContext != NULL && *nodeContext != NULL); + g_assert(plugin != NULL); + g_assert(plugin->logger != NULL); + + /* loop over the 'properties' of this object (children nodes) */ + while (iop_obj->browse_name != NULL) { + /* update node value with data provided via the nodeContext */ + ua_status = UA_Server_writeObjectProperty_scalar( + server, + *nodeId, + UA_QUALIFIEDNAME(plugin->ns, iop_obj->browse_name), + get_member_from_browsename(node_ctx, iop_obj->browse_name), + &iop_obj->data_type_array[iop_obj->data_type_index]); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_writeObjectProperty_scalar(\"%s\") failed: %s", + iop_obj->browse_name, + UA_StatusCode_name(ua_status)); + goto err_out; + } + + /* we need the nodeId of the child property node (prop_nodeId) to be able + * to update the access level mask */ + if (!iop_ua_get_nodeid_from_browsename(server, + nodeId, + UA_NS0ID_HASPROPERTY, + iop_obj->browse_name, + &prop_nodeId, + &lerr)) { + LOG_E(plugin->logger, + "Failed to get nodeId of property '%s': %s", + iop_obj->browse_name, + GERROR_MSG(lerr)); + + ua_status = UA_STATUSCODE_BAD; + goto err_out; + } + + access_level = UA_ACCESSLEVELMASK_READ; + + /* set the correct access levels on certain properties */ + if (node_ctx->disabled) { + /* all property nodes are R/O if the port is disabled */ + ua_status = UA_Server_writeAccessLevel(server, prop_nodeId, access_level); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "Unable to set the access level for property '%s': %s", + iop_obj->browse_name, + UA_StatusCode_name(ua_status)); + goto err_out; + } + } else { + /* 'Direction' - can be r/o or r/w depending on 'Configurable' */ + if (g_strcmp0(iop_obj->browse_name, DIRECTION_BNAME) == 0) { + if (node_ctx->configurable) { + access_level = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE; + } else { + access_level = UA_ACCESSLEVELMASK_READ; + } + + ua_status = + UA_Server_writeAccessLevel(server, prop_nodeId, access_level); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "Unable to set the access level for property '%s': %s", + iop_obj->browse_name, + UA_StatusCode_name(ua_status)); + goto err_out; + } + } /* 'Direction' property */ + + /* 'State' - can be r/o or r/w depending on 'Direction' */ + if (g_strcmp0(iop_obj->browse_name, STATE_BNAME) == 0) { + if (node_ctx->direction == UA_IOPORTDIRECTIONTYPE_INPUT) { + access_level = UA_ACCESSLEVELMASK_READ; + } else { + access_level = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE; + } + + ua_status = + UA_Server_writeAccessLevel(server, prop_nodeId, access_level); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "Unable to set the access level for property '%s': %s", + iop_obj->browse_name, + UA_StatusCode_name(ua_status)); + goto err_out; + } + } /* 'State' property */ + } /* if disabled */ + + /* attach dataSource callbacks to each property where it makes sense */ + + /* attach access callbacks to the 'Name' property */ + if (g_strcmp0(iop_obj->browse_name, NAME_BNAME) == 0) { + ua_status = UA_Server_setVariableNode_dataSource(server, + prop_nodeId, + iop_name_cb); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "Unable to set dataSource callback for property '%s': %s", + iop_obj->browse_name, + UA_StatusCode_name(ua_status)); + goto err_out; + } + } + + /* attach access callbacks to the 'Usage' property */ + if (g_strcmp0(iop_obj->browse_name, USAGE_BNAME) == 0) { + ua_status = UA_Server_setVariableNode_dataSource(server, + prop_nodeId, + iop_usage_cb); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "Unable to set dataSource callback for property '%s': %s", + iop_obj->browse_name, + UA_StatusCode_name(ua_status)); + goto err_out; + } + } + + /* attach access callback to the 'Direction' property */ + if (g_strcmp0(iop_obj->browse_name, DIRECTION_BNAME) == 0) { + ua_status = UA_Server_setVariableNode_dataSource(server, + prop_nodeId, + iop_dir_cb); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "Unable to set dataSource callback for property '%s': %s", + iop_obj->browse_name, + UA_StatusCode_name(ua_status)); + goto err_out; + } + } + + /* attach access callback to the 'State' property */ + if (g_strcmp0(iop_obj->browse_name, STATE_BNAME) == 0) { + ua_status = UA_Server_setVariableNode_dataSource(server, + prop_nodeId, + iop_state_cb); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "Unable to set dataSource callback for property '%s': %s", + iop_obj->browse_name, + UA_StatusCode_name(ua_status)); + goto err_out; + } + } + + /* attach access callback to the 'NormalState' property */ + if (g_strcmp0(iop_obj->browse_name, NORMALSTATE_BNAME) == 0) { + ua_status = UA_Server_setVariableNode_dataSource(server, + prop_nodeId, + iop_normalstate_cb); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "Unable to set dataSource callback for property '%s': %s", + iop_obj->browse_name, + UA_StatusCode_name(ua_status)) + goto err_out; + } + } + + /* done with this property node */ + UA_NodeId_clear(&prop_nodeId); + + /* go to next entry */ + iop_obj++; + } /* while */ + + /* set the EventNotifier attribute for this I/O port object node */ + ua_status = UA_Server_writeEventNotifier(server, + *nodeId, + UA_EVENTNOTIFIER_SUBSCRIBE_TO_EVENT); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_writeEventNotifier() failed: %s", + UA_StatusCode_name(ua_status)); + goto err_out; + } + + return ua_status; + +err_out: + g_clear_error(&lerr); + UA_NodeId_clear(&prop_nodeId); + + return ua_status; +} + +/** + * Attaches lifecycle callbacks (an object constructor in particular) to nodes + * of IOPortObjType. + * + * Input parameters: + * * server - OPC-UA server instance + * * ns - the namespace index of the IOPortObjType + * + * Return value: + * * UA_STATUSCODE_GOOD if successful or a different UA_StatusCode otherwise + */ +static UA_StatusCode +set_ioport_lifecycle_cb(UA_Server *server, UA_UInt16 ns) +{ + UA_NodeTypeLifecycle lifecycle; + + g_assert(server != NULL); + + lifecycle.constructor = iop_ua_obj_constructor; + lifecycle.destructor = NULL; + + return UA_Server_setNodeTypeLifecycle( + server, + UA_NODEID_NUMERIC(ns, UA_IOPID_IOPORTOBJTYPE), + lifecycle); +} + +/** + * Creates a new OPC-UA event object to be emitted when an I/O port changes its + * state. + * + * Input parameters: + * * server - OPC-UA server instance + * * eventSourceName - browse name of originating node + * * ua_state - the new state of the I/O port + * * eventTypeId - the type ID of the event + * * eventSeverity - event severity + * + * Output parameters: + * * outId - node ID of the OPC-UA event object + * + * Returns: + * * UA_STATUSCODE_GOOD if successful or a different UA_StatusCode otherwise + */ +static UA_StatusCode +iop_ua_create_event(UA_Server *server, + UA_String eventSourceName, + UA_IOPortStateType ua_state, + UA_UInt32 eventTypeId, + UA_UInt16 eventSeverity, + UA_NodeId *outId) +{ + UA_StatusCode ret; + UA_DateTime eventTime; + UA_LocalizedText eventMessage; + + g_assert(server != NULL); + g_assert(outId != NULL); + g_assert(plugin != NULL); + g_assert(plugin->logger != NULL); + + ret = UA_Server_createEvent(server, + UA_NODEID_NUMERIC(plugin->ns, eventTypeId), + outId); + if (ret != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_createEvent() failed: %s", + UA_StatusCode_name(ret)); + return ret; + } + + /* Set the Event Attributes */ + /* Setting the Time is required or else the event will not show up in + * UAExpert! */ + eventTime = UA_DateTime_now(); + ret = UA_Server_writeObjectProperty_scalar(server, + *outId, + UA_QUALIFIEDNAME(0, "Time"), + &eventTime, + &UA_TYPES[UA_TYPES_DATETIME]); + if (ret != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_writeObjectProperty_scalar('Time') failed: %s", + UA_StatusCode_name(ret)); + return ret; + } + + ret = UA_Server_writeObjectProperty_scalar(server, + *outId, + UA_QUALIFIEDNAME(0, "Severity"), + &eventSeverity, + &UA_TYPES[UA_TYPES_UINT16]); + if (ret != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_writeObjectProperty_scalar('Severity') failed: %s", + UA_StatusCode_name(ret)); + return ret; + } + + if (ua_state == UA_IOPORTSTATETYPE_OPEN) { + eventMessage = UA_LOCALIZEDTEXT("en-US", "New state: OPEN"); + } else if (ua_state == UA_IOPORTSTATETYPE_CLOSED) { + eventMessage = UA_LOCALIZEDTEXT("en-US", "New state: CLOSED"); + } + + ret = UA_Server_writeObjectProperty_scalar(server, + *outId, + UA_QUALIFIEDNAME(0, "Message"), + &eventMessage, + &UA_TYPES[UA_TYPES_LOCALIZEDTEXT]); + if (ret != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_writeObjectProperty_scalar('Message') failed: %s", + UA_StatusCode_name(ret)); + return ret; + } + + ret = UA_Server_writeObjectProperty_scalar(server, + *outId, + UA_QUALIFIEDNAME(0, "SourceName"), + &eventSourceName, + &UA_TYPES[UA_TYPES_STRING]); + if (ret != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_writeObjectProperty_scalar('SourceName') failed: %s", + UA_StatusCode_name(ret)); + return ret; + } + + return UA_STATUSCODE_GOOD; +} + +/** + * Returns the new state for an I/O port given the new 'active' state and the + * configured 'Normal state'. + * + * Input parameters: + * * active - the 'active' (true/false) state as indicated by the AxEvent + * * normal_state - the currently configured 'Normal state' + * + * Returns: + * * the new corresponding state (open/closed) + */ +static UA_IOPortStateType +iop_new_state(gboolean active, UA_IOPortStateType normal_state) +{ + UA_IOPortStateType new_state; + + if (active) { + if (normal_state == UA_IOPORTSTATETYPE_OPEN) { + new_state = UA_IOPORTSTATETYPE_CLOSED; + } else { + new_state = UA_IOPORTSTATETYPE_OPEN; + } + } else { + if (normal_state == UA_IOPORTSTATETYPE_OPEN) { + new_state = UA_IOPORTSTATETYPE_OPEN; + } else { + new_state = UA_IOPORTSTATETYPE_CLOSED; + } + } + + return new_state; +} + +/** + * AXSubscriptionCallback to handle I/O port state change events. + * Interprets the AxEvent to update our local cache holding the current state + * of the I/O ports and emits an OPC-UA event according to the AxEvent. + */ +static void +iop_state_ev_cb(G_GNUC_UNUSED guint subscription, + AXEvent *event, + G_GNUC_UNUSED gpointer user_data) +{ + const AXEventKeyValueSet *key_value_set; + gint port; + ioport_obj_t *iop = NULL; + gboolean active; + gchar *topic2 = NULL; + gchar *id_str = NULL; + GError *lerr = NULL; + UA_StatusCode retval; + UA_NodeId iop_node = UA_NODEID_NULL; + UA_NodeId eventNodeId; + UA_NodeId ioports_obj = UA_NODEID_NUMERIC(plugin->ns, UA_IOPID_IOPORTS); + UA_IOPortStateType ua_state; + + g_assert(event != NULL); + g_assert(plugin != NULL); + g_assert(plugin->iop_ht != NULL); + g_assert(plugin->logger != NULL); + g_assert(plugin->server != NULL); + + /* extract the AXEventKeyValueSet from the event. */ + key_value_set = ax_event_get_key_value_set(event); + if (key_value_set == NULL) { + LOG_E(plugin->logger, "ax_event_get_key_value_set() failed, event ignored"); + goto err_out; + } + + /* fetch the I/O port number */ + if (!ax_event_key_value_set_get_integer(key_value_set, + "port", + NULL, + &port, + &lerr)) { + LOG_E(plugin->logger, + "'port' key missing from event: %s", + GERROR_MSG(lerr)); + goto err_out; + } + + /* fetch the 'active' state of port */ + if (!ax_event_key_value_set_get_boolean(key_value_set, + "state", + NULL, + &active, + &lerr)) { + LOG_E(plugin->logger, + "'active' key missing from event: %s", + GERROR_MSG(lerr)); + goto err_out; + } + + /* fetch the 'topic2' key: the value ("Port" or "OutputPort") will tell us + * if the port is an input or an output and will also help us filter out + * virtual ports for example. */ + if (!ax_event_key_value_set_get_string(key_value_set, + "topic2", + "tnsaxis", + &topic2, + &lerr)) { + LOG_E(plugin->logger, + "'topic2' key missing from event: %s", + GERROR_MSG(lerr)); + goto err_out; + } + + LOG_D(plugin->logger, + "I/O port: %d (\"topic2:%s\"), active: %d", + port, + topic2, + active); + + if ((g_strcmp0(topic2, "Port") == 0 || + g_strcmp0(topic2, "OutputPort") == 0) && + (port > -1)) { + /* look up this port in our hash table and update the 'state' accordingly */ + IOP_HT_LOCK(iop_mtx); + iop = g_hash_table_lookup(plugin->iop_ht, &port); + if (!iop) { + LOG_W(plugin->logger, "port: %d not found, ignoring AxEvent!", port); + IOP_HT_UNLOCK(iop_mtx); + goto err_out; + } + + /* update the current state of the port in our cache based on the AxEvent + * ('active') we received and the current value of the "NormalState" + * property of the port. */ + iop->state = iop_new_state(active, iop->normal_state); + ua_state = iop->state; + IOP_HT_UNLOCK(iop_mtx); + + LOG_D(plugin->logger, + "I/O port: %d, new state: %s", + port, + ua_state == UA_IOPORTSTATETYPE_OPEN ? "OPEN" : "CLOSED"); + + /* NOTE: web GUI uses 1-based indexing */ + id_str = g_strdup_printf(IOP_LABEL_FMT, port + 1); + + /* find the node id corresponding to the browseName */ + if (!iop_ua_get_nodeid_from_browsename(plugin->server, + &ioports_obj, + UA_NS0ID_ORGANIZES, + id_str, + &iop_node, + &lerr)) { + LOG_E(plugin->logger, + "iop_ua_get_nodeid_from_browsename() failed: %s", + GERROR_MSG(lerr)); + goto err_out; + } + + /* set up a node representation of the OPC-UA event */ + retval = iop_ua_create_event(plugin->server, + UA_STRING(id_str), + ua_state, + UA_IOPID_IOPSTATEEVENTTYPE, + IOP_STATE_CHANGE_EV_SEVERITY, + &eventNodeId); + if (retval != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "iop_ua_create_event() failed: %s", + UA_StatusCode_name(retval)); + goto err_out; + } + + /* trigger the OPC-UA event */ + retval = UA_Server_triggerEvent(plugin->server, + eventNodeId, + iop_node, /* event origin node */ + NULL, + UA_TRUE); + if (retval != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_triggerEvent failed: %s", + UA_StatusCode_name(retval)); + goto err_out; + } + } + +err_out: + g_clear_error(&lerr); + g_clear_pointer(&topic2, g_free); + g_clear_pointer(&id_str, g_free); + UA_NodeId_clear(&iop_node); + + /* the callback must always free 'event', NULL-case handled by the API */ + ax_event_free(event); + + return; +} + +/** + * AXSubscriptionCallback to handle I/O port configuration change events. + * Interprets the AxEvent and updates our local cache holding the current state + * of the I/O ports. + */ +static void +iop_cfg_ev_cb(G_GNUC_UNUSED guint subscription, + AXEvent *event, + G_GNUC_UNUSED gpointer user_data) +{ + const AXEventKeyValueSet *key_value_set; + gint64 port_nr; + ioport_obj_t *iop = NULL; + gchar *cfg_changes = NULL; + gchar *cfg_changes_unq = NULL; + gchar **strv; + gchar *param, *val; + gchar *id_str = NULL; + gchar *iop_index = NULL; + GError *lerr = NULL; + + g_assert(event != NULL); + g_assert(plugin != NULL); + g_assert(plugin->iop_ht != NULL); + g_assert(plugin->logger != NULL); + + /* extract the AXEventKeyValueSet from the event. */ + key_value_set = ax_event_get_key_value_set(event); + if (key_value_set == NULL) { + LOG_E(plugin->logger, "ax_event_get_key_value_set() failed, event ignored"); + goto err_out; + } + + /* get the value of the 'configuration_changes' key */ + if (!ax_event_key_value_set_get_string(key_value_set, + "configuration_changes", + NULL, + &cfg_changes, + &lerr)) { + LOG_E(plugin->logger, + "'configuration_changes' key missing from event: %s", + GERROR_MSG(lerr)); + goto err_out; + } + + /* get the value of the 'id' key */ + if (!ax_event_key_value_set_get_string(key_value_set, + "id", + NULL, + &id_str, + &lerr)) { + LOG_E(plugin->logger, "'id' key missing from event: %s", GERROR_MSG(lerr)); + goto err_out; + } + + /* extract the port number (index) from the 'id' key of the AxEvent which is + * of the following form: + * /com/axis/Configuration/Legacy/IOControl/IOPort/ */ + iop_index = g_strrstr(id_str, "/"); + if (!iop_index) { + LOG_E(plugin->logger, "Can't parse AxEvent for a port index!"); + goto err_out; + } + + /* iop_index + 1: skip over the '/' character */ + port_nr = ascii_strtoll_dec(iop_index + 1, &lerr); + if (lerr != NULL) { + LOG_E(plugin->logger, + "Invalid port index in AxEvent: %s", + GERROR_MSG(lerr)); + goto err_out; + } + + /* The string describing the parameter change is expected to be in form of: + * `"%s=%s"`. For example a name change of an I/O port can look like this: + * "Name=Port 01" */ + + /* strip away the double quotes */ + cfg_changes_unq = g_shell_unquote(cfg_changes, &lerr); + if (lerr != NULL) { + LOG_E(plugin->logger, "g_shell_unquote() failed: %s", GERROR_MSG(lerr)); + goto err_out; + } + + /* split the string into 2 tokens (parameter and value) + * using '=' as separator */ + strv = g_strsplit(cfg_changes_unq, "=", 2); + if (strv == NULL) { + LOG_E(plugin->logger, "Can't parse AxEvent key: 'configuration_changes'!"); + goto err_out; + } + + /* We expect to have 2 tokens in our 'strv' array of strings. This also + * implies they are not NULL. */ + if (g_strv_length(strv) != 2) { + LOG_E(plugin->logger, + "Unexpected result parsing AxEvent key: 'configuration_changes'!"); + goto err_out; + } + + param = *strv; + val = *(strv + 1); + + LOG_D(plugin->logger, + "configuration_changes: %s, id: %s ==> port: %" G_GINT64_FORMAT + ", param: %s, val: %s", + cfg_changes, + id_str, + port_nr, + param, + val); + + /* update our cached information in 'iop_ht' */ + IOP_HT_LOCK(iop_mtx); + iop = g_hash_table_lookup(plugin->iop_ht, &port_nr); + if (iop == NULL) { + LOG_W(plugin->logger, + "port: %" G_GINT64_FORMAT " not found, ignoring AxEvent!", + port_nr); + IOP_HT_UNLOCK(iop_mtx); + goto err_out; + } + + if (g_strcmp0(param, IOP_CFG_CHANGE_NAME) == 0) { + /* 'Name' got changed */ + g_clear_pointer(&iop->name, g_free); + iop->name = g_strdup(val); + } else if (g_strcmp0(param, IOP_CFG_CHANGE_USAGE) == 0) { + /* 'Usage' got changed */ + g_clear_pointer(&iop->usage, g_free); + iop->usage = g_strdup(val); + } else if (g_strcmp0(param, IOP_CFG_CHANGE_DIR) == 0) { + /* 'Direction' got changed */ + if (g_strcmp0(val, IO_VAPIX_DIR_INPUT) == 0) { + iop->direction = UA_IOPORTDIRECTIONTYPE_INPUT; + } else { + iop->direction = UA_IOPORTDIRECTIONTYPE_OUTPUT; + } + } else if ((g_strcmp0(param, IOP_CFG_CHANGE_NS_OUT) == 0) || + (g_strcmp0(param, IOP_CFG_CHANGE_NS_IN) == 0)) { + /* 'Normal state' got changed */ + if (g_strcmp0(val, IO_VAPIX_STATE_OPEN) == 0) { + iop->normal_state = UA_IOPORTSTATETYPE_CLOSED; + } else { + iop->normal_state = UA_IOPORTSTATETYPE_OPEN; + } + } + IOP_HT_UNLOCK(iop_mtx); + +err_out: + g_strfreev(strv); + g_clear_pointer(&cfg_changes_unq, g_free); + g_clear_pointer(&cfg_changes, g_free); + g_clear_pointer(&id_str, g_free); + g_clear_error(&lerr); + + /* the callback must always free 'event', NULL-case handled by the API */ + ax_event_free(event); + + return; +} + +/* Subscribe to the events we are interested in: + * - port state change events: emitted when an I/O port transitions between + * open/closed + * - port configuration change events: emitted when a configuration parameter + * for a certain I/O port gets changed (for example a change of the name, + * usage or direction)*/ +static gboolean +iop_subscribe_events(GError **err) +{ + AXEventKeyValueSet *key_value_set = NULL; + guint subscription; + + g_assert(plugin != NULL); + g_assert(plugin->iopstate_evh != NULL); + g_assert(plugin->iopcfg_evh != NULL); + g_assert(err == NULL || *err == NULL); + + key_value_set = ax_event_key_value_set_new(); + if (key_value_set == NULL) { + SET_ERROR(err, -1, "ax_event_key_value_set_new() failed!"); + return FALSE; + } + + /* Initialize an AXEventKeyValueSet to match I/O port state events: + * tns1:topic0=Device + * tnsaxis:topic1=IO + * port=* + * active=* <-- Subscribe to all states */ + /* clang-format off */ + if (!ax_event_key_value_set_add_key_values(key_value_set, err, + "topic0", "tns1", "Device", AX_VALUE_TYPE_STRING, + "topic1", "tnsaxis", "IO", AX_VALUE_TYPE_STRING, + "port", NULL, NULL, AX_VALUE_TYPE_INT, + "state", NULL, NULL, AX_VALUE_TYPE_BOOL, + NULL)) { + g_prefix_error(err, "ax_event_key_value_set_add_key_values() failed: "); + ax_event_key_value_set_free(key_value_set); + return FALSE; + } + /* clang-format on */ + + if (!ax_event_handler_subscribe(plugin->iopstate_evh, + key_value_set, + &subscription, + (AXSubscriptionCallback) iop_state_ev_cb, + plugin, + err)) { + g_prefix_error(err, "ax_event_handler_subscribe() failed: "); + ax_event_key_value_set_free(key_value_set); + return FALSE; + } + + plugin->event_subs[IOP_STATE_CHANGE] = subscription; + ax_event_key_value_set_free(key_value_set); + + /* prepare a new AXEventKeyValueSet */ + key_value_set = ax_event_key_value_set_new(); + if (key_value_set == NULL) { + SET_ERROR(err, -1, "ax_event_key_value_set_new() failed!"); + return FALSE; + } + + /* Initialize an AXEventKeyValueSet that matches I/O port configuration + * change events: + * tns1:topic0=Device + * tnsaxis:topic1=Configuration + * service=com.axis.Configuration.Legacy.IOControl1.IOPort + * configuration_changes=* <-- the parameter and its new/changed value + * id=* <-- the port id */ + /* clang-format off */ + if (!ax_event_key_value_set_add_key_values(key_value_set, err, + "topic0", "tns1", "Device", AX_VALUE_TYPE_STRING, + "topic1", "tnsaxis", "Configuration", AX_VALUE_TYPE_STRING, + "service", NULL, IOP_DBUS_CFG_SERVICE, AX_VALUE_TYPE_STRING, + NULL)) { + g_prefix_error(err, "ax_event_key_value_set_add_key_values() failed: "); + ax_event_key_value_set_free(key_value_set); + return FALSE; + } + /* clang-format on */ + + if (!ax_event_handler_subscribe(plugin->iopcfg_evh, + key_value_set, + &subscription, + (AXSubscriptionCallback) iop_cfg_ev_cb, + plugin, + err)) { + g_prefix_error(err, "ax_event_handler_subscribe() failed: "); + ax_event_key_value_set_free(key_value_set); + return FALSE; + } + + plugin->event_subs[IOP_CFG_CHANGE] = subscription; + ax_event_key_value_set_free(key_value_set); + + return TRUE; +} + +static gboolean +iop_ua_do_rollback(GError **err) +{ + g_assert(err == NULL || *err == NULL); + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); + g_assert(plugin->server != NULL); + + return ua_utils_do_rollback(plugin->server, plugin->rbd, err); +} + +static void +plugin_cleanup(void) +{ + GError *lerr = NULL; + + g_assert(plugin != NULL); + g_assert(plugin->logger != NULL); + + if (plugin->curl_h != NULL) { + curl_easy_cleanup(plugin->curl_h); + } + + IOP_HT_LOCK(iop_mtx); + if (plugin->iop_ht) { + g_hash_table_destroy(plugin->iop_ht); + } + IOP_HT_UNLOCK(iop_mtx); + g_mutex_clear(&plugin->iop_mtx); + + if (plugin->iopstate_evh != NULL) { + /* unsubscribe from I/O port state change events */ + if (!ax_event_handler_unsubscribe_and_notify( + plugin->iopstate_evh, + plugin->event_subs[IOP_STATE_CHANGE], + NULL, + NULL, + &lerr)) { + LOG_E(plugin->logger, + "ax_event_handler_unsubscribe_and_notify() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + ax_event_handler_free(plugin->iopstate_evh); + } + + if (plugin->iopcfg_evh != NULL) { + /* unsubscribe from I/O port configuration change events */ + if (!ax_event_handler_unsubscribe_and_notify( + plugin->iopcfg_evh, + plugin->event_subs[IOP_CFG_CHANGE], + NULL, + NULL, + &lerr)) { + LOG_E(plugin->logger, + "ax_event_handler_unsubscribe_and_notify() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + ax_event_handler_free(plugin->iopcfg_evh); + } + + g_clear_pointer(&plugin->name, g_free); + g_clear_pointer(&plugin->vapix_credentials, g_free); + + /* free up allocated rollback data, if any */ + ua_utils_clear_rbd(&plugin->rbd); + + g_clear_pointer(&plugin, g_free); +} + +/* Exported functions */ +gboolean +opc_ua_create(UA_Server *server, + UA_Logger *logger, + G_GNUC_UNUSED gpointer *params, + GError **err) +{ + size_t ns_idx; + UA_StatusCode ua_status; + GHashTableIter ht_iter; + gpointer key; + gpointer value; + GError *lerr = NULL; + + g_return_val_if_fail(server != NULL, FALSE); + g_return_val_if_fail(logger != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + if (plugin != NULL) { + return TRUE; + } + + plugin = g_new0(plugin_t, 1); + + plugin->name = g_strdup(UA_PLUGIN_NAME); + plugin->logger = logger; + plugin->iop_ht = NULL; + plugin->server = server; + plugin->rbd = g_new0(rollback_data_t, 1); + g_mutex_init(&plugin->iop_mtx); + + /* allocate a curl handle that we are going to use throughout our requests */ + plugin->curl_h = curl_easy_init(); + if (plugin->curl_h == NULL) { + SET_ERROR(err, -1, "curl_easy_init() failed!"); + goto err_out; + } + + /* obtain credentials to make VAPIX calls */ + plugin->vapix_credentials = vapix_get_credentials("vapix-ioports-user", err); + if (plugin->vapix_credentials == NULL) { + g_prefix_error(err, "Failed to get the VAPIX credentials: "); + goto err_out; + } + + /* check API version compatibility */ + if (!iop_vapix_check_api_ver(plugin->curl_h, + plugin->vapix_credentials, + err)) { + g_prefix_error(err, "iop_vapix_check_api_ver() failed: "); + goto err_out; + } + + /* add the "I/O Ports" namespace to the information model */ + ua_status = ioports_ns(server, plugin->rbd); + if (ua_status != UA_STATUSCODE_GOOD) { + g_prefix_error(err, "ns_ioports() failed: "); + goto err_out; + } + + ua_status = UA_Server_getNamespaceByName(server, + UA_STRING(UA_PLUGIN_NAMESPACE), + &ns_idx); + if (ua_status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_getNamespaceByName('%s') failed: %s", + UA_PLUGIN_NAMESPACE, + UA_StatusCode_name(ua_status)); + return FALSE; + } + plugin->ns = ns_idx; + + /* add a constructor callback for the IOPortObjType object type */ + ua_status = set_ioport_lifecycle_cb(server, plugin->ns); + if (ua_status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "Failed to install constructor for IOPortType nodes: %s", + UA_StatusCode_name(ua_status)); + goto err_out; + } + + IOP_HT_LOCK(iop_mtx); + /* Fetch the available I/O ports on the device */ + if (!iop_vapix_get_ports(plugin->curl_h, + plugin->vapix_credentials, + &plugin->iop_ht, + err)) { + g_prefix_error(err, "iop_vapix_get_ports() failed: "); + IOP_HT_UNLOCK(iop_mtx); + goto err_out; + } + + /* iterate over the hash table elements and add the ports to the UA + * information model */ + g_hash_table_iter_init(&ht_iter, plugin->iop_ht); + while (g_hash_table_iter_next(&ht_iter, &key, &value)) { + if (!iop_add_ioport_object(server, + *(guint32 *) key, + (ioport_obj_t *) value, + err)) { + g_prefix_error(err, "iop_add_ioport_object() failed: "); + IOP_HT_UNLOCK(iop_mtx); + goto err_out; + } + } + IOP_HT_UNLOCK(iop_mtx); + + plugin->iopstate_evh = ax_event_handler_new(); + if (!plugin->iopstate_evh) { + SET_ERROR(err, -1, "Could not allocate AXEventHandler!"); + goto err_out; + } + + plugin->iopcfg_evh = ax_event_handler_new(); + if (!plugin->iopcfg_evh) { + SET_ERROR(err, -1, "Could not allocate AXEventHandler!"); + goto err_out; + } + + if (!iop_subscribe_events(err)) { + g_prefix_error(err, "iop_subscribe_events() failed: "); + goto err_out; + } + + /* the information model was successfully populated so now we can free up our + * rollback data since we no longer need it */ + ua_utils_clear_rbd(&plugin->rbd); + + return TRUE; + +err_out: + /* remove any nodes created so far */ + if (!iop_ua_do_rollback(&lerr)) { + /* NOTE: if we land here we might just as well terminate the whole + * application, something is totally out of whack */ + LOG_E(plugin->logger, "iop_ua_do_rollback() failed: %s", GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + + /* clean up potentially allocated resources */ + plugin_cleanup(); + + return FALSE; +} + +void +opc_ua_destroy(void) +{ + if (plugin == NULL) { + return; + } + + plugin_cleanup(); +} + +const gchar * +opc_ua_get_plugin_name(void) +{ + if (plugin == NULL) { + return ERR_NOT_INITIALIZED; + } else if (plugin->name == NULL) { + return ERR_NO_NAME; + } + + return plugin->name; +} diff --git a/app/plugins/ioports/ioports_plugin.h b/app/plugins/ioports/ioports_plugin.h new file mode 100644 index 0000000..53a57bb --- /dev/null +++ b/app/plugins/ioports/ioports_plugin.h @@ -0,0 +1,69 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __IOPORTS_PLUGIN_H__ +#define __IOPORTS_PLUGIN_H__ + +#include + +/** + * opc_ua_create: + * @server: OPC-UA Server object + * @logger: an open62541 logger instance + * @params: pointer to user data (can be used to pass in additional parameters + * for the initialization of the plugin) + * @err: return location for a #GError + * + * Sets up and initializes the plugin. + * + * Returns: TRUE if setup was successful, FALSE otherwise. + */ +gboolean +opc_ua_create(UA_Server *server, + UA_Logger *logger, + G_GNUC_UNUSED gpointer *params, + GError **err); + +/** + * opc_ua_destroy: + * + * Frees allocated resources and performs any necessary shutdown operations for + * the plugin. + */ +void +opc_ua_destroy(void); + +/** + * opc_ua_get_plugin_name: + * + * Retrieves the name of the plugin. + * + * Returns: the plugin name as set in the plugin constructor or an error string + * if no name was set. + */ +const gchar * +opc_ua_get_plugin_name(void); + +#endif /* __IOPORTS_PLUGIN_H__ */ diff --git a/app/plugins/ioports/ioports_types.c b/app/plugins/ioports/ioports_types.c new file mode 100644 index 0000000..1144c50 --- /dev/null +++ b/app/plugins/ioports/ioports_types.c @@ -0,0 +1,64 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include "ioports_nodeids.h" +#include "ioports_types.h" + +/* IOPortDirectionType */ +#define IOPortDirectionType_members NULL + +/* IOPortStateType */ +#define IOPortStateType_members NULL + +/* clang-format off */ +UA_DataType UA_TYPES_IOP[UA_TYPES_IOP_COUNT] = { + /* IOPortDirectionType */ + { + UA_TYPENAME("IOPortDirectionType") /* .typeName */ + { 0, UA_NODEIDTYPE_NUMERIC, { UA_IOPID_IOPORTDIRECTIONTYPE } }, + /* .typeId */ + { 0, UA_NODEIDTYPE_NUMERIC, { 0 } }, /* .binaryEncodingId */ + sizeof(UA_IOPortDirectionType), /* .memSize */ + UA_DATATYPEKIND_ENUM, /* .typeKind */ + true, /* .pointerFree */ + UA_BINARY_OVERLAYABLE_INTEGER, /* .overlayable */ + 0, /* .membersSize */ + IOPortDirectionType_members /* .members */ + }, + /* IOPortStateType */ + { + UA_TYPENAME("IOPortStateType") /* .typeName */ + { 0, UA_NODEIDTYPE_NUMERIC, { UA_IOPID_IOPORTSTATETYPE } }, + /* .typeId */ + { 0, UA_NODEIDTYPE_NUMERIC, { 0 } }, /* .binaryEncodingId */ + sizeof(UA_IOPortStateType), /* .memSize */ + UA_DATATYPEKIND_ENUM, /* .typeKind */ + true, /* .pointerFree */ + UA_BINARY_OVERLAYABLE_INTEGER, /* .overlayable */ + 0, /* .membersSize */ + IOPortStateType_members /* .members */ + }, +}; +/* clang-format on */ diff --git a/app/plugins/ioports/ioports_types.h b/app/plugins/ioports/ioports_types.h new file mode 100644 index 0000000..d8b9026 --- /dev/null +++ b/app/plugins/ioports/ioports_types.h @@ -0,0 +1,65 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __IOPORTS_TYPES_H__ +#define __IOPORTS_TYPES_H__ + +#include + +/** + * Every type is assigned an index in an array containing the type descriptions. + * These descriptions are used during type handling (copying, deletion, + * binary encoding, ...). */ +#define UA_TYPES_IOP_COUNT 2 +extern UA_EXPORT UA_DataType UA_TYPES_IOP[UA_TYPES_IOP_COUNT]; + +/* IOPortDirectionType */ +typedef enum { + UA_IOPORTDIRECTIONTYPE_INPUT = 0, + UA_IOPORTDIRECTIONTYPE_OUTPUT = 1, + __UA_IOPORTDIRECTIONTYPE_FORCE32BIT = 0x7fffffff +} UA_IOPortDirectionType; + +UA_STATIC_ASSERT(sizeof(UA_IOPortDirectionType) == sizeof(UA_Int32), + enum_must_be_32bit); + +#define UA_TYPES_IOP_IOPORTDIRECTIONTYPE 0 + +/* IOPortStateType */ +typedef enum { + UA_IOPORTSTATETYPE_OPEN = 0, + UA_IOPORTSTATETYPE_CLOSED = 1, + __UA_IOPORTSTATETYPE_FORCE32BIT = 0x7fffffff +} UA_IOPortStateType; + +UA_STATIC_ASSERT(sizeof(UA_IOPortStateType) == sizeof(UA_Int32), + enum_must_be_32bit); + +#define UA_TYPES_IOP_IOPORTSTATETYPE 1 + +#define UA_TYPE_IOP_STATETYPE_NAME "IOPortStateType" +#define UA_TYPE_IOP_DIRTYPE_NAME "IOPortDirectionType" + +#endif /* __IOPORTS_TYPES_H__ */ diff --git a/app/plugins/ioports/ioports_vapix.c b/app/plugins/ioports/ioports_vapix.c new file mode 100644 index 0000000..9eff576 --- /dev/null +++ b/app/plugins/ioports/ioports_vapix.c @@ -0,0 +1,592 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include +#include +#include + +#include "error.h" +#include "ioports_ns.h" +#include "ioports_vapix.h" +#include "vapix_utils.h" + +DEFINE_GQUARK("ioports-vapix") + +#define IO_VAPIX_CGI_ENDPOINT "io/portmanagement.cgi" +#define IO_VAPIX_GET_API_VER "getSupportedVersions" +#define IO_VAPIX_GET_PORTS "getPorts" +#define IO_VAPIX_SET_PORTS "setPorts" +/* the API version we currently support */ +#define IO_VAPIX_VERSION "1.1" + +/* JSON payload: getSupportedVersions */ +#define IO_VAPIX_GET_API_VER_FMT \ + "{" \ + " \"method\": \"" IO_VAPIX_GET_API_VER "\"" \ + "}" + +/* JSON payload: getPorts */ +#define IO_VAPIX_GET_PORTS_FMT \ + "{" \ + " \"apiVersion\": \"" IO_VAPIX_VERSION "\"," \ + " \"method\": \"" IO_VAPIX_GET_PORTS "\"" \ + "}" + +/* JSON payload: setPorts + * NOTE: we do a single property for a single port at time with this request + * %d - port number + * %s - key (name/usage/direction/state/normalState) + * %s - value + **/ +#define IO_VAPIX_SET_PORT_FMT \ + "{" \ + " \"apiVersion\": \"" IO_VAPIX_VERSION "\"," \ + " \"method\": \"" IO_VAPIX_SET_PORTS "\"," \ + " \"params\": {" \ + " \"ports\": [" \ + " {" \ + " \"port\": \"%d\"," \ + " \"%s\": \"%s\"" \ + " }" \ + " ]" \ + " }" \ + "}" + +/* clang-format off */ +typedef enum json_key_type { + J_STRING, + J_BOOLEAN +} json_type_t; +/* clang-format on */ + +typedef struct port_json_obj { + const gchar *jkey; + json_type_t jtype; + const gchar *browse_name; +} port_json_t; + +/* frees up an 'ioport_obj_t' structure pointed to by 'data' */ +static void +free_ioport_obj(gpointer data) +{ + ioport_obj_t *iop = data; + + if (iop == NULL) { + return; + } + + g_clear_pointer(&iop->name, g_free); + g_clear_pointer(&iop->usage, g_free); + g_clear_pointer(&iop, g_free); + + return; +} + +gboolean +iop_vapix_check_api_ver(CURL *curl_h, const gchar *credentials, GError **err) +{ + gboolean found = FALSE; + gchar *response; + json_error_t parse_err; + json_t *json_response = NULL; + + json_t *json_err = NULL; + json_t *json_err_msg = NULL; + + json_t *data; + json_t *api_versions; + json_t *version; + size_t size; + guint i = 0; + + g_return_val_if_fail(curl_h != NULL, FALSE); + g_return_val_if_fail(credentials != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + response = vapix_request(curl_h, + credentials, + IO_VAPIX_CGI_ENDPOINT, + HTTP_POST, + JSON_data, + IO_VAPIX_GET_API_VER_FMT, + err); + if (response == NULL) { + g_prefix_error(err, + "Failed to get %s API versions: ", + IO_VAPIX_CGI_ENDPOINT); + goto err_out; + } + + json_response = json_loads(response, 0, &parse_err); + if (json_response == NULL) { + SET_ERROR(err, + -1, + "Invalid JSON response: L:%d/C:%d: %s", + parse_err.line, + parse_err.column, + parse_err.text); + goto err_out; + } + + /* is this an "error" response? */ + json_err = json_object_get(json_response, IO_VAPIX_JSON_ERR); + if (json_err) { + json_err_msg = json_object_get(json_err, IO_VAPIX_JSON_ERRMSG); + if (json_err_msg) { + SET_ERROR(err, + -1, + "'%s' error: %s", + IO_VAPIX_GET_API_VER, + json_string_value(json_err_msg)); + } else { + SET_ERROR(err, -1, "'%s': unknown error", IO_VAPIX_GET_API_VER); + } + goto err_out; + } + + data = json_object_get(json_response, IO_VAPIX_JSON_DATA); + if (data == NULL) { + SET_ERROR(err, -1, "No '%s' key in response", IO_VAPIX_JSON_DATA); + goto err_out; + } + + api_versions = json_object_get(data, IO_VAPIX_JSON_APIVER); + if (api_versions == NULL) { + SET_ERROR(err, -1, "No '%s' key in response", IO_VAPIX_JSON_APIVER); + goto err_out; + } + + if (!json_is_array(api_versions)) { + SET_ERROR(err, -1, "No valid '%s' in response", IO_VAPIX_JSON_APIVER); + goto err_out; + } + + size = json_array_size(api_versions); + if (size < 1) { + SET_ERROR(err, -1, "No supported version in response"); + goto err_out; + } + + /* Iterate over the supported versions array and check if the version we + * support is found. */ + while (!found && (i < size)) { + version = json_array_get(api_versions, i); + if (!json_is_string(version)) { + SET_ERROR(err, + -1, + "Bad version format in '%s', index: %d", + IO_VAPIX_JSON_APIVER, + i); + goto err_out; + } + + if (g_strcmp0(IO_VAPIX_VERSION, json_string_value(version)) == 0) { + /* our supported IO_VAPIX_VERSION was found */ + found = TRUE; + } + + i++; + } + + if (!found) { + SET_ERROR(err, + -1, + "%s ver. %s is not supported by the device.", + IO_VAPIX_CGI_ENDPOINT, + IO_VAPIX_VERSION); + } + +err_out: + g_clear_pointer(&response, g_free); + g_clear_pointer(&json_response, json_decref); + + return found; +} + +gboolean +iop_vapix_get_ports(CURL *curl_h, + const gchar *credentials, + GHashTable **iop_ht, + GError **err) +{ + gboolean retv = FALSE; + gchar *response; + /* clang-format off */ + const port_json_t port_map[IOP_OBJ_NR_PROPS] = { + /* JSON key, JSON type, Browse name */ + { IO_VAPIX_JSON_PORT, J_STRING, "Index" }, + { IO_VAPIX_JSON_CFGABLE, J_BOOLEAN, "Configurable"}, + { IO_VAPIX_JSON_USAGE, J_STRING, "Usage" }, + { IO_VAPIX_JSON_NAME, J_STRING, "Name" }, + { IO_VAPIX_JSON_DIR, J_STRING, "Direction" }, + { IO_VAPIX_JSON_STATE, J_STRING, "State" }, + { IO_VAPIX_JSON_NSTATE, J_STRING, "NormalState" }, + /* NOTE: 'readonly' property only present in the JSON payload if its value + * is TRUE. This entry must be _last_ in this table! */ + { IO_VAPIX_JSON_RO, J_BOOLEAN, "Disabled" }, + }; + /* clang-format on */ + + /* key in iop_ht */ + guint64 *iop_nr; + /* value in iop_ht */ + ioport_obj_t *iop; + + json_error_t parse_err; + json_t *json_response = NULL; + json_t *json_err = NULL; + json_error_t jerr; + json_t *json_err_msg = NULL; + json_t *data; + json_t *json_nrports = NULL; + json_t *items = NULL; + json_t *key; + + guint nr_ports; + size_t size; + size_t i, j; + json_t *port_item; + + g_return_val_if_fail(curl_h != NULL, FALSE); + g_return_val_if_fail(credentials != NULL, FALSE); + g_return_val_if_fail(iop_ht == NULL || *iop_ht == NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + response = vapix_request(curl_h, + credentials, + IO_VAPIX_CGI_ENDPOINT, + HTTP_POST, + JSON_data, + IO_VAPIX_GET_PORTS_FMT, + err); + if (response == NULL) { + g_prefix_error(err, "'%s' failed: ", IO_VAPIX_GET_PORTS); + goto err_out; + } + + json_response = json_loads(response, 0, &parse_err); + if (json_response == NULL) { + SET_ERROR(err, + -1, + "Invalid JSON response: L:%d/C:%d: %s", + parse_err.line, + parse_err.column, + parse_err.text); + goto err_out; + } + + /* is this an "error" response? */ + json_err = json_object_get(json_response, IO_VAPIX_JSON_ERR); + if (json_err) { + json_err_msg = json_object_get(json_err, IO_VAPIX_JSON_ERRMSG); + if (json_err_msg) { + SET_ERROR(err, + -1, + "'%s' error: %s", + IO_VAPIX_GET_PORTS, + json_string_value(json_err_msg)); + } else { + SET_ERROR(err, -1, "'%s': unknown error", IO_VAPIX_GET_PORTS); + } + goto err_out; + } + + data = json_object_get(json_response, IO_VAPIX_JSON_DATA); + if (data == NULL) { + SET_ERROR(err, -1, "No '%s' key in response", IO_VAPIX_JSON_DATA); + goto err_out; + } + + if (json_unpack_ex(data, + &jerr, + JSON_VALIDATE_ONLY, + "{s:i,s:[o]}", + IO_VAPIX_JSON_NRPORTS, + IO_VAPIX_JSON_ITEMS) != 0) { + SET_ERROR(err, + -1, + "Invalid JSON data: L:%d/C:%d: %s", + jerr.line, + jerr.column, + jerr.text); + goto err_out; + } + + json_nrports = json_object_get(data, IO_VAPIX_JSON_NRPORTS); + if (json_nrports == NULL) { + SET_ERROR(err, -1, "No '%s' key in respose", IO_VAPIX_JSON_NRPORTS); + goto err_out; + } + + if (json_typeof(json_nrports) != JSON_INTEGER) { + SET_ERROR(err, -1, "'%s' not an integer", IO_VAPIX_JSON_NRPORTS); + goto err_out; + } + + nr_ports = json_integer_value(json_nrports); + + items = json_object_get(data, IO_VAPIX_JSON_ITEMS); + if (items == NULL) { + SET_ERROR(err, -1, "No '%s' key in response", IO_VAPIX_JSON_ITEMS); + goto err_out; + } + + if (!json_is_array(items)) { + SET_ERROR(err, -1, "No valid port items in response"); + goto err_out; + } + + size = json_array_size(items); + if (size != nr_ports) { + SET_ERROR(err, + -1, + "Ports array size: %zu mismatches '%s': %d", + size, + IO_VAPIX_JSON_NRPORTS, + nr_ports); + goto err_out; + } + + *iop_ht = g_hash_table_new_full(g_int_hash, + g_int_equal, + g_free, + free_ioport_obj); + + /* loop over the array of JSON port objects */ + for (i = 0; i < size; i++) { + port_item = json_array_get(items, i); + + if (json_is_object(port_item)) { + key = NULL; + iop_nr = g_new0(guint64, 1); + iop = g_new0(ioport_obj_t, 1); + + /* FALSE per default, the JSON key will be present and set to TRUE if + * the I/O port is in fact configured as disabled/read-only. */ + iop->readonly = FALSE; + + /* loop over all the properties of this JSON port object */ + for (j = 0; j < IOP_OBJ_NR_PROPS; j++) { + key = json_object_get(port_item, port_map[j].jkey); + if (key) { + if (port_map[j].jtype == J_STRING) { + if (g_strcmp0(port_map[j].jkey, IO_VAPIX_JSON_PORT) == 0) { + /* JSON: port */ + *iop_nr = g_ascii_strtoll(json_string_value(key), NULL, 10); + } else if (g_strcmp0(port_map[j].jkey, IO_VAPIX_JSON_USAGE) == 0) { + /* JSON: usage */ + iop->usage = g_strdup(json_string_value(key)); + } else if (g_strcmp0(port_map[j].jkey, IO_VAPIX_JSON_NAME) == 0) { + /* JSON: name */ + iop->name = g_strdup(json_string_value(key)); + } else if (g_strcmp0(port_map[j].jkey, IO_VAPIX_JSON_DIR) == 0) { + /* JSON: direction */ + if (g_strcmp0(json_string_value(key), IO_VAPIX_DIR_INPUT) == 0) { + iop->direction = UA_IOPORTDIRECTIONTYPE_INPUT; + } else { + iop->direction = UA_IOPORTDIRECTIONTYPE_OUTPUT; + } + } else if (g_strcmp0(port_map[j].jkey, IO_VAPIX_JSON_STATE) == 0) { + /* JSON: state */ + if (g_strcmp0(json_string_value(key), IO_VAPIX_STATE_OPEN) == 0) { + iop->state = UA_IOPORTSTATETYPE_OPEN; + } else { + iop->state = UA_IOPORTSTATETYPE_CLOSED; + } + } else if (g_strcmp0(port_map[j].jkey, IO_VAPIX_JSON_NSTATE) == 0) { + /* JSON: normalState */ + if (g_strcmp0(json_string_value(key), IO_VAPIX_STATE_OPEN) == 0) { + iop->normal_state = UA_IOPORTSTATETYPE_OPEN; + } else { + iop->normal_state = UA_IOPORTSTATETYPE_CLOSED; + } + } + } + + if (port_map[j].jtype == J_BOOLEAN) { + if (g_strcmp0(port_map[j].jkey, IO_VAPIX_JSON_CFGABLE) == 0) { + /* JSON: configurable */ + iop->configurable = json_boolean_value(key); + } else if (g_strcmp0(port_map[j].jkey, IO_VAPIX_JSON_RO) == 0) { + /* JSON: readonly */ + iop->readonly = json_boolean_value(key); + } + } + } else { + if (j < IOP_OBJ_NR_PROPS - 1) { + /* We expect all the port properties (JSON port object keys) to be + * present except the 'readonly' property/key which _is_ allowed to + * be missing */ + SET_ERROR(err, + -1, + "port: %zu missing property: '%s'", + i, + port_map[j].jkey); + + /* free the pre-allocated key/value pair for the hash table */ + g_clear_pointer(&iop_nr, g_free); + g_clear_pointer(&iop, g_free); + + /* free the potentially allocated resources in the hash table */ + g_hash_table_destroy(*iop_ht); + *iop_ht = NULL; + + goto err_out; + } + } + } /* for loop through port properties [index, name, usage, dir...] */ + + g_hash_table_insert(*iop_ht, iop_nr, iop); + + } else { + SET_ERROR(err, -1, "Not a JSON object at index: %zu in ports array", i); + /* free the potentially allocated resources in the hash table */ + g_hash_table_destroy(*iop_ht); + *iop_ht = NULL; + goto err_out; + } + } /* for loop through port objects in the array of returned ports */ + + retv = TRUE; + +err_out: + g_clear_pointer(&response, g_free); + g_clear_pointer(&json_response, json_decref); + + return retv; +} + +gboolean +iop_vapix_set_port(CURL *curl_h, + const gchar *credentials, + UA_UInt32 portnr, + const gchar *iop_key, + const gchar *iop_value, + GError **err) +{ + /* properties supported by the 'setPorts' API */ + /* clang-format off */ + const gchar *port_props[] = { + IO_VAPIX_JSON_PORT, + IO_VAPIX_JSON_USAGE, + IO_VAPIX_JSON_DIR, + IO_VAPIX_JSON_NAME, + IO_VAPIX_JSON_NSTATE, + IO_VAPIX_JSON_STATE, + NULL + }; + /* clang-format on */ + guint i = 0; + const gchar *property = port_props[i]; + gboolean found = FALSE; + gboolean retv = FALSE; + gchar *response; + gchar *request = NULL; + json_error_t parse_err; + json_t *json_response = NULL; + json_t *json_err = NULL; + json_t *json_err_msg = NULL; + json_t *data; + + g_return_val_if_fail(curl_h != NULL, FALSE); + g_return_val_if_fail(credentials != NULL, FALSE); + g_return_val_if_fail(iop_key != NULL, FALSE); + g_return_val_if_fail(iop_value != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + /* NOTE: the portmanagement.cgi doesn't do a very good job at properly + * validating the properties to be set via 'setPort'. It can return success + * even if for example 'name' is misspelled as 'nameee'. At least the property + * values are properly validated but we better do our own input validation of + * the keys/properties. */ + while ((property != NULL) && !found) { + if (g_strcmp0(iop_key, property) == 0) { + found = TRUE; + } else { + property = port_props[++i]; + } + } + + if (!found) { + SET_ERROR(err, -1, "Invalid port property: \"%s\"!", iop_key); + return FALSE; + } + + request = g_strdup_printf(IO_VAPIX_SET_PORT_FMT, portnr, iop_key, iop_value); + response = vapix_request(curl_h, + credentials, + IO_VAPIX_CGI_ENDPOINT, + HTTP_POST, + JSON_data, + request, + err); + g_clear_pointer(&request, g_free); + if (response == NULL) { + g_prefix_error(err, "'%s' failed: ", IO_VAPIX_SET_PORTS); + goto err_out; + } + + json_response = json_loads(response, 0, &parse_err); + if (json_response == NULL) { + SET_ERROR(err, + -1, + "Invalid JSON response: L:%d/C:%d: %s", + parse_err.line, + parse_err.column, + parse_err.text); + goto err_out; + } + + /* is this an "error" response? */ + json_err = json_object_get(json_response, IO_VAPIX_JSON_ERR); + if (json_err) { + json_err_msg = json_object_get(json_err, IO_VAPIX_JSON_ERRMSG); + if (json_err_msg) { + SET_ERROR(err, + -1, + "'%s' error: %s", + IO_VAPIX_SET_PORTS, + json_string_value(json_err_msg)); + } else { + SET_ERROR(err, -1, "'%s': unknown error", IO_VAPIX_SET_PORTS); + } + goto err_out; + } + + data = json_object_get(json_response, IO_VAPIX_JSON_DATA); + if (data == NULL) { + SET_ERROR(err, -1, "No '%s' key in response", IO_VAPIX_JSON_DATA); + goto err_out; + } + + retv = TRUE; + +err_out: + g_clear_pointer(&response, g_free); + g_clear_pointer(&json_response, json_decref); + + return retv; +} diff --git a/app/plugins/ioports/ioports_vapix.h b/app/plugins/ioports/ioports_vapix.h new file mode 100644 index 0000000..7ef86ed --- /dev/null +++ b/app/plugins/ioports/ioports_vapix.h @@ -0,0 +1,87 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __IOPORTS_VAPIX_H__ +#define __IOPORTS_VAPIX_H__ + +#define IO_VAPIX_JSON_ERR "error" +#define IO_VAPIX_JSON_ERRMSG "message" +#define IO_VAPIX_JSON_DATA "data" +#define IO_VAPIX_JSON_NRPORTS "numberOfPorts" +#define IO_VAPIX_JSON_ITEMS "items" +#define IO_VAPIX_JSON_PORT "port" +#define IO_VAPIX_JSON_STATE "state" +#define IO_VAPIX_JSON_CFGABLE "configurable" +#define IO_VAPIX_JSON_RO "readonly" +#define IO_VAPIX_JSON_USAGE "usage" +#define IO_VAPIX_JSON_DIR "direction" +#define IO_VAPIX_JSON_NAME "name" +#define IO_VAPIX_JSON_NSTATE "normalState" +#define IO_VAPIX_JSON_APIVER "apiVersions" + +#define IO_VAPIX_DIR_INPUT "input" +#define IO_VAPIX_DIR_OUTPUT "output" +#define IO_VAPIX_STATE_OPEN "open" +#define IO_VAPIX_STATE_CLOSED "closed" + +/* structure used to hold a local cache (hash table) with I/O ports state and + * config information */ +typedef struct ioport_obj { + gboolean configurable; + gboolean readonly; + gchar *name; + gchar *usage; + UA_IOPortStateType normal_state; + UA_IOPortStateType state; + UA_IOPortDirectionType direction; +} ioport_obj_t; + +/* Checks if the device supports version `IO_VAPIX_VERSION` of the + * "portmanagement.cgi" API. */ +gboolean +iop_vapix_check_api_ver(CURL *curl_h, const gchar *credentials, GError **err); + +/** + * Calls the 'getPorts' method of the "portmanagement.cgi" API and returns a + * hash table (iop_ht) with information about the I/O ports found on the device. + */ +gboolean +iop_vapix_get_ports(CURL *curl_h, + const gchar *credentials, + GHashTable **iop_ht, + GError **err); + +/** + * Calls the 'setPorts' method of the "portmanagement.cgi" API. Only one + * property ('iop_key') of an I/O port can be set ('iop_value') at a time. */ +gboolean +iop_vapix_set_port(CURL *curl_h, + const gchar *credentials, + UA_UInt32 portnr, + const gchar *iop_key, + const gchar *iop_value, + GError **err); + +#endif /* __IOPORTS_VAPIX_H__ */ diff --git a/app/plugins/simple_event/Makefile b/app/plugins/simple_event/Makefile new file mode 100644 index 0000000..52a0f8e --- /dev/null +++ b/app/plugins/simple_event/Makefile @@ -0,0 +1,59 @@ +# The name of the plugin module (shared object) is built up by concatenating: +# * the prefix: 'libopcua_' +# * the directory/plugin name under 'plugins' +# * the suffix: '.so' +TARGET_LIB = libopcua_$(notdir $(CURDIR)).so +SRCS = $(wildcard *.c) +OBJS = $(SRCS:.c=.o) +DEPS = $(patsubst %.c,%.d,$(SRCS)) + +PKGS = glib-2.0 gmodule-2.0 axevent +CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) +LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) + +LIB_OPEN62541 = $(SDKTARGETSYSROOT)/usr/lib/libopen62541.a +LIBS += $(LIB_OPEN62541) + +# app/include +INCLUDE = ../../include + +# All the plugin modules are to be placed in 'app/lib'. 'eap-create.sh' will +# automatically pick up the 'lib' folder and add it to the final .eap file. +TARGET_LIB_DIR = ../../lib + +CFLAGS += -I. +CFLAGS += $(addprefix -I, $(INCLUDE)) + +CFLAGS += -Wall \ + -Wextra \ + -Wformat=2 \ + -Wpointer-arith \ + -Wbad-function-cast \ + -Wstrict-prototypes \ + -Wmissing-prototypes \ + -Winline \ + -Wdisabled-optimization \ + -Wfloat-equal \ + -W \ + -Werror \ + -Wno-maybe-uninitialized + +# preprocessor flags to generate Makefile dependencies +CPPFLAGS = -MMD -MP + +CFLAGS_MODULES = $(CFLAGS) -fPIC + +all: $(TARGET_LIB_DIR)/$(TARGET_LIB) + +$(TARGET_LIB_DIR)/$(TARGET_LIB): $(TARGET_LIB) + mkdir -p $(TARGET_LIB_DIR) + cp $(TARGET_LIB) $(TARGET_LIB_DIR) + +$(TARGET_LIB): $(OBJS) + $(CC) $(CFLAGS_MODULES) $(CPPFLAGS) $(LDFLAGS) -shared $^ $(LIBS) $(LDLIBS) \ + -o $@ + +-include $(DEPS) + +clean: + rm -f $(DEPS) *.o core *.so $(TARGET_LIB_DIR)/$(TARGET_LIB) diff --git a/app/plugins/simple_event/README.md b/app/plugins/simple_event/README.md new file mode 100644 index 0000000..3eb431e --- /dev/null +++ b/app/plugins/simple_event/README.md @@ -0,0 +1,55 @@ +# Simple Event Plugin + +## Description + +This example plugin demonstrates how to integrate Axis events with OPC-UA, it: + +- Subscribes to Axis "LiveStreamAccessed" events. +- Generates OPC-UA events when the Axis `LiveStreamAccessed` event is triggered. +- Shows how to map Axis event data to standard OPC-UA event properties. + +## Features + +- Creates an OPC-UA object node called **LiveStreamAccessed**, which acts as +an event notifier for these events. +- Exposes an `Accessed` boolean property showing the current state of the +live stream access. +- Triggers OPC-UA events with the following standard properties: + - `Timestamp` + - `Severity` (fixed to 500 in this example). + - `Message` + - `SourceName` + +## Usage + +- Subscribe to the `Accessed` variable to see the current state of the Axis +event +- To read the OPC-UA Events, subscribe to the `LiveStreamAccessed` object using +the event view in UAExpert + +## Development + +To adapt this plugin for other Axis events (functions mentioned below are found +in `simple_event_plugin.c`): + +1. **Change the event subscription**: + - Modify the topics / key-value pairs in `setup_ax_event()` to subscribe to + different Axis events. + - Update the event topic names in the key-value set to match the desired + Axis events. + +2. **Customize the event object**: + - Change the object name and description as presented in the OPC-UA address + space. + - Add or remove properties in `create_event_object()` to map additional Axis + event data. + +3. **Handle different event data**: + - Update the callback function `simple_opc_event_cb()` to process the + specific data fields from the new Axis event. + - Extract different fields from the Axis event and map them to the + properties of the OPC-UA event object. + +## License + +**[MIT License](../../../LICENSE)** diff --git a/app/plugins/simple_event/simple_event_plugin.c b/app/plugins/simple_event/simple_event_plugin.c new file mode 100644 index 0000000..01f605d --- /dev/null +++ b/app/plugins/simple_event/simple_event_plugin.c @@ -0,0 +1,563 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include +#include + +#include "error.h" +#include "log.h" +#include "simple_event_plugin.h" +#include "ua_utils.h" + +#define UA_PLUGIN_NAMESPACE "http://www.axis.com/OpcUA/SimpleEvent/" +#define UA_PLUGIN_NAME "opc-simple-event-plugin" + +#define TIME_PROPERTY "Time" +#define SEVERITY_PROPERTY "Severity" +#define MESSAGE_PROPERTY "Message" +#define SOURCE_NAME_PROPERTY "SourceName" +#define ACCESSED_VARIABLE_NAME "Accessed" +#define UA_LIVESTREAM_OBJ_DISPLAY_NAME "LiveStreamAccessed" +#define UA_LIVESTREAM_OBJ_DESCRIPTION "Livestream Accessed Object" + +#define SEVERITY 500 + +#define ERR_NOT_INITIALIZED "The " UA_PLUGIN_NAME " is not initialized" +#define ERR_NO_NAME "The " UA_PLUGIN_NAME " was not given a name" + +DEFINE_GQUARK(UA_PLUGIN_NAME) + +typedef struct plugin { + /* user-friendly name of the plugin */ + gchar *name; + /* OPC-UA namespace index */ + UA_UInt16 ns; + /* an open62541 logger */ + UA_Logger *logger; + /* Axis event handler */ + AXEventHandler *event_handler; + /* Axis event sub id */ + guint sub_id; + /* event object node id */ + UA_NodeId event_obj; + /* keep track of data that needs to be rolled back in case of failure */ + rollback_data_t *rbd; +} plugin_t; + +static plugin_t *plugin; + +/* Local functions */ +static gboolean +create_opc_event(UA_Server *server, + const gchar *source_name, + UA_UInt16 eventSeverity, + UA_LocalizedText *eventMessage, + UA_DateTime ax_eventTime, + UA_NodeId *eventId, + GError **err) +{ + UA_StatusCode ret; + UA_String eventSourceName; + gchar *reason = ""; + + g_assert(server != NULL); + g_assert(source_name != NULL); + g_assert(eventMessage != NULL); + g_assert(eventId != NULL); + g_assert(err == NULL || *err == NULL); + + ret = UA_Server_createEvent(server, + UA_NODEID_NUMERIC(0, UA_NS0ID_BASEEVENTTYPE), + eventId); + + if (ret != UA_STATUSCODE_GOOD) { + SET_ERROR(err, -1, "Failed to create event: %s", UA_StatusCode_name(ret)); + return FALSE; + } + + /* Set properties for base event type */ + + /* Set the event Time */ + ret = UA_Server_writeObjectProperty_scalar(server, + *eventId, + UA_QUALIFIEDNAME(0, TIME_PROPERTY), + &ax_eventTime, + &UA_TYPES[UA_TYPES_DATETIME]); + + if (ret != UA_STATUSCODE_GOOD) { + reason = TIME_PROPERTY; + goto out; + } + + /* Set the event Severity */ + ret = UA_Server_writeObjectProperty_scalar( + server, + *eventId, + UA_QUALIFIEDNAME(0, SEVERITY_PROPERTY), + &eventSeverity, + &UA_TYPES[UA_TYPES_UINT16]); + + if (ret != UA_STATUSCODE_GOOD) { + reason = SEVERITY_PROPERTY; + goto out; + } + + /* Set the event Message */ + ret = UA_Server_writeObjectProperty_scalar(server, + *eventId, + UA_QUALIFIEDNAME(0, + MESSAGE_PROPERTY), + eventMessage, + &UA_TYPES[UA_TYPES_LOCALIZEDTEXT]); + + if (ret != UA_STATUSCODE_GOOD) { + reason = MESSAGE_PROPERTY; + goto out; + } + + /* Set the event SourceName */ + eventSourceName = UA_STRING((gchar *) source_name); + ret = UA_Server_writeObjectProperty_scalar( + server, + *eventId, + UA_QUALIFIEDNAME(0, SOURCE_NAME_PROPERTY), + &eventSourceName, + &UA_TYPES[UA_TYPES_STRING]); + + if (ret != UA_STATUSCODE_GOOD) { + reason = SOURCE_NAME_PROPERTY; + goto out; + } + + return TRUE; + +out: + SET_ERROR(err, + -1, + "Failed to create event '%s': %s", + reason, + UA_StatusCode_name(ret)); + + return FALSE; +} + +static gboolean +trigger_opc_event(UA_Server *server, + UA_UInt16 eventSeverity, + const gchar *source_name, + UA_LocalizedText *eventMessage, + UA_DateTime ax_eventTime, + GError **err) +{ + UA_StatusCode ret; + UA_NodeId eventNodeId; + + g_assert(server != NULL); + g_assert(source_name != NULL); + g_assert(eventMessage != NULL); + g_assert(err == NULL || *err == NULL); + g_assert(plugin != NULL); + + LOG_I(plugin->logger, "Try to create event %s ...", eventMessage->text.data); + + /* Write the event */ + if (!create_opc_event(server, + source_name, + eventSeverity, + eventMessage, + ax_eventTime, + &eventNodeId, + err)) { + g_prefix_error(err, "create_opc_event() failed: "); + return FALSE; + } + + /* Trigger an event */ + ret = UA_Server_triggerEvent(server, + eventNodeId, + plugin->event_obj, + NULL, + UA_TRUE); + + if (ret != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "%s Failed to trigger event: %s", + __func__, + UA_StatusCode_name(ret)); + return FALSE; + } + + LOG_I(plugin->logger, + "Event: %s created successfully", + eventMessage->text.data); + + return TRUE; +}; + +static void +simple_opc_event_cb(G_GNUC_UNUSED guint subscription, + AXEvent *event, + gpointer user_data) +{ + const AXEventKeyValueSet *key_value_set; + gboolean active; + gchar *s1 = NULL; + UA_Server *server = (UA_Server *) user_data; + GError *lerr = NULL; + UA_StatusCode status; + + g_assert(event != NULL); + g_assert(server != NULL); + g_assert(plugin != NULL); + + key_value_set = ax_event_get_key_value_set(event); + + if (key_value_set == NULL) { + LOG_E(plugin->logger, "ax_event_get_key_value_set() failed: returned NULL"); + goto out; + } + + if (!ax_event_key_value_set_get_string(key_value_set, + "topic1", + "tnsaxis", + &s1, + &lerr)) { + LOG_E(plugin->logger, + "ax_event_key_value_set_get_string() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + goto out; + } + + if (!ax_event_key_value_set_get_boolean(key_value_set, + "accessed", + NULL, + &active, + NULL)) { + LOG_E(plugin->logger, + "ax_event_key_value_set_get_boolean() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + goto out; + } + + LOG_D(plugin->logger, "%s: Accessed=%s", s1, active ? "true" : "false"); + + if (active) { + GDateTime *d = ax_event_get_time_stamp2(event); + UA_UInt16 severity = SEVERITY; + UA_LocalizedText eventMsg; + const gchar *source_name = UA_LIVESTREAM_OBJ_DISPLAY_NAME; + + eventMsg = UA_LOCALIZEDTEXT("en-US", s1); + + if (!trigger_opc_event(server, + severity, + source_name, + &eventMsg, + UA_DateTime_fromUnixTime(g_date_time_to_unix(d)), + &lerr)) { + LOG_E(plugin->logger, "Event failure: %s", GERROR_MSG(lerr)); + g_clear_error(&lerr); + goto out; + } + } + + status = UA_Server_writeObjectProperty_scalar( + server, + plugin->event_obj, + UA_QUALIFIEDNAME(plugin->ns, ACCESSED_VARIABLE_NAME), + &active, + &UA_TYPES[UA_TYPES_BOOLEAN]); + + if (status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_writeObjectProperty_scalar() failed: %s", + UA_StatusCode_name(status)); + goto out; + } + +out: + g_clear_pointer(&s1, g_free); + ax_event_free(event); +} + +/* This is how the 'LiveStreamAccessed' event looks like + * + * ---- Event ------------------------ + * < Property > + * Global Declaration Id: 139 + * Local Declaration Id: 81 + * Producer Id: 25 + * Timestamp: 1742564428.730070 + * [accessed = '0' (Accessed)] {onvif-data} {property-state} + * [tns1:topic0 = 'VideoSource'] + * [tnsaxis:topic1 = 'LiveStreamAccessed' (Live stream accessed)] + * ----------------------------------- + */ +static gboolean +setup_ax_event(UA_Server *server, AXSubscriptionCallback cb, GError **err) +{ + gboolean retval; + AXEventKeyValueSet *key_value_set = NULL; + + g_assert(server != NULL); + g_assert(cb != NULL); + g_assert(err == NULL || *err == NULL); + g_assert(plugin != NULL); + + /* Set keys and namespaces for the event to be subscribed */ + key_value_set = ax_event_key_value_set_new(); + + if (key_value_set == NULL) { + LOG_E(plugin->logger, "ax_event_key_value_set_new() failed: returned NULL"); + return FALSE; + } + + retval = ax_event_key_value_set_add_key_values(key_value_set, + err, + "topic0", + "tns1", + "VideoSource", + AX_VALUE_TYPE_STRING, + "topic1", + "tnsaxis", + "LiveStreamAccessed", + AX_VALUE_TYPE_STRING, + NULL); + + if (!retval) { + goto out; + } + + plugin->event_handler = ax_event_handler_new(); + + if (plugin->event_handler == NULL) { + LOG_E(plugin->logger, "ax_event_handler_new() failed: returned NULL"); + retval = FALSE; + goto out; + } + + retval = ax_event_handler_subscribe(plugin->event_handler, + key_value_set, + &plugin->sub_id, + cb, + server, + err); + +out: + ax_event_key_value_set_free(key_value_set); + + return retval; +} + +/* The variable will change according to the latest value received from the + * axevent */ +static gboolean +create_event_object(UA_Server *server, GError **err) +{ + UA_StatusCode status; + UA_ObjectAttributes attr = UA_ObjectAttributes_default; + UA_VariableAttributes vattr = UA_VariableAttributes_default; + UA_Boolean init_val = FALSE; + + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); + g_assert(server != NULL); + g_assert(err == NULL || *err == NULL); + + attr.displayName = UA_LOCALIZEDTEXT("en-US", UA_LIVESTREAM_OBJ_DISPLAY_NAME); + attr.description = UA_LOCALIZEDTEXT("en-US", UA_LIVESTREAM_OBJ_DESCRIPTION); + + status = UA_Server_addObjectNode_rb( + server, + UA_NODEID_NUMERIC(plugin->ns, 0), + UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER), + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + UA_QUALIFIEDNAME(plugin->ns, UA_LIVESTREAM_OBJ_DISPLAY_NAME), + UA_NODEID_NUMERIC(0, UA_NS0ID_BASEOBJECTTYPE), + attr, + NULL, + plugin->rbd, + &plugin->event_obj); + + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_addObjectNode_rb() failed: %s", + UA_StatusCode_name(status)); + return FALSE; + } + + /* set the EventNotifier attribute for the 'LiveStreamAccessed' object node */ + status = UA_Server_writeEventNotifier(server, + plugin->event_obj, + UA_EVENTNOTIFIER_SUBSCRIBE_TO_EVENT); + if (status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Server_writeEventNotifier() failed: %s", + UA_StatusCode_name(status)); + return FALSE; + } + + vattr.accessLevel = UA_ACCESSLEVELMASK_READ; + UA_Variant_setScalar(&vattr.value, &init_val, &UA_TYPES[UA_TYPES_BOOLEAN]); + + attr.displayName = UA_LOCALIZEDTEXT("en-US", ACCESSED_VARIABLE_NAME); + attr.description = UA_LOCALIZEDTEXT("en-US", ACCESSED_VARIABLE_NAME); + + status = + UA_Server_addVariableNode_rb(server, + UA_NODEID_NUMERIC(plugin->ns, 0), + plugin->event_obj, + UA_NODEID_NUMERIC(0, + UA_NS0ID_HASPROPERTY), + UA_QUALIFIEDNAME(plugin->ns, + ACCESSED_VARIABLE_NAME), + UA_NODEID_NUMERIC(0, + UA_NS0ID_PROPERTYTYPE), + vattr, + NULL, + plugin->rbd, + NULL); + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_addVariableNode_rb() failed: %s", + UA_StatusCode_name(status)); + return FALSE; + } + + return TRUE; +} + +static void +plugin_cleanup(void) +{ + g_assert(plugin != NULL); + + if (plugin->event_handler != NULL) { + if (plugin->sub_id) { + ax_event_handler_unsubscribe_and_notify(plugin->event_handler, + plugin->sub_id, + NULL, + NULL, + NULL); + } + + ax_event_handler_free(plugin->event_handler); + } + + /* If the node is created the namespace is never 0 */ + if (plugin->event_obj.namespaceIndex) { + UA_NodeId_clear(&plugin->event_obj); + } + + plugin->logger = NULL; + g_clear_pointer(&plugin->name, g_free); + + /* free up allocated rollback data, if any */ + ua_utils_clear_rbd(&plugin->rbd); + + g_clear_pointer(&plugin, g_free); +} + +/* Exported functions */ +gboolean +opc_ua_create(UA_Server *server, + UA_Logger *logger, + G_GNUC_UNUSED gpointer *params, + GError **err) +{ + GError *lerr = NULL; + + g_return_val_if_fail(server != NULL, FALSE); + g_return_val_if_fail(logger != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + if (plugin != NULL) { + return TRUE; + } + + plugin = g_new0(plugin_t, 1); + + plugin->name = g_strdup(UA_PLUGIN_NAME); + plugin->logger = logger; + plugin->rbd = g_new0(rollback_data_t, 1); + + plugin->ns = UA_Server_addNamespace(server, UA_PLUGIN_NAMESPACE); + + if (!create_event_object(server, err)) { + g_prefix_error(err, "create_event_object() failed: "); + goto err_out; + } + + if (!setup_ax_event(server, simple_opc_event_cb, err)) { + g_prefix_error(err, "setup_ax_event() failed: "); + goto err_out; + } + + /* the information model was successfully populated so now we can free up our + * rollback data since we no longer need it */ + ua_utils_clear_rbd(&plugin->rbd); + + return TRUE; + +err_out: + if (!ua_utils_do_rollback(server, plugin->rbd, &lerr)) { + LOG_E(plugin->logger, + "ua_utils_do_rollback() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + + plugin_cleanup(); + + return FALSE; +} + +void +opc_ua_destroy(void) +{ + if (plugin == NULL) { + return; + } + + plugin_cleanup(); +} + +const gchar * +opc_ua_get_plugin_name(void) +{ + if (plugin == NULL) { + return ERR_NOT_INITIALIZED; + } else if (plugin->name == NULL) { + return ERR_NO_NAME; + } + + return plugin->name; +} diff --git a/app/plugins/simple_event/simple_event_plugin.h b/app/plugins/simple_event/simple_event_plugin.h new file mode 100644 index 0000000..7502f3d --- /dev/null +++ b/app/plugins/simple_event/simple_event_plugin.h @@ -0,0 +1,67 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __SIMPLE_EVENT_PLUGIN_H__ +#define __SIMPLE_EVENT_PLUGIN_H__ + +/** + * opc_ua_create: + * @server: OPC-UA Server object + * @logger: an open62541 logger instance + * @params: pointer to user data (can be used to pass in additional parameters + * for the initialization of the plugin) + * @err: return location for a #GError + * + * Sets up and initializes the plugin. + * + * Returns: TRUE if setup was successful, FALSE otherwise. + */ +gboolean +opc_ua_create(UA_Server *server, + UA_Logger *logger, + G_GNUC_UNUSED gpointer *params, + GError **err); + +/** + * opc_ua_destroy: + * + * Frees allocated resources and performs any necessary shutdown operations for + * the plugin. + */ +void +opc_ua_destroy(void); + +/** + * opc_ua_get_plugin_name: + * + * Retrieves the name of the plugin. + * + * Returns: the plugin name as set in the plugin constructor or an error string + * if no name was set. + */ +const gchar * +opc_ua_get_plugin_name(void); + +#endif /* __SIMPLE_EVENT_PLUGIN_H__ */ diff --git a/app/plugins/thermal/Makefile b/app/plugins/thermal/Makefile new file mode 100644 index 0000000..638b106 --- /dev/null +++ b/app/plugins/thermal/Makefile @@ -0,0 +1,59 @@ +# The name of the plugin module (shared object) is built up by concatenating: +# * the prefix: 'libopcua_' +# * the directory/plugin name under 'plugins' +# * the suffix: '.so' +TARGET_LIB = libopcua_$(notdir $(CURDIR)).so +SRCS = $(wildcard *.c) +OBJS = $(SRCS:.c=.o) +DEPS = $(patsubst %.c,%.d,$(SRCS)) + +PKGS = gio-2.0 glib-2.0 gmodule-2.0 jansson +CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) +LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) + +LIB_OPEN62541 = $(SDKTARGETSYSROOT)/usr/lib/libopen62541.a +LIBS += $(LIB_OPEN62541) + +# app/include +INCLUDE = ../../include + +# All the plugin modules are to be placed in 'app/lib'. 'eap-create.sh' will +# automatically pick up the 'lib' folder and add it to the final .eap file. +TARGET_LIB_DIR = ../../lib + +CFLAGS += -I. +CFLAGS += $(addprefix -I, $(INCLUDE)) + +CFLAGS += -Wall \ + -Wextra \ + -Wformat=2 \ + -Wpointer-arith \ + -Wbad-function-cast \ + -Wstrict-prototypes \ + -Wmissing-prototypes \ + -Winline \ + -Wdisabled-optimization \ + -Wfloat-equal \ + -W \ + -Werror \ + -Wno-maybe-uninitialized + +# preprocessor flags to generate Makefile dependencies +CPPFLAGS = -MMD -MP + +CFLAGS_MODULES = $(CFLAGS) -fPIC + +all: $(TARGET_LIB_DIR)/$(TARGET_LIB) + +$(TARGET_LIB_DIR)/$(TARGET_LIB): $(TARGET_LIB) + mkdir -p $(TARGET_LIB_DIR) + cp $(TARGET_LIB) $(TARGET_LIB_DIR) + +$(TARGET_LIB): $(OBJS) + $(CC) $(CFLAGS_MODULES) $(CPPFLAGS) $(LDFLAGS) -shared $^ $(LIBS) $(LDLIBS) \ + -o $@ + +-include $(DEPS) + +clean: + rm -f $(DEPS) *.o core *.so $(TARGET_LIB_DIR)/$(TARGET_LIB) diff --git a/app/plugins/thermal/README.md b/app/plugins/thermal/README.md new file mode 100644 index 0000000..ded11f1 --- /dev/null +++ b/app/plugins/thermal/README.md @@ -0,0 +1,53 @@ +# Thermal Plugin + +## Description + +This plugin exposes thermal camera functionality as OPC-UA nodes, including: + +- Thermal area configurations and status +- Temperature measurements (min, max, avg) +- Threshold settings and triggered states +- Temperature scale control (Celsius/Fahrenheit) + +## Features + +- **Thermal Area Management**: Exposes all configured thermal areas as OPC-UA +objects +- **Temperature Monitoring**: Provides real-time temperature readings +- **Threshold Control**: Shows threshold values and triggered states +- **Scale Conversion**: Allows changing temperature scale between Celsius and +Fahrenheit +- **Automatic Updates**: Regularly polls for updated temperature values + +## Usage + +The plugin creates the following OPC-UA structure: + +- **ThermalAreas** object (parent node) + - **Set Scale Method**: Method to change temperature scale (to Celsius or + Fahrenheit) + - Multiple **ThermalX** objects (one per configured thermal area) + - Properties: + - `Id`: Area identifier + - `Name`: Area name + - `Enabled`: Activation state (boolean) + - `PresetNumber`: Associated preset number (integer) + - `TempMin`: Minimum temperature reading (integer) + - `TempMax`: Maximum temperature reading (integer) + - `TempAvg`: Average temperature reading (integer) + - `ThresholdValue`: Configured threshold value (integer) + - `ThresholdMeasurement`: Measurement type ('above', 'below', + 'increasing', 'decreasing') + - `Triggered`: Trigger state (boolean, true if threshold is met) + - `DetectionType`: Detection type ('warmest', 'coldest', 'average') + +## Important Notes + +- This plugin requires an Axis thermal camera to be able to function. +- Thermal areas must be configured prior to starting the ACAP application. +- Any changes to thermal area configurations on the camera require a restart of +the OPC-UA Server ACAP application for them to be reflected. + +## License + +**[MIT License](../../../LICENSE)** diff --git a/app/plugins/thermal/thermal_plugin.c b/app/plugins/thermal/thermal_plugin.c new file mode 100644 index 0000000..cf65202 --- /dev/null +++ b/app/plugins/thermal/thermal_plugin.c @@ -0,0 +1,807 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include +#include +#include +#include + +#include "error.h" +#include "log.h" +#include "plugin.h" +#include "thermal_plugin.h" +#include "thermal_vapix.h" +#include "ua_utils.h" +#include "vapix_utils.h" + +#define THERMAL_DESCRIPTION "Thermal Area" +#define THERMAL_OBJECT_DESCRIPTION "Thermal Areas" +#define THERMAL_NAMESPACE_URI "http://www.axis.com/OpcUA/Thermal/" +#define UA_PLUGIN_NAME "opc-thermal-plugin" + +#define ERR_NOT_INITIALIZED "The " UA_PLUGIN_NAME " is not initialized" +#define ERR_NO_NAME "The " UA_PLUGIN_NAME " was not given a name" + +#define NBR_OF_RETRIES 10 + +#define DETECTION_TYPE_BNAME "DetectionType" +#define ENABLED_BNAME "Enabled" +#define ID_BNAME "Id" +#define NAME_BNAME "Name" +#define PRESET_NBR_BNAME "PresetNumber" +#define TEMP_MIN_BNAME "TempMin" +#define TEMP_MAX_BNAME "TempMax" +#define TEMP_AVG_BNAME "TempAvg" +#define THRESHOLD_MEASUREMENT_BNAME "ThresholdMeasurement" +#define THRESHOLD_VALUE_BNAME "ThresholdValue" +#define TRIGGERED_BNAME "Triggered" + +DEFINE_GQUARK("opc-thermal-plugin") + +typedef struct plugin { + /* user-friendly name of the plugin */ + gchar *name; + /* OPC-UA namespace index */ + UA_UInt16 ns; + /* an open62541 logger */ + UA_Logger *logger; + /* copy of the server object */ + UA_Server *server; + /* node id of the thermal object */ + UA_NodeId thermal_parent; + /* callback id for getAreaStatus callback */ + guint cb_id; + /* count number of times polling temperature values fails */ + gint counter; + gchar *vapix_credentials; + CURL *curl_h; + /* mutex for curl_h */ + GMutex curl_mutex; + /* keep track of data that needs to be rolled back in case of failure */ + rollback_data_t *rbd; +} plugin_t; + +static plugin_t *plugin; + +typedef struct property { + gchar *name; + gint32 value_type; +} property_t; + +/* clang-format off */ +static property_t thermal_properties[] = { + { + .name = ID_BNAME, + .value_type = UA_TYPES_UINT32 + }, + { + .name = PRESET_NBR_BNAME, + .value_type = UA_TYPES_INT32 + }, + { + .name = TEMP_AVG_BNAME, + .value_type = UA_TYPES_INT32 + }, + { + .name = TEMP_MAX_BNAME, + .value_type = UA_TYPES_INT32 + }, + { + .name = TEMP_MIN_BNAME, + .value_type = UA_TYPES_INT32 + }, + { + .name = THRESHOLD_VALUE_BNAME, + .value_type = UA_TYPES_INT32 + }, + { + .name = TRIGGERED_BNAME, + .value_type = UA_TYPES_BOOLEAN + }, + { + .name = ENABLED_BNAME, + .value_type = UA_TYPES_BOOLEAN + }, + { + .name = NAME_BNAME, + .value_type = UA_TYPES_STRING + }, + { + .name = DETECTION_TYPE_BNAME, + .value_type = UA_TYPES_STRING + }, + { + .name = THRESHOLD_MEASUREMENT_BNAME, + .value_type = UA_TYPES_STRING + }, + { + .name = NULL, + .value_type = 0 + }, +}; +/* clang-format on */ + +/* Local functions */ +static gboolean +opc_write_property(UA_NodeId parent, + UA_QualifiedName browseName, + void *data, + const UA_DataType *type, + GError **err) +{ + UA_StatusCode retval; + + g_assert(type != NULL); + g_assert(data != NULL); + g_assert(plugin != NULL); + g_assert(plugin->server != NULL); + g_assert(err == NULL || *err == NULL); + + retval = UA_Server_writeObjectProperty_scalar(plugin->server, + parent, + browseName, + data, + type); + + if (retval != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_writeObjectProperty_scalar(%.*s) failed: %s", + (int) browseName.name.length, + browseName.name.data, + UA_StatusCode_name(retval)); + return FALSE; + } + + return TRUE; +} + +static gboolean +ua_server_add_themal_properties(UA_NodeId parent, GError **err) +{ + UA_StatusCode status; + UA_VariableAttributes attr = UA_VariableAttributes_default; + + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); + g_assert(plugin->server != NULL); + g_assert(err == NULL || *err == NULL); + + for (gint i = 0; thermal_properties[i].name != NULL; i++) { + attr.accessLevel = UA_ACCESSLEVELMASK_READ; + UA_Variant_setScalar(&attr.value, + NULL, + &UA_TYPES[thermal_properties[i].value_type]); + + attr.displayName = UA_LOCALIZEDTEXT("en-US", thermal_properties[i].name); + attr.description = UA_LOCALIZEDTEXT("en-US", thermal_properties[i].name); + + status = UA_Server_addVariableNode_rb( + plugin->server, + UA_NODEID_NUMERIC(plugin->ns, 0), + parent, + UA_NODEID_NUMERIC(0, UA_NS0ID_HASPROPERTY), + UA_QUALIFIEDNAME(plugin->ns, thermal_properties[i].name), + UA_NODEID_NUMERIC(0, UA_NS0ID_PROPERTYTYPE), + attr, + NULL, + plugin->rbd, + NULL); + + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "Failed to add variable %s: %s", + thermal_properties[i].name, + UA_StatusCode_name(status)); + return FALSE; + } + } + + return TRUE; +} + +static gboolean +ua_server_add_thermal_area(UA_UInt32 id, + gchar *name, + UA_Boolean enabled, + UA_Int32 preset_nbr, + UA_Int32 threshold_val, + gchar *threshold, + gchar *detection, + GError **err) +{ + UA_ObjectAttributes oAttr = UA_ObjectAttributes_default; + gchar *title; + UA_StatusCode status; + gboolean ret = TRUE; + UA_NodeId areaId; + UA_String ua_name; + UA_String ua_detection; + UA_String ua_threshold; + + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); + g_assert(plugin->server != NULL); + g_assert(name != NULL); + g_assert(threshold != NULL); + g_assert(detection != NULL); + g_assert(err == NULL || *err == NULL); + + title = g_strdup_printf("Thermal%u", id); + oAttr.displayName = UA_LOCALIZEDTEXT("en-US", name); + oAttr.description = UA_LOCALIZEDTEXT("en-US", THERMAL_DESCRIPTION); + + areaId = UA_NODEID_STRING(plugin->ns, title); + + status = + UA_Server_addObjectNode_rb(plugin->server, + areaId, + plugin->thermal_parent, + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + UA_QUALIFIEDNAME(plugin->ns, title), + UA_NODEID_NUMERIC(0, + UA_NS0ID_BASEOBJECTTYPE), + oAttr, + NULL, + plugin->rbd, + NULL); + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_addObjectNode_rb() failed: %s", + UA_StatusCode_name(status)); + ret = FALSE; + goto err_out; + } + + if (!ua_server_add_themal_properties(areaId, err)) { + g_prefix_error(err, "ua_server_add_thermal_properties() failed: "); + ret = FALSE; + goto err_out; + } + + ua_name = UA_STRING(name); + ua_detection = UA_STRING(detection); + ua_threshold = UA_STRING(threshold); + + if (!opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, NAME_BNAME), + &ua_name, + &UA_TYPES[UA_TYPES_STRING], + err) || + !opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, ENABLED_BNAME), + &enabled, + &UA_TYPES[UA_TYPES_BOOLEAN], + err) || + !opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, THRESHOLD_VALUE_BNAME), + &threshold_val, + &UA_TYPES[UA_TYPES_INT32], + err) || + !opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, PRESET_NBR_BNAME), + &preset_nbr, + &UA_TYPES[UA_TYPES_INT32], + err) || + !opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, ID_BNAME), + &id, + &UA_TYPES[UA_TYPES_UINT32], + err) || + !opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, + THRESHOLD_MEASUREMENT_BNAME), + &ua_threshold, + &UA_TYPES[UA_TYPES_STRING], + err) || + !opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, DETECTION_TYPE_BNAME), + &ua_detection, + &UA_TYPES[UA_TYPES_STRING], + err)) { + g_prefix_error(err, "opc_write_property() failed: "); + ret = FALSE; + goto err_out; + } + +err_out: + g_clear_pointer(&title, g_free); + + return ret; +} + +static void +free_thermal_area(gpointer data) +{ + thermal_area_t *values = (thermal_area_t *) data; + + if (values == NULL) { + return; + } + + g_clear_pointer(&values->name, g_free); + g_clear_pointer(&values->detectionType, g_free); + g_clear_pointer(&values->measurement, g_free); + g_clear_pointer(&values, g_free); +} + +static gboolean +add_thermal_areas(GError **err) +{ + gboolean retval = TRUE; + GList *lst = NULL; + + g_assert(plugin != NULL); + g_assert(plugin->curl_h != NULL); + g_assert(plugin->logger != NULL); + g_assert(plugin->vapix_credentials != NULL); + g_assert(err == NULL || *err == NULL); + + if (!vapix_get_thermal_areas(plugin->vapix_credentials, + plugin->curl_h, + &lst, + err)) { + g_prefix_error(err, "vapix_get_thermal_areas() failed: "); + retval = FALSE; + goto err_out; + } + + for (GList *iter = lst; iter != NULL; iter = iter->next) { + thermal_area_t *values = (thermal_area_t *) iter->data; + if (!ua_server_add_thermal_area(values->id, + values->name, + values->enabled, + values->presetNbr, + values->threshold, + values->measurement, + values->detectionType, + err)) { + g_prefix_error(err, "ua_server_add_thermal_area() failed: "); + retval = FALSE; + goto err_out; + } + } + +err_out: + g_list_free_full(lst, free_thermal_area); + + return retval; +} + +static gboolean +ua_server_update_thermal(UA_UInt32 id, + UA_Int32 min, + UA_Int32 avg, + UA_Int32 max, + UA_Boolean triggered, + GError **err) +{ + gchar *title; + UA_NodeId areaId; + gboolean ret = TRUE; + + g_assert(NULL != plugin); + g_assert(NULL != plugin->server); + g_assert(err == NULL || *err == NULL); + + title = g_strdup_printf("Thermal%u", id); + + areaId = UA_NODEID_STRING(plugin->ns, title); + + if (!opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, TEMP_MIN_BNAME), + &min, + &UA_TYPES[UA_TYPES_INT32], + err) || + !opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, TEMP_AVG_BNAME), + &avg, + &UA_TYPES[UA_TYPES_INT32], + err) || + !opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, TEMP_MAX_BNAME), + &max, + &UA_TYPES[UA_TYPES_INT32], + err) || + !opc_write_property(areaId, + UA_QUALIFIEDNAME(plugin->ns, TRIGGERED_BNAME), + &triggered, + &UA_TYPES[UA_TYPES_BOOLEAN], + err)) { + g_prefix_error(err, "opc_write_property() failed: "); + ret = G_SOURCE_REMOVE; + } + + g_clear_pointer(&title, g_free); + + return ret; +} + +/* Retry to poll for temperature values */ +static gboolean +check_counter(void) +{ + g_assert(plugin != NULL); + + plugin->counter++; + + if (plugin->counter < NBR_OF_RETRIES) { + return G_SOURCE_CONTINUE; + } else { + plugin->cb_id = 0; + return G_SOURCE_REMOVE; + } +} + +static void +free_thermal_area_values(gpointer data) +{ + thermal_area_values_t *values = (thermal_area_values_t *) data; + + if (values == NULL) { + return; + } + + g_clear_pointer(&values, g_free); +} + +static gboolean +update_thermal_cb(G_GNUC_UNUSED gpointer userdata) +{ + GError *lerr = NULL; + gboolean ret = G_SOURCE_CONTINUE; + GList *lst = NULL; + + g_assert(plugin != NULL); + g_assert(plugin->logger != NULL); + g_assert(plugin->curl_h != NULL); + g_assert(plugin->vapix_credentials != NULL); + + g_mutex_lock(&plugin->curl_mutex); + + if (!vapix_get_thermal_area_status(plugin->vapix_credentials, + plugin->curl_h, + &lst, + &lerr)) { + LOG_E(plugin->logger, + "vapix_get_thermal_area_status() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + g_mutex_unlock(&plugin->curl_mutex); + ret = check_counter(); + goto err_out; + } + + g_mutex_unlock(&plugin->curl_mutex); + + for (GList *iter = lst; iter != NULL; iter = iter->next) { + thermal_area_values_t *values = (thermal_area_values_t *) iter->data; + if (!ua_server_update_thermal(values->id, + (gint) values->min, + (gint) values->avg, + (gint) values->max, + values->triggered, + &lerr)) { + LOG_E(plugin->logger, + "ua_server_update_thermal() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + ret = G_SOURCE_REMOVE; + goto err_out; + } + } + + plugin->counter = 0; + +err_out: + g_list_free_full(lst, free_thermal_area_values); + + return ret; +} + +/* callback executed when the 'Set Scale' method */ +static UA_StatusCode +thermal_change_scale_cb(G_GNUC_UNUSED UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionHandle, + G_GNUC_UNUSED const UA_NodeId *methodId, + G_GNUC_UNUSED void *methodContext, + G_GNUC_UNUSED const UA_NodeId *objectId, + G_GNUC_UNUSED void *objectContext, + G_GNUC_UNUSED size_t inputSize, + const UA_Variant *input, + G_GNUC_UNUSED size_t outputSize, + G_GNUC_UNUSED UA_Variant *output) +{ + UA_String scale; + gchar *scale_lower; + GError *lerr = NULL; + UA_StatusCode status = UA_STATUSCODE_GOOD; + + g_assert(plugin != NULL); + g_assert(plugin->logger != NULL); + g_assert(input != NULL); + + scale = *(UA_String *) input[0].data; + scale_lower = g_ascii_strdown((gchar *) scale.data, scale.length); + + if (g_strcmp0(scale_lower, "celsius") != 0 && + g_strcmp0(scale_lower, "fahrenheit") != 0) { + LOG_E(plugin->logger, "Scale: %s is not supported", scale_lower); + status = UA_STATUSCODE_BADINVALIDARGUMENT; + goto err_out; + } + + g_mutex_lock(&plugin->curl_mutex); + + if (!vapix_set_temperature_scale(plugin->vapix_credentials, + plugin->curl_h, + scale_lower, + &lerr)) { + LOG_E(plugin->logger, + "vapix_set_temperature_scale() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + status = UA_STATUSCODE_BADCOMMUNICATIONERROR; + } + + g_mutex_unlock(&plugin->curl_mutex); + +err_out: + g_clear_pointer(&scale_lower, g_free); + + return status; +} + +static UA_StatusCode +thermal_add_scale_method(GError **err) +{ + UA_StatusCode status; + UA_MethodAttributes mattr; + UA_Argument in_arg; + + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); + g_assert(plugin->server != NULL); + g_assert(err == NULL || *err == NULL); + + /* prepare a node for the Activate method */ + UA_Argument_init(&in_arg); + in_arg.description = + UA_LOCALIZEDTEXT("en-US", "Temperature Scale: Celsius or Fahrenheit"); + in_arg.name = UA_STRING("Scale"); + in_arg.dataType = UA_TYPES[UA_TYPES_STRING].typeId; + in_arg.valueRank = UA_VALUERANK_SCALAR; + + mattr = UA_MethodAttributes_default; + mattr.description = UA_LOCALIZEDTEXT("en-US", "Change Temperature Scale"); + mattr.displayName = UA_LOCALIZEDTEXT("en-US", "Set Scale"); + mattr.executable = true; + mattr.userExecutable = true; + + status = UA_Server_addMethodNode_rb(plugin->server, + UA_NODEID_NUMERIC(plugin->ns, 0), + plugin->thermal_parent, + UA_NODEID_NUMERIC(0, + UA_NS0ID_HASCOMPONENT), + UA_QUALIFIEDNAME(plugin->ns, + "Set Scale Method"), + mattr, + &thermal_change_scale_cb, + 1, + &in_arg, + 0, + NULL, + NULL, + plugin->rbd, + NULL); + + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_addMethodNode_rb() failed, error code: %s", + UA_StatusCode_name(status)); + } + + return status; +} + +static gboolean +add_thermal_object(GError **err) +{ + UA_StatusCode status; + UA_ObjectAttributes attr = UA_ObjectAttributes_default; + + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); + g_assert(plugin->server != NULL); + g_assert(err == NULL || *err == NULL); + + attr.displayName = UA_LOCALIZEDTEXT("en-US", THERMAL_OBJECT_DESCRIPTION); + attr.description = UA_LOCALIZEDTEXT("en-US", THERMAL_OBJECT_DESCRIPTION); + + status = + UA_Server_addObjectNode_rb(plugin->server, + UA_NODEID_NUMERIC(plugin->ns, 0), + UA_NODEID_NUMERIC(0, + UA_NS0ID_OBJECTSFOLDER), + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + UA_QUALIFIEDNAME(plugin->ns, + "ThermalAreas"), + UA_NODEID_NUMERIC(0, + UA_NS0ID_BASEOBJECTTYPE), + attr, + NULL, + plugin->rbd, + &plugin->thermal_parent); + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "Failed to add object 'ThermalAreas': %s", + UA_StatusCode_name(status)); + return FALSE; + } + + status = thermal_add_scale_method(err); + + if (status != UA_STATUSCODE_GOOD) { + g_prefix_error(err, "Failed to add 'Set Scale' method"); + return FALSE; + } + + return TRUE; +} + +static void +plugin_cleanup(void) +{ + g_assert(plugin != NULL); + g_assert(plugin->logger != NULL); + + if (plugin->cb_id != 0 && !g_source_remove(plugin->cb_id)) { + LOG_E(plugin->logger, + "Failed to remove timed vapix retrieval function from main loop"); + } + + if (plugin->curl_h != NULL) { + curl_easy_cleanup(plugin->curl_h); + } + + plugin->logger = NULL; + plugin->server = NULL; + g_mutex_clear(&plugin->curl_mutex); + g_clear_pointer(&plugin->name, g_free); + g_clear_pointer(&plugin->vapix_credentials, g_free); + + /* free up allocated rollback data, if any */ + ua_utils_clear_rbd(&plugin->rbd); + + g_clear_pointer(&plugin, g_free); +} + +/* Exported functions */ +gboolean +opc_ua_create(UA_Server *server, + UA_Logger *logger, + G_GNUC_UNUSED gpointer *params, + GError **err) +{ + GError *lerr = NULL; + + g_return_val_if_fail(server != NULL, FALSE); + g_return_val_if_fail(logger != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + if (plugin != NULL) { + return TRUE; + } + + plugin = g_new0(plugin_t, 1); + + plugin->name = g_strdup(UA_PLUGIN_NAME); + plugin->logger = logger; + plugin->rbd = g_new0(rollback_data_t, 1); + plugin->server = server; + g_mutex_init(&plugin->curl_mutex); + + plugin->curl_h = curl_easy_init(); + if (plugin->curl_h == NULL) { + SET_ERROR(err, -1, "curl_easy_init() failed"); + goto err_out; + } + + /* obtain credentials to make VAPIX calls */ + plugin->vapix_credentials = + vapix_get_credentials("vapix-thermometry-user", err); + if (plugin->vapix_credentials == NULL) { + g_prefix_error(err, "Failed to get the VAPIX credentials: "); + goto err_out; + } + + /* If thermometry isn't supported don't return false */ + if (!vapix_get_supported_versions(plugin->vapix_credentials, + plugin->curl_h, + err)) { + g_prefix_error(err, "No supported versions available for 'thermometry': "); + goto err_out; + } + + plugin->ns = UA_Server_addNamespace(server, THERMAL_NAMESPACE_URI); + + /* Add thermal-object and scale variable */ + if (!add_thermal_object(err)) { + g_prefix_error(err, "add_thermal_object() failed: "); + goto err_out; + } + + if (!add_thermal_areas(err)) { + g_prefix_error(err, "add_thermal_areas() failed: "); + goto err_out; + } + + plugin->cb_id = g_timeout_add_seconds(1, update_thermal_cb, NULL); + + /* the information model was successfully populated so now we can free up our + * rollback data since we no longer need it */ + ua_utils_clear_rbd(&plugin->rbd); + + return TRUE; + +err_out: + if (!ua_utils_do_rollback(server, plugin->rbd, &lerr)) { + LOG_E(plugin->logger, + "ua_utils_do_rollback() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + plugin_cleanup(); + + return FALSE; +} + +void +opc_ua_destroy(void) +{ + if (plugin == NULL) { + return; + } + + plugin_cleanup(); +} + +const gchar * +opc_ua_get_plugin_name(void) +{ + if (plugin == NULL) { + return ERR_NOT_INITIALIZED; + } else if (plugin->name == NULL) { + return ERR_NO_NAME; + } + + return plugin->name; +} diff --git a/app/plugins/thermal/thermal_plugin.h b/app/plugins/thermal/thermal_plugin.h new file mode 100644 index 0000000..89893f7 --- /dev/null +++ b/app/plugins/thermal/thermal_plugin.h @@ -0,0 +1,67 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __THERMAL_PLUGIN_H__ +#define __THERMAL_PLUGIN_H__ + +/** + * opc_ua_create: + * @server: OPC-UA Server object + * @logger: an open62541 logger instance + * @params: pointer to user data (can be used to pass in additional parameters + * for the initialization of the plugin) + * @err: return location for a #GError + * + * Sets up and initializes the plugin. + * + * Returns: TRUE if setup was successful, FALSE otherwise. + */ +gboolean +opc_ua_create(UA_Server *server, + UA_Logger *logger, + G_GNUC_UNUSED gpointer *params, + GError **err); + +/** + * opc_ua_destroy: + * + * Frees allocated resources and performs any necessary shutdown operations for + * the plugin. + */ +void +opc_ua_destroy(void); + +/** + * opc_ua_get_plugin_name: + * + * Retrieves the name of the plugin. + * + * Returns: the plugin name as set in the plugin constructor or an error string + * if no name was set. + */ +const gchar * +opc_ua_get_plugin_name(void); + +#endif /* __THERMAL_PLUGIN_H__ */ diff --git a/app/plugins/thermal/thermal_vapix.c b/app/plugins/thermal/thermal_vapix.c new file mode 100644 index 0000000..eb9e8c6 --- /dev/null +++ b/app/plugins/thermal/thermal_vapix.c @@ -0,0 +1,425 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include +#include + +#include "error.h" +#include "log.h" +#include "thermal_vapix.h" +#include "vapix_utils.h" + +#define THERMOMETRY_API_VERSION "1.2" +#define THERMOMETRY_CGI_ENDPOINT "thermometry.cgi" + +#define GET_SUPPORTED_VERSIONS_REQUEST \ + "{ \"method\" : \"getSupportedVersions\" }" +#define LIST_AREAS_REQUEST \ + "{" \ + " \"apiVersion\" : \"" THERMOMETRY_API_VERSION "\"," \ + " \"method\": \"listAreas\"," \ + " \"params\":{\"presetNbr\":0}" \ + "}" +#define GET_AREA_STATUS_REQUEST \ + "{" \ + " \"apiVersion\" : \"" THERMOMETRY_API_VERSION "\"," \ + " \"method\": \"getAreaStatus\"," \ + " \"params\":{}" \ + "}" +#define SET_SCALE_REQUEST \ + "{" \ + " \"apiVersion\" : \"" THERMOMETRY_API_VERSION "\"," \ + " \"method\": \"setTemperatureScale\"," \ + " \"params\":{ " \ + " \"unit\": \"%s\" }" \ + "}" + +DEFINE_GQUARK("opc-thermal-vapix-plugin"); + +gboolean +vapix_get_supported_versions(gchar *credentials, CURL *curl_h, GError **err) +{ + gchar *response; + json_t *json_response; + json_error_t json_error; + gboolean retval = FALSE; + const gchar *fmt_str = "{s:{s:o}}"; + json_t *api_versions; + json_t *version; + gsize index; + + g_return_val_if_fail(curl_h != NULL, FALSE); + g_return_val_if_fail(credentials != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + response = vapix_request(curl_h, + credentials, + THERMOMETRY_CGI_ENDPOINT, + HTTP_POST, + JSON_data, + GET_SUPPORTED_VERSIONS_REQUEST, + err); + + if (response == NULL) { + g_prefix_error(err, "vapix call: 'getSupportedVersions' failed: "); + return FALSE; + } + + json_response = json_loads(response, 0, &json_error); + + if (json_response == NULL) { + SET_ERROR(err, + -1, + "json_loads() failed: %d/%d/%d - %s", + json_error.line, + json_error.column, + json_error.position, + json_error.text); + retval = FALSE; + goto out; + } + + if (json_unpack_ex(json_response, + &json_error, + 0, + fmt_str, + "data", + "apiVersions", + &api_versions) != 0) { + SET_ERROR(err, + -1, + "json_unpack_ex() failed: %d/%d/%d - %s", + json_error.line, + json_error.column, + json_error.position, + json_error.text); + retval = FALSE; + goto out; + } + + json_array_foreach(api_versions, index, version) + { + gboolean exit = FALSE; + gint major1; + gint major2; + gint minor1; + gint minor2; + gchar **version_in_split = g_strsplit(json_string_value(version), ".", 2); + gchar **version_split = g_strsplit(THERMOMETRY_API_VERSION, ".", 2); + + if (version_in_split == NULL || version_split == NULL || + g_strv_length(version_in_split) < 2 || + g_strv_length(version_split) < 2) { + SET_ERROR(err, -1, "Invalid api version format"); + exit = TRUE; + goto cleanup; + } + + major1 = g_ascii_strtoll(version_split[0], NULL, 10); + major2 = g_ascii_strtoll(version_in_split[0], NULL, 10); + minor1 = g_ascii_strtoll(version_split[1], NULL, 10); + minor2 = g_ascii_strtoll(version_in_split[1], NULL, 10); + + if (major1 == major2 && minor1 <= minor2) { + retval = TRUE; + exit = TRUE; + } + +cleanup: + g_clear_pointer(&version_in_split, g_strfreev); + g_clear_pointer(&version_split, g_strfreev); + if (exit) { + break; + } + } + + if (!retval && *err == NULL) { + SET_ERROR(err, + -1, + "Api version - %s is not supported on this device.", + THERMOMETRY_API_VERSION); + } + +out: + g_clear_pointer(&response, g_free); + g_clear_pointer(&json_response, json_decref); + + return retval; +} + +gboolean +vapix_get_thermal_areas(gchar *credentials, + CURL *curl_h, + GList **areas, + GError **err) +{ + json_t *area; + json_t *area_list; + gsize index; + json_error_t json_error; + json_t *json_response; + gchar *response; + gboolean retval = TRUE; + + const gchar *area_fmt = "{s:i, s:b, s:s, s:s, s:s, s:i, s:i}"; + const gchar *fmt_string = "{s:{s:o}}"; + + g_return_val_if_fail(curl_h != NULL, FALSE); + g_return_val_if_fail(credentials != NULL, FALSE); + g_return_val_if_fail(areas != NULL, FALSE); + g_return_val_if_fail(*areas == NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + response = vapix_request(curl_h, + credentials, + THERMOMETRY_CGI_ENDPOINT, + HTTP_POST, + JSON_data, + LIST_AREAS_REQUEST, + err); + + if (response == NULL) { + g_prefix_error(err, "Failed to list thermal areas: "); + retval = FALSE; + goto out; + } + + json_response = json_loads(response, 0, &json_error); + + if (json_response == NULL) { + SET_ERROR(err, + -1, + "Invalid json response: %d/%d/%d - %s", + json_error.line, + json_error.column, + json_error.position, + json_error.text); + retval = FALSE; + goto out; + } + + /* clang-format off */ + if (json_unpack_ex(json_response, + &json_error, + 0, + fmt_string, + "data", + "arealist", &area_list) != 0) { + /* clang-format on */ + SET_ERROR(err, + -1, + "json_unpack_ex() failed: %d/%d/%d - %s", + json_error.line, + json_error.column, + json_error.position, + json_error.text); + retval = FALSE; + goto out; + } + + json_array_foreach(area_list, index, area) + { + gchar *name; + gchar *detectionType; + gchar *measurement; + thermal_area_t *values = g_new(thermal_area_t, 1); + + /* clang-format off */ + if (json_unpack_ex(area, + &json_error, + 0, + area_fmt, + "id", &values->id, + "enabled", &values->enabled, + "name", &name, + "detectionType", &detectionType, + "measurement", &measurement, + "threshold", &values->threshold, + "presetNbr" , &values->presetNbr) != 0) { + /* clang-format on */ + SET_ERROR(err, + -1, + "json_unpack_ex() failed: %d/%d/%d - %s", + json_error.line, + json_error.column, + json_error.position, + json_error.text); + retval = FALSE; + goto out; + } + + values->name = g_strdup(name); + values->detectionType = g_strdup(detectionType); + values->measurement = g_strdup(measurement); + + *areas = g_list_prepend(*areas, values); + } + +out: + g_clear_pointer(&json_response, json_decref); + g_clear_pointer(&response, g_free); + + return retval; +} + +gboolean +vapix_get_thermal_area_status(gchar *credentials, + CURL *curl_h, + GList **areas, + GError **err) +{ + json_t *area; + json_t *area_list; + gsize index; + json_error_t json_error; + json_t *json_response; + gchar *response; + gboolean ret = TRUE; + + const gchar *area_fmt = "{s:i, s:f, s:f, s:f, s:b}"; + const gchar *fmt_string = "{s:{s:o}}"; + + g_return_val_if_fail(curl_h != NULL, FALSE); + g_return_val_if_fail(credentials != NULL, FALSE); + g_return_val_if_fail(areas != NULL, FALSE); + g_return_val_if_fail(*areas == NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + response = vapix_request(curl_h, + credentials, + THERMOMETRY_CGI_ENDPOINT, + HTTP_POST, + JSON_data, + GET_AREA_STATUS_REQUEST, + err); + + if (response == NULL) { + g_prefix_error(err, "vapix call: 'getAreaStatus' failed: "); + ret = FALSE; + goto err_out; + } + + json_response = json_loads(response, 0, &json_error); + + if (json_response == NULL) { + SET_ERROR(err, + -1, + "Invalid json response: %d/%d/%d - %s", + json_error.line, + json_error.column, + json_error.position, + json_error.text); + ret = FALSE; + goto err_out; + } + + /* clang-format off */ + if (json_unpack_ex(json_response, + &json_error, + 0, + fmt_string, + "data", + "arealist", &area_list) != 0) { + SET_ERROR(err, -1, "json_unpack_ex() failed: %d/%d/%d - %s", + json_error.line, + json_error.column, + json_error.position, + json_error.text); + ret = FALSE; + goto err_out; + } + /* clang-format on */ + + json_array_foreach(area_list, index, area) + { + thermal_area_values_t *values = g_new(thermal_area_values_t, 1); + + /* clang-format off */ + if (json_unpack_ex(area, + &json_error, + 0, + area_fmt, + "id", &values->id, + "avg", &values->avg, + "min", &values->min, + "max", &values->max, + "triggered", &values->triggered) != 0) { + SET_ERROR(err, -1, "json_unpack_ex() failed: %d/%d/%d - %s", + json_error.line, + json_error.column, + json_error.position, + json_error.text); + ret = FALSE; + goto err_out; + } + /* clang-format on */ + *areas = g_list_prepend(*areas, values); + } + +err_out: + g_clear_pointer(&json_response, json_decref); + g_clear_pointer(&response, g_free); + + return ret; +} + +gboolean +vapix_set_temperature_scale(gchar *credentials, + CURL *curl_h, + gchar *scale, + GError **err) +{ + gchar *request; + gchar *response; + gboolean retval = TRUE; + + g_return_val_if_fail(credentials != NULL, FALSE); + g_return_val_if_fail(curl_h != NULL, FALSE); + g_return_val_if_fail(scale != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + request = g_strdup_printf(SET_SCALE_REQUEST, scale); + + response = vapix_request(curl_h, + credentials, + THERMOMETRY_CGI_ENDPOINT, + HTTP_POST, + JSON_data, + request, + err); + + if (response == NULL) { + g_prefix_error(err, "vapix call: 'setTemperatureScale' failed: "); + retval = FALSE; + goto err_out; + } + +err_out: + g_clear_pointer(&request, g_free); + g_clear_pointer(&response, g_free); + + return retval; +} diff --git a/app/plugins/thermal/thermal_vapix.h b/app/plugins/thermal/thermal_vapix.h new file mode 100644 index 0000000..6ff0674 --- /dev/null +++ b/app/plugins/thermal/thermal_vapix.h @@ -0,0 +1,71 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __THERMAL_VAPIX_H__ +#define __THERMAL_VAPIX_H__ + +#include +#include + +typedef struct thermal_area { + gchar *detectionType; + gboolean enabled; + guint32 id; + gchar *measurement; + gint32 presetNbr; + gint32 threshold; + gchar *name; +} thermal_area_t; + +typedef struct thermal_area_values { + guint32 id; + gdouble avg; + gdouble max; + gdouble min; + gboolean triggered; +} thermal_area_values_t; + +gboolean +vapix_get_supported_versions(gchar *credentials, CURL *curl_h, GError **err); + +gboolean +vapix_get_thermal_areas(gchar *credentials, + CURL *curl_h, + GList **areas, + GError **err); + +gboolean +vapix_get_thermal_area_status(gchar *credentials, + CURL *curl_h, + GList **areas, + GError **err); + +gboolean +vapix_set_temperature_scale(gchar *credentials, + CURL *curl_h, + gchar *scale, + GError **err); + +#endif /* __THERMAL_VAPIX_H__ */ diff --git a/app/plugins/vinput/Makefile b/app/plugins/vinput/Makefile new file mode 100644 index 0000000..b83dda7 --- /dev/null +++ b/app/plugins/vinput/Makefile @@ -0,0 +1,59 @@ +# The name of the plugin module (shared object) is built up by concatenating: +# * the prefix: 'libopcua_' +# * the directory/plugin name under 'plugins' +# * the suffix: '.so' +TARGET_LIB = libopcua_$(notdir $(CURDIR)).so +SRCS = $(wildcard *.c) +OBJS = $(SRCS:.c=.o) +DEPS = $(patsubst %.c,%.d,$(SRCS)) + +PKGS = gio-2.0 glib-2.0 gmodule-2.0 axevent +CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS)) +LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS)) + +LIB_OPEN62541 = $(SDKTARGETSYSROOT)/usr/lib/libopen62541.a +LIBS += $(LIB_OPEN62541) + +# app/include +INCLUDE = ../../include + +# All the plugin modules are to be placed in 'app/lib'. 'eap-create.sh' will +# automatically pick up the 'lib' folder and add it to the final .eap file. +TARGET_LIB_DIR = ../../lib + +CFLAGS += -I. +CFLAGS += $(addprefix -I, $(INCLUDE)) + +CFLAGS += -Wall \ + -Wextra \ + -Wformat=2 \ + -Wpointer-arith \ + -Wbad-function-cast \ + -Wstrict-prototypes \ + -Wmissing-prototypes \ + -Winline \ + -Wdisabled-optimization \ + -Wfloat-equal \ + -W \ + -Werror \ + -Wno-maybe-uninitialized + +# preprocessor flags to generate Makefile dependencies +CPPFLAGS = -MMD -MP + +CFLAGS_MODULES = $(CFLAGS) -fPIC + +all: $(TARGET_LIB_DIR)/$(TARGET_LIB) + +$(TARGET_LIB_DIR)/$(TARGET_LIB): $(TARGET_LIB) + mkdir -p $(TARGET_LIB_DIR) + cp $(TARGET_LIB) $(TARGET_LIB_DIR) + +$(TARGET_LIB): $(OBJS) + $(CC) $(CFLAGS_MODULES) $(CPPFLAGS) $(LDFLAGS) -shared $^ $(LIBS) $(LDLIBS) \ + -o $@ + +-include $(DEPS) + +clean: + rm -f $(DEPS) *.o core *.so $(TARGET_LIB_DIR)/$(TARGET_LIB) diff --git a/app/plugins/vinput/README.md b/app/plugins/vinput/README.md new file mode 100644 index 0000000..25376d4 --- /dev/null +++ b/app/plugins/vinput/README.md @@ -0,0 +1,35 @@ +# Virtual Input Plugin + +## Description + +This plugin exposes the [Virtual Input API](https://developer.axis.com/vapix/network-video/input-and-outputs/#virtual-input-api) over OPC-UA. + +## Features + +The Virtual Inputs are presented in the form of an OPC-UA object with +`Activate`/`Deactivate` methods and boolean variable nodes modelling the virtual +input ports. These can also be accessed via OPC-UA read/write operations. + +- **VirtualInputs** (object) + - **Activate** (method) + - **Deactivate** (method) + - **VirtualInput-1** (variable) + - **VirtualInput-2** (variable) + - **...** + +## Usage + +The `Activate` method maps to the `/axis-cgi/virtualinput/activate.cgi` CGI. +It takes two input parameters: `` and ``. Use `"-1"` as +duration value to leave the port in active state indefinitely. + +The `Deactivate` method maps to the `/axis-cgi/virtualinput/deactivate.cgi` CGI. +It takes only one input parameter: the ``. It deactivates the +virtual input port. + +The state of the virtual input ports can also be read or written via direct +OPC-UA read/write operations, they are exposed as boolean variable nodes. + +## License + +**[MIT License](../../../LICENSE)** diff --git a/app/plugins/vinput/vinput_plugin.c b/app/plugins/vinput/vinput_plugin.c new file mode 100644 index 0000000..aeaa419 --- /dev/null +++ b/app/plugins/vinput/vinput_plugin.c @@ -0,0 +1,785 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include +#include +#include +#include + +#include "error.h" +#include "log.h" +#include "ua_utils.h" +#include "vapix_utils.h" +#include "vinput_plugin.h" +#include "vinput_vapix.h" + +#define UA_PLUGIN_NAMESPACE "http://www.axis.com/OpcUA/VirtualInput/" +#define UA_PLUGIN_NAME "opc-vinput-plugin" +#define UA_VINP_OBJ_DISPLAY_NAME "VirtualInputs" +#define UA_VINP_OBJ_DESCRIPTION "VirtualInputs" + +#define UA_VINPUTID_VIRTUALINPUTS_STARTID 6100 +#define VIN_BROWSE_NAME "VirtualInput-" +#define VIN_BROWSE_NAME_FMT VIN_BROWSE_NAME "%d" + +/* the max possible as of today, the actual nr. could be less on older f/w */ +#define VINPUT_MAX_PORTS 64 + +#define ERR_NOT_INITIALIZED "The " UA_PLUGIN_NAME " is not initialized" +#define ERR_NO_NAME "The " UA_PLUGIN_NAME " was not given a name" + +DEFINE_GQUARK(UA_PLUGIN_NAME) + +typedef struct plugin { + UA_Server *server; + /* user-friendly name of the plugin */ + gchar *name; + /* OPC-UA namespace index */ + UA_UInt16 ns; + /* an open62541 logger */ + UA_Logger *logger; + rollback_data_t *rbd; + + AXEventHandler *event_handler; + guint event_subscription; + gboolean *vin_states; + gchar *schema_version; + gchar *vapix_credentials; + CURL *curl_h; +} plugin_t; + +static plugin_t *plugin; + +/* Local functions */ + +/* Performs a rollback on the nodes added to the information model by deleting + * them from the server. This must always be called before the server thread + * is started. */ +static gboolean +vin_ua_do_rollback(GError **err) +{ + g_assert(err == NULL || *err == NULL); + g_assert(plugin != NULL); + g_assert(plugin->rbd != NULL); + g_assert(plugin->server != NULL); + + return ua_utils_do_rollback(plugin->server, plugin->rbd, err); +} + +static UA_StatusCode +vin_ua_activate_cb(G_GNUC_UNUSED UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionHandle, + G_GNUC_UNUSED const UA_NodeId *methodId, + G_GNUC_UNUSED void *methodContext, + G_GNUC_UNUSED const UA_NodeId *objectId, + G_GNUC_UNUSED void *objectContext, + G_GNUC_UNUSED size_t inputSize, + const UA_Variant *input, + G_GNUC_UNUSED size_t outputSize, + UA_Variant *output) +{ + UA_UInt32 port_nr; + UA_Int32 duration; + UA_StatusCode ua_status; + GError *lerr = NULL; + gboolean state_changed; + + g_assert(input != NULL); + g_assert(output != NULL); + + port_nr = *(UA_UInt32 *) input[0].data; + duration = *(UA_Int32 *) input[1].data; + + LOG_D(plugin->logger, "port_nr: %d, duration: %d", port_nr, duration); + + /* 'port_nr' must be in the range [1..VINPUT_MAX_PORTS] */ + if ((port_nr < 1) || (port_nr > VINPUT_MAX_PORTS)) { + return UA_STATUSCODE_BADOUTOFRANGE; + } + + ua_status = vin_set_port_state(plugin->curl_h, + plugin->vapix_credentials, + plugin->schema_version, + port_nr, + TRUE, + duration, + plugin->vin_states, + &state_changed, + &lerr); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, "vin_set_port_state() failed: %s", GERROR_MSG(lerr)); + g_clear_error(&lerr); + } else { + LOG_D(plugin->logger, + "result: port_nr: %d set ACTIVE, state_changed: %d", + port_nr, + state_changed); + ua_status = UA_Variant_setScalarCopy(output, + &state_changed, + &UA_TYPES[UA_TYPES_BOOLEAN]); + } + + return ua_status; +} + +static UA_StatusCode +vin_ua_deactivate_cb(G_GNUC_UNUSED UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionHandle, + G_GNUC_UNUSED const UA_NodeId *methodId, + G_GNUC_UNUSED void *methodContext, + G_GNUC_UNUSED const UA_NodeId *objectId, + G_GNUC_UNUSED void *objectContext, + G_GNUC_UNUSED size_t inputSize, + const UA_Variant *input, + G_GNUC_UNUSED size_t outputSize, + UA_Variant *output) +{ + UA_UInt32 port_nr; + gboolean state_changed; + GError *lerr = NULL; + UA_StatusCode ua_status; + + g_assert(input != NULL); + g_assert(output != NULL); + + port_nr = *(UA_UInt32 *) input[0].data; + + LOG_D(plugin->logger, "port_nr: %d", port_nr); + + if ((port_nr < 1) || (port_nr > VINPUT_MAX_PORTS)) { + return UA_STATUSCODE_BADOUTOFRANGE; + } + + ua_status = vin_set_port_state(plugin->curl_h, + plugin->vapix_credentials, + plugin->schema_version, + port_nr, + FALSE, + 0, + plugin->vin_states, + &state_changed, + &lerr); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, "vin_set_port_state() failed: %s", GERROR_MSG(lerr)); + g_clear_error(&lerr); + } else { + LOG_D(plugin->logger, + "result: port_nr: %d set INACTIVE, state_changed: %d", + port_nr, + state_changed); + ua_status = UA_Variant_setScalarCopy(output, + &state_changed, + &UA_TYPES[UA_TYPES_BOOLEAN]); + } + + return ua_status; +} + +static gboolean +vin_ua_add_methods(const UA_NodeId parent, GError **err) +{ + UA_StatusCode status; + UA_MethodAttributes mattr; + /* input/output argument definitions for the UA Activate/Deactivate methods */ + UA_Argument in_args[2]; + UA_Argument out_arg; + + g_assert(plugin != NULL); + g_assert(plugin->server != NULL); + g_assert(plugin->rbd != NULL); + g_assert(err == NULL || *err == NULL); + + /* prepare a node for the Activate method */ + UA_Argument_init(&in_args[0]); + in_args[0].description = + UA_LOCALIZEDTEXT("en-US", "Virtual Input port number (1..64)"); + in_args[0].name = UA_STRING("Virtual Input"); + in_args[0].dataType = UA_TYPES[UA_TYPES_UINT32].typeId; + in_args[0].valueRank = UA_VALUERANK_SCALAR; + + UA_Argument_init(&in_args[1]); + in_args[1].description = + UA_LOCALIZEDTEXT("en-US", "Duration in seconds (-1 to ignore)"); + in_args[1].name = UA_STRING("Duration"); + in_args[1].dataType = UA_TYPES[UA_TYPES_INT32].typeId; + in_args[1].valueRank = UA_VALUERANK_SCALAR; + + UA_Argument_init(&out_arg); + out_arg.description = UA_LOCALIZEDTEXT("en-US", "State Changed"); + out_arg.name = UA_STRING("State Changed"); + out_arg.dataType = UA_TYPES[UA_TYPES_BOOLEAN].typeId; + out_arg.valueRank = UA_VALUERANK_SCALAR; + + mattr = UA_MethodAttributes_default; + mattr.description = UA_LOCALIZEDTEXT("en-US", "Activate Virtual Input"); + mattr.displayName = UA_LOCALIZEDTEXT("en-US", "Activate"); + mattr.executable = TRUE; + mattr.userExecutable = TRUE; + + status = UA_Server_addMethodNode_rb(plugin->server, + UA_NODEID_NUMERIC(plugin->ns, 0), + parent, + UA_NODEID_NUMERIC(0, + UA_NS0ID_HASCOMPONENT), + UA_QUALIFIEDNAME(1, "Activate Method"), + mattr, + &vin_ua_activate_cb, + 2, + in_args, + 1, + &out_arg, + NULL, + plugin->rbd, + NULL); + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_addMethodNode_rb() failed, error code: %s", + UA_StatusCode_name(status)); + return FALSE; + } + + /* prepare a node for the Deactivate method */ + /* Note: has the same I/O arguments as the "Activate" method except it is + * missing "duration". */ + mattr = UA_MethodAttributes_default; + mattr.description = UA_LOCALIZEDTEXT("en-US", "Deactivate Virtual Input"); + mattr.displayName = UA_LOCALIZEDTEXT("en-US", "Deactivate"); + mattr.executable = TRUE; + mattr.userExecutable = TRUE; + + status = UA_Server_addMethodNode_rb(plugin->server, + UA_NODEID_NUMERIC(plugin->ns, 0), + parent, + UA_NODEID_NUMERIC(0, + UA_NS0ID_HASCOMPONENT), + UA_QUALIFIEDNAME(1, "Deactivate Method"), + mattr, + &vin_ua_deactivate_cb, + 1, + in_args, + 1, + &out_arg, + NULL, + plugin->rbd, + NULL); + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_addMethodNode_rb() failed, error code: %s", + UA_StatusCode_name(status)); + return FALSE; + } + + return TRUE; +} + +static UA_StatusCode +vin_ua_read_cb(UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionContext, + const UA_NodeId *nodeId, + G_GNUC_UNUSED void *nodeContext, + G_GNUC_UNUSED UA_Boolean includeSourceTimeStamp, + G_GNUC_UNUSED const UA_NumericRange *range, + UA_DataValue *dataValue) +{ + UA_StatusCode ua_status = UA_STATUSCODE_BAD; + UA_Boolean ua_vin_state; + UA_UInt32 portnr; + + g_assert(plugin != NULL); + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(dataValue != NULL); + + dataValue->hasValue = FALSE; + + portnr = nodeId->identifier.numeric - UA_VINPUTID_VIRTUALINPUTS_STARTID; + g_assert((portnr >= 1) && (portnr <= VINPUT_MAX_PORTS)); + + portnr--; /* adjust for 0-indexing in our array */ + + LOG_D(plugin->logger, + "cached VirtualInput-%d state: %d", + portnr + 1, + plugin->vin_states[portnr]); + + if (plugin->vin_states[portnr]) { + ua_vin_state = TRUE; + } else { + ua_vin_state = FALSE; + } + + ua_status = UA_Variant_setScalarCopy(&dataValue->value, + &ua_vin_state, + &UA_TYPES[UA_TYPES_BOOLEAN]); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, + "UA_Variant_setScalarCopy() failed: %s", + UA_StatusCode_name(ua_status)); + goto err_out; + } + + dataValue->hasValue = TRUE; + +err_out: + + return ua_status; +} + +static UA_StatusCode +vin_ua_write_cb(UA_Server *server, + G_GNUC_UNUSED const UA_NodeId *sessionId, + G_GNUC_UNUSED void *sessionContext, + const UA_NodeId *nodeId, + G_GNUC_UNUSED void *nodeContext, + G_GNUC_UNUSED const UA_NumericRange *range, + const UA_DataValue *dataValue) +{ + GError *lerr = NULL; + gboolean state_changed; + UA_UInt32 portnr; + UA_Boolean new_state; + UA_StatusCode ua_status; + + g_assert(plugin != NULL); + g_assert(server != NULL); + g_assert(nodeId != NULL); + g_assert(dataValue != NULL); + + portnr = nodeId->identifier.numeric - UA_VINPUTID_VIRTUALINPUTS_STARTID; + g_assert((portnr >= 1) && (portnr <= VINPUT_MAX_PORTS)); + + new_state = *(UA_Boolean *) dataValue->value.data; + LOG_D(plugin->logger, "vinput: %d OPC-UA new state: %d", portnr, new_state); + + ua_status = vin_set_port_state(plugin->curl_h, + plugin->vapix_credentials, + plugin->schema_version, + portnr, + new_state, + -1, + plugin->vin_states, + &state_changed, + &lerr); + if (ua_status != UA_STATUSCODE_GOOD) { + LOG_E(plugin->logger, "vin_set_port_state() failed: %s", GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + + LOG_D(plugin->logger, "return: %s", UA_StatusCode_name(ua_status)); + + return ua_status; +} + +static gboolean +vin_ua_add_object(UA_NodeId *outId, GError **err) +{ + UA_StatusCode status; + UA_ObjectAttributes attr = UA_ObjectAttributes_default; + + g_assert(plugin != NULL); + g_assert(plugin->server != NULL); + g_assert(plugin->rbd != NULL); + g_assert(outId != NULL); + g_assert(err == NULL || *err == NULL); + + attr.displayName = UA_LOCALIZEDTEXT("en-US", UA_VINP_OBJ_DISPLAY_NAME); + attr.description = UA_LOCALIZEDTEXT("en-US", UA_VINP_OBJ_DESCRIPTION); + + status = + UA_Server_addObjectNode_rb(plugin->server, + UA_NODEID_NUMERIC(plugin->ns, 0), + UA_NODEID_NUMERIC(0, + UA_NS0ID_OBJECTSFOLDER), + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + UA_QUALIFIEDNAME(plugin->ns, + UA_VINP_OBJ_DISPLAY_NAME), + UA_NODEID_NUMERIC(0, + UA_NS0ID_BASEOBJECTTYPE), + attr, + NULL, + plugin->rbd, + outId); + if (status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "Failed to add variable node BasicDeviceInfo: %s", + UA_StatusCode_name(status)); + return FALSE; + } + + return TRUE; +} + +static gboolean +vin_ua_add_instances(UA_NodeId parent, GError **err) +{ + UA_StatusCode retVal = UA_STATUSCODE_GOOD; + /* clang-format off */ + UA_DataSource ua_vinp_cb = { + .read = vin_ua_read_cb, + .write = vin_ua_write_cb + }; + /* clang-format on */ + UA_VariableAttributes vattr = UA_VariableAttributes_default; + gint i = 0; + gchar *port_name = NULL; + + g_assert(plugin != NULL); + g_assert(plugin->server != NULL); + g_assert(plugin->rbd != NULL); + g_assert(err == NULL || *err == NULL); + + vattr.accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE; + vattr.dataType = UA_TYPES[UA_TYPES_BOOLEAN].typeId; + + for (i = 1; i <= VINPUT_MAX_PORTS; i++) { + port_name = g_strdup_printf(VIN_BROWSE_NAME_FMT, i); + + LOG_D(plugin->logger, "Adding virtual input: %s", port_name); + + /* add variable nodes for all existing VirtualInput ports */ + vattr.displayName = UA_LOCALIZEDTEXT("", port_name); + retVal |= UA_Server_addVariableNode_rb( + plugin->server, + UA_NODEID_NUMERIC(plugin->ns, + UA_VINPUTID_VIRTUALINPUTS_STARTID + i), + parent, + UA_NODEID_NUMERIC(0, UA_NS0ID_HASCOMPONENT), + UA_QUALIFIEDNAME(plugin->ns, port_name), + UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE), + vattr, + NULL, + plugin->rbd, + NULL); + + retVal |= UA_Server_setVariableNode_dataSource( + plugin->server, + UA_NODEID_NUMERIC(plugin->ns, + UA_VINPUTID_VIRTUALINPUTS_STARTID + i), + ua_vinp_cb); + + g_clear_pointer(&port_name, g_free); + } + + if (retVal != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "Unable to add 'VirtualInput' ports to 'VirtualInputs' object!"); + goto bail_out; + } + +bail_out: + return (retVal == UA_STATUSCODE_GOOD); +} + +static void +vin_event_cb(G_GNUC_UNUSED guint subscription, + AXEvent *event, + G_GNUC_UNUSED gpointer user_data) +{ + const AXEventKeyValueSet *key_value_set; + gint port; + gboolean active; + GError *lerr = NULL; + + g_assert(plugin != NULL); + g_assert(plugin->vin_states != NULL); + g_assert(event != NULL); + + /* Extract the AXEventKeyValueSet from the event. */ + key_value_set = ax_event_get_key_value_set(event); + + if (key_value_set == NULL) { + goto err_out; + } + + /* fetch the VirtualInput port number */ + if (!ax_event_key_value_set_get_integer(key_value_set, + "port", + NULL, + &port, + &lerr)) { + LOG_E(plugin->logger, + "'port' key missing from event: %s", + GERROR_MSG(lerr)); + goto err_out; + } + + /* fetch the 'active' state of port */ + if (!ax_event_key_value_set_get_boolean(key_value_set, + "active", + NULL, + &active, + &lerr)) { + LOG_E(plugin->logger, + "'active' key missing from event: %s", + GERROR_MSG(lerr)); + goto err_out; + } + + /* !NOTE!: the D-Bus numbering of the Virtual Inputs starts from 1 not 0! */ + plugin->vin_states[port - 1] = active; + + LOG_D(plugin->logger, "VirtualInput-%d: %d", port, active); + +err_out: + g_clear_error(&lerr); + /* the callback must always free 'event', NULL-case handled by the API */ + ax_event_free(event); + + return; +} + +static gboolean +vin_subscribe_event(GError **err) +{ + AXEventKeyValueSet *key_value_set = NULL; + guint subscription; + + g_assert(plugin != NULL); + g_assert(err == NULL || *err == NULL); + + key_value_set = ax_event_key_value_set_new(); + if (key_value_set == NULL) { + SET_ERROR(err, -1, "ax_event_key_value_set_new() failed!"); + return FALSE; + } + + /* Initialize an AXEventKeyValueSet that matches 'VirtualInput' events. + * + * tns1:topic0=Device + * tnsaxis:topic1=IO + * tnsaxis:topic2=VirtualInput + * active=* <-- Subscribe to all states + */ + /* clang-format off */ + if (!ax_event_key_value_set_add_key_values(key_value_set, err, + "topic0", "tns1", "Device", AX_VALUE_TYPE_STRING, + "topic1", "tnsaxis", "IO", AX_VALUE_TYPE_STRING, + "topic2", "tnsaxis", "VirtualInput", AX_VALUE_TYPE_STRING, + "port", NULL, NULL, AX_VALUE_TYPE_INT, + "active", NULL, NULL, AX_VALUE_TYPE_BOOL, + NULL)) { + g_prefix_error(err, "ax_event_key_value_set_add_key_values() failed: "); + ax_event_key_value_set_free(key_value_set); + return FALSE; + } + /* clang-format on */ + + if (!ax_event_handler_subscribe(plugin->event_handler, + key_value_set, + &subscription, + (AXSubscriptionCallback) vin_event_cb, + plugin, + err)) { + g_prefix_error(err, "ax_event_handler_subscribe() failed: "); + ax_event_key_value_set_free(key_value_set); + return FALSE; + } + + plugin->event_subscription = subscription; + + /* The key/value set is no longer needed */ + ax_event_key_value_set_free(key_value_set); + + LOG_D(plugin->logger, + "Device/IO/VirtualInput subscr. id: %d", + plugin->event_subscription); + + return TRUE; +} + +static void +plugin_cleanup(void) +{ + GError *lerr = NULL; + + g_assert(plugin != NULL); + + if (plugin->curl_h != NULL) { + curl_easy_cleanup(plugin->curl_h); + } + + if (plugin->event_handler != NULL) { + if (plugin->event_subscription > 0) { + if (!ax_event_handler_unsubscribe_and_notify(plugin->event_handler, + plugin->event_subscription, + NULL, + NULL, + &lerr)) { + LOG_E(plugin->logger, + "ax_event_handler_unsubscribe_and_notify() failed: %s", + GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + } + + ax_event_handler_free(plugin->event_handler); + } + + g_clear_pointer(&plugin->name, g_free); + g_clear_pointer(&plugin->vin_states, g_free); + g_clear_pointer(&plugin->vapix_credentials, g_free); + g_clear_pointer(&plugin->schema_version, g_free); + + /* free up allocated rollback data, if any */ + ua_utils_clear_rbd(&plugin->rbd); + + g_clear_pointer(&plugin, g_free); + + return; +} + +/* Exported functions */ +gboolean +opc_ua_create(UA_Server *server, + UA_Logger *logger, + G_GNUC_UNUSED gpointer *params, + GError **err) +{ + UA_NodeId vinp_obj_node; + GError *lerr = NULL; + + g_return_val_if_fail(server != NULL, FALSE); + g_return_val_if_fail(logger != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + if (plugin != NULL) { + return TRUE; + } + + plugin = g_new0(plugin_t, 1); + + plugin->name = g_strdup(UA_PLUGIN_NAME); + plugin->logger = logger; + plugin->server = server; + plugin->ns = UA_Server_addNamespace(server, UA_PLUGIN_NAMESPACE); + plugin->rbd = g_new0(rollback_data_t, 1); + + /* allocate an array of booleans to keep track of the vinput states */ + plugin->vin_states = g_new0(gboolean, VINPUT_MAX_PORTS); + + /* allocate a curl handle that we are going to use throughout our requests */ + plugin->curl_h = curl_easy_init(); + if (plugin->curl_h == NULL) { + SET_ERROR(err, -1, "curl_easy_init() failed"); + goto err_out; + } + + /* subscribe to "VirtualInput" events */ + plugin->event_handler = ax_event_handler_new(); + if (!plugin->event_handler) { + SET_ERROR(err, -1, "Could not allocate AXEventHandler!"); + goto err_out; + } + + if (!vin_subscribe_event(err)) { + g_prefix_error(err, "vin_subscribe_event() failed: "); + goto err_out; + } + + /* obtain credentials to make VAPIX calls */ + plugin->vapix_credentials = + vapix_get_credentials("vapix-virtualinput-user", err); + if (plugin->vapix_credentials == NULL) { + g_prefix_error(err, "Failed to get the VAPIX credentials: "); + goto err_out; + } + + plugin->schema_version = vin_get_schema_version(plugin->curl_h, + plugin->vapix_credentials, + err); + if (plugin->schema_version == NULL) { + g_prefix_error(err, "Failed to get VAPIX schema version: "); + goto err_out; + } + LOG_D(plugin->logger, "plugin->schema_version: %s", plugin->schema_version); + + /* add the VirtualInputs object to the opc-ua server */ + if (!vin_ua_add_object(&vinp_obj_node, err)) { + g_prefix_error(err, "vin_ua_add_object() failed: "); + goto err_out; + } + + /* add variable nodes to the object node */ + if (!vin_ua_add_instances(vinp_obj_node, err)) { + g_prefix_error(err, "vin_ua_add_instances() failed: "); + goto err_out; + } + + /* add methods (Activate/Deactivate) to the object node */ + if (!vin_ua_add_methods(vinp_obj_node, err)) { + g_prefix_error(err, "vin_ua_add_methods() failed: "); + goto err_out; + } + + /* the information model was successfully populated so now we can free up our + * rollback data since we no longer need it */ + ua_utils_clear_rbd(&plugin->rbd); + + return TRUE; + +err_out: + /* remove any nodes created so far */ + if (!vin_ua_do_rollback(&lerr)) { + /* NOTE: if we land here we might just as well terminate the whole + * application, something is totally out of whack */ + LOG_E(plugin->logger, "vin_ua_do_rollback() failed: %s", GERROR_MSG(lerr)); + g_clear_error(&lerr); + } + + /* clean up potentially allocated resources */ + plugin_cleanup(); + + return FALSE; +} + +void +opc_ua_destroy(void) +{ + if (plugin == NULL) { + return; + } + + plugin_cleanup(); +} + +const gchar * +opc_ua_get_plugin_name(void) +{ + if (plugin == NULL) { + return ERR_NOT_INITIALIZED; + } else if (plugin->name == NULL) { + return ERR_NO_NAME; + } + + return plugin->name; +} diff --git a/app/plugins/vinput/vinput_plugin.h b/app/plugins/vinput/vinput_plugin.h new file mode 100644 index 0000000..cef9dd6 --- /dev/null +++ b/app/plugins/vinput/vinput_plugin.h @@ -0,0 +1,67 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __VINPUT_PLUGIN_H__ +#define __VINPUT_PLUGIN_H__ + +/** + * opc_ua_create: + * @server: OPC-UA Server object + * @logger: an open62541 logger instance + * @params: pointer to user data (can be used to pass in additional parameters + * for the initialization of the plugin) + * @err: return location for a #GError + * + * Sets up and initializes the plugin. + * + * Returns: TRUE if setup was successful, FALSE otherwise. + */ +gboolean +opc_ua_create(UA_Server *server, + UA_Logger *logger, + G_GNUC_UNUSED gpointer *params, + GError **err); + +/** + * opc_ua_destroy: + * + * Frees allocated resources and performs any necessary shutdown operations for + * the plugin. + */ +void +opc_ua_destroy(void); + +/** + * opc_ua_get_plugin_name: + * + * Retrieves the name of the plugin. + * + * Returns: the plugin name as set in the plugin constructor or an error string + * if no name was set. + */ +const gchar * +opc_ua_get_plugin_name(void); + +#endif /* __VINPUT_PLUGIN_H__ */ diff --git a/app/plugins/vinput/vinput_vapix.c b/app/plugins/vinput/vinput_vapix.c new file mode 100644 index 0000000..95bb15f --- /dev/null +++ b/app/plugins/vinput/vinput_vapix.c @@ -0,0 +1,407 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include + +#include "error.h" +#include "vapix_utils.h" +#include "vinput_vapix.h" + +/* XML tags */ +#define VINPUT_XML_TAG_RESP "VirtualInputResponse" +#define VINPUT_XML_TAG_SUCCESS "Success" +#define VINPUT_XML_TAG_ERROR "Error" +#define VINPUT_XML_TAG_ERROR_DESC "ErrorDescription" +#define VINPUT_XML_TAG_SCHVER "SchemaVersion" +#define VINPUT_XML_TAG_MAJVER "MajorVersion" +#define VINPUT_XML_TAG_ACTIVATE_SUCC "ActivateSuccess" +#define VINPUT_XML_TAG_DEACTIVATE_SUCC "DeactivateSuccess" +#define VINPUT_XML_TAG_STATE_CHNG "StateChanged" +#define VINPUT_XML_TXT_TRUE "true" +#define VINPUT_XML_TXT_FALSE "false" + +/* bitmask positions */ +#define VAPIX_VIN_RESP (1 << 0) +#define VAPIX_SUCCESS (1 << 1) +#define VAPIX_SCHEMA (1 << 2) +#define VAPIX_ACTIVATE (1 << 3) +#define VAPIX_DEACTIVATE (1 << 4) +#define VAPIX_ERR (1 << 5) + +DEFINE_GQUARK("vinput-vapix") + +static void +vin_xml_start_element(G_GNUC_UNUSED GMarkupParseContext *context, + const gchar *element_name, + G_GNUC_UNUSED const gchar **attribute_names, + G_GNUC_UNUSED const gchar **attribute_values, + gpointer user_data, + G_GNUC_UNUSED GError **err) +{ + parser_status_t *pst; + + g_assert(element_name != NULL); + g_assert(user_data != NULL); + + pst = user_data; + + if (g_strcmp0(element_name, VINPUT_XML_TAG_RESP) == 0) { + pst->element = VINPUT_XML_TAG_RESP; + pst->vapix_mask |= VAPIX_VIN_RESP; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_ERROR) == 0) { + pst->element = VINPUT_XML_TAG_ERROR; + pst->vapix_mask |= VAPIX_ERR; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_ERROR_DESC) == 0) { + pst->element = VINPUT_XML_TAG_ERROR_DESC; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_SUCCESS) == 0) { + pst->element = VINPUT_XML_TAG_SUCCESS; + pst->vapix_mask |= VAPIX_SUCCESS; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_SCHVER) == 0) { + pst->element = VINPUT_XML_TAG_SCHVER; + pst->vapix_mask |= VAPIX_SCHEMA; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_MAJVER) == 0) { + pst->element = VINPUT_XML_TAG_MAJVER; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_ACTIVATE_SUCC) == 0) { + pst->element = VINPUT_XML_TAG_ACTIVATE_SUCC; + pst->vapix_mask |= VAPIX_ACTIVATE; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_DEACTIVATE_SUCC) == 0) { + pst->element = VINPUT_XML_TAG_DEACTIVATE_SUCC; + pst->vapix_mask |= VAPIX_DEACTIVATE; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_STATE_CHNG) == 0) { + pst->element = VINPUT_XML_TAG_STATE_CHNG; + } +} + +static void +vin_xml_end_element(G_GNUC_UNUSED GMarkupParseContext *context, + const gchar *element_name, + gpointer user_data, + G_GNUC_UNUSED GError **err) +{ + parser_status_t *pst; + + g_assert(element_name != NULL); + g_assert(user_data != NULL); + + pst = user_data; + + if (g_strcmp0(element_name, VINPUT_XML_TAG_RESP) == 0) { + pst->element = NULL; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_SUCCESS) == 0) { + pst->element = NULL; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_ERROR) == 0) { + pst->element = NULL; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_ERROR_DESC) == 0) { + pst->element = NULL; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_SCHVER) == 0) { + pst->element = NULL; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_MAJVER) == 0) { + pst->element = NULL; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_ACTIVATE_SUCC) == 0) { + pst->element = NULL; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_DEACTIVATE_SUCC) == 0) { + pst->element = NULL; + } else if (g_strcmp0(element_name, VINPUT_XML_TAG_STATE_CHNG) == 0) { + pst->element = NULL; + } +} + +static void +vin_xml_text(G_GNUC_UNUSED GMarkupParseContext *context, + const gchar *text, + gsize text_len, + gpointer user_data, + GError **err) +{ + gchar *buf = NULL; + parser_status_t *pst; + + g_assert(text != NULL); + g_assert(user_data != NULL); + g_assert(err == NULL || *err == NULL); + + /* strip any leading or trailing whitespace of data/value: we need a + * modifiable copy of 'text' allocated dynamically */ + buf = g_strndup(text, text_len); + if (buf) { + buf = g_strstrip(buf); + } + + pst = user_data; + + if ((g_strcmp0(pst->element, VINPUT_XML_TAG_ERROR_DESC) == 0) && + (pst->vapix_mask & VAPIX_VIN_RESP) && (pst->vapix_mask & VAPIX_ERR)) { + /* the VAPIX request returned an response, + * save the */ + if (strlen(buf) > 0) { + /* NOTE: needs to be freed with vin_xml_parse_clear() */ + pst->error_descr = g_strdup(buf); + } else { + /* this is a parsing error */ + SET_ERROR(err, -1, "<%s>: missing value", VINPUT_XML_TAG_ERROR_DESC); + } + } else if ((g_strcmp0(pst->element, VINPUT_XML_TAG_MAJVER) == 0) && + (pst->vapix_mask & VAPIX_VIN_RESP) && + (pst->vapix_mask & VAPIX_SUCCESS) && + (pst->vapix_mask & VAPIX_SCHEMA)) { + /* current element is and prior expected tags found: save + * the of */ + + if (strlen(buf) > 0) { + /* NOTE: needs to be freed with vin_xml_parse_clear() */ + pst->schema_version = g_strdup(buf); + } else { + /* this is a parsing error */ + SET_ERROR(err, -1, "<%s>: missing value", VINPUT_XML_TAG_MAJVER); + } + + } else if ((g_strcmp0(pst->element, VINPUT_XML_TAG_STATE_CHNG) == 0) && + (pst->vapix_mask & VAPIX_VIN_RESP) && + (pst->vapix_mask & VAPIX_SUCCESS)) { + /* check if the current element is */ + + if ((pst->vapix_mask & VAPIX_ACTIVATE) || + (pst->vapix_mask & VAPIX_DEACTIVATE)) { + /* if or */ + + /* save the value of */ + if (g_strcmp0(buf, VINPUT_XML_TXT_TRUE) == 0) { + pst->state_changed = TRUE; + } else if (g_strcmp0(buf, VINPUT_XML_TXT_FALSE) == 0) { + pst->state_changed = FALSE; + } else { + /* this is a parsing error */ + /* unexpected: the value was neither 'true' nor 'false' */ + SET_ERROR(err, -1, "<%s>: unexpected value", VINPUT_XML_TAG_STATE_CHNG); + } + } /* || */ + } /* */ + + g_clear_pointer(&buf, g_free); + + return; +} + +static gboolean +vin_xml_parse(const gchar *xml_txt, parser_status_t *result, GError **err) +{ + GMarkupParseContext *parse_ctx = NULL; + /* clang-format off */ + GMarkupParser vinput_xml_parser = { + .start_element = vin_xml_start_element, + .end_element = vin_xml_end_element, + .text = vin_xml_text, + .passthrough = NULL, + .error = NULL, + }; + /* clang-format on */ + gboolean res = FALSE; + + g_return_val_if_fail(xml_txt != NULL, FALSE); + g_return_val_if_fail(result != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + parse_ctx = g_markup_parse_context_new(&vinput_xml_parser, 0, result, NULL); + if (parse_ctx == NULL) { + SET_ERROR(err, -1, "g_markup_parse_context_new() failed"); + return res; + } + + res = g_markup_parse_context_parse(parse_ctx, xml_txt, strlen(xml_txt), err); + if (!res) { + g_prefix_error(err, "g_markup_parse_context_parse(): "); + goto err_out; + } + + res = g_markup_parse_context_end_parse(parse_ctx, err); + if (!res) { + g_prefix_error(err, "g_markup_parse_context_end_parse(): "); + goto err_out; + } + +err_out: + g_markup_parse_context_free(parse_ctx); + + return res; +} + +/* Must be called after each call to vin_xml_parse() to free parsing result */ +static void +vin_xml_parse_clear(parser_status_t *parser_status) +{ + g_return_if_fail(parser_status != NULL); + + g_clear_pointer(&parser_status->schema_version, g_free); + g_clear_pointer(&parser_status->error_descr, g_free); +} + +/* NOTE: "duration" (in seconds) is an optional parameter of the "activate.cgi" + * request. OPC UA methods cannot take optional parameters. We use the + * convention that a negative "duration" value will be ignored. + * See: + * https://developer.axis.com/vapix/network-video/input-and-outputs/#activate-a-virtual-input: + * */ +UA_StatusCode +vin_set_port_state(CURL *curl_h, + const gchar *credentials, + const gchar *schema_version, + UA_UInt32 portnr, + UA_Boolean state, + UA_Int32 duration, + gboolean *vin_states, + gboolean *state_changed, + GError **err) +{ + UA_StatusCode ua_status = UA_STATUSCODE_BAD; + gchar *vapix_req = NULL; + gchar *vapix_params = NULL; + gchar *response = NULL; + parser_status_t parse_res = { 0 }; + + g_return_val_if_fail(curl_h != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(credentials != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(schema_version != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(vin_states != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(state_changed != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(err == NULL || *err == NULL, UA_STATUSCODE_BAD); + + if (state && (duration >= 0)) { + /* NOTE: only "activate.cgi" (i.e. "state == true") has an + * optional "duration" parameter */ + vapix_params = g_strdup_printf(VINPUT_VAPIX_CGI_PARAMS_FULL, + schema_version, + portnr, + duration); + } else { + vapix_params = + g_strdup_printf(VINPUT_VAPIX_CGI_PARAMS, schema_version, portnr); + } + + if (state) { + /* activate request */ + vapix_req = g_strdup_printf("%s?%s", + VINPUT_ACTIVATE_CGI_ENDPOINT, + vapix_params); + } else { + /* deactivate request */ + vapix_req = g_strdup_printf("%s?%s", + VINPUT_DEACTIVATE_CGI_ENDPOINT, + vapix_params); + } + + response = vapix_request(curl_h, + credentials, + vapix_req, + HTTP_GET, + NONE_data, + NULL, + err); + if (response != NULL) { + if (vin_xml_parse(response, &parse_res, err)) { + if (parse_res.vapix_mask & VAPIX_ERR) { + /* VAPIX response: error */ + if (parse_res.error_descr) { + SET_ERROR(err, + -1, + "%s: error response: %s", + vapix_req, + parse_res.error_descr); + } + } else { + /* VAPIX response: success */ + ua_status = UA_STATUSCODE_GOOD; + *state_changed = parse_res.state_changed; + + if (parse_res.state_changed) { + /* request successfull with a state change, update our + * 'vin_states' cache */ + if (state) { + vin_states[portnr - 1] = TRUE; + } else { + vin_states[portnr - 1] = FALSE; + } + } + } + } else { + g_prefix_error(err, "vin_xml_parse() failed: "); + } /* vin_xml_parse(): FALSE */ + + vin_xml_parse_clear(&parse_res); + g_clear_pointer(&response, g_free); + + } else { + /* failed request, response == NULL */ + g_prefix_error(err, "vapix_request() failed: "); + } + + g_clear_pointer(&vapix_params, g_free); + g_clear_pointer(&vapix_req, g_free); + + return ua_status; +} + +gchar * +vin_get_schema_version(CURL *curl_h, const gchar *credentials, GError **err) +{ + parser_status_t parse_res = { 0 }; + gchar *schema_version = NULL; + gchar *response = NULL; + + g_return_val_if_fail(curl_h != NULL, NULL); + g_return_val_if_fail(credentials != NULL, NULL); + g_return_val_if_fail(err == NULL || *err == NULL, NULL); + + /* fetch the schema version of the Virtual Input VAPIX */ + response = vapix_request(curl_h, + credentials, + VINPUT_SCHEMA_CGI_ENDPOINT, + HTTP_GET, + NONE_data, + NULL, + err); + if (response != NULL) { + if (vin_xml_parse(response, &parse_res, err)) { + /* response: save the of as we + * need it for subsequent activate/deactivate VAPIX calls */ + schema_version = g_strdup(parse_res.schema_version); + } else { + /* response */ + g_prefix_error(err, "vin_xml_parse() failed: "); + + vin_xml_parse_clear(&parse_res); + g_clear_pointer(&response, g_free); + goto err_out; + } + vin_xml_parse_clear(&parse_res); + g_clear_pointer(&response, g_free); + } else { + g_prefix_error(err, "vapix_request() failed: "); + } /* response == NULL*/ + +err_out: + + return schema_version; +} diff --git a/app/plugins/vinput/vinput_vapix.h b/app/plugins/vinput/vinput_vapix.h new file mode 100644 index 0000000..8db66d4 --- /dev/null +++ b/app/plugins/vinput/vinput_vapix.h @@ -0,0 +1,71 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef __VINPUT_VAPIX_H__ +#define __VINPUT_VAPIX_H__ + +#define VINPUT_ACTIVATE_CGI_ENDPOINT "virtualinput/activate.cgi" +#define VINPUT_DEACTIVATE_CGI_ENDPOINT "virtualinput/deactivate.cgi" +#define VINPUT_SCHEMA_CGI_ENDPOINT "virtualinput/getschemaversions.cgi" +#define VINPUT_VAPIX_CGI_PARAMS "schemaversion=%s&port=%d" +#define VINPUT_VAPIX_CGI_PARAMS_FULL "schemaversion=%s&port=%d&duration=%d" + +/* helper structure to interpret the result of a VAPIX call */ +typedef struct parser_status { + /* bitmask with status bits */ + guint vapix_mask; + /* the value of */ + gboolean state_changed; + /* the value of */ + gchar *schema_version; + /* the value of , if any */ + gchar *error_descr; + + /* Points to the current XML tag of interest while parsing to help + * interpreting the text. Set to NULL after parsing is finished. */ + const gchar *element; +} parser_status_t; + +gchar * +vin_get_schema_version(CURL *curl_h, const gchar *credentials, GError **err); + +/* NOTE: "duration" (in seconds) is an optional parameter of the "activate.cgi" + * request. OPC UA methods cannot take optional parameters. We use the + * convention that a negative "duration" value will be ignored. + * See: + * https://developer.axis.com/vapix/network-video/input-and-outputs/#activate-a-virtual-input: + * */ +UA_StatusCode +vin_set_port_state(CURL *curl_h, + const gchar *credentials, + const gchar *schema_version, + UA_UInt32 portnr, + UA_Boolean state, + UA_Int32 duration, + gboolean *vin_states, + gboolean *state_changed, + GError **err); + +#endif /* __VINPUT_VAPIX_H__ */ diff --git a/app/ua_utils.c b/app/ua_utils.c new file mode 100644 index 0000000..2262232 --- /dev/null +++ b/app/ua_utils.c @@ -0,0 +1,331 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include + +#include "error.h" +#include "ua_utils.h" + +DEFINE_GQUARK("ua-utils") + +/* Local functions */ + +static UA_StatusCode +add_nodeid_to_rbd(const UA_NodeId *node_id, rollback_data_t *rbd) +{ + UA_StatusCode ua_status; + UA_NodeId *rb_nodeid; + + g_assert(node_id != NULL); + g_assert(rbd != NULL); + + /* allocate a UA_NodeId struct and add it to our rollback data + * (rbd->node_ids) */ + rb_nodeid = UA_NodeId_new(); + if (rb_nodeid == NULL) { + return UA_STATUSCODE_BADOUTOFMEMORY; + } + + ua_status = UA_NodeId_copy(node_id, rb_nodeid); + if (ua_status != UA_STATUSCODE_GOOD) { + UA_NodeId_delete(rb_nodeid); + return ua_status; + } + + /* add the allocated NodeId to our list */ + rbd->node_ids = g_list_prepend(rbd->node_ids, rb_nodeid); + + return UA_STATUSCODE_GOOD; +} + +/* Exported functions */ + +void +ua_utils_clear_rbd(rollback_data_t **rbd) +{ + g_return_if_fail(rbd != NULL); + + if (*rbd == NULL) { + return; + } + + if ((*rbd)->node_ids != NULL) { + /* free up the allocated UA_NodeId structures from the 'node_ids' list */ + g_clear_list(&((*rbd)->node_ids), (GDestroyNotify) UA_NodeId_delete); + } + + /* free up the rollback_data_t structure itself */ + g_clear_pointer(rbd, g_free); + + return; +} + +gboolean +ua_utils_do_rollback(UA_Server *server, rollback_data_t *rbd, GError **err) +{ + gboolean ret = FALSE; + GList *l; + UA_StatusCode ua_status; + UA_ServerConfig *config; + + g_return_val_if_fail(server != NULL, FALSE); + g_return_val_if_fail(rbd != NULL, FALSE); + g_return_val_if_fail(err == NULL || *err == NULL, FALSE); + + config = UA_Server_getConfig(server); + if (config == NULL) { + /* Unlikely, documentation says: + * "Get the configuration. Always succeeds as this simply + * resolves a pointer." */ + return ret; + } + + if (rbd->saved_cdt != NULL) { + /* restore the custom data type arrays registered with the server */ + config->customDataTypes = rbd->saved_cdt; + } + + l = g_list_first(rbd->node_ids); + while (l != NULL) { + if (l->data) { + ua_status = UA_Server_deleteNode(server, *(UA_NodeId *) l->data, TRUE); + if (ua_status != UA_STATUSCODE_GOOD) { + SET_ERROR(err, + -1, + "UA_Server_deleteNode() failed: %s", + UA_StatusCode_name(ua_status)); + goto err_out; + } + } + l = g_list_next(l); + } + + ret = TRUE; + +err_out: + + return ret; +} + +UA_StatusCode +UA_Server_addObjectNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_NodeId typeDefinition, + const UA_ObjectAttributes attr, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId) +{ + UA_StatusCode ua_status; + UA_NodeId out_nodeid = UA_NODEID_NULL; + + g_return_val_if_fail(server != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(rbd != NULL, UA_STATUSCODE_BAD); + + ua_status = UA_Server_addObjectNode(server, + requestedNewNodeId, + parentNodeId, + referenceTypeId, + browseName, + typeDefinition, + attr, + nodeContext, + &out_nodeid); + if (ua_status == UA_STATUSCODE_GOOD) { + if (outNewNodeId) { + /* caller wants to know the NodeId of the new node */ + *outNewNodeId = out_nodeid; + } + + ua_status |= add_nodeid_to_rbd(&out_nodeid, rbd); + } + + return ua_status; +} + +UA_StatusCode +UA_Server_addDataTypeNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_DataTypeAttributes attr, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId) +{ + UA_StatusCode ua_status; + UA_NodeId out_nodeid = UA_NODEID_NULL; + + g_return_val_if_fail(server != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(rbd != NULL, UA_STATUSCODE_BAD); + + ua_status = UA_Server_addDataTypeNode(server, + requestedNewNodeId, + parentNodeId, + referenceTypeId, + browseName, + attr, + nodeContext, + &out_nodeid); + if (ua_status == UA_STATUSCODE_GOOD) { + if (outNewNodeId) { + /* caller wants to know the NodeId of the new node */ + *outNewNodeId = out_nodeid; + } + + ua_status |= add_nodeid_to_rbd(&out_nodeid, rbd); + } + + return ua_status; +} + +UA_StatusCode +UA_Server_addVariableNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_NodeId typeDefinition, + const UA_VariableAttributes attr, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId) +{ + UA_StatusCode ua_status; + UA_NodeId out_nodeid = UA_NODEID_NULL; + + g_return_val_if_fail(server != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(rbd != NULL, UA_STATUSCODE_BAD); + + ua_status = UA_Server_addVariableNode(server, + requestedNewNodeId, + parentNodeId, + referenceTypeId, + browseName, + typeDefinition, + attr, + nodeContext, + &out_nodeid); + if (ua_status == UA_STATUSCODE_GOOD) { + if (outNewNodeId) { + /* caller wants to know the NodeId of the new node */ + *outNewNodeId = out_nodeid; + } + + ua_status |= add_nodeid_to_rbd(&out_nodeid, rbd); + } + + return ua_status; +} + +UA_StatusCode +UA_Server_addObjectTypeNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_ObjectTypeAttributes attr, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId) +{ + UA_StatusCode ua_status; + UA_NodeId out_nodeid = UA_NODEID_NULL; + + g_return_val_if_fail(server != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(rbd != NULL, UA_STATUSCODE_BAD); + + ua_status = UA_Server_addObjectTypeNode(server, + requestedNewNodeId, + parentNodeId, + referenceTypeId, + browseName, + attr, + nodeContext, + &out_nodeid); + if (ua_status == UA_STATUSCODE_GOOD) { + if (outNewNodeId) { + /* caller wants to know the NodeId of the new node */ + *outNewNodeId = out_nodeid; + } + + ua_status |= add_nodeid_to_rbd(&out_nodeid, rbd); + } + + return ua_status; +} + +UA_StatusCode +UA_Server_addMethodNode_rb(UA_Server *server, + const UA_NodeId requestedNewNodeId, + const UA_NodeId parentNodeId, + const UA_NodeId referenceTypeId, + const UA_QualifiedName browseName, + const UA_MethodAttributes attr, + UA_MethodCallback method, + size_t inputArgumentsSize, + const UA_Argument *inputArguments, + size_t outputArgumentsSize, + const UA_Argument *outputArguments, + void *nodeContext, + rollback_data_t *rbd, + UA_NodeId *outNewNodeId) +{ + UA_StatusCode ua_status; + UA_NodeId out_nodeid = UA_NODEID_NULL; + + g_return_val_if_fail(server != NULL, UA_STATUSCODE_BAD); + g_return_val_if_fail(rbd != NULL, UA_STATUSCODE_BAD); + + ua_status = UA_Server_addMethodNode(server, + requestedNewNodeId, + parentNodeId, + referenceTypeId, + browseName, + attr, + method, + inputArgumentsSize, + inputArguments, + outputArgumentsSize, + outputArguments, + nodeContext, + &out_nodeid); + + if (ua_status == UA_STATUSCODE_GOOD) { + if (outNewNodeId) { + /* caller wants to know the NodeId of the new node */ + *outNewNodeId = out_nodeid; + } + + ua_status |= add_nodeid_to_rbd(&out_nodeid, rbd); + } + + return ua_status; +} diff --git a/app/vapix_utils.c b/app/vapix_utils.c new file mode 100644 index 0000000..2157598 --- /dev/null +++ b/app/vapix_utils.c @@ -0,0 +1,312 @@ +/** + * MIT License + * + * Copyright (c) 2025 Axis Communications AB + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include + +#include "error.h" +#include "vapix_utils.h" + +DEFINE_GQUARK("vapix-utils") + +#define VAPIX_URL "http://127.0.0.12/axis-cgi/%s" + +#define CONF1_DBUS_SERVICE "com.axis.HTTPConf1" +#define CONF1_DBUS_OBJECT_PATH "/com/axis/HTTPConf1/VAPIXServiceAccounts1" +#define CONF1_DBUS_INTERFACE "com.axis.HTTPConf1.VAPIXServiceAccounts1" + +#define HTTP_HDR_CONTENT "Content-Type" +#define HTTP_HDR_ACCEPT "Accept" +#define MIME_XML "application/xml" +#define MIME_JSON "application/json" + +/* Local functions */ +static size_t +write_cb(gchar *ptr, size_t size, size_t nmemb, void *userdata) +{ + size_t processed_bytes; + + g_assert(ptr != NULL); + g_assert(userdata != NULL); + + processed_bytes = size * nmemb; + + g_string_append_len((GString *) userdata, ptr, processed_bytes); + + return processed_bytes; +} + +static void +set_curl_setopt_error(CURLcode res, GError **err) +{ + g_assert(err == NULL || *err == NULL); + + SET_ERROR(err, + -1, + "curl_easy_setopt error %d: '%s'", + res, + curl_easy_strerror(res)); +} + +static gchar * +parse_credentials(GVariant *result, GError **err) +{ + gchar *v_creds = NULL; + gchar *credentials = NULL; + gchar **split = NULL; + guint len; + + g_assert(result != NULL); + g_assert(err == NULL || *err == NULL); + + g_variant_get(result, "(&s)", &v_creds); + + split = g_strsplit(v_creds, ":", -1); + if (split == NULL) { + SET_ERROR(err, -1, "Error parsing credential string: '%s'", v_creds); + goto out; + } + + len = g_strv_length(split); + if (len != 2) { + SET_ERROR(err, + -1, + "Invalid credential string length (%u): '%s'", + len, + v_creds); + goto out; + } + + credentials = g_strdup_printf("%s:%s", split[0], split[1]); + +out: + if (split != NULL) { + g_strfreev(split); + } + + return credentials; +} + +/* Exported functions */ +gchar * +vapix_request(CURL *handle, + const gchar *credentials, + const gchar *endpoint, + HTTP_req_method_t req_type, + HTTP_media_t media_type, + const gchar *post_req, + GError **err) +{ + glong code; + GString *response; + gchar *vapix_resp = NULL; + gchar *url = NULL; + gchar *content_hdr = NULL; + gchar *accept_hdr = NULL; + CURLcode res; + struct curl_slist *headers = NULL; + + g_return_val_if_fail(handle != NULL, NULL); + g_return_val_if_fail(credentials != NULL, NULL); + g_return_val_if_fail(endpoint != NULL, NULL); + g_return_val_if_fail(err == NULL || *err == NULL, NULL); + g_return_val_if_fail(((req_type == HTTP_GET) && (post_req == NULL)) || + ((req_type == HTTP_POST) && (post_req != NULL)), + NULL); + + /* start clean */ + curl_easy_reset(handle); + + url = g_strdup_printf(VAPIX_URL, endpoint); + response = g_string_new(NULL); + + res = curl_easy_setopt(handle, CURLOPT_URL, url); + + if (res != CURLE_OK) { + set_curl_setopt_error(res, err); + goto err_out; + } + + res = curl_easy_setopt(handle, CURLOPT_USERPWD, credentials); + + if (res != CURLE_OK) { + set_curl_setopt_error(res, err); + goto err_out; + } + + res = curl_easy_setopt(handle, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + + if (res != CURLE_OK) { + set_curl_setopt_error(res, err); + goto err_out; + } + + if (req_type == HTTP_POST) { + /* prepare corresponding HTTP headers */ + if (media_type == XML_data) { + content_hdr = g_strdup_printf("%s: %s", HTTP_HDR_CONTENT, MIME_XML); + accept_hdr = g_strdup_printf("%s: %s", HTTP_HDR_ACCEPT, MIME_XML); + } else if (media_type == JSON_data) { + content_hdr = g_strdup_printf("%s: %s", HTTP_HDR_CONTENT, MIME_JSON); + accept_hdr = g_strdup_printf("%s: %s", HTTP_HDR_ACCEPT, MIME_JSON); + } + + if (content_hdr && accept_hdr) { + headers = curl_slist_append(headers, content_hdr); + if (!headers) { + SET_ERROR(err, + -1, + "curl_slist_append(): failed adding 'Content-Type:'"); + goto err_out; + } + + headers = curl_slist_append(headers, accept_hdr); + if (!headers) { + SET_ERROR(err, -1, "curl_slist_append(): failed adding 'Accept:'"); + goto err_out; + } + + res = curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers); + if (res != CURLE_OK) { + set_curl_setopt_error(res, err); + goto err_out; + } + } + + res = curl_easy_setopt(handle, CURLOPT_POSTFIELDS, post_req); + + if (res != CURLE_OK) { + set_curl_setopt_error(res, err); + goto err_out; + } + } + + res = curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, write_cb); + + if (res != CURLE_OK) { + set_curl_setopt_error(res, err); + goto err_out; + } + + res = curl_easy_setopt(handle, CURLOPT_WRITEDATA, response); + + if (res != CURLE_OK) { + set_curl_setopt_error(res, err); + goto err_out; + } + + res = curl_easy_perform(handle); + + if (res != CURLE_OK) { + SET_ERROR(err, + -1, + "curl_easy_perform error %d: '%s'", + res, + curl_easy_strerror(res)); + + goto err_out; + } + + res = curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &code); + + if (res != CURLE_OK) { + SET_ERROR(err, + -1, + "curl_easy_getinfo error %d: '%s'", + res, + curl_easy_strerror(res)); + + goto err_out; + } + + if (code != 200) { + SET_ERROR(err, + -1, + "Got response code %ld from request to %s with response '%s'", + code, + endpoint, + response->str); + g_string_free(response, TRUE); + goto err_out; + } + + vapix_resp = g_string_free(response, FALSE); + +err_out: + g_clear_pointer(&url, g_free); + g_clear_pointer(&content_hdr, g_free); + g_clear_pointer(&accept_hdr, g_free); + + /* handles NULL-case too */ + curl_slist_free_all(headers); + + return vapix_resp; +} + +gchar * +vapix_get_credentials(const gchar *username, GError **err) +{ + GDBusConnection *con = NULL; + GVariant *result = NULL; + gchar *credentials = NULL; + + g_return_val_if_fail(username != NULL, NULL); + g_return_val_if_fail(err == NULL || *err == NULL, NULL); + + con = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, err); + if (con == NULL) { + g_prefix_error(err, "Error connecting to D-Bus: "); + return NULL; + } + + result = g_dbus_connection_call_sync(con, + CONF1_DBUS_SERVICE, + CONF1_DBUS_OBJECT_PATH, + CONF1_DBUS_INTERFACE, + "GetCredentials", + g_variant_new("(s)", username), + NULL, + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + err); + if (result == NULL) { + g_prefix_error(err, "Failed to get credentials: "); + goto out; + } + + credentials = parse_credentials(result, err); + + if (credentials == NULL) { + g_prefix_error(err, "parse_credentials() failed: "); + } + + g_variant_unref(result); + +out: + g_object_unref(con); + + return credentials; +} diff --git a/devel-containers/Dockerfile.devimg b/devel-containers/Dockerfile.devimg index 41e84f4..3890099 100644 --- a/devel-containers/Dockerfile.devimg +++ b/devel-containers/Dockerfile.devimg @@ -3,8 +3,8 @@ # The ARCH can be overridden by providing it a value in the command line # with the "--build-arg=XXX" argument fed to 'docker build'. ARG ARCH=aarch64 -ARG VERSION=1.14 -ARG UBUNTU_VERSION=22.04 +ARG VERSION=12.7.0 +ARG UBUNTU_VERSION=24.04 ARG REPO=axisecp ARG SDK=acap-native-sdk ARG BUILD_DIR=/opt/app