diff options
| author | Thomas Vanbesien <tvanbesi@proton.me> | 2026-02-20 10:41:09 +0100 |
|---|---|---|
| committer | Thomas Vanbesien <tvanbesi@proton.me> | 2026-02-20 10:41:09 +0100 |
| commit | 0012cb312e92c33f5263478d318eb82da22ee879 (patch) | |
| tree | caac374dd3716b42d13cb85b85a7f90c7d5aac45 /src/OpcUaClient.cpp | |
| parent | 11b99fda8727f2225961c0b83ecdb18674a9670a (diff) | |
| download | BobinkQtOpcUa-0012cb312e92c33f5263478d318eb82da22ee879.tar.gz BobinkQtOpcUa-0012cb312e92c33f5263478d318eb82da22ee879.zip | |
Rename classes to OpcUa* prefix, replace BobinkNode with OpcUaMonitoredNode boilerplate
Rename BobinkAuth → OpcUaAuth, BobinkClient → OpcUaClient (C++ class
names only; QML module URI and singleton name stay as Bobink).
Remove BobinkNode (QQuickItem-based) and add OpcUaMonitoredNode
skeleton using QObject + QQmlParserStatus, following Qt convention
for non-visual QML types.
Diffstat (limited to 'src/OpcUaClient.cpp')
| -rw-r--r-- | src/OpcUaClient.cpp | 569 |
1 files changed, 569 insertions, 0 deletions
diff --git a/src/OpcUaClient.cpp b/src/OpcUaClient.cpp new file mode 100644 index 0000000..91ce47b --- /dev/null +++ b/src/OpcUaClient.cpp @@ -0,0 +1,569 @@ +/** + * @file OpcUaClient.cpp + * @brief OpcUaClient implementation. + */ +#include "OpcUaClient.h" +#include "OpcUaAuth.h" + +#include <QDir> +#include <QMetaEnum> +#include <QOpcUaPkiConfiguration> +#include <QOpcUaUserTokenPolicy> +#include <QStandardPaths> + +using namespace Qt::Literals::StringLiterals; + +static constexpr qint32 DISCOVERY_INTERVAL = 30000; // ms + +static QString +defaultPkiDir () +{ + return QStandardPaths::writableLocation (QStandardPaths::AppDataLocation) + + QStringLiteral ("/pki"); +} + +/** @brief Create the standard OPC UA PKI directory tree. */ +static void +ensurePkiDirs (const QString &base) +{ + for (const auto sub : + { u"own/certs"_s, u"own/private"_s, u"trusted/certs"_s, + u"trusted/crl"_s, u"issuers/certs"_s, u"issuers/crl"_s }) + { + QDir ().mkpath (base + QLatin1Char ('/') + sub); + } +} + +static QString +securityPolicyUri (OpcUaClient::SecurityPolicy policy) +{ + switch (policy) + { + case OpcUaClient::Basic256Sha256: + return QStringLiteral ( + "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256"); + case OpcUaClient::Aes128_Sha256_RsaOaep: + return QStringLiteral ( + "http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep"); + case OpcUaClient::Aes256_Sha256_RsaPss: + return QStringLiteral ( + "http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss"); + } + return {}; +} + +/* ====================================== + * Construction + * ====================================== */ + +OpcUaClient::OpcUaClient (QObject *parent) + : QObject (parent), m_provider (new QOpcUaProvider (this)), + m_pkiDir (defaultPkiDir ()) +{ + Q_ASSERT (!s_instance); + ensurePkiDirs (m_pkiDir); + setupClient (); + autoDetectPki (); + applyPki (); + connect (&m_discoveryTimer, &QTimer::timeout, this, + &OpcUaClient::doDiscovery); + s_instance = this; +} + +void +OpcUaClient::setupClient () +{ + m_client = m_provider->createClient (QStringLiteral ("open62541")); + if (!m_client) + { + qWarning () << "OpcUaClient: failed to create open62541 backend"; + return; + } + + connect (m_client, &QOpcUaClient::stateChanged, this, + &OpcUaClient::handleStateChanged); + connect (m_client, &QOpcUaClient::endpointsRequestFinished, this, + &OpcUaClient::handleEndpointsReceived); + connect (m_client, &QOpcUaClient::connectError, this, + &OpcUaClient::handleConnectError); + connect (m_client, &QOpcUaClient::findServersFinished, this, + &OpcUaClient::handleFindServersFinished); + connect (m_client, &QOpcUaClient::errorChanged, this, + &OpcUaClient::handleClientError); +} + +/* ====================================== + * Connection + * ====================================== */ + +OpcUaClient * +OpcUaClient::instance () +{ + return s_instance; +} + +bool +OpcUaClient::connected () const +{ + return m_connected; +} + +QString +OpcUaClient::serverUrl () const +{ + return m_serverUrl; +} + +void +OpcUaClient::setServerUrl (const QString &url) +{ + if (m_serverUrl == url) + return; + m_serverUrl = url; + emit serverUrlChanged (); +} + +OpcUaAuth * +OpcUaClient::auth () const +{ + return m_auth; +} + +void +OpcUaClient::setAuth (OpcUaAuth *auth) +{ + if (m_auth == auth) + return; + m_auth = auth; + emit authChanged (); +} + +QOpcUaClient * +OpcUaClient::opcuaClient () const +{ + return m_client; +} + +void +OpcUaClient::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; + } + + QUrl url (m_serverUrl); + if (!url.isValid ()) + { + emit connectionError ( + QStringLiteral ("Invalid server URL: %1").arg (m_serverUrl)); + return; + } + m_client->requestEndpoints (url); +} + +void +OpcUaClient::connectDirect (SecurityPolicy policy, SecurityMode mode) +{ + 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; + } + + QOpcUaEndpointDescription endpoint; + endpoint.setEndpointUrl (m_serverUrl); + endpoint.setSecurityPolicy (securityPolicyUri (policy)); + endpoint.setSecurityMode ( + static_cast<QOpcUaEndpointDescription::MessageSecurityMode> (mode)); + + QOpcUaUserTokenPolicy tokenPolicy; + if (m_auth) + { + switch (m_auth->mode ()) + { + case OpcUaAuth::Anonymous: + tokenPolicy.setTokenType ( + QOpcUaUserTokenPolicy::TokenType::Anonymous); + break; + case OpcUaAuth::UserPass: + tokenPolicy.setTokenType ( + QOpcUaUserTokenPolicy::TokenType::Username); + break; + case OpcUaAuth::Certificate: + tokenPolicy.setTokenType ( + QOpcUaUserTokenPolicy::TokenType::Certificate); + break; + } + m_client->setAuthenticationInformation ( + m_auth->toAuthenticationInformation ()); + } + else + { + tokenPolicy.setTokenType (QOpcUaUserTokenPolicy::TokenType::Anonymous); + } + endpoint.setUserIdentityTokens ({ tokenPolicy }); + + m_client->connectToEndpoint (endpoint); +} + +void +OpcUaClient::disconnectFromServer () +{ + if (m_client) + m_client->disconnectFromEndpoint (); +} + +void +OpcUaClient::acceptCertificate () +{ + m_certAccepted = true; + if (m_certLoop) + m_certLoop->quit (); +} + +void +OpcUaClient::rejectCertificate () +{ + m_certAccepted = false; + if (m_certLoop) + m_certLoop->quit (); +} + +void +OpcUaClient::handleStateChanged (QOpcUaClient::ClientState state) +{ + bool nowConnected = (state == QOpcUaClient::Connected); + if (m_connected != nowConnected) + { + m_connected = nowConnected; + emit connectedChanged (); + } +} + +void +OpcUaClient::handleEndpointsReceived ( + const QList<QOpcUaEndpointDescription> &endpoints, + QOpcUa::UaStatusCode statusCode, const QUrl &) +{ + if (statusCode != QOpcUa::Good || endpoints.isEmpty ()) + { + emit connectionError (QStringLiteral ("Failed to retrieve endpoints")); + return; + } + + // Pick the endpoint with the highest securityLevel (server-assigned score). + 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 +OpcUaClient::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; + QTimer::singleShot (30000, &loop, &QEventLoop::quit); + 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<quint32> (errorState->errorCode ()), 8, 16, + QLatin1Char ('0'))); + } +} + +void +OpcUaClient::handleClientError (QOpcUaClient::ClientError error) +{ + if (error == QOpcUaClient::NoError) + return; + auto me = QMetaEnum::fromType<QOpcUaClient::ClientError> (); + const char *key = me.valueToKey (static_cast<int> (error)); + emit connectionError ( + QStringLiteral ("Client error: %1") + .arg (key ? QLatin1StringView (key) : "Unknown"_L1)); +} + +/* ====================================== + * Discovery + * ====================================== */ + +QString +OpcUaClient::discoveryUrl () const +{ + return m_discoveryUrl; +} + +void +OpcUaClient::setDiscoveryUrl (const QString &url) +{ + if (m_discoveryUrl == url) + return; + m_discoveryUrl = url; + emit discoveryUrlChanged (); +} + +bool +OpcUaClient::discovering () const +{ + return m_discovering; +} + +const QList<QOpcUaApplicationDescription> & +OpcUaClient::discoveredServers () const +{ + return m_discoveredServers; +} + +QVariantList +OpcUaClient::servers () const +{ + return m_serversCache; +} + +void +OpcUaClient::startDiscovery () +{ + if (m_discoveryUrl.isEmpty () || !m_client) + return; + + doDiscovery (); + m_discoveryTimer.start (DISCOVERY_INTERVAL); + + if (!m_discovering) + { + m_discovering = true; + emit discoveringChanged (); + } +} + +void +OpcUaClient::stopDiscovery () +{ + m_discoveryTimer.stop (); + + if (m_discovering) + { + m_discovering = false; + emit discoveringChanged (); + } +} + +void +OpcUaClient::doDiscovery () +{ + if (!m_client || m_discoveryUrl.isEmpty ()) + return; + QUrl url (m_discoveryUrl); + if (!url.isValid ()) + return; + m_client->findServers (url); +} + +void +OpcUaClient::handleFindServersFinished ( + const QList<QOpcUaApplicationDescription> &servers, + QOpcUa::UaStatusCode statusCode, const QUrl &) +{ + if (statusCode != QOpcUa::Good) + return; + + m_discoveredServers = servers; + m_serversCache.clear (); + 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 ()); + m_serversCache.append (entry); + } + emit serversChanged (); +} + +/* ====================================== + * PKI + * ====================================== */ + +QString +OpcUaClient::pkiDir () const +{ + return m_pkiDir; +} + +void +OpcUaClient::setPkiDir (const QString &path) +{ + if (m_pkiDir == path) + return; + m_pkiDir = path; + ensurePkiDirs (m_pkiDir); + emit pkiDirChanged (); +} + +QString +OpcUaClient::certFile () const +{ + return m_certFile; +} + +void +OpcUaClient::setCertFile (const QString &path) +{ + if (m_certFile == path) + return; + m_certFile = path; + emit certFileChanged (); +} + +QString +OpcUaClient::keyFile () const +{ + return m_keyFile; +} + +void +OpcUaClient::setKeyFile (const QString &path) +{ + if (m_keyFile == path) + return; + m_keyFile = path; + emit keyFileChanged (); +} + +void +OpcUaClient::autoDetectPki () +{ + if (m_pkiDir.isEmpty ()) + return; + + QDir certDir (m_pkiDir + QStringLiteral ("/own/certs")); + QStringList certs + = certDir.entryList ({ QStringLiteral ("*.der") }, QDir::Files); + if (!certs.isEmpty ()) + setCertFile (certDir.filePath (certs.first ())); + + QDir keyDir (m_pkiDir + QStringLiteral ("/own/private")); + QStringList keys = keyDir.entryList ( + { QStringLiteral ("*.pem"), QStringLiteral ("*.crt") }, QDir::Files); + if (!keys.isEmpty ()) + setKeyFile (keyDir.filePath (keys.first ())); +} + +void +OpcUaClient::applyPki () +{ + if (!m_client || m_pkiDir.isEmpty ()) + return; + + if (!m_certFile.isEmpty () && !QFile::exists (m_certFile)) + { + emit statusMessage ( + QStringLiteral ("PKI error: certificate not found: %1") + .arg (m_certFile)); + return; + } + if (!m_keyFile.isEmpty () && !QFile::exists (m_keyFile)) + { + emit statusMessage ( + QStringLiteral ("PKI error: private key not found: %1") + .arg (m_keyFile)); + return; + } + if (!m_certFile.isEmpty () && m_keyFile.isEmpty ()) + { + emit statusMessage ( + QStringLiteral ("PKI error: certificate set but no private key")); + return; + } + if (m_certFile.isEmpty () && !m_keyFile.isEmpty ()) + { + emit statusMessage ( + QStringLiteral ("PKI error: private key set but no certificate")); + return; + } + + QOpcUaPkiConfiguration pki; + if (!m_certFile.isEmpty ()) + pki.setClientCertificateFile (m_certFile); + if (!m_keyFile.isEmpty ()) + pki.setPrivateKeyFile (m_keyFile); + 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 ()) + { + auto identity = pki.applicationIdentity (); + if (!identity.isValid ()) + { + emit statusMessage (QStringLiteral ( + "PKI error: certificate could not be parsed (invalid DER?)")); + return; + } + m_client->setApplicationIdentity (identity); + emit statusMessage (QStringLiteral ("PKI applied: %1") + .arg (m_certFile.split ('/').last ())); + } + else + { + emit statusMessage ( + QStringLiteral ("PKI applied (no client certificate)")); + } +} |
