summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-02-17 23:58:08 +0100
committerThomas Vanbesien <tvanbesi@proton.me>2026-02-17 23:58:08 +0100
commit343169dff6b062074fd3c4a5e240b449ffc4a449 (patch)
treea2ef1edc8dd1b1c1dbac757192fa681d8ec76717
downloadBobinkQtOpcUa-343169dff6b062074fd3c4a5e240b449ffc4a449.tar.gz
BobinkQtOpcUa-343169dff6b062074fd3c4a5e240b449ffc4a449.zip
Initial Bobink library: BobinkAuth, BobinkClient, and demo app
Implements the core OPC UA wrapper library with: - Build system with automatic dep building (open62541, QtOpcUa) - BobinkAuth: QML auth component (anonymous/userpass/certificate) - BobinkClient: QML singleton managing connection, LDS discovery, PKI configuration, endpoint selection, and certificate trust flow - Demo app for manual testing of the full connection flow
-rw-r--r--.clang-format1
-rw-r--r--.gitignore3
-rw-r--r--.gitmodules6
-rw-r--r--CMakeLists.txt26
-rw-r--r--cmake/BuildDeps.cmake105
-rw-r--r--demo/CMakeLists.txt18
-rw-r--r--demo/Main.qml188
-rw-r--r--demo/main.cpp18
m---------deps/open625410
m---------deps/qtopcua0
-rw-r--r--src/BobinkAuth.cpp73
-rw-r--r--src/BobinkAuth.h60
-rw-r--r--src/BobinkClient.cpp315
-rw-r--r--src/BobinkClient.h123
-rw-r--r--src/CMakeLists.txt12
15 files changed, 948 insertions, 0 deletions
diff --git a/.clang-format b/.clang-format
new file mode 100644
index 0000000..a6cc54a
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1 @@
+BasedOnStyle: GNU
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ca7b255
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+CLAUDE.md
+.cache
+build
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..dd6ae2a
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+[submodule "deps/open62541"]
+ path = deps/open62541
+ url = https://github.com/open62541/open62541.git
+[submodule "deps/qtopcua"]
+ path = deps/qtopcua
+ url = https://github.com/qt/qtopcua.git
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..73ab94d
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,26 @@
+cmake_minimum_required(VERSION 3.16)
+project(Bobink LANGUAGES CXX)
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+
+# Build external dependencies (open62541, qtopcua) if not already built
+include(cmake/BuildDeps.cmake)
+
+# Local QtOpcUa must come before system Qt so find_package picks up our
+# build instead of any system-installed QtOpcUa.
+list(PREPEND CMAKE_PREFIX_PATH "${QTOPCUA_BUILD_DIR}")
+list(PREPEND CMAKE_PREFIX_PATH "${OPEN62541_INSTALL_DIR}")
+
+find_package(Qt6 6.10.2 REQUIRED COMPONENTS Core Qml Quick OpcUa)
+qt_standard_project_setup(REQUIRES 6.10.2)
+
+# Ensure the local QtOpcUa and open62541 libs are findable at runtime
+# (needed because the Qt plugin loader dlopen's the open62541 backend).
+set(CMAKE_BUILD_RPATH
+ "${QTOPCUA_BUILD_DIR}/lib"
+ "${OPEN62541_INSTALL_DIR}/lib")
+
+add_subdirectory(src)
+add_subdirectory(demo)
diff --git a/cmake/BuildDeps.cmake b/cmake/BuildDeps.cmake
new file mode 100644
index 0000000..8a1fa89
--- /dev/null
+++ b/cmake/BuildDeps.cmake
@@ -0,0 +1,105 @@
+set(OPEN62541_SOURCE_DIR "${CMAKE_SOURCE_DIR}/deps/open62541")
+set(OPEN62541_BUILD_DIR "${CMAKE_BINARY_DIR}/deps/open62541-build")
+set(OPEN62541_INSTALL_DIR "${CMAKE_BINARY_DIR}/deps/open62541-install")
+set(QTOPCUA_SOURCE_DIR "${CMAKE_SOURCE_DIR}/deps/qtopcua")
+set(QTOPCUA_BUILD_DIR "${CMAKE_BINARY_DIR}/deps/qtopcua-build")
+
+# Verify submodules are initialized
+if(NOT EXISTS "${OPEN62541_SOURCE_DIR}/CMakeLists.txt")
+ message(
+ FATAL_ERROR
+ "open62541 submodule not initialized. Run: git submodule update --init --recursive"
+ )
+endif()
+if(NOT EXISTS "${QTOPCUA_SOURCE_DIR}/CMakeLists.txt")
+ message(
+ FATAL_ERROR
+ "qtopcua submodule not initialized. Run: git submodule update --init --recursive"
+ )
+endif()
+
+# --- open62541 ---
+if(NOT EXISTS "${OPEN62541_INSTALL_DIR}/lib/libopen62541.so")
+
+ message(STATUS "Configuring open62541 in ${OPEN62541_BUILD_DIR}...")
+ set(_cmd
+ ${CMAKE_COMMAND}
+ -S
+ "${OPEN62541_SOURCE_DIR}"
+ -B
+ "${OPEN62541_BUILD_DIR}"
+ -G
+ Ninja
+ -DCMAKE_INSTALL_PREFIX=${OPEN62541_INSTALL_DIR}
+ -DBUILD_SHARED_LIBS=ON
+ -DUA_ENABLE_ENCRYPTION=OPENSSL
+ -DUA_ENABLE_DISCOVERY=ON)
+ execute_process(COMMAND ${_cmd} RESULT_VARIABLE _result)
+ if(_result)
+ list(JOIN _cmd " " _cmd_str)
+ message(FATAL_ERROR "${_cmd_str} failed: ${_result}")
+ endif()
+
+ message(STATUS "Building open62541 in ${OPEN62541_BUILD_DIR}...")
+ set(_cmd ${CMAKE_COMMAND} --build "${OPEN62541_BUILD_DIR}" --parallel)
+ execute_process(COMMAND ${_cmd} RESULT_VARIABLE _result)
+ if(_result)
+ list(JOIN _cmd " " _cmd_str)
+ message(FATAL_ERROR "${_cmd_str} failed: ${_result}")
+ endif()
+
+ message(STATUS "Installing open62541 to ${OPEN62541_INSTALL_DIR}...")
+ set(_cmd ${CMAKE_COMMAND} --install "${OPEN62541_BUILD_DIR}")
+ execute_process(COMMAND ${_cmd} RESULT_VARIABLE _result)
+ if(_result)
+ list(JOIN _cmd " " _cmd_str)
+ message(FATAL_ERROR "${_cmd_str} failed: ${_result}")
+ endif()
+
+else()
+ message(STATUS "open62541 already built, skipping")
+endif()
+
+# --- qtopcua ---
+
+message(STATUS "CMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH}")
+find_program(QT_CMAKE_COMMAND bin/qt-cmake REQUIRED)
+
+if(NOT EXISTS "${QTOPCUA_BUILD_DIR}/lib/libQt6OpcUa.so")
+
+ message(STATUS "Configuring qtopcua in ${QTOPCUA_BUILD_DIR}...")
+ set(_cmd
+ ${QT_CMAKE_COMMAND}
+ -S
+ "${QTOPCUA_SOURCE_DIR}"
+ -B
+ "${QTOPCUA_BUILD_DIR}"
+ -G
+ Ninja
+ -DINPUT_open62541=system
+ -DCMAKE_PREFIX_PATH=${OPEN62541_INSTALL_DIR})
+ execute_process(COMMAND ${_cmd} RESULT_VARIABLE _result)
+ if(_result)
+ list(JOIN _cmd " " _cmd_str)
+ message(FATAL_ERROR "${_cmd_str} failed: ${_result}")
+ endif()
+
+ message(STATUS "Building qtopcua in ${QTOPCUA_BUILD_DIR}...")
+ set(_cmd ${CMAKE_COMMAND} --build "${QTOPCUA_BUILD_DIR}" --parallel)
+ execute_process(COMMAND ${_cmd} RESULT_VARIABLE _result)
+ if(_result)
+ list(JOIN _cmd " " _cmd_str)
+ message(FATAL_ERROR "${_cmd_str} failed: ${_result}")
+ endif()
+
+ message(STATUS "Installing qtopcua to ${QTOPCUA_BUILD_DIR}...")
+ set(_cmd ${CMAKE_COMMAND} --install "${QTOPCUA_BUILD_DIR}")
+ execute_process(COMMAND ${_cmd} RESULT_VARIABLE _result)
+ if(_result)
+ list(JOIN _cmd " " _cmd_str)
+ message(FATAL_ERROR "${_cmd_str} failed: ${_result}")
+ endif()
+
+else()
+ message(STATUS "qtopcua already built, skipping")
+endif()
diff --git a/demo/CMakeLists.txt b/demo/CMakeLists.txt
new file mode 100644
index 0000000..a12e090
--- /dev/null
+++ b/demo/CMakeLists.txt
@@ -0,0 +1,18 @@
+qt_add_executable(bobink-demo main.cpp)
+
+qt_add_qml_module(bobink-demo
+ URI BobinkDemo
+ VERSION 1.0
+ QML_FILES
+ Main.qml
+ # LoginPage.qml
+ # NodePage.qml
+ NO_RESOURCE_TARGET_PATH
+)
+
+target_link_libraries(bobink-demo PRIVATE Qt6::Quick bobink)
+
+# Tell the demo where to find the locally-built OpcUa plugin at runtime
+target_compile_definitions(bobink-demo PRIVATE
+ QTOPCUA_PLUGIN_PATH="${QTOPCUA_BUILD_DIR}/plugins"
+)
diff --git a/demo/Main.qml b/demo/Main.qml
new file mode 100644
index 0000000..6d1327d
--- /dev/null
+++ b/demo/Main.qml
@@ -0,0 +1,188 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Bobink
+
+ApplicationWindow {
+ width: 600
+ height: 600
+ visible: true
+ title: "Bobink Demo"
+
+ Connections {
+ target: BobinkClient
+ function onServersChanged() {
+ console.log("Discovered server list updated")
+ }
+ function onConnectedChanged() {
+ console.log("Connected:", BobinkClient.connected)
+ }
+ function onConnectionError(message) {
+ console.log("Connection error:", message)
+ }
+ function onDiscoveringChanged() {
+ console.log("Discovering:", BobinkClient.discovering)
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: 20
+ spacing: 12
+
+ Label { text: "Discovery URL"; font.bold: true }
+
+ RowLayout {
+ TextField {
+ id: discoveryUrlField
+ Layout.fillWidth: true
+ text: "opc.tcp://localhost:4840"
+ }
+ Button {
+ text: BobinkClient.discovering ? "Stop" : "Discover"
+ onClicked: {
+ if (BobinkClient.discovering) {
+ BobinkClient.stopDiscovery()
+ } else {
+ BobinkClient.discoveryUrl = discoveryUrlField.text
+ BobinkClient.startDiscovery()
+ }
+ }
+ }
+ }
+
+ Label {
+ text: BobinkClient.discovering
+ ? "Discovering... (" + BobinkClient.servers.length + " found)"
+ : BobinkClient.servers.length + " server(s)"
+ font.italic: true
+ }
+
+ ListView {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 100
+ clip: true
+ model: BobinkClient.servers
+ delegate: ItemDelegate {
+ width: ListView.view.width
+ text: modelData.serverName + " — " + modelData.applicationUri
+ onClicked: {
+ if (modelData.discoveryUrls.length > 0)
+ serverUrlField.text = modelData.discoveryUrls[0]
+ }
+ }
+ ScrollBar.vertical: ScrollBar {}
+ }
+
+ Label { text: "PKI Directory"; font.bold: true }
+
+ RowLayout {
+ TextField {
+ id: pkiDirField
+ Layout.fillWidth: true
+ text: BobinkClient.pkiDir
+ onEditingFinished: BobinkClient.pkiDir = text
+ }
+ Button {
+ text: "Apply PKI"
+ onClicked: {
+ BobinkClient.pkiDir = pkiDirField.text
+ BobinkClient.applyPki()
+ console.log("PKI applied:", BobinkClient.pkiDir)
+ }
+ }
+ }
+
+ Label { text: "Server URL"; font.bold: true }
+
+ RowLayout {
+ TextField {
+ id: serverUrlField
+ Layout.fillWidth: true
+ placeholderText: "opc.tcp://..."
+ }
+ }
+
+ Label { text: "Authentication"; font.bold: true }
+
+ BobinkAuth {
+ id: auth
+ mode: {
+ switch (authModeCombo.currentIndex) {
+ case 0: return BobinkAuth.Anonymous
+ case 1: return BobinkAuth.UserPass
+ case 2: return BobinkAuth.Certificate
+ default: return BobinkAuth.Anonymous
+ }
+ }
+ username: usernameField.text
+ password: passwordField.text
+ certPath: certPathField.text
+ keyPath: keyPathField.text
+ }
+
+ ComboBox {
+ id: authModeCombo
+ Layout.fillWidth: true
+ model: ["Anonymous", "Username / Password", "Certificate"]
+ }
+
+ GridLayout {
+ columns: 2
+ visible: authModeCombo.currentIndex === 1
+ Layout.fillWidth: true
+
+ Label { text: "Username:" }
+ TextField {
+ id: usernameField
+ Layout.fillWidth: true
+ }
+ Label { text: "Password:" }
+ TextField {
+ id: passwordField
+ Layout.fillWidth: true
+ echoMode: TextInput.Password
+ }
+ }
+
+ GridLayout {
+ columns: 2
+ visible: authModeCombo.currentIndex === 2
+ Layout.fillWidth: true
+
+ Label { text: "Certificate:" }
+ TextField {
+ id: certPathField
+ Layout.fillWidth: true
+ placeholderText: "/path/to/cert.pem"
+ }
+ Label { text: "Private key:" }
+ TextField {
+ id: keyPathField
+ Layout.fillWidth: true
+ placeholderText: "/path/to/key.pem"
+ }
+ }
+
+ Button {
+ text: BobinkClient.connected ? "Disconnect" : "Connect"
+ Layout.fillWidth: true
+ onClicked: {
+ if (BobinkClient.connected) {
+ BobinkClient.disconnectFromServer()
+ } else {
+ BobinkClient.auth = auth
+ BobinkClient.serverUrl = serverUrlField.text
+ BobinkClient.connectToServer()
+ }
+ }
+ }
+
+ Label {
+ text: "Status: " + (BobinkClient.connected ? "Connected" : "Disconnected")
+ color: BobinkClient.connected ? "green" : "red"
+ }
+
+ Item { Layout.fillHeight: true }
+ }
+}
diff --git a/demo/main.cpp b/demo/main.cpp
new file mode 100644
index 0000000..de3c2fc
--- /dev/null
+++ b/demo/main.cpp
@@ -0,0 +1,18 @@
+#include <QGuiApplication>
+#include <QQmlApplicationEngine>
+
+int main(int argc, char *argv[])
+{
+ QCoreApplication::addLibraryPath(QStringLiteral(QTOPCUA_PLUGIN_PATH));
+
+ QGuiApplication app(argc, argv);
+ QQmlApplicationEngine engine;
+
+ QObject::connect(
+ &engine, &QQmlApplicationEngine::objectCreationFailed,
+ &app, []() { QCoreApplication::exit(1); },
+ Qt::QueuedConnection);
+
+ engine.loadFromModule("BobinkDemo", "Main");
+ return app.exec();
+}
diff --git a/deps/open62541 b/deps/open62541
new file mode 160000
+Subproject 484348f3076da13a9d0971f3d66ffe57ab3b7a9
diff --git a/deps/qtopcua b/deps/qtopcua
new file mode 160000
+Subproject 2c227fa036d1b9215c8ec67bdd71fa3621e7889
diff --git a/src/BobinkAuth.cpp b/src/BobinkAuth.cpp
new file mode 100644
index 0000000..fed1da2
--- /dev/null
+++ b/src/BobinkAuth.cpp
@@ -0,0 +1,73 @@
+#include "BobinkAuth.h"
+
+BobinkAuth::BobinkAuth(QObject *parent)
+ : QObject(parent)
+{
+}
+
+BobinkAuth::AuthMode BobinkAuth::mode() const { return m_mode; }
+
+void BobinkAuth::setMode(AuthMode mode)
+{
+ if (m_mode == mode)
+ return;
+ m_mode = mode;
+ emit modeChanged();
+}
+
+QString BobinkAuth::username() const { return m_username; }
+
+void BobinkAuth::setUsername(const QString &username)
+{
+ if (m_username == username)
+ return;
+ m_username = username;
+ emit usernameChanged();
+}
+
+QString BobinkAuth::password() const { return m_password; }
+
+void BobinkAuth::setPassword(const QString &password)
+{
+ if (m_password == password)
+ return;
+ m_password = password;
+ emit passwordChanged();
+}
+
+QString BobinkAuth::certPath() const { return m_certPath; }
+
+void BobinkAuth::setCertPath(const QString &path)
+{
+ if (m_certPath == path)
+ return;
+ m_certPath = path;
+ emit certPathChanged();
+}
+
+QString BobinkAuth::keyPath() const { return m_keyPath; }
+
+void BobinkAuth::setKeyPath(const QString &path)
+{
+ if (m_keyPath == path)
+ return;
+ m_keyPath = path;
+ emit keyPathChanged();
+}
+
+QOpcUaAuthenticationInformation BobinkAuth::toAuthenticationInformation() const
+{
+ QOpcUaAuthenticationInformation info;
+ switch (m_mode) {
+ case Anonymous:
+ info.setAnonymousAuthentication();
+ break;
+ case UserPass:
+ info.setUsernameAuthentication(m_username, m_password);
+ break;
+ case Certificate:
+ info.setCertificateAuthentication(m_certPath, m_keyPath);
+ break;
+ }
+ return info;
+}
diff --git a/src/BobinkAuth.h b/src/BobinkAuth.h
new file mode 100644
index 0000000..2e3ea6a
--- /dev/null
+++ b/src/BobinkAuth.h
@@ -0,0 +1,60 @@
+#ifndef BOBINKAUTH_H
+#define BOBINKAUTH_H
+
+#include <QObject>
+#include <QOpcUaAuthenticationInformation>
+#include <QQmlEngine>
+
+class BobinkAuth : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+
+ Q_PROPERTY(AuthMode mode READ mode WRITE setMode NOTIFY modeChanged)
+ Q_PROPERTY(
+ QString username READ username WRITE setUsername NOTIFY usernameChanged)
+ Q_PROPERTY(
+ QString password READ password WRITE setPassword NOTIFY passwordChanged)
+ Q_PROPERTY(
+ QString certPath READ certPath WRITE setCertPath NOTIFY certPathChanged)
+ Q_PROPERTY(
+ QString keyPath READ keyPath WRITE setKeyPath NOTIFY keyPathChanged)
+
+public:
+ enum AuthMode { Anonymous, UserPass, Certificate };
+ Q_ENUM(AuthMode)
+
+ explicit BobinkAuth(QObject *parent = nullptr);
+
+ AuthMode mode() const;
+ void setMode(AuthMode mode);
+
+ QString username() const;
+ void setUsername(const QString &username);
+
+ QString password() const;
+ void setPassword(const QString &password);
+
+ QString certPath() const;
+ void setCertPath(const QString &path);
+
+ QString keyPath() const;
+ void setKeyPath(const QString &path);
+
+ QOpcUaAuthenticationInformation toAuthenticationInformation() const;
+
+signals:
+ void modeChanged();
+ void usernameChanged();
+ void passwordChanged();
+ void certPathChanged();
+ void keyPathChanged();
+
+private:
+ AuthMode m_mode = Anonymous;
+ QString m_username;
+ QString m_password;
+ QString m_certPath;
+ QString m_keyPath;
+};
+
+#endif // BOBINKAUTH_H
diff --git a/src/BobinkClient.cpp b/src/BobinkClient.cpp
new file mode 100644
index 0000000..ea61583
--- /dev/null
+++ b/src/BobinkClient.cpp
@@ -0,0 +1,315 @@
+#include "BobinkClient.h"
+#include "BobinkAuth.h"
+
+#include <QDir>
+#include <QStandardPaths>
+
+BobinkClient *BobinkClient::s_instance = nullptr;
+
+static QString defaultPkiDir()
+{
+ return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
+ + QStringLiteral("/pki");
+}
+
+static void ensurePkiDirs(const QString &base)
+{
+ for (const auto *sub : {"own/certs", "own/private",
+ "trusted/certs", "trusted/crl",
+ "issuers/certs", "issuers/crl"}) {
+ QDir().mkpath(base + QLatin1Char('/') + QLatin1String(sub));
+ }
+}
+
+BobinkClient::BobinkClient(QObject *parent)
+ : QObject(parent)
+ , m_provider(new QOpcUaProvider(this))
+ , m_pkiDir(defaultPkiDir())
+{
+ ensurePkiDirs(m_pkiDir);
+ setupClient();
+ connect(&m_discoveryTimer, &QTimer::timeout, this, &BobinkClient::doDiscovery);
+}
+
+BobinkClient::~BobinkClient()
+{
+ if (s_instance == this)
+ s_instance = nullptr;
+}
+
+BobinkClient *BobinkClient::instance()
+{
+ return s_instance;
+}
+
+BobinkClient *BobinkClient::create(QQmlEngine *, QJSEngine *)
+{
+ if (!s_instance) {
+ s_instance = new BobinkClient;
+ QJSEngine::setObjectOwnership(s_instance, QJSEngine::CppOwnership);
+ }
+ return s_instance;
+}
+
+void BobinkClient::setupClient()
+{
+ m_client = m_provider->createClient(QStringLiteral("open62541"));
+ if (!m_client) {
+ qWarning() << "BobinkClient: failed to create open62541 backend";
+ return;
+ }
+
+ connect(m_client, &QOpcUaClient::stateChanged,
+ this, &BobinkClient::handleStateChanged);
+ connect(m_client, &QOpcUaClient::endpointsRequestFinished,
+ this, &BobinkClient::handleEndpointsReceived);
+ connect(m_client, &QOpcUaClient::connectError,
+ this, &BobinkClient::handleConnectError);
+ connect(m_client, &QOpcUaClient::findServersFinished,
+ this, &BobinkClient::handleFindServersFinished);
+}
+
+// --- Connection properties ---
+
+bool BobinkClient::connected() const { return m_connected; }
+
+QString BobinkClient::serverUrl() const { return m_serverUrl; }
+
+void BobinkClient::setServerUrl(const QString &url)
+{
+ if (m_serverUrl == url)
+ return;
+ m_serverUrl = url;
+ emit serverUrlChanged();
+}
+
+BobinkAuth *BobinkClient::auth() const { return m_auth; }
+
+void BobinkClient::setAuth(BobinkAuth *auth)
+{
+ if (m_auth == auth)
+ return;
+ m_auth = auth;
+ emit authChanged();
+}
+
+QOpcUaClient *BobinkClient::opcuaClient() const { return m_client; }
+
+// --- Connection methods ---
+
+void BobinkClient::connectToServer()
+{
+ if (!m_client) {
+ emit connectionError(QStringLiteral("OPC UA backend not available"));
+ return;
+ }
+ if (m_serverUrl.isEmpty()) {
+ emit connectionError(QStringLiteral("No server URL set"));
+ return;
+ }
+ if (m_client->state() != QOpcUaClient::Disconnected) {
+ emit connectionError(QStringLiteral("Already connected or connecting"));
+ return;
+ }
+
+ m_client->requestEndpoints(QUrl(m_serverUrl));
+}
+
+void BobinkClient::disconnectFromServer()
+{
+ if (m_client)
+ m_client->disconnectFromEndpoint();
+}
+
+void BobinkClient::acceptCertificate()
+{
+ m_certAccepted = true;
+ if (m_certLoop)
+ m_certLoop->quit();
+}
+
+void BobinkClient::rejectCertificate()
+{
+ m_certAccepted = false;
+ if (m_certLoop)
+ m_certLoop->quit();
+}
+
+// --- Discovery properties ---
+
+QString BobinkClient::discoveryUrl() const { return m_discoveryUrl; }
+
+void BobinkClient::setDiscoveryUrl(const QString &url)
+{
+ if (m_discoveryUrl == url)
+ return;
+ m_discoveryUrl = url;
+ emit discoveryUrlChanged();
+}
+
+int BobinkClient::discoveryInterval() const { return m_discoveryInterval; }
+
+void BobinkClient::setDiscoveryInterval(int ms)
+{
+ if (m_discoveryInterval == ms)
+ return;
+ m_discoveryInterval = ms;
+ emit discoveryIntervalChanged();
+
+ if (m_discoveryTimer.isActive())
+ m_discoveryTimer.setInterval(ms);
+}
+
+bool BobinkClient::discovering() const { return m_discovering; }
+
+const QList<QOpcUaApplicationDescription> &BobinkClient::discoveredServers() const
+{
+ return m_discoveredServers;
+}
+
+QVariantList BobinkClient::servers() const
+{
+ QVariantList list;
+ for (const auto &s : m_discoveredServers) {
+ QVariantMap entry;
+ entry[QStringLiteral("serverName")] = s.applicationName().text();
+ entry[QStringLiteral("applicationUri")] = s.applicationUri();
+ entry[QStringLiteral("discoveryUrls")] = QVariant::fromValue(s.discoveryUrls());
+ list.append(entry);
+ }
+ return list;
+}
+
+// --- PKI ---
+
+QString BobinkClient::pkiDir() const { return m_pkiDir; }
+
+void BobinkClient::setPkiDir(const QString &path)
+{
+ if (m_pkiDir == path)
+ return;
+ m_pkiDir = path;
+ ensurePkiDirs(m_pkiDir);
+ emit pkiDirChanged();
+}
+
+void BobinkClient::applyPki()
+{
+ if (!m_client || m_pkiDir.isEmpty())
+ return;
+
+ QOpcUaPkiConfiguration pki;
+ pki.setClientCertificateFile(m_pkiDir + QStringLiteral("/own/certs/BobinkDemo_cert.der"));
+ pki.setPrivateKeyFile(m_pkiDir + QStringLiteral("/own/private/BobinkDemo_key.pem"));
+ pki.setTrustListDirectory(m_pkiDir + QStringLiteral("/trusted/certs"));
+ pki.setRevocationListDirectory(m_pkiDir + QStringLiteral("/trusted/crl"));
+ pki.setIssuerListDirectory(m_pkiDir + QStringLiteral("/issuers/certs"));
+ pki.setIssuerRevocationListDirectory(m_pkiDir + QStringLiteral("/issuers/crl"));
+
+ m_client->setPkiConfiguration(pki);
+
+ if (pki.isKeyAndCertificateFileSet())
+ m_client->setApplicationIdentity(pki.applicationIdentity());
+}
+
+// --- Discovery methods ---
+
+void BobinkClient::startDiscovery()
+{
+ if (m_discoveryUrl.isEmpty() || !m_client)
+ return;
+
+ doDiscovery();
+ m_discoveryTimer.start(m_discoveryInterval);
+
+ if (!m_discovering) {
+ m_discovering = true;
+ emit discoveringChanged();
+ }
+}
+
+void BobinkClient::stopDiscovery()
+{
+ m_discoveryTimer.stop();
+
+ if (m_discovering) {
+ m_discovering = false;
+ emit discoveringChanged();
+ }
+}
+
+void BobinkClient::doDiscovery()
+{
+ if (m_client && !m_discoveryUrl.isEmpty())
+ m_client->findServers(QUrl(m_discoveryUrl));
+}
+
+// --- Private slots ---
+
+void BobinkClient::handleStateChanged(QOpcUaClient::ClientState state)
+{
+ bool nowConnected = (state == QOpcUaClient::Connected);
+ if (m_connected != nowConnected) {
+ m_connected = nowConnected;
+ emit connectedChanged();
+ }
+}
+
+void BobinkClient::handleEndpointsReceived(
+ const QList<QOpcUaEndpointDescription> &endpoints,
+ QOpcUa::UaStatusCode statusCode, const QUrl &)
+{
+ if (statusCode != QOpcUa::Good || endpoints.isEmpty()) {
+ emit connectionError(QStringLiteral("Failed to retrieve endpoints"));
+ return;
+ }
+
+ QOpcUaEndpointDescription best = endpoints.first();
+ for (const auto &ep : endpoints) {
+ if (ep.securityLevel() > best.securityLevel())
+ best = ep;
+ }
+
+ if (m_auth)
+ m_client->setAuthenticationInformation(m_auth->toAuthenticationInformation());
+
+ m_client->connectToEndpoint(best);
+}
+
+void BobinkClient::handleConnectError(QOpcUaErrorState *errorState)
+{
+ if (errorState->connectionStep() ==
+ QOpcUaErrorState::ConnectionStep::CertificateValidation) {
+ // connectError uses BlockingQueuedConnection — the backend thread is
+ // blocked waiting for us to return. The errorState pointer is stack-
+ // allocated in the backend, so it is only valid during this call.
+ // Spin a local event loop so QML can show a dialog and call
+ // acceptCertificate() / rejectCertificate() while we stay in scope.
+ m_certAccepted = false;
+ emit certificateTrustRequested(
+ QStringLiteral("The server certificate is not trusted. Accept?"));
+
+ QEventLoop loop;
+ m_certLoop = &loop;
+ loop.exec();
+ m_certLoop = nullptr;
+
+ errorState->setIgnoreError(m_certAccepted);
+ } else {
+ emit connectionError(
+ QStringLiteral("Connection error at step %1, code 0x%2")
+ .arg(static_cast<int>(errorState->connectionStep()))
+ .arg(static_cast<uint>(errorState->errorCode()), 8, 16, QLatin1Char('0')));
+ }
+}
+
+void BobinkClient::handleFindServersFinished(
+ const QList<QOpcUaApplicationDescription> &servers,
+ QOpcUa::UaStatusCode statusCode, const QUrl &)
+{
+ if (statusCode != QOpcUa::Good)
+ return;
+
+ m_discoveredServers = servers;
+ emit serversChanged();
+}
diff --git a/src/BobinkClient.h b/src/BobinkClient.h
new file mode 100644
index 0000000..b8e5624
--- /dev/null
+++ b/src/BobinkClient.h
@@ -0,0 +1,123 @@
+#ifndef BOBINKCLIENT_H
+#define BOBINKCLIENT_H
+
+#include <QEventLoop>
+#include <QObject>
+#include <QOpcUaApplicationDescription>
+#include <QOpcUaClient>
+#include <QOpcUaEndpointDescription>
+#include <QOpcUaErrorState>
+#include <QOpcUaPkiConfiguration>
+#include <QOpcUaProvider>
+#include <QQmlEngine>
+#include <QTimer>
+
+class BobinkAuth;
+
+class BobinkClient : public QObject
+{
+ Q_OBJECT
+ QML_ELEMENT
+ QML_SINGLETON
+
+ Q_PROPERTY (bool connected READ connected NOTIFY connectedChanged)
+ Q_PROPERTY (QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY
+ serverUrlChanged)
+ Q_PROPERTY (BobinkAuth *auth READ auth WRITE setAuth NOTIFY authChanged)
+
+ Q_PROPERTY (QString discoveryUrl READ discoveryUrl WRITE setDiscoveryUrl
+ NOTIFY discoveryUrlChanged)
+ Q_PROPERTY (int discoveryInterval READ discoveryInterval WRITE
+ setDiscoveryInterval NOTIFY discoveryIntervalChanged)
+ Q_PROPERTY (bool discovering READ discovering NOTIFY discoveringChanged)
+ Q_PROPERTY (QVariantList servers READ servers NOTIFY serversChanged)
+
+ Q_PROPERTY (QString pkiDir READ pkiDir WRITE setPkiDir NOTIFY pkiDirChanged)
+
+public:
+ explicit BobinkClient (QObject *parent = nullptr);
+ ~BobinkClient () override;
+
+ static BobinkClient *instance ();
+ static BobinkClient *create (QQmlEngine *qmlEngine, QJSEngine *jsEngine);
+
+ bool connected () const;
+
+ QString serverUrl () const;
+ void setServerUrl (const QString &url);
+
+ BobinkAuth *auth () const;
+ void setAuth (BobinkAuth *auth);
+
+ QOpcUaClient *opcuaClient () const;
+
+ QString discoveryUrl () const;
+ void setDiscoveryUrl (const QString &url);
+
+ int discoveryInterval () const;
+ void setDiscoveryInterval (int ms);
+
+ bool discovering () const;
+
+ const QList<QOpcUaApplicationDescription> &discoveredServers () const;
+ QVariantList servers () const;
+
+ QString pkiDir () const;
+ void setPkiDir (const QString &path);
+
+ Q_INVOKABLE void connectToServer ();
+ Q_INVOKABLE void disconnectFromServer ();
+ Q_INVOKABLE void acceptCertificate ();
+ Q_INVOKABLE void rejectCertificate ();
+
+ Q_INVOKABLE void startDiscovery ();
+ Q_INVOKABLE void stopDiscovery ();
+ Q_INVOKABLE void applyPki ();
+
+signals:
+ void connectedChanged ();
+ void serverUrlChanged ();
+ void authChanged ();
+ void certificateTrustRequested (const QString &certInfo);
+ void connectionError (const QString &message);
+
+ void discoveryUrlChanged ();
+ void discoveryIntervalChanged ();
+ void discoveringChanged ();
+ void serversChanged ();
+ void pkiDirChanged ();
+
+private slots:
+ void handleStateChanged (QOpcUaClient::ClientState state);
+ void
+ handleEndpointsReceived (const QList<QOpcUaEndpointDescription> &endpoints,
+ QOpcUa::UaStatusCode statusCode,
+ const QUrl &requestUrl);
+ void handleConnectError (QOpcUaErrorState *errorState);
+ void handleFindServersFinished (
+ const QList<QOpcUaApplicationDescription> &servers,
+ QOpcUa::UaStatusCode statusCode, const QUrl &requestUrl);
+ void doDiscovery ();
+
+private:
+ void setupClient ();
+
+ static BobinkClient *s_instance;
+ QOpcUaProvider *m_provider = nullptr;
+ QOpcUaClient *m_client = nullptr;
+ BobinkAuth *m_auth = nullptr;
+ QString m_serverUrl;
+ bool m_connected = false;
+ QEventLoop *m_certLoop = nullptr;
+ bool m_certAccepted = false;
+
+ QString m_discoveryUrl;
+ int m_discoveryInterval = 10000;
+ QTimer m_discoveryTimer;
+ bool m_discovering = false;
+ QList<QOpcUaApplicationDescription> m_discoveredServers;
+
+ QString m_pkiDir;
+};
+
+#endif // BOBINKCLIENT_H
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
new file mode 100644
index 0000000..9b7cac2
--- /dev/null
+++ b/src/CMakeLists.txt
@@ -0,0 +1,12 @@
+qt_add_qml_module(bobink
+ URI Bobink
+ VERSION 1.0
+ SOURCES
+ BobinkAuth.h BobinkAuth.cpp
+ BobinkClient.h BobinkClient.cpp
+ # BobinkServerDiscovery.h BobinkServerDiscovery.cpp
+ # BobinkNode.h BobinkNode.cpp
+ OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml/Bobink"
+)
+
+target_link_libraries(bobink PRIVATE Qt6::Core Qt6::Quick Qt6::OpcUa)