diff options
| -rw-r--r-- | .clang-format | 1 | ||||
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | .gitmodules | 6 | ||||
| -rw-r--r-- | CMakeLists.txt | 26 | ||||
| -rw-r--r-- | cmake/BuildDeps.cmake | 105 | ||||
| -rw-r--r-- | demo/CMakeLists.txt | 18 | ||||
| -rw-r--r-- | demo/Main.qml | 188 | ||||
| -rw-r--r-- | demo/main.cpp | 18 | ||||
| m--------- | deps/open62541 | 0 | ||||
| m--------- | deps/qtopcua | 0 | ||||
| -rw-r--r-- | src/BobinkAuth.cpp | 73 | ||||
| -rw-r--r-- | src/BobinkAuth.h | 60 | ||||
| -rw-r--r-- | src/BobinkClient.cpp | 315 | ||||
| -rw-r--r-- | src/BobinkClient.h | 123 | ||||
| -rw-r--r-- | src/CMakeLists.txt | 12 |
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) |
