diff --git a/CMakeLists.txt b/CMakeLists.txt index ffb97a0f..a5b04381 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ opendaq_setup_project_specific_build_options(${REPO_OPTION_PREFIX}) option(${REPO_OPTION_PREFIX}_ENABLE_EXAMPLE_APP "Enable ${REPO_NAME} example applications" ${PROJECT_IS_TOP_LEVEL}) option(${REPO_OPTION_PREFIX}_ENABLE_TESTS "Enable ${REPO_NAME} testing" ${PROJECT_IS_TOP_LEVEL}) option(${REPO_OPTION_PREFIX}_ENABLE_CLIENT "Enable ${REPO_NAME} client module" ${PROJECT_IS_TOP_LEVEL}) +option(${REPO_OPTION_PREFIX}_ENABLE_GENERIC_CLIENT "Enable ${REPO_NAME} client module" ${PROJECT_IS_TOP_LEVEL}) option(${REPO_OPTION_PREFIX}_ENABLE_SERVER "Enable ${REPO_NAME} server module" ${PROJECT_IS_TOP_LEVEL}) option(OPCUA_ENABLE_ENCRYPTION "Enable OpcUa encryption" OFF) cmake_dependent_option(OPENDAQ_ENABLE_OPCUA_INTEGRATION_TESTS "Enable ${REPO_NAME} integration testing" ${PROJECT_IS_TOP_LEVEL} "${REPO_OPTION_PREFIX}_ENABLE_TESTS" OFF) diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index a1cb0efb..3aa9f4f1 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -8,6 +8,10 @@ if (${REPO_OPTION_PREFIX}_ENABLE_CLIENT) add_subdirectory(opcua_client_module) endif() +if (${REPO_OPTION_PREFIX}_ENABLE_GENERIC_CLIENT) + add_subdirectory(opcua_generic_client_module) +endif() + if (${REPO_OPTION_PREFIX}_ENABLE_SERVER) add_subdirectory(opcua_server_module) endif() diff --git a/modules/opcua_generic_client_module/CMakeLists.txt b/modules/opcua_generic_client_module/CMakeLists.txt new file mode 100644 index 00000000..63e42a7e --- /dev/null +++ b/modules/opcua_generic_client_module/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.10) +opendaq_set_cmake_folder_context(TARGET_FOLDER_NAME) +project(ClientModule VERSION ${${REPO_OPTION_PREFIX}_VERSION} LANGUAGES C CXX) + +add_subdirectory(src) + +if (${REPO_OPTION_PREFIX}_ENABLE_TESTS) + add_subdirectory(tests) +endif() diff --git a/modules/opcua_generic_client_module/include/opcua_generic_client_module/common.h b/modules/opcua_generic_client_module/include/opcua_generic_client_module/common.h new file mode 100644 index 00000000..4f1cc670 --- /dev/null +++ b/modules/opcua_generic_client_module/include/opcua_generic_client_module/common.h @@ -0,0 +1,21 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include + +#define BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC_CLIENT_MODULE BEGIN_NAMESPACE_OPENDAQ_MODULE(opcua_generic_client_module) +#define END_NAMESPACE_OPENDAQ_OPCUA_GENERIC_CLIENT_MODULE END_NAMESPACE_OPENDAQ_MODULE diff --git a/modules/opcua_generic_client_module/include/opcua_generic_client_module/constants.h b/modules/opcua_generic_client_module/include/opcua_generic_client_module/constants.h new file mode 100644 index 00000000..e284ad86 --- /dev/null +++ b/modules/opcua_generic_client_module/include/opcua_generic_client_module/constants.h @@ -0,0 +1,32 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC_CLIENT_MODULE + +static const char* DaqOpcUaGenericProtocolId = "OPCUAGeneric"; +static const char* DaqOpcUaGenericProtocolName = "OPCUAGeneric"; +static const char* DaqOpcUaGenericComponentName = "OPCUAGenericClient"; +static const char* DaqOpcUaGenericDevicePrefix = "daq.opcua.generic"; +static const char* OpcUaGenericScheme = "opc.tcp"; + +static const char* MODULE_NAME = "OpenDAQOPCUAGenericClientModule"; +static const char* MODULE_ID = "OpenDAQOPCUAGenericClientModule"; + +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC_CLIENT_MODULE diff --git a/modules/opcua_generic_client_module/include/opcua_generic_client_module/module_dll.h b/modules/opcua_generic_client_module/include/opcua_generic_client_module/module_dll.h new file mode 100644 index 00000000..db00d74f --- /dev/null +++ b/modules/opcua_generic_client_module/include/opcua_generic_client_module/module_dll.h @@ -0,0 +1,20 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include + +DECLARE_MODULE_EXPORTS(OpcUaGenericClientModule) diff --git a/modules/opcua_generic_client_module/include/opcua_generic_client_module/opcua_generic_client_module_impl.h b/modules/opcua_generic_client_module/include/opcua_generic_client_module/opcua_generic_client_module_impl.h new file mode 100644 index 00000000..a30df901 --- /dev/null +++ b/modules/opcua_generic_client_module/include/opcua_generic_client_module/opcua_generic_client_module_impl.h @@ -0,0 +1,49 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC_CLIENT_MODULE + +class OpcUaGenericClientModule final : public Module +{ +public: + OpcUaGenericClientModule(ContextPtr context); + + ListPtr onGetAvailableDevices() override; + DictPtr onGetAvailableDeviceTypes() override; + DevicePtr onCreateDevice(const StringPtr& connectionString, + const ComponentPtr& parent, + const PropertyObjectPtr& config) override; + bool acceptsConnectionParameters(const StringPtr& connectionString, const PropertyObjectPtr& config); + Bool onCompleteServerCapability(const ServerCapabilityPtr& source, const ServerCapabilityConfigPtr& target) override; + +private: + StringPtr formConnectionString(const StringPtr& connectionString, const PropertyObjectPtr& config, std::string& host, int& port, std::string& hostType); + static DeviceTypePtr createDeviceType(); + static PropertyObjectPtr createDefaultConfig(); + static PropertyObjectPtr populateDefaultConfig(const PropertyObjectPtr& config); + static DeviceInfoPtr populateDiscoveredDevice(const discovery::MdnsDiscoveredDevice& discoveredDevice); + discovery::DiscoveryClient discoveryClient; + + std::mutex sync; +}; + +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC_CLIENT_MODULE diff --git a/modules/opcua_generic_client_module/src/CMakeLists.txt b/modules/opcua_generic_client_module/src/CMakeLists.txt new file mode 100644 index 00000000..bd78aefd --- /dev/null +++ b/modules/opcua_generic_client_module/src/CMakeLists.txt @@ -0,0 +1,50 @@ +set(LIB_NAME opcua_generic_client_module) +set(MODULE_HEADERS_DIR ../include/${TARGET_FOLDER_NAME}) + +set(SRC_Include common.h + constants.h + module_dll.h + opcua_generic_client_module_impl.h +) + +set(SRC_Srcs module_dll.cpp + opcua_generic_client_module_impl.cpp +) + +opendaq_prepend_include(${TARGET_FOLDER_NAME} SRC_Include) + +source_group("module" FILES ${MODULE_HEADERS_DIR}/opcua_generic_client_module_impl.h + ${MODULE_HEADERS_DIR}/module_dll.h + ${MODULE_HEADERS_DIR}/common.h + ${MODULE_HEADERS_DIR}/constants.h + module_dll.cpp + opcua_generic_client_module_impl.cpp +) + + +add_library(${LIB_NAME} SHARED ${SRC_Include} + ${SRC_Srcs} +) +add_library(${OPENDAQ_SDK_TARGET_NAMESPACE}::${LIB_NAME} ALIAS ${LIB_NAME}) + +if (MSVC) + target_compile_options(${LIB_NAME} PRIVATE /bigobj) +endif() + +target_link_libraries(${LIB_NAME} PUBLIC ${OPENDAQ_SDK_TARGET_NAMESPACE}::opendaq + Boost::uuid + PRIVATE ${OPENDAQ_SDK_TARGET_NAMESPACE}::discovery + ${OPENDAQ_SDK_TARGET_NAMESPACE}::opcuageneric_client +) + +if (MSVC) + target_compile_options(${LIB_NAME} PRIVATE /bigobj) +endif() + +target_include_directories(${LIB_NAME} PUBLIC $ + $ + $ +) + +opendaq_set_module_properties(${LIB_NAME} ${PROJECT_VERSION_MAJOR}) +opendaq_generate_version_header(${LIB_NAME}) diff --git a/modules/opcua_generic_client_module/src/module_dll.cpp b/modules/opcua_generic_client_module/src/module_dll.cpp new file mode 100644 index 00000000..a7d6f226 --- /dev/null +++ b/modules/opcua_generic_client_module/src/module_dll.cpp @@ -0,0 +1,9 @@ +#include +#include + +#include + +using namespace daq::modules::opcua_generic_client_module; + +DEFINE_MODULE_EXPORTS(OpcUaGenericClientModule) + diff --git a/modules/opcua_generic_client_module/src/opcua_generic_client_module_impl.cpp b/modules/opcua_generic_client_module/src/opcua_generic_client_module_impl.cpp new file mode 100644 index 00000000..1b0c2c08 --- /dev/null +++ b/modules/opcua_generic_client_module/src/opcua_generic_client_module_impl.cpp @@ -0,0 +1,351 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC_CLIENT_MODULE + +static const std::regex RegexIpv6Hostname(R"(^(.+://)?(\[[a-fA-F0-9:]+(?:\%[a-zA-Z0-9_\.-~]+)?\])(?::(\d+))?(/.*)?$)"); +static const std::regex RegexIpv4Hostname(R"(^(.+://)?([^:/\s]+)(?::(\d+))?(/.*)?$)"); + +using namespace discovery; +using namespace daq::opcua; +using namespace daq::opcua::generic; + +OpcUaGenericClientModule::OpcUaGenericClientModule(ContextPtr context) + : Module(MODULE_NAME, + daq::VersionInfo(OPCUA_GENERIC_CLIENT_MODULE_MAJOR_VERSION, + OPCUA_GENERIC_CLIENT_MODULE_MINOR_VERSION, + OPCUA_GENERIC_CLIENT_MODULE_PATCH_VERSION), + std::move(context), + MODULE_ID) + , discoveryClient() +{ + loggerComponent = this->context.getLogger().getOrAddComponent(DaqOpcUaGenericComponentName); + discoveryClient.initMdnsClient(List("_opcua-tcp._tcp.local.")); +} + +ListPtr OpcUaGenericClientModule::onGetAvailableDevices() +{ + auto availableDevices = List(); + for (const auto& device : discoveryClient.discoverMdnsDevices()) + availableDevices.pushBack(populateDiscoveredDevice(device)); + return availableDevices; +} + +DictPtr OpcUaGenericClientModule::onGetAvailableDeviceTypes() +{ + auto result = Dict(); + + auto deviceType = createDeviceType(); + result.set(deviceType.getId(), deviceType); + return result; +} + +DevicePtr OpcUaGenericClientModule::onCreateDevice(const StringPtr& connectionString, + const ComponentPtr& parent, + const PropertyObjectPtr& config) +{ + if (!connectionString.assigned()) + DAQ_THROW_EXCEPTION(ArgumentNullException); + + PropertyObjectPtr configPtr = config; + if (!configPtr.assigned()) + configPtr = createDefaultConfig(); + else + configPtr = populateDefaultConfig(configPtr); + + if (!acceptsConnectionParameters(connectionString, configPtr)) + DAQ_THROW_EXCEPTION(InvalidParameterException); + + if (!context.assigned()) + DAQ_THROW_EXCEPTION(InvalidParameterException, "Context is not available."); + + std::string host; + std::string hostType; + int port; + const auto opcuaConnStr = formConnectionString(connectionString, configPtr, host, port, hostType); + + std::scoped_lock lock(sync); + + std::shared_ptr client; + std::string deviceName; + // we let a user to set device local id in the config, but if it's not set, we will try to get it from the server's + // ApplicationDescription. If that's also not set, we will generate a local id for the device. + // A user need to have this opportunity to set local id in the config when they want to have a stable local id for the device across + // different runs of the application, and they don't want to rely on the server to provide it (especially it the server uses default values). + std::string deviceLocalId = configPtr.getPropertyValue(PROPERTY_NAME_OPCUA_MI_LOCAL_ID); + + try + { + client = std::make_shared(OpcUaEndpoint(opcuaConnStr, + configPtr.getPropertyValue(PROPERTY_NAME_OPCUA_USERNAME), + configPtr.getPropertyValue(PROPERTY_NAME_OPCUA_PASSWORD))); + const auto desc = client->readApplicationDescription(); + + deviceName = desc.name.empty() ? GENERIC_OPCUA_CLIENT_DEVICE_NAME : desc.name; + if (deviceLocalId.empty()) + { + deviceLocalId = desc.uri.empty() ? "" : desc.uri; + std::replace(deviceLocalId.begin(), deviceLocalId.end(), '/', '-'); + } + } + + catch (const OpcUaException& e) + { + switch (e.getStatusCode()) + { + case UA_STATUSCODE_BADUSERACCESSDENIED: + case UA_STATUSCODE_BADIDENTITYTOKENINVALID: + DAQ_THROW_EXCEPTION(AuthenticationFailedException, e.what()); + default: + DAQ_THROW_EXCEPTION(NotFoundException, e.what()); + } + } + + DevicePtr device(createWithImplementation(context, parent, configPtr, client, deviceLocalId, deviceName)); + + // Set the connection info for the device + DeviceInfoPtr deviceInfo = device.getInfo(); + deviceInfo.asPtr().setProtectedPropertyValue("connectionString", connectionString); + ServerCapabilityConfigPtr connectionInfo = deviceInfo.getConfigurationConnectionInfo(); + + const auto addressInfo = AddressInfoBuilder().setAddress(host) + .setReachabilityStatus(AddressReachabilityStatus::Reachable) + .setType(hostType) + .setConnectionString(connectionString) + .build(); + + connectionInfo.setProtocolId(DaqOpcUaGenericProtocolId) + .setProtocolName(DaqOpcUaGenericProtocolName) + .setProtocolType(ProtocolType::Unknown) + .setConnectionType("TCP/IP") + .addAddress(host) + .setPort(port) + .setPrefix(DaqOpcUaGenericDevicePrefix) + .setConnectionString(connectionString) + .addAddressInfo(addressInfo) + .freeze(); + + return device; +} + +PropertyObjectPtr OpcUaGenericClientModule::populateDefaultConfig(const PropertyObjectPtr& config) +{ + const auto defConfig = createDefaultConfig(); + for (const auto& prop : defConfig.getAllProperties()) + { + const auto name = prop.getName(); + if (config.hasProperty(name)) + defConfig.setPropertyValue(name, config.getPropertyValue(name)); + } + + return defConfig; +} + +DeviceInfoPtr OpcUaGenericClientModule::populateDiscoveredDevice(const MdnsDiscoveredDevice& discoveredDevice) +{ + auto cap = ServerCapability(DaqOpcUaGenericProtocolId, DaqOpcUaGenericProtocolName, ProtocolType::Unknown); + + for (const auto& ipAddress : discoveredDevice.ipv4Addresses) + { + auto connectionStringIpv4 = fmt::format("{}://{}:{}{}", + DaqOpcUaGenericDevicePrefix, + ipAddress, + discoveredDevice.servicePort, + discoveredDevice.getPropertyOrDefault("path", "/")); + cap.addConnectionString(connectionStringIpv4); + cap.addAddress(ipAddress); + + const auto addressInfo = AddressInfoBuilder().setAddress(ipAddress) + .setReachabilityStatus(AddressReachabilityStatus::Unknown) + .setType("IPv4") + .setConnectionString(connectionStringIpv4) + .build(); + cap.addAddressInfo(addressInfo); + } + + for (const auto& ipAddress : discoveredDevice.ipv6Addresses) + { + auto connectionStringIpv6 = fmt::format("{}://{}:{}{}", + DaqOpcUaGenericDevicePrefix, + ipAddress, + discoveredDevice.servicePort, + discoveredDevice.getPropertyOrDefault("path", "/")); + cap.addConnectionString(connectionStringIpv6); + cap.addAddress(ipAddress); + + const auto addressInfo = AddressInfoBuilder().setAddress(ipAddress) + .setReachabilityStatus(AddressReachabilityStatus::Unknown) + .setType("IPv6") + .setConnectionString(connectionStringIpv6) + .build(); + cap.addAddressInfo(addressInfo); + } + + cap.setConnectionType("TCP/IP"); + cap.setPrefix(DaqOpcUaGenericDevicePrefix); + cap.setProtocolVersion(""); + if (discoveredDevice.servicePort > 0) + cap.setPort(discoveredDevice.servicePort); + + auto devInfo = populateDiscoveredDeviceInfo(DiscoveryClient::populateDiscoveredInfoProperties, discoveredDevice, cap, createDeviceType()); + devInfo.asPtr().setName(discoveredDevice.serviceInstance); + return devInfo; +} + +StringPtr OpcUaGenericClientModule::formConnectionString(const StringPtr& connectionString, const PropertyObjectPtr& config, std::string& host, int& port, std::string& hostType) +{ + std::string urlString = connectionString.toStdString(); + std::smatch match; + + std::string prefix = ""; + std::string path = "/"; + + if (config.assigned()) + { + if (config.hasProperty(PROPERTY_NAME_OPCUA_PORT)) + port = config.getPropertyValue(PROPERTY_NAME_OPCUA_PORT); + + if (config.hasProperty(PROPERTY_NAME_OPCUA_PATH)) + path = String(config.getPropertyValue(PROPERTY_NAME_OPCUA_PATH)).toStdString(); + } + + bool parsed = false; + parsed = std::regex_search(urlString, match, RegexIpv6Hostname); + hostType = "IPv6"; + + if (!parsed) + { + parsed = std::regex_search(urlString, match, RegexIpv4Hostname); + hostType = "IPv4"; + } + + if (parsed) + { + prefix = match[1]; + host = match[2]; + + if (match[3].matched) + port = std::stoi(match[3]); + + if (match[4].matched) + path = match[4]; + } + else + DAQ_THROW_EXCEPTION(InvalidParameterException, "Host name not found in url: {}", connectionString); + + if (prefix != std::string(DaqOpcUaGenericDevicePrefix) + "://") + DAQ_THROW_EXCEPTION(InvalidParameterException, "OpcUa does not support connection string with prefix {}", prefix); + + + if (config.assigned()) + { + if (config.hasProperty(PROPERTY_NAME_OPCUA_PORT)) + config.setPropertyValue(PROPERTY_NAME_OPCUA_PORT, port); + + if (config.hasProperty(PROPERTY_NAME_OPCUA_PATH)) + config.setPropertyValue(PROPERTY_NAME_OPCUA_PATH, path); + + if (config.hasProperty(PROPERTY_NAME_OPCUA_HOST)) + config.setPropertyValue(PROPERTY_NAME_OPCUA_HOST, host); + } + + return std::string(OpcUaGenericScheme) + "://" + host + ":" + std::to_string(port) + path; +} + +bool OpcUaGenericClientModule::acceptsConnectionParameters(const StringPtr& connectionString, const PropertyObjectPtr& config) +{ + std::string connStr = connectionString; + auto found = connStr.find(std::string(DaqOpcUaGenericDevicePrefix) + "://"); + return found == 0; +} + +Bool OpcUaGenericClientModule::onCompleteServerCapability(const ServerCapabilityPtr& source, const ServerCapabilityConfigPtr& target) +{ + if (target.getProtocolId() != DaqOpcUaGenericProtocolId) + return false; + + if (source.getConnectionType() != "TCP/IP") + return false; + + if (!source.getAddresses().assigned() || !source.getAddresses().getCount()) + { + LOG_W("Source server capability address is not available when filling in missing OPC UA capability information.") + return false; + } + + const auto addrInfos = source.getAddressInfo(); + if (!addrInfos.assigned() || !addrInfos.getCount()) + { + LOG_W("Source server capability addressInfo is not available when filling in missing OPC UA capability information.") + return false; + } + + auto port = target.getPort(); + if (port == -1) + { + port = DEFAULT_OPCUA_PORT; + target.setPort(port); + LOG_W("OPC UA server capability is missing port. Defaulting to {}.", DEFAULT_OPCUA_PORT) + } + + const auto path = target.hasProperty(PROPERTY_NAME_OPCUA_PATH) ? target.getPropertyValue(PROPERTY_NAME_OPCUA_PATH) : DEFAULT_OPCUA_PATH; + const auto targetAddress = target.getAddresses(); + for (const auto& addrInfo : addrInfos) + { + const auto address = addrInfo.getAddress(); + if (auto it = std::find(targetAddress.begin(), targetAddress.end(), address); it != targetAddress.end()) + continue; + + const auto prefix = target.getPrefix(); + StringPtr connectionString; + if (source.getPrefix() == prefix) + connectionString = addrInfo.getConnectionString(); + else + connectionString = fmt::format("{}://{}:{}{}", prefix, address, port, path); + + const auto targetAddrInfo = AddressInfoBuilder() + .setAddress(address) + .setReachabilityStatus(addrInfo.getReachabilityStatus()) + .setType(addrInfo.getType()) + .setConnectionString(connectionString) + .build(); + + target.addAddressInfo(targetAddrInfo) + .setConnectionString(connectionString) + .addAddress(address); + } + + return true; +} + +DeviceTypePtr OpcUaGenericClientModule::createDeviceType() +{ + return DeviceTypeBuilder() + .setId(DaqOpcUaGenericProtocolId) + .setName("OpcUa enabled device") + .setDescription("Network device connected over OpcUa protocol") + .setConnectionStringPrefix(DaqOpcUaGenericDevicePrefix) + .setDefaultConfig(createDefaultConfig()) + .build(); +} + +PropertyObjectPtr OpcUaGenericClientModule::createDefaultConfig() +{ + return OpcuaGenericClientDeviceImpl::createDefaultConfig(); +} + +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC_CLIENT_MODULE diff --git a/modules/opcua_generic_client_module/tests/CMakeLists.txt b/modules/opcua_generic_client_module/tests/CMakeLists.txt new file mode 100644 index 00000000..9f7c89c1 --- /dev/null +++ b/modules/opcua_generic_client_module/tests/CMakeLists.txt @@ -0,0 +1,22 @@ +set(MODULE_NAME opcua_generic_client_module) +set(TEST_APP test_${MODULE_NAME}) + +set(TEST_SOURCES test_opcua_generic_client_module.cpp + test_app.cpp +) + +add_executable(${TEST_APP} ${TEST_SOURCES} +) + +target_link_libraries(${TEST_APP} PRIVATE ${OPENDAQ_SDK_TARGET_NAMESPACE}::opendaq_test_utils gtest + ${OPENDAQ_SDK_TARGET_NAMESPACE}::${MODULE_NAME} +) + +add_test(NAME ${TEST_APP} + COMMAND $ + WORKING_DIRECTORY $ +) + +if (COMMAND setup_target_for_coverage AND OPENDAQ_ENABLE_COVERAGE) + setup_target_for_coverage(${TEST_APP}coverage ${TEST_APP} ${TEST_APP}coverage) +endif() diff --git a/modules/opcua_generic_client_module/tests/test_app.cpp b/modules/opcua_generic_client_module/tests/test_app.cpp new file mode 100644 index 00000000..a257dacb --- /dev/null +++ b/modules/opcua_generic_client_module/tests/test_app.cpp @@ -0,0 +1,19 @@ +#include + +#include +#include + +int main(int argc, char** args) +{ + { + daq::ModuleManager("."); + } + testing::InitGoogleTest(&argc, args); + + testing::TestEventListeners& listeners = testing::UnitTest::GetInstance()->listeners(); + listeners.Append(new DaqMemCheckListener()); + + auto res = RUN_ALL_TESTS(); + + return res; +} diff --git a/modules/opcua_generic_client_module/tests/test_opcua_generic_client_module.cpp b/modules/opcua_generic_client_module/tests/test_opcua_generic_client_module.cpp new file mode 100644 index 00000000..c19729e4 --- /dev/null +++ b/modules/opcua_generic_client_module/tests/test_opcua_generic_client_module.cpp @@ -0,0 +1,168 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using OpcUaGenericClientModuleTest = testing::Test; +using namespace daq; + +static ModulePtr CreateModule() +{ + ModulePtr module; + createModule(&module, NullContext()); + return module; +} + +TEST_F(OpcUaGenericClientModuleTest, CreateModule) +{ + IModule* module = nullptr; + ErrCode errCode = createModule(&module, NullContext()); + ASSERT_TRUE(OPENDAQ_SUCCEEDED(errCode)); + + ASSERT_NE(module, nullptr); + module->releaseRef(); +} + +TEST_F(OpcUaGenericClientModuleTest, ModuleName) +{ + auto module = CreateModule(); + ASSERT_EQ(module.getModuleInfo().getName(), "OpenDAQOPCUAGenericClientModule"); +} + +TEST_F(OpcUaGenericClientModuleTest, VersionAvailable) +{ + auto module = CreateModule(); + ASSERT_TRUE(module.getModuleInfo().getVersionInfo().assigned()); +} + +TEST_F(OpcUaGenericClientModuleTest, VersionCorrect) +{ + auto module = CreateModule(); + auto version = module.getModuleInfo().getVersionInfo(); + + ASSERT_EQ(version.getMajor(), OPCUA_GENERIC_CLIENT_MODULE_MAJOR_VERSION); + ASSERT_EQ(version.getMinor(), OPCUA_GENERIC_CLIENT_MODULE_MINOR_VERSION); + ASSERT_EQ(version.getPatch(), OPCUA_GENERIC_CLIENT_MODULE_PATCH_VERSION); +} + +TEST_F(OpcUaGenericClientModuleTest, EnumerateDevices) +{ + auto module = CreateModule(); + + ListPtr deviceInfo; + ASSERT_NO_THROW(deviceInfo = module.getAvailableDevices()); +} + +TEST_F(OpcUaGenericClientModuleTest, CreateDeviceConnectionStringNull) +{ + auto module = CreateModule(); + + DevicePtr device; + ASSERT_THROW(device = module.createDevice(nullptr, nullptr), ArgumentNullException); +} + +TEST_F(OpcUaGenericClientModuleTest, CreateDeviceConnectionStringEmpty) +{ + auto module = CreateModule(); + + ASSERT_THROW(module.createDevice("", nullptr), InvalidParameterException); +} + +TEST_F(OpcUaGenericClientModuleTest, CreateDeviceConnectionStringInvalid) +{ + auto module = CreateModule(); + + ASSERT_THROW(module.createDevice("fdfdfdfdde", nullptr), InvalidParameterException); +} + +TEST_F(OpcUaGenericClientModuleTest, CreateDeviceConnectionStringInvalidId) +{ + auto module = CreateModule(); + + ASSERT_THROW(module.createDevice("daqref://devicett3axxr1", nullptr), InvalidParameterException); +} + +TEST_F(OpcUaGenericClientModuleTest, GetAvailableComponentTypes) +{ + const auto module = CreateModule(); + + DictPtr functionBlockTypes; + ASSERT_NO_THROW(functionBlockTypes = module.getAvailableFunctionBlockTypes()); + ASSERT_EQ(functionBlockTypes.getCount(), 0u); + + DictPtr deviceTypes; + ASSERT_NO_THROW(deviceTypes = module.getAvailableDeviceTypes()); + ASSERT_EQ(deviceTypes.getCount(), 1u); + ASSERT_TRUE(deviceTypes.hasKey("OPCUAGeneric")); + ASSERT_EQ(deviceTypes.get("OPCUAGeneric").getId(), "OPCUAGeneric"); + + DictPtr serverTypes; + ASSERT_NO_THROW(serverTypes = module.getAvailableServerTypes()); + ASSERT_EQ(serverTypes.getCount(), 0u); + + // Check module info for module + ModuleInfoPtr moduleInfo; + ASSERT_NO_THROW(moduleInfo = module.getModuleInfo()); + ASSERT_NE(moduleInfo, nullptr); + ASSERT_EQ(moduleInfo.getName(), "OpenDAQOPCUAGenericClientModule"); + ASSERT_EQ(moduleInfo.getId(), "OpenDAQOPCUAGenericClientModule"); + + // Check version info for module + VersionInfoPtr versionInfoModule; + ASSERT_NO_THROW(versionInfoModule = moduleInfo.getVersionInfo()); + ASSERT_NE(versionInfoModule, nullptr); + ASSERT_EQ(versionInfoModule.getMajor(), OPCUA_GENERIC_CLIENT_MODULE_MAJOR_VERSION); + ASSERT_EQ(versionInfoModule.getMinor(), OPCUA_GENERIC_CLIENT_MODULE_MINOR_VERSION); + ASSERT_EQ(versionInfoModule.getPatch(), OPCUA_GENERIC_CLIENT_MODULE_PATCH_VERSION); + + // Check module and version info for device types + for (const auto& deviceType : deviceTypes) + { + ModuleInfoPtr moduleInfoDeviceType; + ASSERT_NO_THROW(moduleInfoDeviceType = deviceType.second.getModuleInfo()); + ASSERT_NE(moduleInfoDeviceType, nullptr); + ASSERT_EQ(moduleInfoDeviceType.getName(), "OpenDAQOPCUAGenericClientModule"); + ASSERT_EQ(moduleInfoDeviceType.getId(), "OpenDAQOPCUAGenericClientModule"); + + VersionInfoPtr versionInfoDeviceType; + ASSERT_NO_THROW(versionInfoDeviceType = moduleInfoDeviceType.getVersionInfo()); + ASSERT_NE(versionInfoDeviceType, nullptr); + ASSERT_EQ(versionInfoDeviceType.getMajor(), OPCUA_GENERIC_CLIENT_MODULE_MAJOR_VERSION); + ASSERT_EQ(versionInfoDeviceType.getMinor(), OPCUA_GENERIC_CLIENT_MODULE_MINOR_VERSION); + ASSERT_EQ(versionInfoDeviceType.getPatch(), OPCUA_GENERIC_CLIENT_MODULE_PATCH_VERSION); + } +} + +TEST_F(OpcUaGenericClientModuleTest, DefaultDeviceConfig) +{ + const auto module = CreateModule(); + + DictPtr deviceTypes; + ASSERT_NO_THROW(deviceTypes = module.getAvailableDeviceTypes()); + ASSERT_EQ(deviceTypes.getCount(), 1u); + ASSERT_TRUE(deviceTypes.hasKey("OPCUAGeneric")); + auto config = deviceTypes.get("OPCUAGeneric").createDefaultConfig(); + ASSERT_TRUE(config.assigned()); + ASSERT_EQ(config.getAllProperties().getCount(), 6u); +} + +TEST_F(OpcUaGenericClientModuleTest, CreateFunctionBlockIdNull) +{ + auto module = CreateModule(); + + FunctionBlockPtr functionBlock; + ASSERT_THROW(functionBlock = module.createFunctionBlock(nullptr, nullptr, "fb"), ArgumentNullException); +} + +TEST_F(OpcUaGenericClientModuleTest, CreateFunctionBlockIdEmpty) +{ + auto module = CreateModule(); + + ASSERT_THROW(module.createFunctionBlock("", nullptr, "fb"), NotFoundException); +} diff --git a/opendaq_ref b/opendaq_ref index 52e45838..c2e08259 100644 --- a/opendaq_ref +++ b/opendaq_ref @@ -1 +1 @@ -4757349a1db30721bead0cb695ec3a147913aef4 \ No newline at end of file +71b86c79f1d909f305129a82553a007516d478c3 diff --git a/shared/libraries/CMakeLists.txt b/shared/libraries/CMakeLists.txt index cb96157e..699dd04f 100644 --- a/shared/libraries/CMakeLists.txt +++ b/shared/libraries/CMakeLists.txt @@ -1,4 +1,12 @@ opendaq_set_cmake_folder_context(TARGET_FOLDER_NAME) add_subdirectory(opcua) -add_subdirectory(opcuatms) + +if (${REPO_OPTION_PREFIX}_ENABLE_CLIENT OR ${REPO_OPTION_PREFIX}_ENABLE_SERVER) + add_subdirectory(opcuatms) +endif() + +if (${REPO_OPTION_PREFIX}_ENABLE_GENERIC_CLIENT) + add_subdirectory(opcuageneric) +endif() + diff --git a/shared/libraries/opcua/opcuaclient/include/opcuaclient/opcuaclient.h b/shared/libraries/opcua/opcuaclient/include/opcuaclient/opcuaclient.h index 13c67d42..1876ddb4 100644 --- a/shared/libraries/opcua/opcuaclient/include/opcuaclient/opcuaclient.h +++ b/shared/libraries/opcua/opcuaclient/include/opcuaclient/opcuaclient.h @@ -29,7 +29,6 @@ #include #include -#include #include #include #include @@ -40,6 +39,7 @@ #include #include "opcuashared/node/opcuanodemethod.h" +#include "opcuashared/opcuadatavalue.h" #include @@ -95,6 +95,11 @@ class UaClientFactory class OpcUaClient { public: + struct ApplicationDescription{ + std::string name; + std::string uri; + }; + explicit OpcUaClient(const std::string& url); explicit OpcUaClient(const OpcUaEndpoint& endpoint); ~OpcUaClient(); @@ -102,6 +107,7 @@ class OpcUaClient static constexpr size_t CONNECTION_TIMEOUT_SECONDS = 10; void initialize(); + ApplicationDescription readApplicationDescription(); void connect(); void disconnect(bool doClear = true); void clear(); @@ -126,7 +132,9 @@ class OpcUaClient bool nodeExists(const OpcUaNodeId& nodeId); OpcUaVariant readValue(const OpcUaNodeId& node); + OpcUaDataValue readDataValue(const OpcUaNodeId& node); UA_NodeClass readNodeClass(const OpcUaNodeId& nodeId); + UA_Byte readAccessLevel(const OpcUaNodeId& nodeId); std::string readBrowseName(const OpcUaNodeId& nodeId); std::string readDisplayName(const OpcUaNodeId& nodeId); size_t readDimension(const OpcUaNodeId& nodeId); @@ -147,8 +155,6 @@ class OpcUaClient OpcUaObject readNodeAttributes(const OpcUaObject& request); - void readNodeAttributes(const std::vector& request); - Subscription* createSubscription(const OpcUaObject& request, const StatusChangeNotificationCallbackType& statusChangeCallback = nullptr); diff --git a/shared/libraries/opcua/opcuaclient/include/opcuaclient/opcuareadvalueid.h b/shared/libraries/opcua/opcuaclient/include/opcuaclient/opcuareadvalueid.h deleted file mode 100644 index 0593203b..00000000 --- a/shared/libraries/opcua/opcuaclient/include/opcuaclient/opcuareadvalueid.h +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2022-2025 openDAQ d.o.o. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once -#include -#include -#include -#include - -BEGIN_NAMESPACE_OPENDAQ_OPCUA - -struct OpcUaReadValueId : public OpcUaObject -{ -public: - using AttributeIdType = decltype(UA_ReadValueId::attributeId); -}; - -struct OpcUaReadValueIdWithCallback : public OpcUaReadValueId -{ - using ProcessFunctionType = std::function; - OpcUaReadValueIdWithCallback(const OpcUaNodeId& nodeId, - const ProcessFunctionType& processFunction, - AttributeIdType attributeId = UA_ATTRIBUTEID_VALUE); - - ProcessFunctionType processFunction; -}; - -END_NAMESPACE_OPENDAQ_OPCUA diff --git a/shared/libraries/opcua/opcuaclient/src/CMakeLists.txt b/shared/libraries/opcua/opcuaclient/src/CMakeLists.txt index 8bc86e79..3796a8cd 100644 --- a/shared/libraries/opcua/opcuaclient/src/CMakeLists.txt +++ b/shared/libraries/opcua/opcuaclient/src/CMakeLists.txt @@ -5,7 +5,6 @@ set(SOURCE_CPPS opcuaclient.cpp opcuatimertaskhelper.cpp opcuanodefactory.cpp opcuaasyncexecthread.cpp - opcuareadvalueid.cpp opcuacallmethodrequest.cpp subscriptions.cpp monitored_item_create_request.cpp @@ -27,7 +26,6 @@ set(SOURCE_HEADERS opcuaclient.h opcuatimertaskhelper.h opcuanodefactory.h opcuaasyncexecthread.h - opcuareadvalueid.h opcuacallmethodrequest.h subscriptions.h monitored_item_create_request.h diff --git a/shared/libraries/opcua/opcuaclient/src/opcuaclient.cpp b/shared/libraries/opcua/opcuaclient/src/opcuaclient.cpp index ebb9ccb1..f81a7557 100644 --- a/shared/libraries/opcua/opcuaclient/src/opcuaclient.cpp +++ b/shared/libraries/opcua/opcuaclient/src/opcuaclient.cpp @@ -62,7 +62,7 @@ OpcUaClient::OpcUaClient(const std::string& url) OpcUaClient::~OpcUaClient() { - disconnect(); + disconnect(true); } void OpcUaClient::initialize() @@ -195,6 +195,42 @@ UA_Client* UaClientFactory::build() return client; } +OpcUaClient::ApplicationDescription OpcUaClient::readApplicationDescription() +{ + std::lock_guard guard(getLock()); + ApplicationDescription desc; + if (!uaclient) + initialize(); + + UA_StatusCode status = UA_STATUSCODE_GOOD; + + size_t endpointCount = 0; + UA_EndpointDescription *endpointArray = NULL; + + status = UA_Client_getEndpoints( + uaclient, + endpoint.getUrl().c_str(), + &endpointCount, + &endpointArray + ); + const auto url = endpoint.getUrl(); + if (OPCUA_STATUSCODE_SUCCEEDED(status)) + { + for (size_t i = 0; i < endpointCount; ++i) { + const std::string_view endpointUrl(reinterpret_cast(endpointArray[i].endpointUrl.data), endpointArray[i].endpointUrl.length); + if (url == endpointUrl) + { + const UA_ApplicationDescription& app = endpointArray[i].server; + desc.uri = std::string(reinterpret_cast(app.applicationUri.data), (int)app.applicationUri.length); + desc.name = utils::ToStdString(app.applicationName.text); + } + } + } + + UA_Array_delete(endpointArray, endpointCount, &UA_TYPES[UA_TYPES_ENDPOINTDESCRIPTION]); + return desc; +} + void OpcUaClient::connect() { std::lock_guard guard(getLock()); @@ -394,34 +430,34 @@ OpcUaVariant OpcUaClient::readValue(const OpcUaNodeId& node) return val; } -OpcUaObject OpcUaClient::readNodeAttributes(const OpcUaObject& request) +OpcUaDataValue OpcUaClient::readDataValue(const OpcUaNodeId& node) { - return UA_Client_Service_read(getLockedUaClient(), *request); -} + OpcUaObject request; -void OpcUaClient::readNodeAttributes(const std::vector& requests) -{ - size_t requestCnt = std::size(requests); - if (requestCnt == 0) - return; + request->nodesToRead = UA_ReadValueId_new(); + request->nodesToReadSize = 1; - OpcUaObject readRequest; - CheckStatusCodeException( - UA_Array_resize((void**) &readRequest->nodesToRead, &readRequest->nodesToReadSize, requestCnt, &UA_TYPES[UA_TYPES_READVALUEID])); + request->nodesToRead[0].nodeId = node.copyAndGetDetachedValue(); + request->nodesToRead[0].attributeId = UA_ATTRIBUTEID_VALUE; - for (size_t i = 0; i < requestCnt; i++) - UA_ReadValueId_copy(requests[i].get(), &readRequest->nodesToRead[i]); + request->timestampsToReturn = UA_TIMESTAMPSTORETURN_BOTH; - readRequest->timestampsToReturn = UA_TimestampsToReturn::UA_TIMESTAMPSTORETURN_NEITHER; + OpcUaObject response = UA_Client_Service_read(getLockedUaClient(), *request); + const auto status = response->responseHeader.serviceResult; + if (status != UA_STATUSCODE_GOOD) + throw OpcUaException(status, "Attribute read request failed"); + if (response->resultsSize != 1) + throw OpcUaException(UA_STATUSCODE_BADINVALIDSTATE, "Read request returned incorrect number of results"); - OpcUaObject response = readNodeAttributes(readRequest); - CheckStatusCodeException(response->responseHeader.serviceResult); - for (size_t i = 0; i < requestCnt; i++) - { - UA_DataValue* v = &response->results[i]; - requests[i].processFunction(v); - } + OpcUaDataValue result(*response->results); + + return result; +} + +OpcUaObject OpcUaClient::readNodeAttributes(const OpcUaObject& request) +{ + return UA_Client_Service_read(getLockedUaClient(), *request); } OpcUaObject OpcUaClient::callMethods(const OpcUaObject& request) @@ -436,6 +472,13 @@ UA_NodeClass OpcUaClient::readNodeClass(const OpcUaNodeId& nodeId) return nodeClass; } +UA_Byte OpcUaClient::readAccessLevel(const OpcUaNodeId& nodeId) +{ + UA_Byte accessLevel; + CheckStatusCodeException(UA_Client_readUserAccessLevelAttribute(getLockedUaClient(), *nodeId, &accessLevel)); + return accessLevel; +} + bool OpcUaClient::nodeExists(const OpcUaNodeId& node) { UA_NodeClass nodeClass; diff --git a/shared/libraries/opcua/opcuaclient/src/opcuareadvalueid.cpp b/shared/libraries/opcua/opcuaclient/src/opcuareadvalueid.cpp deleted file mode 100644 index 735f9b7c..00000000 --- a/shared/libraries/opcua/opcuaclient/src/opcuareadvalueid.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#include "opcuaclient/opcuareadvalueid.h" - -BEGIN_NAMESPACE_OPENDAQ_OPCUA - -OpcUaReadValueIdWithCallback::OpcUaReadValueIdWithCallback(const OpcUaNodeId& nodeId, - const ProcessFunctionType& processFunction, - AttributeIdType attributeId) - : processFunction(processFunction) -{ - getValue().nodeId = nodeId.copyAndGetDetachedValue(); - getValue().attributeId = attributeId; -} - -END_NAMESPACE_OPENDAQ_OPCUA diff --git a/shared/libraries/opcua/opcuaserver/src/CMakeLists.txt b/shared/libraries/opcua/opcuaserver/src/CMakeLists.txt index 6b73eb4f..6ac2401a 100644 --- a/shared/libraries/opcua/opcuaserver/src/CMakeLists.txt +++ b/shared/libraries/opcua/opcuaserver/src/CMakeLists.txt @@ -6,7 +6,6 @@ set(SOURCE_CPPS opcuaserver.cpp opcuataskqueue.cpp opcuaaddnodeparams.cpp opcuatmstypes.cpp - node_event_manager.cpp opcuaservernode.cpp opcuaservernodefactory.cpp event_attributes.cpp @@ -20,7 +19,6 @@ set(SOURCE_HEADERS common.h opcuataskqueue.h opcuatmstypes.h opcuaaddnodeparams.h - node_event_manager.h opcuaservernode.h opcuaservernodefactory.h event_attributes.h @@ -47,7 +45,6 @@ target_link_libraries(${MODULE_NAME} PUBLIC ${OPENDAQ_SDK_TARGET_NAMESPACE}::op ${OPENDAQ_SDK_TARGET_NAMESPACE}::opendaq_utils PRIVATE ${OPENDAQ_SDK_TARGET_NAMESPACE}::opcua_daq_types ${OPENDAQ_SDK_TARGET_NAMESPACE}::opendaq - ${OPENDAQ_SDK_TARGET_NAMESPACE}::opcuatms_server ) set_target_properties(${MODULE_NAME} PROPERTIES PUBLIC_HEADER "${SOURCE_HEADERS}") diff --git a/shared/libraries/opcua/opcuashared/include/opcuashared/opcuadatavalue.h b/shared/libraries/opcua/opcuashared/include/opcuashared/opcuadatavalue.h index ba0950f8..42e69679 100644 --- a/shared/libraries/opcua/opcuashared/include/opcuashared/opcuadatavalue.h +++ b/shared/libraries/opcua/opcuashared/include/opcuashared/opcuadatavalue.h @@ -16,7 +16,6 @@ #pragma once -#include "opcuacommon.h" #include "opcuavariant.h" BEGIN_NAMESPACE_OPENDAQ_OPCUA @@ -24,24 +23,54 @@ BEGIN_NAMESPACE_OPENDAQ_OPCUA class OpcUaDataValue; using OpcUaDataValuePtr = std::shared_ptr; -class OpcUaDataValue +class OpcUaDataValue : public OpcUaObject { public: - OpcUaDataValue(const UA_DataValue* dataValue); - virtual ~OpcUaDataValue(); + using OpcUaObject::OpcUaObject; + + static uint64_t toUnixTimeUs(UA_DateTime date); + static UA_DateTime fromUnixTimeUs(uint64_t date); + + const UA_DataValue& getDataValue() const; bool hasValue() const; - const OpcUaVariant& getValue() const; - const UA_StatusCode& getStatusCode() const; + + UA_StatusCode getStatusCode() const; + + bool hasServerTimestamp() const; + UA_DateTime getServerTimestampUnixEpoch() const; // us + + bool hasSourceTimestamp() const; + UA_DateTime getSourceTimestampUnixEpoch() const; // us bool isStatusOK() const; - const UA_DataValue* getDataValue() const; - operator const UA_DataValue*() const; + bool isInteger() const; + bool isString() const; + bool isDouble() const; + bool isNull() const; + bool isReal() const; + bool isNumber() const; + + std::string toString() const; + int64_t toInteger() const; + + template + inline T readScalar() const + { + return VariantUtils::ReadScalar(this->value.value); + } + + template > + void setScalar(const T& value) + { + static_assert(UATYPE::DataType != nullptr, "Implement specialization of TypeToUaDataType"); + + this->clear(); -protected: - const UA_DataValue* dataValue; - const OpcUaVariant variant; + const auto status = UA_Variant_setScalarCopy(&(this->value.value), &value, UATYPE::DataType); + CheckStatusCodeException(status); + } }; END_NAMESPACE_OPENDAQ_OPCUA diff --git a/shared/libraries/opcua/opcuashared/include/opcuashared/opcuavariant.h b/shared/libraries/opcua/opcuashared/include/opcuashared/opcuavariant.h index 64c79804..7bdd36ea 100644 --- a/shared/libraries/opcua/opcuashared/include/opcuashared/opcuavariant.h +++ b/shared/libraries/opcua/opcuashared/include/opcuashared/opcuavariant.h @@ -16,7 +16,6 @@ #pragma once -#include "opcuacommon.h" #include BEGIN_NAMESPACE_OPENDAQ_OPCUA @@ -26,15 +25,17 @@ using OpcUaVariantPtr = std::shared_ptr; namespace VariantUtils { - inline bool IsScalar(const UA_Variant& value) - { - return UA_Variant_isScalar(&value); - } - - inline bool IsVector(const UA_Variant& value) - { - return !IsScalar(value); - } + bool IsScalar(const UA_Variant& value); + bool IsVector(const UA_Variant& value); + bool IsInteger(const UA_Variant& value); + bool isReal(const UA_Variant& value); + bool isNull(const UA_Variant& value); + + std::string ToString(const UA_Variant& value); + int64_t ToNumber(const UA_Variant& value); + OpcUaNodeId ToNodeId(const UA_Variant& value); + void ToInt32Variant(OpcUaVariant& variant); + void ToInt64Variant(OpcUaVariant& variant); template inline bool IsType(const UA_Variant& value) @@ -69,47 +70,6 @@ namespace VariantUtils throw std::runtime_error("Variant does not contain a scalar of specified return type"); return *static_cast(value.data); } - - inline std::string ToString(const UA_Variant& value) - { - return utils::ToStdString(ReadScalar(value)); - } - - inline int64_t ToNumber(const UA_Variant& value) - { - switch (value.type->typeKind) - { - case UA_TYPES_SBYTE: - return ReadScalar(value); - case UA_TYPES_BYTE: - return ReadScalar(value); - case UA_TYPES_INT16: - return ReadScalar(value); - case UA_TYPES_UINT16: - return ReadScalar(value); - case UA_TYPES_INT32: - case UA_TYPES_ENUMERATION: - return ReadScalar(value); - case UA_TYPES_UINT32: - return ReadScalar(value); - case UA_TYPES_INT64: - return ReadScalar(value); - case UA_TYPES_UINT64: - return ReadScalar(value); - - default: - throw std::runtime_error("Type not supported!"); - } - } - - inline OpcUaNodeId ToNodeId(const UA_Variant& value) - { - UA_NodeId nodeId = ReadScalar(value); - return OpcUaNodeId(nodeId); - } - - void ToInt32Variant(OpcUaVariant& variant); - void ToInt64Variant(OpcUaVariant& variant); } class OpcUaVariant : public OpcUaObject @@ -170,6 +130,8 @@ class OpcUaVariant : public OpcUaObject bool isNull() const; bool isReal() const; bool isNumber() const; + bool isScalar() const; + bool isVector() const; std::string toString() const; int64_t toInteger() const; @@ -177,17 +139,6 @@ class OpcUaVariant : public OpcUaObject float toFloat() const; bool toBool() const; OpcUaNodeId toNodeId() const; - - inline bool isScalar() const - { - return VariantUtils::IsScalar(value); - } - inline bool isVector() const - { - return VariantUtils::IsVector(value); - } - - static bool IsInteger(const UA_Variant& value); }; class OpcUaVariableConversionError : public OpcUaException diff --git a/shared/libraries/opcua/opcuashared/src/opcuadatavalue.cpp b/shared/libraries/opcua/opcuashared/src/opcuadatavalue.cpp index 7665deba..5d47e000 100644 --- a/shared/libraries/opcua/opcuashared/src/opcuadatavalue.cpp +++ b/shared/libraries/opcua/opcuashared/src/opcuadatavalue.cpp @@ -2,44 +2,103 @@ BEGIN_NAMESPACE_OPENDAQ_OPCUA -OpcUaDataValue::OpcUaDataValue(const UA_DataValue* dataValue) - : dataValue(dataValue) - , variant(dataValue->value, true) +UA_StatusCode OpcUaDataValue::getStatusCode() const { + if (!getDataValue().hasStatus) + return UA_STATUSCODE_BADUNEXPECTEDERROR; + return OpcUaObject::getValue().status; } -OpcUaDataValue::~OpcUaDataValue() +bool OpcUaDataValue::isStatusOK() const +{ + if (!getDataValue().hasStatus) + return false; + return (getStatusCode() == UA_STATUSCODE_GOOD); +} + +bool OpcUaDataValue::hasServerTimestamp() const +{ + return getDataValue().hasServerTimestamp; +} + +bool OpcUaDataValue::hasSourceTimestamp() const +{ + return getDataValue().hasSourceTimestamp; +} + +UA_DateTime OpcUaDataValue::getServerTimestampUnixEpoch() const +{ + return (hasServerTimestamp()) ? toUnixTimeUs(getDataValue().serverTimestamp) : 0; +} + +UA_DateTime OpcUaDataValue::getSourceTimestampUnixEpoch() const +{ + return (hasSourceTimestamp()) ? toUnixTimeUs(getDataValue().sourceTimestamp) : 0; +} + +const UA_DataValue& OpcUaDataValue::getDataValue() const { + return OpcUaObject::getValue(); } bool OpcUaDataValue::hasValue() const { - return dataValue->hasValue; + return getDataValue().hasValue; } -const OpcUaVariant& OpcUaDataValue::getValue() const +uint64_t OpcUaDataValue::toUnixTimeUs(UA_DateTime date) { - return variant; + if (date == 0) + return 0; + return static_cast((date - UA_DATETIME_UNIX_EPOCH) / UA_DATETIME_USEC); } -const UA_StatusCode& OpcUaDataValue::getStatusCode() const +UA_DateTime OpcUaDataValue::fromUnixTimeUs(uint64_t date) { - return dataValue->status; + if (date == 0) + return 0; + return static_cast(date * UA_DATETIME_USEC + UA_DATETIME_UNIX_EPOCH); } -bool OpcUaDataValue::isStatusOK() const +bool OpcUaDataValue::isInteger() const { - return (getStatusCode() == UA_STATUSCODE_GOOD); + return VariantUtils::IsInteger(this->value.value); +} + +bool OpcUaDataValue::isString() const +{ + return VariantUtils::HasScalarType(value.value) || + VariantUtils::HasScalarType(value.value) || + VariantUtils::HasScalarType(value.value); +} + +bool OpcUaDataValue::isDouble() const +{ + return VariantUtils::HasScalarType(value.value); } -const UA_DataValue* OpcUaDataValue::getDataValue() const +bool OpcUaDataValue::isNull() const { - return dataValue; + return VariantUtils::isNull(value.value); } -OpcUaDataValue::operator const UA_DataValue*() const +bool OpcUaDataValue::isReal() const { - return getDataValue(); + return VariantUtils::isReal(value.value); } +bool OpcUaDataValue::isNumber() const +{ + return isInteger() || isReal(); +} + +std::string OpcUaDataValue::toString() const +{ + return VariantUtils::ToString(value.value); +} + +int64_t OpcUaDataValue::toInteger() const +{ + return VariantUtils::ToNumber(value.value); +} END_NAMESPACE_OPENDAQ_OPCUA diff --git a/shared/libraries/opcua/opcuashared/src/opcuavariant.cpp b/shared/libraries/opcua/opcuashared/src/opcuavariant.cpp index b013c83a..9866b2b9 100644 --- a/shared/libraries/opcua/opcuashared/src/opcuavariant.cpp +++ b/shared/libraries/opcua/opcuashared/src/opcuavariant.cpp @@ -1,7 +1,9 @@ #include "opcuashared/opcuavariant.h" +#include "opcuashared/opcuacommon.h" #include #include + BEGIN_NAMESPACE_OPENDAQ_OPCUA using namespace daq::opcua::utils; @@ -93,7 +95,7 @@ void OpcUaVariant::setValue(const UA_Variant& value, bool shallowCopy) bool OpcUaVariant::isInteger() const { - return OpcUaVariant::IsInteger(this->value); + return VariantUtils::IsInteger(this->value); } bool OpcUaVariant::isString() const @@ -119,22 +121,12 @@ bool OpcUaVariant::isNodeId() const bool OpcUaVariant::isNull() const { - return UA_Variant_isEmpty(&value); + return VariantUtils::isNull(value); } bool OpcUaVariant::isReal() const { - if (value.type == NULL) - return false; - - switch (value.type->typeKind) - { - case UA_TYPES_FLOAT: - case UA_TYPES_DOUBLE: - return true; - default: - return false; - } + return VariantUtils::isReal(value); } bool OpcUaVariant::isNumber() const @@ -142,7 +134,59 @@ bool OpcUaVariant::isNumber() const return isInteger() || isReal(); } -bool OpcUaVariant::IsInteger(const UA_Variant& value) +bool OpcUaVariant::isScalar() const +{ + return VariantUtils::IsScalar(value); +} + +bool OpcUaVariant::isVector() const +{ + return VariantUtils::IsVector(value); +} + +std::string OpcUaVariant::toString() const +{ + return VariantUtils::ToString(value); +} + +int64_t OpcUaVariant::toInteger() const +{ + return VariantUtils::ToNumber(this->value); +} + +double OpcUaVariant::toDouble() const +{ + return readScalar(); +} + +float OpcUaVariant::toFloat() const +{ + return readScalar(); +} + +bool OpcUaVariant::toBool() const +{ + return readScalar(); +} + +OpcUaNodeId OpcUaVariant::toNodeId() const +{ + return VariantUtils::ToNodeId(this->value); +} + +// VariantUtils + +bool VariantUtils::IsScalar(const UA_Variant& value) +{ + return UA_Variant_isScalar(&value); +} + +bool VariantUtils::IsVector(const UA_Variant& value) +{ + return !IsScalar(value); +} + +bool VariantUtils::IsInteger(const UA_Variant& value) { if (value.type && value.type->typeId.namespaceIndex == 0) // built-in types { @@ -164,50 +208,77 @@ bool OpcUaVariant::IsInteger(const UA_Variant& value) return false; } -std::string OpcUaVariant::toString() const +bool VariantUtils::isReal(const UA_Variant& value) { - if (isType()) - { - UA_LocalizedText localizedText = readScalar(); - return ToStdString(localizedText.text); - } + if (value.type == NULL) + return false; - if (isType()) + switch (value.type->typeKind) { - UA_QualifiedName localizedText = readScalar(); - return ToStdString(localizedText.name); + case UA_TYPES_FLOAT: + case UA_TYPES_DOUBLE: + return true; + default: + return false; } - - UA_String str = readScalar(); - return ToStdString(str); } -int64_t OpcUaVariant::toInteger() const +bool VariantUtils::isNull(const UA_Variant& value) { - return VariantUtils::ToNumber(this->value); + return UA_Variant_isEmpty(&value); } -double OpcUaVariant::toDouble() const +std::string VariantUtils::ToString(const UA_Variant& value) { - return readScalar(); + std::string result; + if (IsType(value)) + { + result = utils::ToStdString(ReadScalar(value).text); + } + else if (IsType(value)) + { + result = utils::ToStdString(ReadScalar(value).name); + } + else + { + result = utils::ToStdString(ReadScalar(value)); + } + return result; } -float OpcUaVariant::toFloat() const +int64_t VariantUtils::ToNumber(const UA_Variant& value) { - return readScalar(); -} + switch (value.type->typeKind) + { + case UA_TYPES_SBYTE: + return ReadScalar(value); + case UA_TYPES_BYTE: + return ReadScalar(value); + case UA_TYPES_INT16: + return ReadScalar(value); + case UA_TYPES_UINT16: + return ReadScalar(value); + case UA_TYPES_INT32: + case UA_TYPES_ENUMERATION: + return ReadScalar(value); + case UA_TYPES_UINT32: + return ReadScalar(value); + case UA_TYPES_INT64: + return ReadScalar(value); + case UA_TYPES_UINT64: + return ReadScalar(value); -bool OpcUaVariant::toBool() const -{ - return readScalar(); + default: + throw std::runtime_error("Type not supported!"); + } } -OpcUaNodeId OpcUaVariant::toNodeId() const +OpcUaNodeId VariantUtils::ToNodeId(const UA_Variant& value) { - return VariantUtils::ToNodeId(this->value); + UA_NodeId nodeId = ReadScalar(value); + return OpcUaNodeId(nodeId); } -// VariantUtils void VariantUtils::ToInt32Variant(OpcUaVariant& variant) { diff --git a/shared/libraries/opcua/opcuashared/tests/test_opcuadatavalue.cpp b/shared/libraries/opcua/opcuashared/tests/test_opcuadatavalue.cpp index 6c4f6e4a..090a672a 100644 --- a/shared/libraries/opcua/opcuashared/tests/test_opcuadatavalue.cpp +++ b/shared/libraries/opcua/opcuashared/tests/test_opcuadatavalue.cpp @@ -1,6 +1,6 @@ +#include #include "gtest/gtest.h" #include "opcuashared/opcuadatavalue.h" -#include BEGIN_NAMESPACE_OPENDAQ_OPCUA @@ -12,13 +12,15 @@ TEST_F(OpcUaDataValueTest, CreateWithInt) UA_DataValue_init(&dataValue); dataValue.status = UA_STATUSCODE_BADAGGREGATELISTMISMATCH; + dataValue.hasStatus = true; UA_Int64 val = 0; UA_Variant_setScalarCopy(&dataValue.value, &val, &UA_TYPES[UA_TYPES_INT64]); + dataValue.hasValue = true; - OpcUaDataValue value(&dataValue); + OpcUaDataValue value(dataValue, true); - ASSERT_TRUE(value.getValue().isInteger()); + ASSERT_TRUE(value.isInteger()); ASSERT_EQ(value.getStatusCode(), UA_STATUSCODE_BADAGGREGATELISTMISMATCH); UA_DataValue_clear(&dataValue); @@ -30,17 +32,16 @@ TEST_F(OpcUaDataValueTest, CreateWithIntRawDataValue) UA_DataValue_init(&dataValue); dataValue.status = UA_STATUSCODE_BADAGGREGATELISTMISMATCH; + dataValue.hasStatus = true; UA_Int64 val = 0; UA_Variant_setScalarCopy(&dataValue.value, &val, &UA_TYPES[UA_TYPES_INT64]); + dataValue.hasValue = true; - OpcUaDataValue value(&dataValue); - - const UA_DataValue* rawDataValue = value.getDataValue(); - ASSERT_EQ(rawDataValue, &dataValue); + OpcUaDataValue value(dataValue, true); - rawDataValue = value; - ASSERT_EQ(rawDataValue, &dataValue); + const UA_DataValue& rawDataValue = value.getDataValue(); + ASSERT_EQ(rawDataValue.value.data, dataValue.value.data); UA_DataValue_clear(&dataValue); } @@ -51,19 +52,49 @@ TEST_F(OpcUaDataValueTest, TestNoCopyBehaviour) UA_DataValue_init(&dataValue); dataValue.status = UA_STATUSCODE_BADAGGREGATELISTMISMATCH; + dataValue.hasStatus = true; + + UA_Int64* val = UA_Int64_new(); + *val = 1; + + UA_Variant_setScalar(&dataValue.value, val, &UA_TYPES[UA_TYPES_INT64]); + dataValue.hasValue = true; + + OpcUaDataValue value(dataValue, true); + ASSERT_EQ(value.toInteger(), 1); + *val = 2; + ASSERT_EQ(value.toInteger(), 2); + + ASSERT_EQ(value.getValue().value.data, dataValue.value.data); + + UA_DataValue_clear(&dataValue); +} + +TEST_F(OpcUaDataValueTest, TestCopyBehaviour) +{ + UA_DataValue dataValue; + UA_DataValue_init(&dataValue); + + dataValue.status = UA_STATUSCODE_BADAGGREGATELISTMISMATCH; + dataValue.hasStatus = true; + UA_Int64* val = UA_Int64_new(); *val = 1; UA_Variant_setScalar(&dataValue.value, val, &UA_TYPES[UA_TYPES_INT64]); + dataValue.hasValue = true; - OpcUaDataValue value(&dataValue); - ASSERT_EQ(value.getValue().toInteger(), 1); + OpcUaDataValue value(dataValue); + ASSERT_EQ(value.toInteger(), 1); *val = 2; - ASSERT_EQ(value.getValue().toInteger(), 2); + ASSERT_EQ(value.toInteger(), 1); - ASSERT_EQ(value.getValue().getValue().data, dataValue.value.data); + ASSERT_NE(value.getValue().value.data, dataValue.value.data); UA_DataValue_clear(&dataValue); + ASSERT_EQ(value.toInteger(), 1); + value.setScalar(UA_Int64(5)); + ASSERT_EQ(value.toInteger(), 5); } END_NAMESPACE_OPENDAQ_OPCUA diff --git a/shared/libraries/opcuageneric/CMakeLists.txt b/shared/libraries/opcuageneric/CMakeLists.txt new file mode 100644 index 00000000..9383e1fb --- /dev/null +++ b/shared/libraries/opcuageneric/CMakeLists.txt @@ -0,0 +1,10 @@ +opendaq_set_cmake_folder_context(TARGET_FOLDER_NAME) + +message(STATUS "${OPENDAQ_SDK_NAME} version: ${OPENDAQ_PACKAGE_VERSION}") +add_compile_definitions(OPENDAQ_OPCUA_PACKAGE_VERSION="${OPENDAQ_PACKAGE_VERSION}") + +add_subdirectory(opcuageneric_client) + +if (${REPO_OPTION_PREFIX}_ENABLE_TESTS) + #add_subdirectory(tests) +endif() diff --git a/shared/libraries/opcuageneric/opcuageneric_client/CMakeLists.txt b/shared/libraries/opcuageneric/opcuageneric_client/CMakeLists.txt new file mode 100644 index 00000000..662e3574 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.10) +opendaq_set_cmake_folder_context(TARGET_FOLDER_NAME) +project(opcuatms_client VERSION ${${REPO_OPTION_PREFIX}_VERSION} LANGUAGES CXX) + +add_subdirectory(src) + +if (${REPO_OPTION_PREFIX}_ENABLE_TESTS) + add_subdirectory(tests) +endif() diff --git a/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/constants.h b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/constants.h new file mode 100644 index 00000000..8a6bfd2b --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/constants.h @@ -0,0 +1,53 @@ +#pragma once + +#include "opcuageneric.h" + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC + +// Constants +static const char* GENERIC_OPCUA_CLIENT_DEVICE_NAME = "GenericOPCUAClientPseudoDevice"; +static const char* DaqOpcUaGenericDevicePrefix = "daq.opcua.generic"; +static const char* OpcUaGenericScheme = "opc.tcp"; + +static const char* OPCUA_LOCAL_MONITORED_ITEM_FB_ID_PREFIX = "MonitoredItemFb"; +static constexpr const char* OPCUA_VALUE_SIGNAL_LOCAL_ID = "OpcUaValueSignal"; +static constexpr const char* OPCUA_TS_SIGNAL_LOCAL_ID = "OpcUaDomainSignal"; + + + +// FB names +static constexpr const char* GENERIC_OPCUA_MONITORED_ITEM_FB_NAME = "MonitoredItem"; + +// Property names +// ---------- +// Device and module +static constexpr const char* PROPERTY_NAME_OPCUA_HOST = "Host"; +static constexpr const char* PROPERTY_NAME_OPCUA_PORT = "Port"; +static constexpr const char* PROPERTY_NAME_OPCUA_PATH = "Path"; +static constexpr const char* PROPERTY_NAME_OPCUA_USERNAME = "Username"; +static constexpr const char* PROPERTY_NAME_OPCUA_PASSWORD = "Password"; +static constexpr const char* PROPERTY_NAME_OPCUA_MI_LOCAL_ID = "LocalId"; + +// MonitoredItem FB +static constexpr const char* PROPERTY_NAME_OPCUA_NODE_ID_TYPE = "NodeIDType"; +static constexpr const char* PROPERTY_NAME_OPCUA_NODE_ID_STRING = "NodeIDString"; +static constexpr const char* PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC = "NodeIDNumeric"; +static constexpr const char* PROPERTY_NAME_OPCUA_NAMESPACE_INDEX = "NamespaceIndex"; +static constexpr const char* PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL = "SamplingInterval"; +static constexpr const char* PROPERTY_NAME_OPCUA_TS_MODE = "TimestampMode"; +// ---------- + +// Defaults +// ---------- +// Device and module +static constexpr const char* DEFAULT_OPCUA_HOST = "127.0.0.1"; +static constexpr uint16_t DEFAULT_OPCUA_PORT = 4840; +static constexpr const char* DEFAULT_OPCUA_USERNAME = ""; +static constexpr const char* DEFAULT_OPCUA_PASSWORD = ""; +static constexpr const char* DEFAULT_OPCUA_PATH = ""; + +static constexpr uint32_t DEFAULT_OPCUA_MIFB_SAMPLING_INTERVAL = 100; // in milliseconds +static constexpr uint32_t DEFAULT_RECONNECT_INTERVAL = 5000; // in milliseconds +// ---------- + +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC diff --git a/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/generic_client_device_impl.h b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/generic_client_device_impl.h new file mode 100644 index 00000000..46a42401 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/generic_client_device_impl.h @@ -0,0 +1,81 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include +#include +#include "opcuaclient/opcuaclient.h" +#include +#include +#include + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC + +class OpcuaGenericClientDeviceImpl : public Device +{ +public: + explicit OpcuaGenericClientDeviceImpl(const ContextPtr& ctx, + const ComponentPtr& parent, + const PropertyObjectPtr& config, + std::shared_ptr client, + const std::string& localId, + const std::string& name, + uint32_t reconnectIntervalMs = DEFAULT_RECONNECT_INTERVAL); + ~OpcuaGenericClientDeviceImpl(); + static PropertyObjectPtr createDefaultConfig(); +protected: + static std::atomic localIndex; + static std::string generateLocalId(); + + void removed() override; + DeviceInfoPtr onGetInfo() override; + + bool allowAddFunctionBlocksFromModules() override + { + return true; + }; + DictPtr onGetAvailableFunctionBlockTypes() override; + FunctionBlockPtr onAddFunctionBlock(const StringPtr& typeId, const PropertyObjectPtr& config) override; + + void initNestedFbTypes(); + void initProperties(const PropertyObjectPtr& config); + std::string getConnectionString() const; + + DictObjectPtr nestedFbTypes; + + StatusAdaptor connectionStatus; + + std::atomic connectedDone{false}; + std::unordered_map deviceMap; // device name -> signal list JSON + + daq::opcua::OpcUaClientPtr client; + + // Reconnect monitor + const uint32_t reconnectIntervalMs; + std::thread reconnectThread; + std::atomic reconnectRunning{false}; + std::condition_variable reconnectCv; + std::mutex reconnectMutex; + + void startReconnectMonitor(); + void stopReconnectMonitor(); + void reconnectMonitorLoop(); +}; + +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC diff --git a/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/opcua_monitored_item_fb_impl.h b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/opcua_monitored_item_fb_impl.h new file mode 100644 index 00000000..83ef31e3 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/opcua_monitored_item_fb_impl.h @@ -0,0 +1,121 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include +#include "opcuaclient/opcuaclient.h" + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC + +class OpcUaMonitoredItemFbImpl final : public FunctionBlock +{ + friend class GenericOpcuaMonitoredItemTest; + +public: + enum class NodeIDType : int + { + Numeric = 0, + String = 1, + // Guid, + // Opaque, + _count + }; + + enum class DomainSource : int + { + None = 0, + ServerTimestamp, + SourceTimestamp, + LocalSystemTimestamp, + _count + }; + + explicit OpcUaMonitoredItemFbImpl(const ContextPtr& ctx, + const ComponentPtr& parent, + const FunctionBlockTypePtr& type, + daq::opcua::OpcUaClientPtr client, + const PropertyObjectPtr& config = nullptr); + ~OpcUaMonitoredItemFbImpl(); + /*DAQ_OPCUA_MODULE_API*/ static FunctionBlockTypePtr CreateType(); + +protected: + struct DataPackets + { + daq::DataPacketPtr dataPacket; + daq::DataPacketPtr domainDataPacket; + }; + + struct FbConfig + { + OpcUaNodeId nodeId; + uint32_t samplingInterval; + DomainSource domainSource; + }; + + static std::atomic localIndex; + static std::unordered_map supportedDataTypeNodeIds; + static std::unordered_map supportedDataTypeKinds; + static std::unordered_map dataTypeKindToDataTypeNodeId; + + DataDescriptorPtr outputSignalDescriptor; + SignalConfigPtr outputSignal; + SignalConfigPtr outputDomainSignal; + + FbConfig config; + daq::opcua::OpcUaClientPtr client; + OpcUaNodeId nodeDataType; + + std::thread readerThread; + std::atomic running; + std::recursive_mutex processingMutex; + + std::shared_ptr statuses; + utils::Error configErr; + utils::Error nodeValidationErr; + utils::Error responseValidationErr; + utils::Error valueValidationErr; + utils::Error exceptionErr; + + void removed() override; + static std::string generateLocalId(); + + void initStatusContainer(); + void adjustSignalDescriptor(); + void createSignal(); + void reconfigureSignal(const FbConfig& prevConfig); + SignalConfigPtr createDomainSignal(); + + void initProperties(const PropertyObjectPtr& config); + void readProperties(); + void propertyChanged(); + + void updateStatuses(); + + void validateNode(); + bool validateResponse(const OpcUaDataValue& value); + bool validateValueDataType(const OpcUaDataValue& value); + + void runReaderThread(); + void readerLoop(); + + DataPackets buildDataPacket(const OpcUaDataValue& value); + daq::DataPacketPtr buildDomainDataPacket(const OpcUaDataValue& value); +}; + +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC diff --git a/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/opcuageneric.h b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/opcuageneric.h new file mode 100644 index 00000000..f7281086 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/opcuageneric.h @@ -0,0 +1,27 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#define BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC \ + namespace daq::opcua::generic \ + { +#define END_NAMESPACE_OPENDAQ_OPCUA_GENERIC } + diff --git a/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/status_adaptor.h b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/status_adaptor.h new file mode 100644 index 00000000..8194ddb7 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/status_adaptor.h @@ -0,0 +1,72 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include "opcuageneric_client/opcuageneric.h" + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC + +class StatusAdaptor +{ +public: + StatusAdaptor(const std::string typeName, + const std::string statusName, + ComponentStatusContainerPtr statusContainer, + std::string initState, + TypeManagerPtr typeManager) + : typeName(typeName), + statusName(statusName), + statusContainer(statusContainer), + typeManager(typeManager) + { + currentStatus = Enumeration(typeName, initState, typeManager); + currentMessage = ""; + statusContainer.template asPtr(true).addStatus(statusName, currentStatus); + } + + bool setStatus(const std::string& status, const std::string& message = "") + { + std::scoped_lock lock(statusMutex); + const auto newStatus = Enumeration(typeName, String(status), typeManager); + bool changed = (newStatus != currentStatus || message != currentMessage); + if (changed) + { + currentStatus = newStatus; + currentMessage = message; + statusContainer.template asPtr(true).setStatusWithMessage(statusName, currentStatus, message); + } + return changed; + } + std::string getStatus() + { + std::scoped_lock lock(statusMutex); + return currentStatus.getValue().toStdString(); + } + +private: + const std::string typeName; + const std::string statusName; + std::string currentMessage; + ComponentStatusContainerPtr statusContainer; + EnumerationPtr currentStatus; + TypeManagerPtr typeManager; + std::mutex statusMutex; +}; + +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC diff --git a/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/status_container.h b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/status_container.h new file mode 100644 index 00000000..bd72a808 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/include/opcuageneric_client/status_container.h @@ -0,0 +1,242 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include +#include + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC + +namespace utils +{ + class StatusContainer; + + class Error + { + public: + Error() = default; + Error(std::weak_ptr container, std::string name) + : container(std::move(container)) + , name(std::move(name)) + { + } + + bool isValid() const + { + return !container.expired() && !name.empty(); + } + + void set(std::string msg); + void add(const std::string& msg); + void reset(); + + bool ok() const; + explicit operator bool() const + { + return ok(); + } + std::string getMessage() const; + bool isUpdated() const; + const std::string& getName() const; + std::string buildStatusMessage() const; + + private: + std::weak_ptr container; + std::string name; + }; + + class StatusContainer : public std::enable_shared_from_this + { + public: + StatusContainer() = default; + + Error addStatus(const std::string& name) + { + std::scoped_lock lock(mtx); + if (entries.count(name)) + return Error{}; + entries.emplace(name, Entry{}); + return Error(weak_from_this(), name); + } + + Error getError(const std::string& name) const + { + std::scoped_lock lock(mtx); + if (!entries.count(name)) + return Error{}; + return Error(std::const_pointer_cast(shared_from_this()), name); + } + + bool isUpdated() const + { + std::scoped_lock lock(mtx); + bool result = false; + for (const auto& e : entries) + result |= isUpdatedImpl(e.second); + return result; + } + + void resetAll() + { + std::scoped_lock lock(mtx); + for (auto& e : entries) + { + if (e.second.present) + e.second.updated = true; + e.second.present = false; + e.second.message.clear(); + } + } + + private: + friend class Error; + + struct Entry + { + std::string message; + bool present = false; + mutable bool updated = true; + }; + + static bool isUpdatedImpl(const Entry& e) + { + bool u = e.updated; + e.updated = false; + return u; + } + + void set(const std::string& name, std::string msg) + { + std::scoped_lock lock(mtx); + auto& e = entries.at(name); + if (!e.present || e.message != msg) + { + e.updated = true; + e.present = true; + e.message = std::move(msg); + } + } + + void add(const std::string& name, const std::string& msg) + { + std::scoped_lock lock(mtx); + auto& e = entries.at(name); + e.updated = true; + e.present = true; + if (e.message.empty()) + { + e.message = msg; + } + else + { + e.message.reserve(e.message.size() + msg.size() + 2); + e.message.append("; "); + e.message.append(msg); + } + } + + void reset(const std::string& name) + { + std::scoped_lock lock(mtx); + auto& e = entries.at(name); + if (e.present) + { + e.updated = true; + e.present = false; + e.message.clear(); + } + } + + bool ok(const std::string& name) const + { + std::scoped_lock lock(mtx); + return !entries.at(name).present; + } + + std::string getMessage(const std::string& name) const + { + std::scoped_lock lock(mtx); + return entries.at(name).message; + } + + bool isUpdated(const std::string& name) const + { + std::scoped_lock lock(mtx); + return isUpdatedImpl(entries.at(name)); + } + + std::string buildStatusMessage(const std::string& name) const + { + std::scoped_lock lock(mtx); + return name + " error: " + entries.at(name).message; + } + + mutable std::mutex mtx; + std::unordered_map entries; + }; + + inline void Error::set(std::string msg) + { + if (auto c = container.lock()) + c->set(name, std::move(msg)); + } + + inline void Error::add(const std::string& msg) + { + if (auto c = container.lock()) + c->add(name, msg); + } + + inline void Error::reset() + { + if (auto c = container.lock()) + c->reset(name); + } + + inline bool Error::ok() const + { + auto c = container.lock(); + return !c || c->ok(name); + } + + inline std::string Error::getMessage() const + { + auto c = container.lock(); + return c ? c->getMessage(name) : std::string{}; + } + + inline bool Error::isUpdated() const + { + auto c = container.lock(); + return c && c->isUpdated(name); + } + + inline const std::string& Error::getName() const + { + return name; + } + + inline std::string Error::buildStatusMessage() const + { + auto c = container.lock(); + return c ? c->buildStatusMessage(name) : std::string{}; + } +} + +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC diff --git a/shared/libraries/opcuageneric/opcuageneric_client/src/CMakeLists.txt b/shared/libraries/opcuageneric/opcuageneric_client/src/CMakeLists.txt new file mode 100644 index 00000000..9b2c29b4 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/src/CMakeLists.txt @@ -0,0 +1,61 @@ +set(LIB_NAME opcuageneric_client) +set(HEADERS_DIR ../include/${TARGET_FOLDER_NAME}) + + +set(SRC_PublicHeaders constants.h + opcuageneric.h + generic_client_device_impl.h + opcua_monitored_item_fb_impl.h + status_container.h + status_adaptor.h +) +set(SRC_Cpp generic_client_device_impl.cpp + opcua_monitored_item_fb_impl.cpp +) + +source_group("common" FILES ${HEADERS_DIR}/constants.h + ${HEADERS_DIR}/opcuageneric.h + ${HEADERS_DIR}/status_container.h +) +source_group("device" FILES ${HEADERS_DIR}/generic_client_device_impl.h + generic_client_device_impl.cpp +) +source_group("function_block" FILES ${HEADERS_DIR}/opcua_monitored_item_fb_impl.h + opcua_monitored_item_fb_impl.cpp +) + +opendaq_prepend_include(${LIB_NAME} SRC_PublicHeaders) + +add_library(${LIB_NAME} STATIC ${SRC_Cpp} + ${SRC_PublicHeaders} +) + +add_library(${OPENDAQ_SDK_TARGET_NAMESPACE}::${LIB_NAME} ALIAS ${LIB_NAME}) + +if(BUILD_64Bit OR BUILD_ARM) + set_target_properties(${LIB_NAME} PROPERTIES POSITION_INDEPENDENT_CODE ON) +else() + set_target_properties(${LIB_NAME} PROPERTIES POSITION_INDEPENDENT_CODE OFF) +endif() + +if (MSVC) + target_compile_options(${LIB_NAME} PRIVATE /bigobj) +elseif (MINGW AND CMAKE_COMPILER_IS_GNUCXX) + target_compile_options(${LIB_NAME} PRIVATE -Wa,-mbig-obj) +endif() + +target_link_libraries(${LIB_NAME} + PUBLIC + ${OPENDAQ_SDK_TARGET_NAMESPACE}::opcuaclient + ${OPENDAQ_SDK_TARGET_NAMESPACE}::opendaq +) + +target_include_directories(${LIB_NAME} PUBLIC $ + $ + + $ +) + +set_target_properties(${LIB_NAME} PROPERTIES PUBLIC_HEADER "${SRC_PublicHeaders}") + +opendaq_set_output_lib_name(${LIB_NAME} ${PROJECT_VERSION_MAJOR}) diff --git a/shared/libraries/opcuageneric/opcuageneric_client/src/generic_client_device_impl.cpp b/shared/libraries/opcuageneric/opcuageneric_client/src/generic_client_device_impl.cpp new file mode 100644 index 00000000..d127cdd1 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/src/generic_client_device_impl.cpp @@ -0,0 +1,235 @@ +#include + +#include +#include +#include +#include +#include "opcuashared/opcuaendpoint.h" + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC + +std::atomic OpcuaGenericClientDeviceImpl::localIndex = 0; + +namespace +{ + PropertyObjectPtr populateDefaultConfig(const PropertyObjectPtr& defaultConfig, const PropertyObjectPtr& config) + { + auto newConfig = PropertyObject(); + for (const auto& prop : defaultConfig.getAllProperties()) + { + if (prop.getName() == PROPERTY_NAME_OPCUA_MI_LOCAL_ID) + continue; + newConfig.addProperty(prop.asPtr(true).clone()); + const auto propName = prop.getName(); + newConfig.setPropertyValue(propName, config.hasProperty(propName) ? config.getPropertyValue(propName) : prop.getValue()); + } + return newConfig; + } +} + +OpcuaGenericClientDeviceImpl::OpcuaGenericClientDeviceImpl(const ContextPtr& ctx, + const ComponentPtr& parent, + const PropertyObjectPtr& config, + std::shared_ptr client, + const std::string& localId, + const std::string& name, + uint32_t reconnectIntervalMs) + : Device(ctx, parent, localId.empty() ? generateLocalId() : localId) + , connectionStatus("ConnectionStatusType", "ConnectionStatus", statusContainer, "Connected", context.getTypeManager()) + , client(client) + , reconnectIntervalMs(reconnectIntervalMs) +{ + if (this->client == nullptr) + DAQ_THROW_EXCEPTION(UninitializedException, "OpcUaClient is not initialized"); + + this->name = name.empty() ? GENERIC_OPCUA_CLIENT_DEVICE_NAME : name; + + if (config.assigned()) + initProperties(populateDefaultConfig(createDefaultConfig(), config)); + else + initProperties(createDefaultConfig()); + + initComponentStatus(); + + try + { + this->client->connect(); + this->client->runIterate(); + } + catch (const OpcUaException& e) + { + switch (e.getStatusCode()) + { + case UA_STATUSCODE_BADUSERACCESSDENIED: + case UA_STATUSCODE_BADIDENTITYTOKENINVALID: + DAQ_THROW_EXCEPTION(AuthenticationFailedException, e.what()); + default: + DAQ_THROW_EXCEPTION(GeneralErrorException, e.what()); + } + } + + initNestedFbTypes(); + startReconnectMonitor(); +} + +OpcuaGenericClientDeviceImpl::~OpcuaGenericClientDeviceImpl() +{ + stopReconnectMonitor(); +} + +PropertyObjectPtr OpcuaGenericClientDeviceImpl::createDefaultConfig() +{ + auto defaultConfig = PropertyObject(); + + defaultConfig.addProperty(StringProperty(PROPERTY_NAME_OPCUA_HOST, DEFAULT_OPCUA_HOST)); + defaultConfig.addProperty(IntProperty(PROPERTY_NAME_OPCUA_PORT, DEFAULT_OPCUA_PORT)); + defaultConfig.addProperty(StringProperty(PROPERTY_NAME_OPCUA_PATH, DEFAULT_OPCUA_PATH)); + defaultConfig.addProperty(StringProperty(PROPERTY_NAME_OPCUA_USERNAME, DEFAULT_OPCUA_USERNAME)); + defaultConfig.addProperty(StringProperty(PROPERTY_NAME_OPCUA_PASSWORD, DEFAULT_OPCUA_PASSWORD)); + defaultConfig.addProperty(StringProperty(PROPERTY_NAME_OPCUA_MI_LOCAL_ID, "")); + + + return defaultConfig; +} + +void OpcuaGenericClientDeviceImpl::initProperties(const PropertyObjectPtr& config) +{ + for (const auto& prop : config.getAllProperties()) + { + const auto propName = prop.getName(); + if (propName == PROPERTY_NAME_OPCUA_MI_LOCAL_ID) + continue; + if (!objPtr.hasProperty(propName)) + { + auto propClone = PropertyBuilder(prop.getName()) + .setValueType(prop.getValueType()) + .setDescription(prop.getDescription()) + .setDefaultValue(prop.getValue()) + .setVisible(prop.getVisible()) + .setReadOnly(true) + .build(); + objPtr.addProperty(propClone); + } + } +} + +std::string OpcuaGenericClientDeviceImpl::getConnectionString() const +{ + return client->getEndpoint().getUrl(); +} + +void OpcuaGenericClientDeviceImpl::removed() +{ + stopReconnectMonitor(); + Device::removed(); + client->disconnect(false); +} + +void OpcuaGenericClientDeviceImpl::startReconnectMonitor() +{ + reconnectRunning = true; + reconnectThread = std::thread([this] { reconnectMonitorLoop(); }); +} + +void OpcuaGenericClientDeviceImpl::stopReconnectMonitor() +{ + { + std::lock_guard lock(reconnectMutex); + reconnectRunning = false; + } + reconnectCv.notify_all(); + if (reconnectThread.joinable()) + reconnectThread.join(); +} + +void OpcuaGenericClientDeviceImpl::reconnectMonitorLoop() +{ + auto interruptibleSleep = [&]() + { + std::unique_lock lock(reconnectMutex); + reconnectCv.wait_for(lock, std::chrono::milliseconds(reconnectIntervalMs), [this]() { return !reconnectRunning.load(); }); + }; + + while (reconnectRunning) + { + if (client->isConnected() == false) + { + connectionStatus.setStatus("Reconnecting"); + try + { + client->disconnect(false); + client->connect(); + client->runIterate(); + connectionStatus.setStatus("Connected"); + } + catch (const OpcUaException& e) + { + if (e.getStatusCode() == UA_STATUSCODE_BADUSERACCESSDENIED || + e.getStatusCode() == UA_STATUSCODE_BADIDENTITYTOKENINVALID) + { + connectionStatus.setStatus("Unrecoverable"); + reconnectRunning = false; + } + } + } + interruptibleSleep(); + } +} + +DeviceInfoPtr OpcuaGenericClientDeviceImpl::onGetInfo() +{ + return DeviceInfo(getConnectionString(), GENERIC_OPCUA_CLIENT_DEVICE_NAME); +} + + +void OpcuaGenericClientDeviceImpl::initNestedFbTypes() +{ + nestedFbTypes = Dict(); + // Add a function block type for monitoring an OPCUA node + { + const auto fbType = OpcUaMonitoredItemFbImpl::CreateType(); + nestedFbTypes.set(fbType.getId(), fbType); + } +} + + +DictPtr OpcuaGenericClientDeviceImpl::onGetAvailableFunctionBlockTypes() +{ + return nestedFbTypes; +} + +FunctionBlockPtr OpcuaGenericClientDeviceImpl::onAddFunctionBlock(const StringPtr& typeId, const PropertyObjectPtr& config) +{ + FunctionBlockPtr nestedFunctionBlock; + { + if (nestedFbTypes.hasKey(typeId)) + { + auto fbTypePtr = nestedFbTypes.getOrDefault(typeId); + if (fbTypePtr.getName() == GENERIC_OPCUA_MONITORED_ITEM_FB_NAME) + { + nestedFunctionBlock = createWithImplementation(context, functionBlocks, fbTypePtr, client, config); + } + else + { + setComponentStatusWithMessage(ComponentStatus::Error, "Function block type is not available: " + typeId.toStdString()); + return nestedFunctionBlock; + } + } + if (nestedFunctionBlock.assigned()) + { + addNestedFunctionBlock(nestedFunctionBlock); + setComponentStatus(ComponentStatus::Ok); + } + else + { + DAQ_THROW_EXCEPTION(NotFoundException, "Function block type is not available: " + typeId.toStdString()); + } + } + return nestedFunctionBlock; +} + +std::string OpcuaGenericClientDeviceImpl::generateLocalId() +{ + return std::string(GENERIC_OPCUA_CLIENT_DEVICE_NAME + std::to_string(localIndex++)); +} +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC diff --git a/shared/libraries/opcuageneric/opcuageneric_client/src/opcua_monitored_item_fb_impl.cpp b/shared/libraries/opcuageneric/opcuageneric_client/src/opcua_monitored_item_fb_impl.cpp new file mode 100644 index 00000000..97bb8753 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/src/opcua_monitored_item_fb_impl.cpp @@ -0,0 +1,617 @@ +#include +#include +#include "opendaq/binary_data_packet_factory.h" +#include "opendaq/packet_factory.h" +#include + +#define DISABLE_NODE_DATATYPE_VALIDATION + +BEGIN_NAMESPACE_OPENDAQ_OPCUA_GENERIC + +std::atomic OpcUaMonitoredItemFbImpl::localIndex = 0; + +std::unordered_map OpcUaMonitoredItemFbImpl::supportedDataTypeNodeIds = { + {OpcUaNodeId(), daq::SampleType::Undefined}, + {OpcUaNodeId(0, UA_NS0ID_FLOAT), daq::SampleType::Float32}, + {OpcUaNodeId(0, UA_NS0ID_DOUBLE), daq::SampleType::Float64}, + {OpcUaNodeId(0, UA_NS0ID_SBYTE), daq::SampleType::Int8}, + {OpcUaNodeId(0, UA_NS0ID_BYTE), daq::SampleType::UInt8}, + {OpcUaNodeId(0, UA_NS0ID_INT16), daq::SampleType::Int16}, + {OpcUaNodeId(0, UA_NS0ID_UINT16), daq::SampleType::UInt16}, + {OpcUaNodeId(0, UA_NS0ID_INT32), daq::SampleType::Int32}, + {OpcUaNodeId(0, UA_NS0ID_UINT32), daq::SampleType::UInt32}, + {OpcUaNodeId(0, UA_NS0ID_INT64), daq::SampleType::Int64}, + {OpcUaNodeId(0, UA_NS0ID_UINT64), daq::SampleType::UInt64}, + {OpcUaNodeId(0, UA_NS0ID_STRING), daq::SampleType::String}, + {OpcUaNodeId(0, UA_NS0ID_LOCALIZEDTEXT), daq::SampleType::String}, + {OpcUaNodeId(0, UA_NS0ID_QUALIFIEDNAME), daq::SampleType::String}, + {OpcUaNodeId(0, UA_NS0ID_DATETIME), daq::SampleType::Int64}}; + +std::unordered_map OpcUaMonitoredItemFbImpl::supportedDataTypeKinds = { + {UA_DATATYPEKIND_FLOAT, daq::SampleType::Float32}, + {UA_DATATYPEKIND_DOUBLE, daq::SampleType::Float64}, + {UA_DATATYPEKIND_SBYTE, daq::SampleType::Int8}, + {UA_DATATYPEKIND_BYTE, daq::SampleType::UInt8}, + {UA_DATATYPEKIND_INT16, daq::SampleType::Int16}, + {UA_DATATYPEKIND_UINT16, daq::SampleType::UInt16}, + {UA_DATATYPEKIND_INT32, daq::SampleType::Int32}, + {UA_DATATYPEKIND_UINT32, daq::SampleType::UInt32}, + {UA_DATATYPEKIND_INT64, daq::SampleType::Int64}, + {UA_DATATYPEKIND_UINT64, daq::SampleType::UInt64}, + {UA_DATATYPEKIND_STRING, daq::SampleType::String}, + {UA_DATATYPEKIND_LOCALIZEDTEXT, daq::SampleType::String}, + {UA_DATATYPEKIND_QUALIFIEDNAME, daq::SampleType::String}, + {UA_DATATYPEKIND_DATETIME, daq::SampleType::Int64}}; + +std::unordered_map OpcUaMonitoredItemFbImpl::dataTypeKindToDataTypeNodeId = { + {UA_DATATYPEKIND_FLOAT, OpcUaNodeId(0, UA_NS0ID_FLOAT)}, + {UA_DATATYPEKIND_DOUBLE, OpcUaNodeId(0, UA_NS0ID_DOUBLE)}, + {UA_DATATYPEKIND_SBYTE, OpcUaNodeId(0, UA_NS0ID_SBYTE)}, + {UA_DATATYPEKIND_BYTE, OpcUaNodeId(0, UA_NS0ID_BYTE)}, + {UA_DATATYPEKIND_INT16, OpcUaNodeId(0, UA_NS0ID_INT16)}, + {UA_DATATYPEKIND_UINT16, OpcUaNodeId(0, UA_NS0ID_UINT16)}, + {UA_DATATYPEKIND_INT32, OpcUaNodeId(0, UA_NS0ID_INT32)}, + {UA_DATATYPEKIND_UINT32, OpcUaNodeId(0, UA_NS0ID_UINT32)}, + {UA_DATATYPEKIND_INT64, OpcUaNodeId(0, UA_NS0ID_INT64)}, + {UA_DATATYPEKIND_UINT64, OpcUaNodeId(0, UA_NS0ID_UINT64)}, + {UA_DATATYPEKIND_STRING, OpcUaNodeId(0, UA_NS0ID_STRING)}, + {UA_DATATYPEKIND_LOCALIZEDTEXT, OpcUaNodeId(0, UA_NS0ID_LOCALIZEDTEXT)}, + {UA_DATATYPEKIND_QUALIFIEDNAME, OpcUaNodeId(0, UA_NS0ID_QUALIFIEDNAME)}, + {UA_DATATYPEKIND_DATETIME, OpcUaNodeId(0, UA_NS0ID_DATETIME)}}; + +namespace +{ + PropertyObjectPtr populateDefaultConfig(const PropertyObjectPtr& defaultConfig, const PropertyObjectPtr& config) + { + auto newConfig = PropertyObject(); + for (const auto& prop : defaultConfig.getAllProperties()) + { + newConfig.addProperty(prop.asPtr(true).clone()); + const auto propName = prop.getName(); + newConfig.setPropertyValue(propName, config.hasProperty(propName) ? config.getPropertyValue(propName) : prop.getValue()); + } + return newConfig; + } + + template + retT readProperty(const PropertyObjectPtr objPtr, const std::string& propertyName, const retT defaultValue) + { + retT returnValue{defaultValue}; + if (objPtr.hasProperty(propertyName)) + { + auto property = objPtr.getPropertyValue(propertyName).asPtrOrNull(); + if (property.assigned()) + { + returnValue = property.getValue(defaultValue); + } + } + return returnValue; + } +} + +OpcUaMonitoredItemFbImpl::OpcUaMonitoredItemFbImpl(const ContextPtr& ctx, + const ComponentPtr& parent, + const FunctionBlockTypePtr& type, + daq::opcua::OpcUaClientPtr client, + const PropertyObjectPtr& config) + : FunctionBlock(type, ctx, parent, generateLocalId()) + , client(client) + , running(false) + , statuses(std::make_shared()) +{ + initComponentStatus(); + initStatusContainer(); + if (config.assigned()) + initProperties(populateDefaultConfig(type.createDefaultConfig(), config)); + else + initProperties(type.createDefaultConfig()); + + validateNode(); + adjustSignalDescriptor(); + createSignal(); + updateStatuses(); + runReaderThread(); +} + +OpcUaMonitoredItemFbImpl::~OpcUaMonitoredItemFbImpl() +{ + if (readerThread.joinable()) + { + running = false; + readerThread.join(); + } +} + +void OpcUaMonitoredItemFbImpl::removed() +{ + if (readerThread.joinable()) + { + running = false; + readerThread.join(); + } + FunctionBlock::removed(); +} + +void OpcUaMonitoredItemFbImpl::initStatusContainer() +{ + configErr = statuses->addStatus("Config"); + nodeValidationErr = statuses->addStatus("Node validation"); + valueValidationErr = statuses->addStatus("Value validation"); + responseValidationErr = statuses->addStatus("Response validation"); + exceptionErr = statuses->addStatus("Exception"); +} + +FunctionBlockTypePtr OpcUaMonitoredItemFbImpl::CreateType() +{ + auto defaultConfig = PropertyObject(); + { + auto builder = + SelectionPropertyBuilder(PROPERTY_NAME_OPCUA_NODE_ID_TYPE, List("Numeric", "String"), static_cast(NodeIDType::String)) + .setDescription("Defines the type of the NodeID of the OPCUA node to monitor. By default it is set to String."); + defaultConfig.addProperty(builder.build()); + } + + { + auto builder = + StringPropertyBuilder(PROPERTY_NAME_OPCUA_NODE_ID_STRING, String("")) + .setDescription(fmt::format("Specifies the NodeID of the OPCUA node to monitor. Used when \"{}\" is set to String.", + PROPERTY_NAME_OPCUA_NODE_ID_TYPE)) + .setVisible(EvalValue("$NodeIDType == 1")); + defaultConfig.addProperty(builder.build()); + } + + { + auto builder = + IntPropertyBuilder(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC, Integer(0)) + .setDescription(fmt::format("Specifies the numeric NodeID of the OPCUA node to monitor. Used when \"{}\" is set to Numeric.", + PROPERTY_NAME_OPCUA_NODE_ID_TYPE)) + .setVisible(EvalValue("$NodeIDType == 0")); + defaultConfig.addProperty(builder.build()); + } + + { + auto builder = IntPropertyBuilder(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, Integer(0)) + .setDescription("Specifies the namespace index of the OPCUA node to monitor. This property is optional and can " + "be left empty. If not set, the first occurence of NodeID will be used"); + defaultConfig.addProperty(builder.build()); + } + + { + auto builder = + IntPropertyBuilder(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, Integer(DEFAULT_OPCUA_MIFB_SAMPLING_INTERVAL)) + .setDescription(fmt::format( + "Specifies the sampling interval in milliseconds for monitoring the OPCUA node. By default it is set to {} ms.", + DEFAULT_OPCUA_MIFB_SAMPLING_INTERVAL)); + defaultConfig.addProperty(builder.build()); + } + + { + auto builder = SelectionPropertyBuilder(PROPERTY_NAME_OPCUA_TS_MODE, + List("None", "ServerTimestamp", "SourceTimestamp", "LocalSystemTimestamp"), + static_cast(DomainSource::ServerTimestamp)) + .setDescription("Defines what to use as a domain signal. By default it is set to ServerTimestamp."); + defaultConfig.addProperty(builder.build()); + } + + const auto fbType = FunctionBlockType(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, + GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, + "Monitors a specified OPCUA node and outputs the value and timestamp as signals.", + defaultConfig); + return fbType; +} + +std::string OpcUaMonitoredItemFbImpl::generateLocalId() +{ + return std::string(OPCUA_LOCAL_MONITORED_ITEM_FB_ID_PREFIX + std::to_string(localIndex++)); +} + +void OpcUaMonitoredItemFbImpl::adjustSignalDescriptor() +{ + auto lockProcessing = std::scoped_lock(processingMutex); + if (nodeValidationErr.ok() && supportedDataTypeNodeIds.count(nodeDataType) != 0) + { + outputSignalDescriptor = DataDescriptorBuilder().setSampleType(supportedDataTypeNodeIds[nodeDataType]).build(); + } + else + { + outputSignalDescriptor = DataDescriptorBuilder().setSampleType(daq::SampleType::Undefined).build(); + } +} + +void OpcUaMonitoredItemFbImpl::initProperties(const PropertyObjectPtr& config) +{ + for (const auto& prop : config.getAllProperties()) + { + const auto propName = prop.getName(); + if (!objPtr.hasProperty(propName)) + { + if (const auto internalProp = prop.asPtrOrNull(true); internalProp.assigned()) + { + objPtr.addProperty(internalProp.clone()); + objPtr.setPropertyValue(propName, prop.getValue()); + objPtr.getOnPropertyValueWrite(prop.getName()) += + [this](PropertyObjectPtr&, PropertyValueEventArgsPtr&) { propertyChanged(); }; + } + } + else + { + objPtr.setPropertyValue(propName, prop.getValue()); + } + } + readProperties(); +} + +void OpcUaMonitoredItemFbImpl::readProperties() +{ + auto lock = this->getRecursiveConfigLock(); + auto lockProcessing = std::scoped_lock(processingMutex); + + configErr.reset(); + + const auto tmpNodeIdType = readProperty(objPtr, PROPERTY_NAME_OPCUA_NODE_ID_TYPE, 0); + const auto nodeIdType = (tmpNodeIdType == static_cast(NodeIDType::Numeric)) ? NodeIDType::Numeric : NodeIDType::String; + const auto namespaceIndex = readProperty(objPtr, PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, 0); + + if (nodeIdType == NodeIDType::String) + { + const auto nodeIdString = readProperty(objPtr, PROPERTY_NAME_OPCUA_NODE_ID_STRING, ""); + if (nodeIdString.empty()) + configErr.add(fmt::format("\"{}\" property is empty!", PROPERTY_NAME_OPCUA_NODE_ID_STRING)); + else + config.nodeId = OpcUaNodeId{static_cast(namespaceIndex), nodeIdString}; + } + else + { + const auto nodeIdNumeric = static_cast(readProperty(objPtr, PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC, 0)); + config.nodeId = OpcUaNodeId{static_cast(namespaceIndex), nodeIdNumeric}; + } + + config.samplingInterval = + readProperty(objPtr, PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, DEFAULT_OPCUA_MIFB_SAMPLING_INTERVAL); + if (config.samplingInterval <= 0) + { + configErr.add(fmt::format("Invalid value for the \"{}\" property! Sampling interval must be a positive integer.", + PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL)); + config.samplingInterval = DEFAULT_OPCUA_MIFB_SAMPLING_INTERVAL; + } + + const auto tmpDomainSource = + readProperty(objPtr, PROPERTY_NAME_OPCUA_TS_MODE, static_cast(DomainSource::ServerTimestamp)); + if (tmpDomainSource < static_cast(DomainSource::_count) && tmpDomainSource >= 0) + { + config.domainSource = static_cast(tmpDomainSource); + } + else + { + config.domainSource = DomainSource::ServerTimestamp; + } + + updateStatuses(); +} + +void OpcUaMonitoredItemFbImpl::propertyChanged() +{ + auto lock = this->getRecursiveConfigLock(); + auto lockProcessing = std::scoped_lock(processingMutex); + + statuses->resetAll(); + + auto prevConfig = config; + readProperties(); + validateNode(); + adjustSignalDescriptor(); + reconfigureSignal(prevConfig); + updateStatuses(); +} + +void OpcUaMonitoredItemFbImpl::updateStatuses() +{ + if (!statuses->isUpdated()) + return; + + if (!configErr.ok()) + { + setComponentStatusWithMessage(ComponentStatus::Error, configErr.buildStatusMessage()); + } + else if (!nodeValidationErr.ok()) + { + setComponentStatusWithMessage(ComponentStatus::Error, nodeValidationErr.buildStatusMessage()); + } + else if (!responseValidationErr.ok()) + { + setComponentStatusWithMessage(ComponentStatus::Error, responseValidationErr.buildStatusMessage()); + } + else if (!valueValidationErr.ok()) + { + setComponentStatusWithMessage(ComponentStatus::Error, valueValidationErr.buildStatusMessage()); + } + else if (!exceptionErr.ok()) + { + setComponentStatusWithMessage(ComponentStatus::Error, exceptionErr.buildStatusMessage()); + } + else + { + setComponentStatus(ComponentStatus::Ok); + } +} + +void OpcUaMonitoredItemFbImpl::validateNode() +{ + auto lockProcessing = std::scoped_lock(processingMutex); + try + { + nodeValidationErr.reset(); + auto nodeExist = client->nodeExists(config.nodeId); + if (!nodeExist) + { + nodeValidationErr.add(fmt::format("Node {} does not exist", config.nodeId.toString())); + } + else if (const auto nodeClass = client->readNodeClass(config.nodeId); nodeClass != UA_NodeClass::UA_NODECLASS_VARIABLE) + { + nodeValidationErr.add(fmt::format("Node {} is not a variable node", config.nodeId.toString())); + } + else if (const auto accessLevel = client->readAccessLevel(config.nodeId); (accessLevel & UA_ACCESSLEVELMASK_READ) == 0) + { + nodeValidationErr.add(fmt::format("There is no read permission to node {}", config.nodeId.toString())); + } +#ifndef DISABLE_NODE_DATATYPE_VALIDATION + else if (nodeDataType = client->readDataType(nodeId); supportedDataTypes.count(nodeDataType) == 0) + { + nodeValidationErr.add(fmt::format("Node {} has unsupported DataType ({})", nodeId.toString(), nodeDataType.toString())); + } +#endif + } + catch (OpcUaException& ex) + { + if (ex.getStatusCode() == UA_STATUSCODE_BADUSERACCESSDENIED) + { + nodeValidationErr.add(fmt::format("Access denied for node {}", config.nodeId.toString())); + } + else + { + nodeValidationErr.add(fmt::format("Exception was thrown while node {} validatiion", config.nodeId.toString())); + } + } +} + +bool OpcUaMonitoredItemFbImpl::validateResponse(const OpcUaDataValue& value) +{ + auto lockProcessing = std::scoped_lock(processingMutex); + if (value.getValue().hasStatus && value.getValue().status != UA_STATUSCODE_GOOD) + { + responseValidationErr.set(fmt::format("Reading value error: {} - {}. ", value.getValue().status, UA_StatusCode_name(value.getValue().status))); + return false; + } + if (!value.getValue().hasValue) + { + responseValidationErr.set(std::string("Reading value error: response without a value.")); + return false; + } + if (config.domainSource == DomainSource::ServerTimestamp && !value.getValue().hasServerTimestamp) + { + responseValidationErr.set(std::string("Reading value error: there is no required server timestamp")); + return false; + } + if (config.domainSource == DomainSource::SourceTimestamp && !value.getValue().hasSourceTimestamp) + { + responseValidationErr.set(std::string("Reading value error: there is no required source timestamp")); + return false; + } + + responseValidationErr.reset(); + return true; +} + +bool OpcUaMonitoredItemFbImpl::validateValueDataType(const OpcUaDataValue& value) +{ + auto lockProcessing = std::scoped_lock(processingMutex); + OpcUaNodeId valueDataType; + const UA_DataTypeKind typeKind = static_cast(value.getValue().value.type->typeKind); + if (dataTypeKindToDataTypeNodeId.count(typeKind)) + valueDataType = dataTypeKindToDataTypeNodeId.at(typeKind); + + if (valueDataType != nodeDataType) + { + nodeDataType = std::move(valueDataType); + adjustSignalDescriptor(); + outputSignal.setDescriptor(outputSignalDescriptor); + } + + bool valid = (supportedDataTypeKinds.count(typeKind) != 0); + if (valid) + valueValidationErr.reset(); + else + valueValidationErr.set(fmt::format("Value has unsupported type ({}).", static_cast(typeKind))); + + return valid; +} + +void OpcUaMonitoredItemFbImpl::createSignal() +{ + auto lock = this->getRecursiveConfigLock(); + LOG_I("Creating a signal..."); + + outputSignal = createAndAddSignal(OPCUA_VALUE_SIGNAL_LOCAL_ID, outputSignalDescriptor); + if (config.domainSource != DomainSource::None) + outputSignal.setDomainSignal(createDomainSignal()); +} + +void OpcUaMonitoredItemFbImpl::reconfigureSignal(const FbConfig& prevConfig) +{ + auto lock = this->getRecursiveConfigLock(); + auto lockProcessing = std::scoped_lock(processingMutex); + + if (config.domainSource == DomainSource::None) + { + if (outputDomainSignal.assigned()) + { + outputSignal.setDomainSignal(nullptr); + removeSignal(outputDomainSignal); + outputDomainSignal = nullptr; + } + } + else if (!outputDomainSignal.assigned()) + { + outputSignal.setDomainSignal(createDomainSignal()); + } + if (outputSignal.getDescriptor() != outputSignalDescriptor) + { + outputSignal.setDescriptor(outputSignalDescriptor); + } +} + +SignalConfigPtr OpcUaMonitoredItemFbImpl::createDomainSignal() +{ + auto lock = this->getRecursiveConfigLock(); + + const auto domainSignalDsc = DataDescriptorBuilder() + .setSampleType(SampleType::UInt64) + .setRule(ExplicitDataRule()) + .setUnit(Unit("s", -1, "seconds", "time")) + .setTickResolution(Ratio(1, 1'000'000)) + .setOrigin("1970-01-01T00:00:00Z") + .setName("Time") + .build(); + outputDomainSignal = createAndAddSignal(OPCUA_TS_SIGNAL_LOCAL_ID, domainSignalDsc, false); + return outputDomainSignal; +} + +void OpcUaMonitoredItemFbImpl::runReaderThread() +{ + running = true; + readerThread = std::thread([this] { readerLoop(); }); +} + +void OpcUaMonitoredItemFbImpl::readerLoop() +{ + auto start = std::chrono::high_resolution_clock::now(); + while (running) + { + auto nextTP = start; + { + auto lockProcessing = std::scoped_lock(processingMutex); + nextTP += std::chrono::milliseconds(config.samplingInterval); + if (configErr.ok() && nodeValidationErr.ok()) + { + OpcUaDataValue dataValue; + try + { + dataValue = client->readDataValue(config.nodeId); + + exceptionErr.reset(); + if (validateResponse(dataValue) && validateValueDataType(dataValue)) + { + const auto dps = buildDataPacket(dataValue); + outputSignal.sendPacket(dps.dataPacket); + if (dps.domainDataPacket.assigned() && outputDomainSignal.assigned()) + outputDomainSignal.sendPacket(dps.domainDataPacket); + } + } + catch (OpcUaException&) + { + exceptionErr.set("Exception while reading."); + } + } + } + updateStatuses(); + auto now = std::chrono::high_resolution_clock::now(); + std::chrono::microseconds sleepTime(0); + if (now < nextTP) + sleepTime = std::chrono::duration_cast(nextTP - now); + start = nextTP; + std::this_thread::sleep_for(sleepTime); + } +} + +OpcUaMonitoredItemFbImpl::DataPackets OpcUaMonitoredItemFbImpl::buildDataPacket(const OpcUaDataValue& value) +{ + DataPackets dps; + dps.domainDataPacket = buildDomainDataPacket(value); + + if (value.isString()) + { + const auto convertedValue = value.toString(); + dps.dataPacket = daq::BinaryDataPacket(dps.domainDataPacket, outputSignalDescriptor, convertedValue.size()); + std::memcpy(dps.dataPacket.getRawData(), convertedValue.data(), convertedValue.size()); + } + else if (value.isInteger() || value.isReal()) + { + if (dps.domainDataPacket.assigned()) + dps.dataPacket = daq::DataPacketWithDomain(dps.domainDataPacket, outputSignalDescriptor, 1); + else + dps.dataPacket = daq::DataPacket(outputSignalDescriptor, 1); + + switch (value.getValue().value.type->typeKind) + { + case UA_DATATYPEKIND_SBYTE: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_BYTE: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_INT16: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_UINT16: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_INT32: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_UINT32: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_INT64: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_UINT64: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_DATETIME: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_FLOAT: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + case UA_DATATYPEKIND_DOUBLE: + *(static_cast(dps.dataPacket.getRawData())) = value.readScalar(); + break; + default: + break; + } + } + + return dps; +} + +DataPacketPtr OpcUaMonitoredItemFbImpl::buildDomainDataPacket(const OpcUaDataValue& value) +{ + DataPacketPtr domainDp; + if (!outputDomainSignal.assigned()) + return domainDp; + + auto fillDmainPacket = [this](uint64_t ts) + { + DataPacketPtr domainDp = daq::DataPacket(outputDomainSignal.getDescriptor(), 1); + std::memcpy(domainDp.getRawData(), &ts, sizeof(ts)); + return domainDp; + }; + + if (config.domainSource == DomainSource::ServerTimestamp && value.hasServerTimestamp()) + { + domainDp = fillDmainPacket(value.getServerTimestampUnixEpoch()); + } + else if (config.domainSource == DomainSource::SourceTimestamp && value.hasSourceTimestamp()) + { + domainDp = fillDmainPacket(value.getSourceTimestampUnixEpoch()); + } + else if (config.domainSource == DomainSource::LocalSystemTimestamp) + { + const uint64_t epochTime = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + domainDp = fillDmainPacket(epochTime); + } + + return domainDp; +} + +END_NAMESPACE_OPENDAQ_OPCUA_GENERIC diff --git a/shared/libraries/opcuageneric/opcuageneric_client/tests/CMakeLists.txt b/shared/libraries/opcuageneric/opcuageneric_client/tests/CMakeLists.txt new file mode 100644 index 00000000..e5df0b36 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/tests/CMakeLists.txt @@ -0,0 +1,51 @@ +set(MODULE_NAME opcuageneric_client) +set(TEST_APP test_${MODULE_NAME}) + +set(TEST_SOURCES test_app.cpp + opcuaservertesthelper.h + opcuaservertesthelper.cpp + test_daq_test_helper.h + timer.h + test_opcua_generic_client_device.cpp + test_opcua_monitored_item_fb.cpp +) + +set(SUPPORTS_ASAN 0) + +if(CXX_COMPILER_ID EQUAL Clang OR CXX_COMPILER_ID EQUAL AppleClang OR CXX_COMPILER_ID EQUAL GNU) + set(SUPPORTS_ASAN 1) + set(ASAN_COMPILE_FLAGS -fsanitize=address -fno-omit-frame-pointer) +endif() + +add_executable(${TEST_APP} ${TEST_SOURCES}) + +set_target_properties(${TEST_APP} PROPERTIES DEBUG_POSTFIX _debug) + +if (WIN32) + set(BCRYPT_LIB bcrypt.dll) +endif() + +target_link_libraries(${TEST_APP} PRIVATE ${OPENDAQ_SDK_TARGET_NAMESPACE}::opendaq_test_utils gtest + ${OPENDAQ_SDK_TARGET_NAMESPACE}::opcuageneric_client + ${OPENDAQ_SDK_TARGET_NAMESPACE}::opcua_generic_client_module +) + +if(SUPPORTS_ASAN) + target_link_libraries(${TEST_APP} PRIVATE asan) + target_compile_options(${TEST_APP} PRIVATE -Wall -Werror ${CGOV_COMPILE_FLAGS} ${ASAN_COMPILE_FLAGS}) +endif() + +add_test(NAME ${TEST_APP} + COMMAND $ + WORKING_DIRECTORY $ +) + +if (MSVC) + target_compile_options(${TEST_APP} PRIVATE /wd4324 /bigobj) +elseif (MINGW AND CMAKE_COMPILER_IS_GNUCXX) + target_compile_options(${TEST_APP} PRIVATE -Wa,-mbig-obj) +endif() + +if (COMMAND setup_target_for_coverage AND OPENDAQ_ENABLE_COVERAGE) + setup_target_for_coverage(${MODULE_NAME}testcoverage ${TEST_APP} ${MODULE_NAME}testcoverage) +endif() diff --git a/shared/libraries/opcuageneric/opcuageneric_client/tests/opcuaservertesthelper.cpp b/shared/libraries/opcuageneric/opcuageneric_client/tests/opcuaservertesthelper.cpp new file mode 100644 index 00000000..f42fe76f --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/tests/opcuaservertesthelper.cpp @@ -0,0 +1,407 @@ +#include "opcuaservertesthelper.h" +#include +#include +#include +#include + +BEGIN_NAMESPACE_OPENDAQ_OPCUA + +OpcUaServerTestHelper::OpcUaServerTestHelper() + : sessionTimeoutMs(-1) +{ +} + +OpcUaServerTestHelper::~OpcUaServerTestHelper() +{ + stop(); +} + +void OpcUaServerTestHelper::setSessionTimeout(double sessionTimeoutMs) +{ + this->sessionTimeoutMs = sessionTimeoutMs; +} + +void OpcUaServerTestHelper::runServer() +{ + while (serverRunning) + UA_Server_run_iterate(server, true); + + UA_Server_run_shutdown(server); + UA_Server_delete(server); +} + +void OpcUaServerTestHelper::onConfigure(const OnConfigureCallback& callback) +{ + onConfigureCallback = callback; +} + +void OpcUaServerTestHelper::startServer() +{ + serverRunning = true; + + UA_ServerConfig initConfig; + std::memset(&initConfig, 0, sizeof(UA_ServerConfig)); + + if (onConfigureCallback) + onConfigureCallback(&initConfig); + + UA_ServerConfig_setMinimal(&initConfig, port, nullptr); + server = UA_Server_newWithConfig(&initConfig); + UA_ServerConfig* config = UA_Server_getConfig(server); + + if (sessionTimeoutMs > 0) + config->maxSessionTimeout = sessionTimeoutMs; + +#ifdef UA_ENABLE_WEBSOCKET_SERVER + UA_ServerConfig_addNetworkLayerWS(config, 80, 0, 0); +#endif // UA_ENABLE_WEBSOCKET_SERVER + + createModel(); + + UA_Server_run_startup(server); + +#if SYNTH_SERVER_DEBUG + runServer(); +#else + serverThreadPtr = std::make_unique(&OpcUaServerTestHelper::runServer, this); +#endif +} + +void OpcUaServerTestHelper::stop() +{ + serverRunning = false; + if (serverThreadPtr) + { + serverThreadPtr->join(); + serverThreadPtr.reset(); + } +} + +std::string OpcUaServerTestHelper::getServerUrl() const +{ + std::stringstream ss; + ss << "opc.tcp://127.0.0.1"; + ss << ":"; + ss << port; + return ss.str(); +} + +void OpcUaServerTestHelper::createModel() +{ + auto uaObjectsFolder = UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER); + + publishFolder("f1", &uaObjectsFolder); + + { + UA_Int32 myInt32 = 33; + publishVariable("f1.i", &myInt32, &UA_TYPES[UA_TYPES_INT32], OpcUaNodeId(1, "f1").getPtr()); + } + + { + UA_SByte myInt8 = -8; + publishVariable(".i8", &myInt8, &UA_TYPES[UA_TYPES_SBYTE], &uaObjectsFolder); + + UA_Byte myUInt8 = 8; + publishVariable(".ui8", &myUInt8, &UA_TYPES[UA_TYPES_BYTE], &uaObjectsFolder); + } + + { + UA_Int16 myInt16 = -16; + publishVariable(".i16", &myInt16, &UA_TYPES[UA_TYPES_INT16], &uaObjectsFolder); + + UA_UInt16 myUInt16 = 16; + publishVariable(".ui16", &myUInt16, &UA_TYPES[UA_TYPES_UINT16], &uaObjectsFolder); + } + + { + UA_Int32 myInt32 = -32; + publishVariable(".i32", &myInt32, &UA_TYPES[UA_TYPES_INT32], &uaObjectsFolder); + + UA_UInt32 myUInt32 = 32; + publishVariable(".ui32", &myUInt32, &UA_TYPES[UA_TYPES_UINT32], &uaObjectsFolder); + } + + { + UA_Int64 myInt64 = -64; + publishVariable(".i64", &myInt64, &UA_TYPES[UA_TYPES_INT64], &uaObjectsFolder); + + UA_UInt64 myUInt64 = 64; + publishVariable(".ui64", &myUInt64, &UA_TYPES[UA_TYPES_UINT64], &uaObjectsFolder); + } + + { + UA_Int64 myInt64 = -64; + publishVariable(".pi64", &myInt64, &UA_TYPES[UA_TYPES_INT64], &uaObjectsFolder, "en_US", 1, 1, 0); + + UA_UInt64 myUInt64 = 64; + publishVariable(".pui64", &myUInt64, &UA_TYPES[UA_TYPES_UINT64], &uaObjectsFolder, "en_US", 1, 1, 0); + } + + { + UA_Int32 val = -32; + publishVariable(1001, &val, &UA_TYPES[UA_TYPES_INT32], &uaObjectsFolder); + } + { + UA_Int64 val = -64; + publishVariable(1002, &val, &UA_TYPES[UA_TYPES_INT64], &uaObjectsFolder); + } + + UA_Boolean myBool = true; + publishVariable(".b", &myBool, &UA_TYPES[UA_TYPES_BOOLEAN], &uaObjectsFolder); + + UA_Double myDouble = (UA_Double) 1885 / (UA_Double) 14442; + publishVariable(".d", &myDouble, &UA_TYPES[UA_TYPES_DOUBLE], &uaObjectsFolder); + + UA_Float myFloat = (UA_Float) 1 / (UA_Float) 3; + publishVariable(".f", &myFloat, &UA_TYPES[UA_TYPES_FLOAT], &uaObjectsFolder); + + UA_String myString = UA_STRING_ALLOC("Hello Dewesoft"); + publishVariable(".s", &myString, &UA_TYPES[UA_TYPES_STRING], &uaObjectsFolder); + UA_String_clear(&myString); + + UA_Guid myGuid = UA_GUID("8a336ac1-8632-482c-a565-23e6a9ad1abc"); + publishVariable(".g", &myGuid, &UA_TYPES[UA_TYPES_GUID], &uaObjectsFolder); + + UA_StatusCode myStatus = UA_STATUSCODE_GOODSUBSCRIPTIONTRANSFERRED; + publishVariable(".sc", &myStatus, &UA_TYPES[UA_TYPES_STATUSCODE], &uaObjectsFolder); + + // vectors + + UA_Int32 myVecInt32[] = {12, 13, 15, 18}; + publishVariable(".i32v", &myVecInt32, &UA_TYPES[UA_TYPES_INT32], &uaObjectsFolder, "en_US", 1, 4); + + UA_Int16 myVecInt16[] = {65, 18, 12, 17, 33, 10023, 12, 1, 0, -1}; + publishVariable(".i16v", &myVecInt16, &UA_TYPES[UA_TYPES_INT16], &uaObjectsFolder, "en_US", 1, 10); + + UA_Int64 myVecInt64[] = {55, 1993}; + publishVariable(".i64v", &myVecInt64, &UA_TYPES[UA_TYPES_INT64], &uaObjectsFolder, "en_US", 1, 2); + + UA_Boolean myVecBool[] = {true, false}; + publishVariable(".bv", &myVecBool, &UA_TYPES[UA_TYPES_BOOLEAN], &uaObjectsFolder, "en_US", 1, 2); + + UA_Double myVecDouble[] = {(UA_Double) 1993 / (UA_Double) 6625, (UA_Double) 185 / (UA_Double) 1443, (UA_Double) 1.44, (UA_Double) 9948}; + publishVariable(".dv", &myVecDouble, &UA_TYPES[UA_TYPES_DOUBLE], &uaObjectsFolder, "en_US", 1, 4); + + UA_Float myVecFloat[] = {(UA_Float) 7 / (UA_Float) 2, (UA_Float) 1 / (UA_Float) 5}; + publishVariable(".fv", &myVecFloat, &UA_TYPES[UA_TYPES_FLOAT], &uaObjectsFolder, "en_US", 1, 2); + + // methods + + publishMethod("hello.dewesoft", &uaObjectsFolder); + + // structures + + OpcUaNodeId structureNodeId(1, ".sctA"); + + UA_Int32 myInt32 = 56; + publishVariable(".sctA", &myInt32, &UA_TYPES[UA_TYPES_INT32], &uaObjectsFolder); + + myInt32 = 5641; + publishVariable(".sctA.i32", &myInt32, &UA_TYPES[UA_TYPES_INT32], structureNodeId.getPtr()); + + UA_Double mySctDouble = (UA_Double) 9844 / (UA_Double) 19774; + publishVariable(".sctA.d", &mySctDouble, &UA_TYPES[UA_TYPES_DOUBLE], structureNodeId.getPtr()); + + UA_String mySctString = UA_STRING_ALLOC("Hello Dewesoft @ struct"); + publishVariable(".sctA.s", &mySctString, &UA_TYPES[UA_TYPES_STRING], structureNodeId.getPtr()); + UA_String_clear(&mySctString); +} + +void OpcUaServerTestHelper::publishVariable(std::string identifier, + const void* value, + const UA_DataType* type, + UA_NodeId* parentNodeId, + const char* locale, + uint16_t nodeIndex, + size_t dimension, + UA_Byte accessLevel) +{ + publishVariableImpl( + OpcUaNodeId(nodeIndex, identifier), identifier, value, type, parentNodeId, locale, nodeIndex, dimension, accessLevel); +} + +void OpcUaServerTestHelper::publishVariable(uint32_t numericId, + const void* value, + const UA_DataType* type, + UA_NodeId* parentNodeId, + const char* locale, + uint16_t nodeIndex, + size_t dimension, + UA_Byte accessLevel) +{ + publishVariableImpl( + OpcUaNodeId(nodeIndex, numericId), std::to_string(numericId), value, type, parentNodeId, locale, nodeIndex, dimension, accessLevel); +} + +void OpcUaServerTestHelper::publishVariableImpl(OpcUaNodeId nodeId, + const std::string& name, + const void* value, + const UA_DataType* type, + UA_NodeId* parentNodeId, + const char* locale, + uint16_t nodeIndex, + size_t dimension, + UA_Byte accessLevel) +{ + OpcUaObject attr = UA_VariableAttributes_default; + attr->description = UA_LOCALIZEDTEXT_ALLOC(locale, name.c_str()); + attr->displayName = UA_LOCALIZEDTEXT_ALLOC(locale, name.c_str()); + attr->dataType = type->typeId; + attr->accessLevel = accessLevel; + + OpcUaObject qualifiedName = UA_QUALIFIEDNAME_ALLOC(UA_UInt16(nodeIndex), name.c_str()); + + if (dimension > 1) + { + attr->valueRank = 1; + attr->arrayDimensionsSize = 1; + attr->arrayDimensions = static_cast(UA_Array_new(1, &UA_TYPES[UA_TYPES_UINT32])); + attr->arrayDimensions[0] = UA_UInt32(dimension); + UA_Variant_setArrayCopy(&attr->value, value, dimension, type); + } + else + { + UA_Variant_setScalarCopy(&attr->value, value, type); + } + auto status = UA_Server_addVariableNode(server, + *nodeId, + *parentNodeId, + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + *qualifiedName, + UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE), + *attr, + NULL, + NULL); + + CheckStatusCodeException(status); +} + +void OpcUaServerTestHelper::writeValueNode(const OpcUaNodeId& nodeId, const OpcUaVariant& value) +{ + CheckStatusCodeException(UA_Server_writeValue(server, *nodeId, *value)); +} + +void OpcUaServerTestHelper::writeDataValueNode(const OpcUaNodeId& nodeId, const OpcUaDataValue& value) +{ + CheckStatusCodeException(UA_Server_writeDataValue(server, *nodeId, *value)); +} + +void OpcUaServerTestHelper::publishFolder(const char* identifier, UA_NodeId* parentNodeId, const char* locale, int nodeIndex) +{ + OpcUaObject attr = UA_ObjectAttributes_default; + attr->description = UA_LOCALIZEDTEXT_ALLOC(locale, identifier); + attr->displayName = UA_LOCALIZEDTEXT_ALLOC(locale, identifier); + + OpcUaNodeId newNodeId(nodeIndex, identifier); + + OpcUaObject qualifiedName = UA_QUALIFIEDNAME_ALLOC(UA_UInt16(nodeIndex), identifier); + + auto status = UA_Server_addObjectNode(server, + *newNodeId, + *parentNodeId, + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + *qualifiedName, + UA_NODEID_NUMERIC(0, UA_NS0ID_BASEOBJECTTYPE), + *attr, + NULL, + NULL); + + CheckStatusCodeException(status); +} + +void OpcUaServerTestHelper::publishMethod(std::string identifier, UA_NodeId* parentNodeId, const char* locale, int nodeIndex) +{ + OpcUaObject inputArgument; + inputArgument->description = UA_LOCALIZEDTEXT_ALLOC("en-US", "Input"); + inputArgument->name = UA_STRING_ALLOC("Input"); + inputArgument->dataType = UA_TYPES[UA_TYPES_STRING].typeId; + inputArgument->valueRank = -1; + + OpcUaObject outputArgument; + outputArgument->description = UA_LOCALIZEDTEXT_ALLOC("en-US", "Output"); + outputArgument->name = UA_STRING_ALLOC("Output"); + outputArgument->dataType = UA_TYPES[UA_TYPES_STRING].typeId; + outputArgument->valueRank = -1; + + OpcUaObject attr = UA_MethodAttributes_default; + attr->description = UA_LOCALIZEDTEXT_ALLOC(locale, identifier.c_str()); + attr->displayName = UA_LOCALIZEDTEXT_ALLOC(locale, identifier.c_str()); + attr->executable = true; + attr->userExecutable = true; + + OpcUaNodeId newNodeId(nodeIndex, identifier); + + UA_Server_addMethodNode(server, + *newNodeId, + *parentNodeId, + UA_NODEID_NUMERIC(0, UA_NS0ID_HASORDEREDCOMPONENT), + UA_QUALIFIEDNAME(1, (char*) identifier.c_str()), + *attr, + helloMethodCallback, + 1, + inputArgument.get(), + 1, + outputArgument.get(), + NULL, + NULL); +} + +UA_StatusCode OpcUaServerTestHelper::helloMethodCallback(UA_Server* server, + const UA_NodeId* sessionId, + void* sessionHandle, + const UA_NodeId* methodId, + void* methodContext, + const UA_NodeId* objectId, + void* objectContext, + size_t inputSize, + const UA_Variant* input, + size_t outputSize, + UA_Variant* output) +{ + UA_String* inputStr = (UA_String*) input->data; + std::string out = "Hello!"; + + if (inputStr->length > 0) + { + std::string in = utils::ToStdString(*inputStr); + std::stringstream ss; + ss << out << " (R:" << in << ")"; + out = ss.str(); + } + + UA_String tmp = UA_STRING_ALLOC(out.c_str()); + UA_Variant_setScalarCopy(output, &tmp, &UA_TYPES[UA_TYPES_STRING]); + UA_String_clear(&tmp); + return UA_STATUSCODE_GOOD; +} + +/*SampleServerTest*/ + +void BaseClientTest::SetUp() +{ + testing::Test::SetUp(); + testHelper.startServer(); +} +void BaseClientTest::TearDown() +{ + testHelper.stop(); + testing::Test::TearDown(); +} + +std::string BaseClientTest::getServerUrl() const +{ + return testHelper.getServerUrl(); +} + +OpcUaClientPtr BaseClientTest::prepareAndConnectClient(int timeout) +{ + std::shared_ptr client = std::make_shared(getServerUrl()); + + if (timeout >= 0) + client->setTimeout(timeout); + + client->connect(); + return client; +} + +END_NAMESPACE_OPENDAQ_OPCUA diff --git a/shared/libraries/opcuageneric/opcuageneric_client/tests/opcuaservertesthelper.h b/shared/libraries/opcuageneric/opcuageneric_client/tests/opcuaservertesthelper.h new file mode 100644 index 00000000..804a5496 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/tests/opcuaservertesthelper.h @@ -0,0 +1,126 @@ +/* + * Copyright 2022-2025 openDAQ d.o.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include "opcuaclient/opcuaclient.h" +#include "opcuashared/opcua.h" +#include "opcuashared/opcuacommon.h" + +BEGIN_NAMESPACE_OPENDAQ_OPCUA + +#define ASSERT_EQ_STATUS(status, expectedStatus) ASSERT_EQ(status, (UA_StatusCode) expectedStatus) + +class OpcUaServerTestHelper final +{ +public: + using OnConfigureCallback = std::function; + + OpcUaServerTestHelper(); + ~OpcUaServerTestHelper(); + + void setSessionTimeout(double sessionTimeoutMs); + + void onConfigure(const OnConfigureCallback& callback); + void startServer(); + void stop(); + + std::string getServerUrl() const; + + void publishVariable(std::string identifier, + const void* value, + const UA_DataType* type, + UA_NodeId* parentNodeId, + const char* locale = "en_US", + uint16_t nodeIndex = 1, + size_t dimension = 1, + UA_Byte accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE); + + void publishVariable(uint32_t numericId, + const void* value, + const UA_DataType* type, + UA_NodeId* parentNodeId, + const char* locale = "en_US", + uint16_t nodeIndex = 1, + size_t dimension = 1, + UA_Byte accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE); + + void writeValueNode(const OpcUaNodeId& nodeId, const OpcUaVariant& value); + void writeDataValueNode(const OpcUaNodeId& nodeId, const OpcUaDataValue& value); + +private: + void runServer(); + void createModel(); + void publishFolder(const char* identifier, UA_NodeId* parentNodeId, const char* locale = "en_US", int nodeIndex = 1); + void publishMethod(std::string identifier, UA_NodeId* parentNodeId, const char* locale = "en_US", int nodeIndex = 1); + + void publishVariableImpl(OpcUaNodeId nodeId, + const std::string& name, + const void* value, + const UA_DataType* type, + UA_NodeId* parentNodeId, + const char* locale, + uint16_t nodeIndex, + size_t dimension, + UA_Byte accessLevel); + + static UA_StatusCode helloMethodCallback(UA_Server* server, + const UA_NodeId* sessionId, + void* sessionHandle, + const UA_NodeId* methodId, + void* methodContext, + const UA_NodeId* objectId, + void* objectContext, + size_t inputSize, + const UA_Variant* input, + size_t outputSize, + UA_Variant* output); + + double sessionTimeoutMs; + UA_Server* server{}; + std::unique_ptr serverThreadPtr; + std::atomic serverRunning = false; + + UA_UInt16 port = 4842u; + OnConfigureCallback onConfigureCallback; +}; + +class BaseClientTest : public testing::Test +{ +protected: + void SetUp() override; + void TearDown() override; + OpcUaServerTestHelper testHelper; + + std::string getServerUrl() const; + + static void IterateAndWaitForPromise(OpcUaClient& client, const std::future& future) + { + using namespace std::chrono; + while (client.iterate(milliseconds(10)) == UA_STATUSCODE_GOOD && future.wait_for(milliseconds(1)) != std::future_status::ready) + { + }; + } + + OpcUaClientPtr prepareAndConnectClient(int timeout = -1); +}; + +END_NAMESPACE_OPENDAQ_OPCUA diff --git a/shared/libraries/opcuageneric/opcuageneric_client/tests/test_app.cpp b/shared/libraries/opcuageneric/opcuageneric_client/tests/test_app.cpp new file mode 100644 index 00000000..5e029a55 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/tests/test_app.cpp @@ -0,0 +1,19 @@ +#include + +#include +#include + +int main(int argc, char** args) +{ + { + daq::ModuleManager("."); + } + testing::InitGoogleTest(&argc, args); + + testing::TestEventListeners& listeners = testing::UnitTest::GetInstance()->listeners(); + listeners.Append(new DaqMemCheckListener()); + + auto res = RUN_ALL_TESTS(); + + return res; +} diff --git a/shared/libraries/opcuageneric/opcuageneric_client/tests/test_daq_test_helper.h b/shared/libraries/opcuageneric/opcuageneric_client/tests/test_daq_test_helper.h new file mode 100644 index 00000000..3cedeaa5 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/tests/test_daq_test_helper.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include + +namespace daq::opcua::generic +{ +class DaqTestHelper +{ +public: + daq::InstancePtr daqInstance; + daq::DevicePtr device; + + void StartUp(std::string connectionStr = "daq.opcua.generic://127.0.0.1:4842", daq::PropertyObjectPtr config = nullptr) + { + DaqInstanceInit(); + DaqOpcuaGenericClientDeviceInit(connectionStr); + } + + daq::InstancePtr DaqInstanceInit() + { + if (!daqInstance.assigned()) + daqInstance = daq::Instance(); + return daqInstance; + } + + daq::GenericDevicePtr DaqOpcuaGenericClientDeviceInit(std::string connectionStr, daq::PropertyObjectPtr config = nullptr) + { + if (!device.assigned()) + device = daqInstance.addDevice(connectionStr, config); + + return device; + } + + static daq::ModulePtr CreateModule() + { + daq::ModulePtr module; + createModule(&module, daq::NullContext()); + return module; + } +}; +} diff --git a/shared/libraries/opcuageneric/opcuageneric_client/tests/test_opcua_generic_client_device.cpp b/shared/libraries/opcuageneric/opcuageneric_client/tests/test_opcua_generic_client_device.cpp new file mode 100644 index 00000000..fc37eac0 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/tests/test_opcua_generic_client_device.cpp @@ -0,0 +1,240 @@ +#include +#include +#include +#include +#include +#include +#include +#include "opcuageneric_client/constants.h" +#include "opcuageneric_client/generic_client_device_impl.h" +#include "opcuaservertesthelper.h" +#include "test_daq_test_helper.h" +#include +#include + +namespace daq::opcua::generic +{ +class GenericOpcuaClientDeviceTest : public testing::Test, public DaqTestHelper +{ +public: + bool waitForConnectionStatus(const std::string& expected, int timeoutMs = 10000) + { + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < deadline) + { + try + { + auto status = device.getStatusContainer().getStatus("ConnectionStatus"); + if (status == daq::Enumeration("ConnectionStatusType", expected, daqInstance.getContext().getTypeManager())) + return true; + } + catch (...) + { + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return false; + } + + daq::DevicePtr createDeviceWithShortInterval(const std::string& serverUrl) + { + DaqInstanceInit(); + auto client = std::make_shared(serverUrl); + device = daq::createWithImplementation( + daqInstance.getContext(), daqInstance, nullptr, client, "", "", 100); + return device; + } + +protected: + void SetUp() override + { + testing::Test::SetUp(); + testHelper.startServer(); + } + void TearDown() override + { + testHelper.stop(); + testing::Test::TearDown(); + } + OpcUaServerTestHelper testHelper; +}; +} // namespace daq::modules::mqtt_streaming_module + +using namespace daq; +using namespace daq::opcua::generic; + +TEST_F(GenericOpcuaClientDeviceTest, DefaultDeviceConfig) +{ + const auto module = CreateModule(); + + DictPtr deviceTypes; + ASSERT_NO_THROW(deviceTypes = module.getAvailableDeviceTypes()); + ASSERT_EQ(deviceTypes.getCount(), 1u); + + ASSERT_TRUE(deviceTypes.hasKey("OPCUAGeneric")); + auto defaultConfig = deviceTypes.get("OPCUAGeneric").createDefaultConfig(); + ASSERT_TRUE(defaultConfig.assigned()); + + ASSERT_EQ(defaultConfig.getAllProperties().getCount(), 6u); + + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_HOST)); + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_PORT)); + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_PATH)); + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_USERNAME)); + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_PASSWORD)); + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_MI_LOCAL_ID)); + + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_HOST).getValueType(), CoreType::ctString); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_PORT).getValueType(), CoreType::ctInt); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_PATH).getValueType(), CoreType::ctString); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_USERNAME).getValueType(), CoreType::ctString); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_PASSWORD).getValueType(), CoreType::ctString); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_MI_LOCAL_ID).getValueType(), CoreType::ctString); + + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_HOST), DEFAULT_OPCUA_HOST); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_PORT), DEFAULT_OPCUA_PORT); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_PATH), DEFAULT_OPCUA_PATH); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_USERNAME), DEFAULT_OPCUA_USERNAME); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_PASSWORD), DEFAULT_OPCUA_PASSWORD); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_MI_LOCAL_ID), ""); +} + +TEST_F(GenericOpcuaClientDeviceTest, CreatingDeviceWithDefaultConfig) +{ + const auto instance = DaqInstanceInit(); + const std::string deviceName("open62541-based OPC UA Application"); + daq::GenericDevicePtr device; + ASSERT_NO_THROW(device = instance.addDevice("daq.opcua.generic://127.0.0.1:4842")); + ASSERT_EQ(device.getStatusContainer().getStatus("ComponentStatus"), + Enumeration("ComponentStatusType", "Ok", instance.getContext().getTypeManager())); + ASSERT_EQ(device.getInfo().getName(), deviceName); + auto devices = instance.getDevices(); + bool contain = false; + daq::GenericDevicePtr deviceFromList; + for (const auto& d : devices) + { + contain = (d.getName() == deviceName); + if (contain) + { + deviceFromList = d; + break; + } + } + ASSERT_TRUE(contain); + ASSERT_TRUE(deviceFromList.assigned()); + ASSERT_EQ(deviceFromList.getInfo().getName(), device.getInfo().getName()); + ASSERT_TRUE(deviceFromList == device); + + ASSERT_EQ(device.getAllProperties().getCount(), 5u); + ASSERT_TRUE(device.hasProperty(PROPERTY_NAME_OPCUA_HOST)); + ASSERT_TRUE(device.hasProperty(PROPERTY_NAME_OPCUA_PORT)); + ASSERT_TRUE(device.hasProperty(PROPERTY_NAME_OPCUA_PATH)); + ASSERT_TRUE(device.hasProperty(PROPERTY_NAME_OPCUA_USERNAME)); + ASSERT_TRUE(device.hasProperty(PROPERTY_NAME_OPCUA_PASSWORD)); + ASSERT_FALSE(device.hasProperty(PROPERTY_NAME_OPCUA_MI_LOCAL_ID)); +} + + +TEST_F(GenericOpcuaClientDeviceTest, CreatingDeviceWithLocalId) +{ + const auto module = CreateModule(); + const auto instance = DaqInstanceInit(); + const std::string deviceLocalId("myCustomLocalId"); + daq::GenericDevicePtr device; + auto defaultConfig = module.getAvailableDeviceTypes().get("OPCUAGeneric").createDefaultConfig(); + defaultConfig.setPropertyValue(PROPERTY_NAME_OPCUA_MI_LOCAL_ID, deviceLocalId); + ASSERT_NO_THROW(device = instance.addDevice("daq.opcua.generic://127.0.0.1:4842", defaultConfig)); + ASSERT_EQ(device.getStatusContainer().getStatus("ComponentStatus"), + Enumeration("ComponentStatusType", "Ok", instance.getContext().getTypeManager())); + ASSERT_EQ(device.getLocalId(), deviceLocalId); + ASSERT_NE(device.getGlobalId().toStdString().find(deviceLocalId), std::string::npos); +} + +TEST_F(GenericOpcuaClientDeviceTest, CreatingDeviceWithoutLocalId) +{ + const auto module = CreateModule(); + const auto instance = DaqInstanceInit(); + const std::string deviceLocalId("urn:open62541.server.application"); + daq::GenericDevicePtr device; + auto defaultConfig = module.getAvailableDeviceTypes().get("OPCUAGeneric").createDefaultConfig(); + + ASSERT_NO_THROW(device = instance.addDevice("daq.opcua.generic://127.0.0.1:4842", defaultConfig)); + ASSERT_EQ(device.getStatusContainer().getStatus("ComponentStatus"), + Enumeration("ComponentStatusType", "Ok", instance.getContext().getTypeManager())); + ASSERT_EQ(device.getLocalId(), deviceLocalId); + ASSERT_NE(device.getGlobalId().toStdString().find(deviceLocalId), std::string::npos); +} + +TEST_F(GenericOpcuaClientDeviceTest, RemovingDevice) +{ + const auto instance = DaqInstanceInit(); + daq::GenericDevicePtr device; + { + ASSERT_NO_THROW(device = instance.addDevice("daq.opcua.generic://127.0.0.1:4842")); + ASSERT_EQ(device.getStatusContainer().getStatus("ComponentStatus"), + Enumeration("ComponentStatusType", "Ok", instance.getContext().getTypeManager())); + ASSERT_NO_THROW(instance.removeDevice(device)); + } + + { + ASSERT_NO_THROW(device = instance.addDevice("daq.opcua.generic://127.0.0.1:4842")); + ASSERT_EQ(device.getStatusContainer().getStatus("ComponentStatus"), + Enumeration("ComponentStatusType", "Ok", instance.getContext().getTypeManager())); + ASSERT_NO_THROW(instance.removeDevice(device)); + } +} + +TEST_F(GenericOpcuaClientDeviceTest, CheckDeviceFunctionalBlocks) +{ + StartUp(); + daq::DictPtr fbTypes; + ASSERT_NO_THROW(fbTypes = device.getAvailableFunctionBlockTypes()); + ASSERT_GE(fbTypes.getCount(), 1); + ASSERT_TRUE(fbTypes.hasKey(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME)); +} + +TEST_F(GenericOpcuaClientDeviceTest, CheckDeviceNameLocalId) +{ + StartUp(); + EXPECT_EQ(device.getLocalId().toStdString(), std::string("urn:open62541.server.application")); + EXPECT_EQ(device.getName().toStdString(), std::string("open62541-based OPC UA Application")); +} + +TEST_F(GenericOpcuaClientDeviceTest, ReconnectMonitor_StatusBecomesReconnectingAfterServerStop) +{ + DaqInstanceInit(); + createDeviceWithShortInterval(testHelper.getServerUrl()); + ASSERT_TRUE(waitForConnectionStatus("Connected")); + + testHelper.stop(); + + ASSERT_TRUE(waitForConnectionStatus("Reconnecting")); +} + +TEST_F(GenericOpcuaClientDeviceTest, ReconnectMonitor_ReconnectsAfterServerRestart) +{ + DaqInstanceInit(); + createDeviceWithShortInterval(testHelper.getServerUrl()); + ASSERT_TRUE(waitForConnectionStatus("Connected")); + + testHelper.stop(); + ASSERT_TRUE(waitForConnectionStatus("Reconnecting")); + + testHelper.startServer(); + ASSERT_TRUE(waitForConnectionStatus("Connected")); +} + +TEST_F(GenericOpcuaClientDeviceTest, ReconnectMonitor_StopsCleanlyOnDeviceRemoval) +{ + DaqInstanceInit(); + device = daqInstance.addDevice("daq.opcua.generic://127.0.0.1:4842"); + ASSERT_EQ(device.getStatusContainer().getStatus("ComponentStatus"), + daq::Enumeration("ComponentStatusType", "Ok", daqInstance.getContext().getTypeManager())); + + const auto t0 = std::chrono::steady_clock::now(); + ASSERT_NO_THROW(daqInstance.removeDevice(device)); + const auto elapsedMs = std::chrono::duration_cast(std::chrono::steady_clock::now() - t0).count(); + + // stopReconnectMonitor() uses cv.notify_all() so join should be near-instant + ASSERT_LT(elapsedMs, 1000); +} \ No newline at end of file diff --git a/shared/libraries/opcuageneric/opcuageneric_client/tests/test_opcua_monitored_item_fb.cpp b/shared/libraries/opcuageneric/opcuageneric_client/tests/test_opcua_monitored_item_fb.cpp new file mode 100644 index 00000000..1b691159 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/tests/test_opcua_monitored_item_fb.cpp @@ -0,0 +1,934 @@ +#include +#include +#include +#include +#include +#include +#include "opcuageneric_client/constants.h" +#include "opcuaservertesthelper.h" +#include "opendaq/reader_factory.h" +#include "test_daq_test_helper.h" +#include "timer.h" + +#define ASSERT_DOUBLE_NE(val1, val2) ASSERT_GT(std::abs((val1) - (val2)), 1e-9) + +#define ASSERT_FLOAT_NE(val1, val2) ASSERT_GT(std::fabs((val1) - (val2)), 1e-6f) + +namespace daq::opcua::generic +{ + class GenericOpcuaMonitoredItemHelper : public DaqTestHelper + { + public: + using DS = OpcUaMonitoredItemFbImpl::DomainSource; + daq::FunctionBlockPtr fb; + OpcUaServerTestHelper testHelper; + + void CreateMonitoredItemFB(std::string nodeId, uint32_t index, uint32_t interval = 100, DS ds = DS::ServerTimestamp) + { + using NT = OpcUaMonitoredItemFbImpl::NodeIDType; + auto config = device.getAvailableFunctionBlockTypes().get(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME).createDefaultConfig(); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_TYPE, static_cast(NT::String)); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_STRING, nodeId); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, index); + config.setPropertyValue(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, interval); + config.setPropertyValue(PROPERTY_NAME_OPCUA_TS_MODE, static_cast(ds)); + + ASSERT_NO_THROW(fb = device.addFunctionBlock(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, config)); + } + + void CreateMonitoredItemFB(uint32_t numericNodeId, uint32_t nsIndex, uint32_t interval = 100, DS ds = DS::ServerTimestamp) + { + using NT = OpcUaMonitoredItemFbImpl::NodeIDType; + auto config = device.getAvailableFunctionBlockTypes().get(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME).createDefaultConfig(); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_TYPE, static_cast(NT::Numeric)); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC, numericNodeId); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, nsIndex); + config.setPropertyValue(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, interval); + config.setPropertyValue(PROPERTY_NAME_OPCUA_TS_MODE, static_cast(ds)); + + ASSERT_NO_THROW(fb = device.addFunctionBlock(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, config)); + } + + void CreateMonitoredItemFB(daq::PropertyObjectPtr config) + { + ASSERT_NO_THROW(fb = device.addFunctionBlock(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, config)); + } + + auto okStatus() + { + return Enumeration("ComponentStatusType", "Ok", daqInstance.getContext().getTypeManager()); + } + + auto errStatus() + { + return Enumeration("ComponentStatusType", "Error", daqInstance.getContext().getTypeManager()); + } + + auto getTime() + { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); + } + + auto readValueWithTout(daq::SignalPtr sig, size_t ms, const daq::BaseObjectPtr prevVal = nullptr) + { + daq::BaseObjectPtr value; + helper::utils::Timer timer(ms); + do + { + value = sig.getLastValue(); + } while ((prevVal.assigned() ? value == prevVal : !value.assigned()) && !timer.expired()); + + return value; + }; + + protected: + void SetUp() + { + testHelper.startServer(); + } + + void TearDown() + { + if (fb.assigned()) + device.removeFunctionBlock(fb); + testHelper.stop(); + } + }; + + class GenericOpcuaMonitoredItemTest : public testing::Test, public GenericOpcuaMonitoredItemHelper + { + protected: + void SetUp() override + { + testing::Test::SetUp(); + GenericOpcuaMonitoredItemHelper::SetUp(); + } + void TearDown() override + { + GenericOpcuaMonitoredItemHelper::TearDown(); + testing::Test::TearDown(); + } + }; + + using H = std::variant; + + template + struct HelperValueType + { + using type = T; + }; + + // clang-format off +template struct TypeName { static std::string Get() { return "unknown"; } }; +template<> struct TypeName { static std::string Get() { return "float"; } }; +template<> struct TypeName { static std::string Get() { return "double"; } }; +template<> struct TypeName { static std::string Get() { return "int64_t"; } }; +template<> struct TypeName { static std::string Get() { return "uint64_t"; } }; +template<> struct TypeName { static std::string Get() { return "int32_t"; } }; +template<> struct TypeName { static std::string Get() { return "uint32_t"; } }; +template<> struct TypeName { static std::string Get() { return "int16_t"; } }; +template<> struct TypeName { static std::string Get() { return "uint16_t"; } }; +template<> struct TypeName { static std::string Get() { return "int8_t"; } }; +template<> struct TypeName { static std::string Get() { return "uint8_t"; } }; +template<> struct TypeName { static std::string Get() { return "string"; } }; +// clang-format on + +std::string ParamNameGenerator(const testing::TestParamInfo>& info) +{ + return std::visit( + [](auto& h) + { + using T = typename HelperValueType>::type; + std::string name = "Type_" + TypeName::Get(); + return name; + }, + info.param.second); +} +class GenericOpcuaMonitoredItemPTest : public ::testing::TestWithParam>, public GenericOpcuaMonitoredItemHelper +{ +protected: + void SetUp() override + { + testing::Test::SetUp(); + GenericOpcuaMonitoredItemHelper::SetUp(); + } + void TearDown() override + { + GenericOpcuaMonitoredItemHelper::TearDown(); + testing::Test::TearDown(); + } +}; + +} // namespace daq::modules::mqtt_streaming_module + +using namespace daq; +using namespace daq::opcua; +using namespace daq::opcua::generic; + +TEST_F(GenericOpcuaMonitoredItemTest, DefaultConfig) +{ + daq::PropertyObjectPtr defaultConfig = OpcUaMonitoredItemFbImpl::CreateType().createDefaultConfig(); + + ASSERT_TRUE(defaultConfig.assigned()); + + EXPECT_EQ(defaultConfig.getAllProperties().getCount(), 6u); + + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_NODE_ID_TYPE)); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_TYPE).getValueType(), CoreType::ctInt); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_TYPE).asPtr(), + static_cast(OpcUaMonitoredItemFbImpl::NodeIDType::String)); + EXPECT_TRUE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_TYPE).getVisible()); + + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_NODE_ID_STRING)); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_STRING).getValueType(), CoreType::ctString); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_STRING).asPtr().getLength(), 0u); + EXPECT_TRUE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_STRING).getVisible()); + + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC)); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC).getValueType(), CoreType::ctInt); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC).asPtr(), 0); + EXPECT_FALSE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC).getVisible()); + + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX)); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX).getValueType(), CoreType::ctInt); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX).asPtr(), 0); + EXPECT_TRUE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX).getVisible()); + + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL)); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL).getValueType(), CoreType::ctInt); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL) + .asPtr() + .getValue(DEFAULT_OPCUA_MIFB_SAMPLING_INTERVAL), + DEFAULT_OPCUA_MIFB_SAMPLING_INTERVAL); + EXPECT_TRUE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL).getVisible()); + + ASSERT_TRUE(defaultConfig.hasProperty(PROPERTY_NAME_OPCUA_TS_MODE)); + ASSERT_EQ(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_TS_MODE).getValueType(), CoreType::ctInt); + EXPECT_EQ(defaultConfig.getPropertyValue(PROPERTY_NAME_OPCUA_TS_MODE).asPtr(), + static_cast(OpcUaMonitoredItemFbImpl::DomainSource::ServerTimestamp)); + EXPECT_TRUE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_TS_MODE).getVisible()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, CreationWithDefaultConfig) +{ + StartUp(); + ASSERT_NO_THROW(fb = device.addFunctionBlock(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME)); + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 2u); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, CreationWithPartialConfig) +{ + StartUp(); + { + auto config = PropertyObject(); + config.addProperty(StringProperty(PROPERTY_NAME_OPCUA_NODE_ID_STRING, String("unknownNodeId"))); + ASSERT_NO_THROW(fb = device.addFunctionBlock(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, config)); + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 2u); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); + device.removeFunctionBlock(fb); + } + + { + auto config = PropertyObject(); + config.addProperty(StringProperty(PROPERTY_NAME_OPCUA_NODE_ID_STRING, String(".i32"))); + config.addProperty(IntProperty(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, 1)); + ASSERT_NO_THROW(fb = device.addFunctionBlock(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, config)); + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 2u); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + device.removeFunctionBlock(fb); + } + fb = nullptr; +} + +TEST_F(GenericOpcuaMonitoredItemTest, CreationWithCustomConfig) +{ + StartUp(); + auto config = device.getAvailableFunctionBlockTypes().get(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME).createDefaultConfig(); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_STRING, ".i32"); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, 1); + config.setPropertyValue(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, 100); + ASSERT_NO_THROW(fb = device.addFunctionBlock(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, config)); + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 2u); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, TwoFbCreation) +{ + StartUp(); + { + daq::FunctionBlockPtr fb; + + auto config = device.getAvailableFunctionBlockTypes().get(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME).createDefaultConfig(); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_STRING, ".i32"); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, 1); + config.setPropertyValue(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, 100); + ASSERT_NO_THROW(fb = device.addFunctionBlock(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, config)); + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 2u); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + } + { + daq::FunctionBlockPtr fb; + + auto config = device.getAvailableFunctionBlockTypes().get(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME).createDefaultConfig(); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_STRING, ".i64"); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, 1); + config.setPropertyValue(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, 100); + ASSERT_NO_THROW(fb = device.addFunctionBlock(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME, config)); + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 2u); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + } + auto fbs = device.getFunctionBlocks(); + ASSERT_EQ(fbs.getCount(), 2u); +} + +TEST_P(GenericOpcuaMonitoredItemPTest, ReadValue) +{ + constexpr uint32_t interval = 50; + StartUp(); + auto param = GetParam(); + + CreateMonitoredItemFB(param.first.getIdentifier(), param.first.getNamespaceIndex(), interval); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + std::visit( + [&](auto& templateParam) + { + using T = std::decay_t; + + OpcUaDataValue dataValue; + if constexpr (std::is_same_v) + { + UA_String myString = UA_STRING_ALLOC(templateParam.c_str()); + dataValue.setScalar(myString); + UA_String_clear(&myString); + } + else + { + dataValue.setScalar(templateParam); + } + + daq::BaseObjectPtr prevVal; + daq::BaseObjectPtr val; + { + // before writing + { + // waiting to be sure that FB has read initial value + helper::utils::Timer timer(interval * 3); + do + { + prevVal = fb.getSignals()[0].getLastValue(); + } while (!prevVal.assigned() && !timer.expired()); + ASSERT_TRUE(prevVal.assigned()); + } + { + // check that the initial and target values are different + if constexpr (std::is_same_v) + ASSERT_DOUBLE_NE(prevVal.asPtr().getValue(T(0)), templateParam); + else if constexpr (std::is_same_v) + ASSERT_FLOAT_NE(prevVal.asPtr().getValue(T(0)), templateParam); + else if constexpr (std::is_same_v) + ASSERT_NE(prevVal, templateParam); + else + ASSERT_NE(prevVal.asPtr().getValue(T(0)), templateParam); + } + } + + // write new value to the node + ASSERT_NO_THROW(testHelper.writeDataValueNode(param.first, dataValue)); + + { + // after writing + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + { + // the FB needs time to read the new value from the node + // read the last value until it becomes different from the initial or until the timer expires + helper::utils::Timer timer(interval * 3); + do + { + val = fb.getSignals()[0].getLastValue(); + } while (val == prevVal && !timer.expired()); + } + { + // check that the target and read values are the same + if constexpr (std::is_same_v) + ASSERT_DOUBLE_EQ(val.asPtr().getValue(T(0)), templateParam); + else if constexpr (std::is_same_v) + ASSERT_FLOAT_EQ(val.asPtr().getValue(T(0)), templateParam); + else if constexpr (std::is_same_v) + ASSERT_EQ(val, templateParam); + else + ASSERT_EQ(val.asPtr().getValue(T(0)), templateParam); + } + } + }, + param.second); +} + +INSTANTIATE_TEST_SUITE_P( + ReadNumericValue, + GenericOpcuaMonitoredItemPTest, + ::testing::Values(std::pair{OpcUaNodeId(1, ".ui8"), H{uint8_t{std::numeric_limits::max()}}}, + std::pair{OpcUaNodeId(1, ".i8"), H{int8_t{std::numeric_limits::min()}}}, + std::pair{OpcUaNodeId(1, ".ui16"), H{uint16_t{std::numeric_limits::max()}}}, + std::pair{OpcUaNodeId(1, ".i16"), H{int16_t{std::numeric_limits::min()}}}, + std::pair{OpcUaNodeId(1, ".ui32"), H{uint32_t{std::numeric_limits::max()}}}, + std::pair{OpcUaNodeId(1, ".i32"), H{int32_t{std::numeric_limits::min()}}}, + std::pair{OpcUaNodeId(1, ".ui64"), H{uint64_t{std::numeric_limits::max()}}}, + std::pair{OpcUaNodeId(1, ".i64"), H{int64_t{std::numeric_limits::min()}}}, + std::pair{OpcUaNodeId(1, ".d"), H{double{123.456789}}}, + std::pair{OpcUaNodeId(1, ".f"), H{float{float(-85) / 3}}}, + std::pair{OpcUaNodeId(1, ".s"), H{std::string{"String with a value"}}}), + ParamNameGenerator); + +TEST_F(GenericOpcuaMonitoredItemTest, ReadValueWithServerTimestampUsingLastValue) +{ + constexpr uint32_t interval = 50; + const OpcUaNodeId nodeId(1, ".i64"); + const auto value = int64_t{std::numeric_limits::min()}; + StartUp(); + + CreateMonitoredItemFB(nodeId.getIdentifier(), nodeId.getNamespaceIndex(), interval, DS::ServerTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + auto domainSig = fb.getSignals()[0].getDomainSignal(); + ASSERT_TRUE(domainSig.assigned()); + + const OpcUaVariant variant(value); + + // before writing + // waiting to be sure that FB has read initial value + daq::BaseObjectPtr prevVal = readValueWithTout(fb.getSignals()[0], interval * 3); + ASSERT_TRUE(prevVal.assigned()); + + const auto timeBefore = getTime(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + + ASSERT_NO_THROW(testHelper.writeValueNode(nodeId, variant)); + + // after writing + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + // the FB needs time to read the new value from the node + // read the last value until it becomes different from the initial or until the timer expires + daq::BaseObjectPtr val = readValueWithTout(fb.getSignals()[0], interval * 3, prevVal); + + auto domainVal = domainSig.getLastValue(); + const auto timeAfter = getTime(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + + // check that the target and read values are the same + ASSERT_EQ(val.asPtr().getValue(int64_t(0)), value); + + // check the ts is between start and stop points + ASSERT_TRUE(domainVal.assigned()); + EXPECT_GE(timeAfter, domainVal.asPtr().getValue(uint64_t(0))); + EXPECT_LE(timeBefore, domainVal.asPtr().getValue(uint64_t(0))); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReadValueWithSourceTimestampUsingLastValue) +{ + constexpr uint32_t interval = 50; + const OpcUaNodeId nodeId(1, ".i64"); + const auto value = int64_t{std::numeric_limits::min()}; + StartUp(); + + CreateMonitoredItemFB(nodeId.getIdentifier(), nodeId.getNamespaceIndex(), interval, DS::SourceTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + auto domainSig = fb.getSignals()[0].getDomainSignal(); + ASSERT_TRUE(domainSig.assigned()); + + OpcUaDataValue dataValue; + dataValue.setScalar(value); + + // before writing + // waiting to be sure that FB has read initial value + daq::BaseObjectPtr prevVal = readValueWithTout(fb.getSignals()[0], interval * 3); + ASSERT_TRUE(prevVal.assigned()); + + const auto time = getTime(); + + dataValue.getValue().hasSourceTimestamp = true; + dataValue.getValue().sourceTimestamp = OpcUaDataValue::fromUnixTimeUs(time); + ASSERT_NO_THROW(testHelper.writeDataValueNode(nodeId, dataValue)); + + // after writing + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + // the FB needs time to read the new value from the node + // read the last value until it becomes different from the initial or until the timer expires + daq::BaseObjectPtr val = readValueWithTout(fb.getSignals()[0], interval * 3, prevVal); + + auto domainVal = domainSig.getLastValue(); + + // check that the target and read values are the same + ASSERT_EQ(val.asPtr().getValue(int64_t(0)), value); + + // check that the target and read TSes are the same + ASSERT_TRUE(domainVal.assigned()); + EXPECT_EQ(time, domainVal.asPtr().getValue(uint64_t(0))); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReadValueWithLocalSystemTimestampUsingLastValue) +{ + constexpr uint32_t interval = 50; + const OpcUaNodeId nodeId(1, ".i64"); + const auto value = int64_t{std::numeric_limits::min()}; + StartUp(); + + CreateMonitoredItemFB(nodeId.getIdentifier(), nodeId.getNamespaceIndex(), interval, DS::LocalSystemTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + auto domainSig = fb.getSignals()[0].getDomainSignal(); + ASSERT_TRUE(domainSig.assigned()); + + const OpcUaVariant variant(value); + + // before writing + // waiting to be sure that FB has read initial value + daq::BaseObjectPtr prevVal = readValueWithTout(fb.getSignals()[0], interval * 3); + ASSERT_TRUE(prevVal.assigned()); + + const auto timeBefore = getTime(); + + ASSERT_NO_THROW(testHelper.writeValueNode(nodeId, variant)); + + // after writing + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + // the FB needs time to read the new value from the node + // read the last value until it becomes different from the initial or until the timer expires + daq::BaseObjectPtr val = readValueWithTout(fb.getSignals()[0], interval * 3, prevVal); + + auto domainVal = domainSig.getLastValue(); + const auto timeAfter = getTime(); + + // check that the target and read values are the same + ASSERT_EQ(val.asPtr().getValue(int64_t(0)), value); + + // check the ts is between start and stop points + ASSERT_TRUE(domainVal.assigned()); + EXPECT_GE(timeAfter, domainVal.asPtr().getValue(uint64_t(0))); + EXPECT_LE(timeBefore, domainVal.asPtr().getValue(uint64_t(0))); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReadValueWithServerTimestampUsingTailReader) +{ + constexpr uint32_t interval = 50; + const OpcUaNodeId nodeId(1, ".i64"); + const auto value = int64_t{std::numeric_limits::min()}; + StartUp(); + + CreateMonitoredItemFB(nodeId.getIdentifier(), nodeId.getNamespaceIndex(), interval, DS::ServerTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + auto domainSig = fb.getSignals()[0].getDomainSignal(); + ASSERT_TRUE(domainSig.assigned()); + + const OpcUaVariant variant(value); + + // before writing + // waiting to be sure that FB has read initial value + daq::BaseObjectPtr prevVal = readValueWithTout(fb.getSignals()[0], interval * 3); + ASSERT_TRUE(prevVal.assigned()); + + const auto timeBefore = getTime(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + + auto reader = TailReaderBuilder() + .setSignal(fb.getSignals()[0]) + .setHistorySize(1) + .setValueReadType(SampleType::Int64) + .setDomainReadType(SampleType::UInt64) + .setSkipEvents(true) + .build(); + + ASSERT_NO_THROW(testHelper.writeValueNode(nodeId, variant)); + + // after writing + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + // the FB needs time to read the new value from the node + // read the last value until it becomes different from the initial or until the timer expires + std::this_thread::sleep_for(std::chrono::milliseconds(interval * 3)); + + SizeT count{1}; + int64_t values{}; + uint64_t domain{}; + reader.readWithDomain(&values, &domain, &count); + const auto timeAfter = getTime(); + + // check that the target and read values are the same + ASSERT_EQ(values, value); + + // check the ts is between start and stop points + EXPECT_GE(timeAfter, domain); + EXPECT_LE(timeBefore, domain); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReadValueWithSourceTimestampUsingTailReader) +{ + constexpr uint32_t interval = 50; + const OpcUaNodeId nodeId(1, ".i64"); + const auto value = int64_t{std::numeric_limits::min()}; + StartUp(); + + CreateMonitoredItemFB(nodeId.getIdentifier(), nodeId.getNamespaceIndex(), interval, DS::SourceTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + auto domainSig = fb.getSignals()[0].getDomainSignal(); + ASSERT_TRUE(domainSig.assigned()); + + OpcUaDataValue dataValue; + dataValue.setScalar(value); + + // before writing + // waiting to be sure that FB has read initial value + daq::BaseObjectPtr prevVal = readValueWithTout(fb.getSignals()[0], interval * 3); + ASSERT_TRUE(prevVal.assigned()); + + const auto time = getTime(); + + auto reader = TailReaderBuilder() + .setSignal(fb.getSignals()[0]) + .setHistorySize(1) + .setValueReadType(SampleType::Int64) + .setDomainReadType(SampleType::UInt64) + .setSkipEvents(true) + .build(); + + dataValue.getValue().hasSourceTimestamp = true; + dataValue.getValue().sourceTimestamp = OpcUaDataValue::fromUnixTimeUs(time); + ASSERT_NO_THROW(testHelper.writeDataValueNode(nodeId, dataValue)); + + // after writing + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + // the FB needs time to read the new value from the node + // read the last value until it becomes different from the initial or until the timer expires + std::this_thread::sleep_for(std::chrono::milliseconds(interval * 3)); + + SizeT count{1}; + int64_t values{}; + uint64_t domain{}; + reader.readWithDomain(&values, &domain, &count); + + // check that the target and read values are the same + ASSERT_EQ(values, value); + + // check that the target and read TSes are the same + EXPECT_EQ(time, domain); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReadProtectedValue) +{ + constexpr uint32_t interval = 50; + const OpcUaNodeId nodeId(1, ".pi64"); + const auto value = int64_t{std::numeric_limits::min()}; + StartUp(); + + CreateMonitoredItemFB(nodeId.getIdentifier(), nodeId.getNamespaceIndex(), interval, DS::ServerTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); + + OpcUaDataValue dataValue; + dataValue.setScalar(value); + + daq::BaseObjectPtr prevVal = readValueWithTout(fb.getSignals()[0], interval * 3); + ASSERT_FALSE(prevVal.assigned()); + + ASSERT_NO_THROW(testHelper.writeDataValueNode(nodeId, dataValue)); + + // after writing + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); + + daq::BaseObjectPtr val = readValueWithTout(fb.getSignals()[0], interval * 3, prevVal); + ASSERT_FALSE(val.assigned()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReadValueWithLocalSystemTimestampUsingTailReader) +{ + constexpr uint32_t interval = 50; + const OpcUaNodeId nodeId(1, ".i64"); + const auto value = int64_t{std::numeric_limits::min()}; + StartUp(); + + CreateMonitoredItemFB(nodeId.getIdentifier(), nodeId.getNamespaceIndex(), interval, DS::LocalSystemTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + auto domainSig = fb.getSignals()[0].getDomainSignal(); + ASSERT_TRUE(domainSig.assigned()); + + const OpcUaVariant variant(value); + + // waiting to be sure that FB has read initial value + daq::BaseObjectPtr prevVal = readValueWithTout(fb.getSignals()[0], interval * 3); + ASSERT_TRUE(prevVal.assigned()); + + const auto timeBefore = getTime(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + + auto reader = TailReaderBuilder() + .setSignal(fb.getSignals()[0]) + .setHistorySize(1) + .setValueReadType(SampleType::Int64) + .setDomainReadType(SampleType::UInt64) + .setSkipEvents(true) + .build(); + + ASSERT_NO_THROW(testHelper.writeValueNode(nodeId, variant)); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + std::this_thread::sleep_for(std::chrono::milliseconds(interval * 3)); + + SizeT count{1}; + int64_t values{}; + uint64_t domain{}; + reader.readWithDomain(&values, &domain, &count); + const auto timeAfter = getTime(); + + ASSERT_EQ(values, value); + + EXPECT_GE(timeAfter, domain); + EXPECT_LE(timeBefore, domain); +} + +TEST_F(GenericOpcuaMonitoredItemTest, TsModeNoneCreatesSingleSignal) +{ + StartUp(); + + CreateMonitoredItemFB(".i32", 1, 100, DS::None); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 1u); + EXPECT_FALSE(fb.getSignals()[0].getDomainSignal().assigned()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReconfigureTsModeTogglesDomainSignal) +{ + StartUp(); + + CreateMonitoredItemFB(".i32", 1, 100, DS::ServerTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 2u); + EXPECT_TRUE(fb.getSignals()[0].getDomainSignal().assigned()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_TS_MODE, static_cast(DS::None)); + + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 1u); + EXPECT_FALSE(fb.getSignals()[0].getDomainSignal().assigned()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_TS_MODE, static_cast(DS::ServerTimestamp)); + + EXPECT_EQ(fb.getSignals(daq::search::Any()).getCount(), 2u); + EXPECT_TRUE(fb.getSignals()[0].getDomainSignal().assigned()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReconfigureNodeIdFromInvalidToValid) +{ + StartUp(); + + CreateMonitoredItemFB("nonExistent", 1, 100, DS::ServerTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_STRING, std::string(".i32")); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + daq::BaseObjectPtr val = readValueWithTout(fb.getSignals()[0], 300); + EXPECT_TRUE(val.assigned()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReconfigureNodeIdFromValidToInvalid) +{ + StartUp(); + + CreateMonitoredItemFB(".i32", 1, 100, DS::ServerTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_STRING, std::string("nonExistent")); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReconfigureNamespaceIndex) +{ + StartUp(); + + CreateMonitoredItemFB(".i32", 1, 100, DS::ServerTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, 0); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, 1); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, SignalDescriptorSampleTypeMatchesOpcUaDataType) +{ + StartUp(); + + const std::vector> cases = { + {OpcUaNodeId(1, ".f"), SampleType::Float32}, + {OpcUaNodeId(1, ".d"), SampleType::Float64}, + {OpcUaNodeId(1, ".i32"), SampleType::Int32}, + {OpcUaNodeId(1, ".i64"), SampleType::Int64}, + {OpcUaNodeId(1, ".s"), SampleType::String}, + }; + + for (const auto& [nodeId, expectedType] : cases) + { + CreateMonitoredItemFB(nodeId.getIdentifier(), nodeId.getNamespaceIndex(), 50); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + readValueWithTout(fb.getSignals()[0], 150); + EXPECT_EQ(fb.getSignals()[0].getDescriptor().getSampleType(), expectedType); + device.removeFunctionBlock(fb); + fb = nullptr; + } +} + +TEST_F(GenericOpcuaMonitoredItemTest, UnsupportedDataTypeNode) +{ + StartUp(); + + // .b is a BOOLEAN node — not in supportedDataTypes + CreateMonitoredItemFB(".b", 1, 10, DS::ServerTimestamp); + + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); + + daq::BaseObjectPtr val = readValueWithTout(fb.getSignals()[0], 300); + EXPECT_FALSE(val.assigned()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, FolderNode) +{ + StartUp(); + + // "f1" ns=1 is an ObjectFolder, not a VARIABLE node + CreateMonitoredItemFB("f1", 1, 100, DS::ServerTimestamp); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ZeroSamplingInterval) +{ + StartUp(); + + auto config = device.getAvailableFunctionBlockTypes().get(GENERIC_OPCUA_MONITORED_ITEM_FB_NAME).createDefaultConfig(); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_STRING, std::string(".i32")); + config.setPropertyValue(PROPERTY_NAME_OPCUA_NAMESPACE_INDEX, 1); + config.setPropertyValue(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, 0); + + CreateMonitoredItemFB(config); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, 10); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_SAMPLING_INTERVAL, 0); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, PropertyVisibilityTogglesWithNodeIdType) +{ + using NT = OpcUaMonitoredItemFbImpl::NodeIDType; + daq::PropertyObjectPtr defaultConfig = OpcUaMonitoredItemFbImpl::CreateType().createDefaultConfig(); + + EXPECT_TRUE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_STRING).getVisible()); + EXPECT_FALSE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC).getVisible()); + + defaultConfig.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_TYPE, static_cast(NT::Numeric)); + EXPECT_FALSE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_STRING).getVisible()); + EXPECT_TRUE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC).getVisible()); + + defaultConfig.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_TYPE, static_cast(NT::String)); + EXPECT_TRUE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_STRING).getVisible()); + EXPECT_FALSE(defaultConfig.getProperty(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC).getVisible()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, NumericNodeIdCreation) +{ + StartUp(); + + CreateMonitoredItemFB(1001, 1); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, NumericNodeIdCreationInvalidNode) +{ + StartUp(); + + CreateMonitoredItemFB(9999, 1); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), errStatus()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, NumericNodeIdReadValue) +{ + constexpr uint32_t interval = 50; + const OpcUaNodeId nodeId(1, uint32_t{1001}); + StartUp(); + + CreateMonitoredItemFB(1001, 1, interval); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + daq::BaseObjectPtr prevVal = readValueWithTout(fb.getSignals()[0], interval * 3); + ASSERT_TRUE(prevVal.assigned()); + + const OpcUaVariant variant(int32_t{42}); + ASSERT_NO_THROW(testHelper.writeValueNode(nodeId, variant)); + + daq::BaseObjectPtr val = readValueWithTout(fb.getSignals()[0], interval * 3, prevVal); + ASSERT_NE(val, prevVal); + EXPECT_EQ(val.asPtr().getValue(0), 42); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReconfigureNodeIdTypeStringToNumeric) +{ + using NT = OpcUaMonitoredItemFbImpl::NodeIDType; + StartUp(); + + CreateMonitoredItemFB(".i32", 1, 100, DS::ServerTimestamp); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_TYPE, static_cast(NT::Numeric)); + fb.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_NUMERIC, 1001); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); +} + +TEST_F(GenericOpcuaMonitoredItemTest, ReconfigureNodeIdTypeNumericToString) +{ + using NT = OpcUaMonitoredItemFbImpl::NodeIDType; + StartUp(); + + CreateMonitoredItemFB(1001, 1, 100, DS::ServerTimestamp); + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); + + fb.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_TYPE, static_cast(NT::String)); + fb.setPropertyValue(PROPERTY_NAME_OPCUA_NODE_ID_STRING, std::string(".i32")); + + ASSERT_EQ(fb.getStatusContainer().getStatus("ComponentStatus"), okStatus()); +} \ No newline at end of file diff --git a/shared/libraries/opcuageneric/opcuageneric_client/tests/timer.h b/shared/libraries/opcuageneric/opcuageneric_client/tests/timer.h new file mode 100644 index 00000000..fe103965 --- /dev/null +++ b/shared/libraries/opcuageneric/opcuageneric_client/tests/timer.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +namespace helper::utils +{ +class Timer +{ +public: + Timer(size_t ms, bool start = true) + : period(ms), + firstStart(true) + { + if (start) + restart(); + } + + std::chrono::milliseconds remain() const + { + auto now = std::chrono::steady_clock::now(); + const auto elapsed_ms = std::chrono::duration_cast(now - start); + std::chrono::milliseconds newTout = (elapsed_ms >= timeout) ? std::chrono::milliseconds(0) : timeout - elapsed_ms; + return newTout; + } + + bool expired() + { + return (firstStart) ? true : (remain() == std::chrono::milliseconds(0)); + } + + explicit operator std::chrono::milliseconds() const noexcept + { + return remain(); + } + + void restart() + { + firstStart = false; + start = std::chrono::steady_clock::now(); + timeout = std::chrono::milliseconds(period); + } + +protected: + std::chrono::steady_clock::time_point start; + std::chrono::milliseconds timeout; + std::chrono::milliseconds period; + bool firstStart; +}; + +} // namespace helper::utils diff --git a/shared/libraries/opcua/opcuaserver/include/opcuaserver/node_event_manager.h b/shared/libraries/opcuatms/opcuatms_server/include/opcuatms_server/node_event_manager.h similarity index 100% rename from shared/libraries/opcua/opcuaserver/include/opcuaserver/node_event_manager.h rename to shared/libraries/opcuatms/opcuatms_server/include/opcuatms_server/node_event_manager.h diff --git a/shared/libraries/opcuatms/opcuatms_server/include/opcuatms_server/objects/tms_server_object.h b/shared/libraries/opcuatms/opcuatms_server/include/opcuatms_server/objects/tms_server_object.h index 15e44a83..5d52eefa 100644 --- a/shared/libraries/opcuatms/opcuatms_server/include/opcuatms_server/objects/tms_server_object.h +++ b/shared/libraries/opcuatms/opcuatms_server/include/opcuatms_server/objects/tms_server_object.h @@ -18,7 +18,7 @@ #include #include #include -#include +#include #include #include diff --git a/shared/libraries/opcuatms/opcuatms_server/src/CMakeLists.txt b/shared/libraries/opcuatms/opcuatms_server/src/CMakeLists.txt index 796c6bef..9f77d753 100644 --- a/shared/libraries/opcuatms/opcuatms_server/src/CMakeLists.txt +++ b/shared/libraries/opcuatms/opcuatms_server/src/CMakeLists.txt @@ -2,6 +2,7 @@ set(LIB_NAME opcuatms_server) set(SRC_Cpp tms_server.cpp tms_server_context.cpp + node_event_manager.cpp ) set(SRC_PublicHeaders @@ -9,6 +10,7 @@ set(SRC_PublicHeaders set(SRC_PrivateHeaders tms_server.h tms_server_context.h + node_event_manager.h ) # objects diff --git a/shared/libraries/opcua/opcuaserver/src/node_event_manager.cpp b/shared/libraries/opcuatms/opcuatms_server/src/node_event_manager.cpp similarity index 99% rename from shared/libraries/opcua/opcuaserver/src/node_event_manager.cpp rename to shared/libraries/opcuatms/opcuatms_server/src/node_event_manager.cpp index c180e04f..0578e913 100644 --- a/shared/libraries/opcua/opcuaserver/src/node_event_manager.cpp +++ b/shared/libraries/opcuatms/opcuatms_server/src/node_event_manager.cpp @@ -1,4 +1,4 @@ -#include "opcuaserver/node_event_manager.h" +#include "opcuatms_server/node_event_manager.h" #include BEGIN_NAMESPACE_OPENDAQ_OPCUA