From 38e3a2e587796df133f50363e65a68ec7989cb9b Mon Sep 17 00:00:00 2001 From: Thomas Vanbesien Date: Tue, 24 Feb 2026 18:23:32 +0100 Subject: Initial commit: BobinkQtOpcUa app template with demo UI Qt 6 QML application template using BobinkQtOpcUa as a submodule for OPC UA connectivity. Includes discovery, PKI, auth, node monitoring with read/write, and a multi-page demo UI. --- .gitignore | 2 + .gitmodules | 3 + CMakeLists.txt | 47 ++++++ Main.qml | 438 +++++++++++++++++++++++++++++++++++++++++++++++++++++ NodePage.qml | 341 +++++++++++++++++++++++++++++++++++++++++ deps/BobinkQtOpcUa | 1 + main.cpp | 84 ++++++++++ 7 files changed, 916 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CMakeLists.txt create mode 100644 Main.qml create mode 100644 NodePage.qml create mode 160000 deps/BobinkQtOpcUa create mode 100644 main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e87258 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.qtcreator/ +build/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..99134b0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "deps/BobinkQtOpcUa"] + path = deps/BobinkQtOpcUa + url = https://git.tvcloud.fr/BobinkQtOpcUa diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b464cce --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.16) +project(BobinkQtOpcUaAppTemplate LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# ── Qt setup (must come before BobinkQtOpcUa so AUTOMOC is enabled) ─────── +find_package(Qt6 6.10.2 REQUIRED COMPONENTS Quick) +qt_standard_project_setup(REQUIRES 6.10.2) + +# ── BobinkQtOpcUa ────────────────────────────────────────────────────────── +# Builds open62541 and QtOpcUa from submodules automatically on first +# configure. Subsequent configures skip the dep builds (delete +# build/deps/*-install/ to force a rebuild). +# +# Prerequisites: Qt 6.10.2+ with qt-cmake in PATH, OpenSSL dev headers, +# Ninja, and initialized submodules: +# git submodule update --init --recursive +add_subdirectory(deps/BobinkQtOpcUa) + +qt_add_executable(BobinkQtOpcUaAppTemplate main.cpp) +qt_add_qml_module(BobinkQtOpcUaAppTemplate URI BobinkQtOpcUaAppTemplate VERSION 1.0 QML_FILES Main.qml NodePage.qml) + +# Executable goes to bin/ to avoid clashing with the QML module directory +set_target_properties(BobinkQtOpcUaAppTemplate PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") + +# Link against BobinkQtOpcUaplugin (not BobinkQtOpcUa). The "plugin" +# target includes the QML type registration needed for `import Bobink`. +target_link_libraries(BobinkQtOpcUaAppTemplate PRIVATE Qt6::Quick BobinkQtOpcUaplugin) + +# ── Runtime quirks ────────────────────────────────────────────────────────── +# BobinkQtOpcUa builds open62541 as a shared library. The QtOpcUa backend +# plugin (loaded at runtime via dlopen) needs to find it. These two lines +# make that work during development: + +# 1. RPATH: embed the dep lib dirs so the dynamic linker finds libopen62541 +# and libQt6OpcUa at runtime without installing them system-wide. +set(CMAKE_BUILD_RPATH "${QTOPCUA_INSTALL_DIR}/lib" + "${OPEN62541_INSTALL_DIR}/lib") + +# 2. Plugin path: Qt's plugin loader doesn't know about our locally-built +# QtOpcUa backend. Pass the path as a compile definition and call +# QCoreApplication::addLibraryPath(QTOPCUA_PLUGIN_PATH) in main.cpp +# *before* creating the QGuiApplication. +target_compile_definitions(BobinkQtOpcUaAppTemplate PRIVATE + QTOPCUA_PLUGIN_PATH="${QTOPCUA_INSTALL_DIR}/plugins") diff --git a/Main.qml b/Main.qml new file mode 100644 index 0000000..361e3bd --- /dev/null +++ b/Main.qml @@ -0,0 +1,438 @@ +// Main.qml — Demo app for Bobink library. +// Connects to an OPC UA server, then pushes NodePage for node interaction. + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import Bobink + +ApplicationWindow { + id: root + width: 800 + height: 900 + visible: true + title: "Bobink Demo" + + property bool autoConnectFailed: false + property bool showPkiSettings: false + + Connections { + target: Bobink + function onServersChanged() { + debugConsole.appendLog("Discovered server list updated"); + } + function onConnectedChanged() { + debugConsole.appendLog("Connected: " + Bobink.connected); + if (Bobink.connected) { + root.autoConnectFailed = false; + Bobink.stopDiscovery(); + stack.push("NodePage.qml", { + stackRef: stack, + pageNumber: 1, + logFunction: debugConsole.appendLog + }); + } else { + stack.pop(null); + } + } + function onConnectionError(message) { + debugConsole.appendLog("Connection error: " + message); + root.autoConnectFailed = true; + } + function onStatusMessage(message) { + debugConsole.appendLog(message); + } + function onDiscoveringChanged() { + debugConsole.appendLog("Discovering: " + Bobink.discovering); + } + function onCertificateTrustRequested(certInfo) { + certTrustDialog.certInfo = certInfo; + certTrustDialog.open(); + } + } + + Dialog { + id: certTrustDialog + property string certInfo + anchors.centerIn: parent + title: "Certificate Trust" + modal: true + standardButtons: Dialog.Yes | Dialog.No + Label { + text: certTrustDialog.certInfo + } + onAccepted: Bobink.acceptCertificate() + onRejected: Bobink.rejectCertificate() + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + StackView { + id: stack + Layout.fillWidth: true + Layout.fillHeight: true + + initialItem: Page { + id: connectionPage + Component.onCompleted: { + Bobink.discoveryUrl = discoveryUrlField.text; + Bobink.startDiscovery(); + } + + OpcUaAuth { + id: auth + mode: authModeCombo.currentValue + username: usernameField.text + password: passwordField.text + certPath: Bobink.certFile + keyPath: Bobink.keyFile + } + + FileDialog { + id: certFileDialog + title: "Select Certificate" + currentFolder: "file://" + trustFolderField.text + nameFilters: ["DER certificates (*.der)", "All files (*)"] + onAccepted: certFileField.text = selectedFile.toString().replace("file://", "") + } + FileDialog { + id: keyFileDialog + title: "Select Private Key" + currentFolder: "file://" + trustFolderField.text + nameFilters: ["Key files (*.pem *.crt)", "All files (*)"] + onAccepted: keyFileField.text = selectedFile.toString().replace("file://", "") + } + FolderDialog { + id: trustFolderDialog + title: "Select Trust Folder" + currentFolder: "file://" + trustFolderField.text + onAccepted: trustFolderField.text = selectedFolder.toString().replace("file://", "") + } + + 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: Bobink.discovering ? "Stop" : "Discover" + onClicked: { + if (Bobink.discovering) { + Bobink.stopDiscovery(); + } else { + Bobink.discoveryUrl = discoveryUrlField.text; + Bobink.startDiscovery(); + } + } + } + } + + Label { + text: Bobink.discovering ? "Discovering... (" + Bobink.servers.length + " found)" : Bobink.servers.length + " server(s)" + font.italic: true + } + + ListView { + id: serverListView + Layout.fillWidth: true + Layout.preferredHeight: 100 + clip: true + model: Bobink.servers + delegate: ItemDelegate { + id: serverDelegate + required property var modelData + width: ListView.view.width + contentItem: ColumnLayout { + spacing: 2 + Label { + text: serverDelegate.modelData.serverName + } + Label { + text: serverDelegate.modelData.applicationUri + color: "gray" + font.italic: true + font.pointSize: 8 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + onClicked: { + if (modelData.discoveryUrls.length > 0) + serverUrlField.text = modelData.discoveryUrls[0]; + } + } + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + } + + RowLayout { + Label { + text: "PKI" + font.bold: true + } + Label { + text: Bobink.certFile ? " (" + Bobink.certFile.split("/").pop() + ")" : " (no certificate found)" + font.italic: true + color: Bobink.certFile ? "green" : "gray" + } + Item { + Layout.fillWidth: true + } + Button { + text: root.showPkiSettings ? "Hide" : "Configure" + onClicked: root.showPkiSettings = !root.showPkiSettings + } + } + + GridLayout { + columns: 3 + Layout.fillWidth: true + visible: root.showPkiSettings + + Label { + text: "Certificate:" + } + TextField { + id: certFileField + Layout.fillWidth: true + text: Bobink.certFile + placeholderText: "Client certificate (.der)" + } + Button { + text: "Browse" + onClicked: certFileDialog.open() + } + + Label { + text: "Private key:" + } + TextField { + id: keyFileField + Layout.fillWidth: true + text: Bobink.keyFile + placeholderText: "Private key (.pem, .crt)" + } + Button { + text: "Browse" + onClicked: keyFileDialog.open() + } + + Label { + text: "Trust folder:" + } + TextField { + id: trustFolderField + Layout.fillWidth: true + text: Bobink.pkiDir + } + Button { + text: "Browse" + onClicked: trustFolderDialog.open() + } + } + + Button { + text: "Apply PKI" + Layout.fillWidth: true + visible: root.showPkiSettings + onClicked: { + Bobink.pkiDir = trustFolderField.text; + Bobink.certFile = certFileField.text; + Bobink.keyFile = keyFileField.text; + Bobink.applyPki(); + } + } + + Label { + text: "Server URL" + font.bold: true + } + + TextField { + id: serverUrlField + Layout.fillWidth: true + placeholderText: "opc.tcp://..." + } + + Label { + text: "Authentication" + font.bold: true + } + + ComboBox { + id: authModeCombo + Layout.fillWidth: true + textRole: "text" + valueRole: "mode" + model: [ + { + text: "Anonymous", + mode: OpcUaAuth.Anonymous + }, + { + text: "Username / Password", + mode: OpcUaAuth.UserPass + }, + { + text: "Certificate", + mode: OpcUaAuth.Certificate + } + ] + } + + GridLayout { + columns: 2 + visible: authModeCombo.currentValue === OpcUaAuth.UserPass + Layout.fillWidth: true + + Label { + text: "Username:" + } + TextField { + id: usernameField + Layout.fillWidth: true + } + Label { + text: "Password:" + } + TextField { + id: passwordField + Layout.fillWidth: true + echoMode: TextInput.Password + } + } + + Button { + text: "Connect" + Layout.fillWidth: true + onClicked: { + root.autoConnectFailed = false; + Bobink.auth = auth; + Bobink.serverUrl = serverUrlField.text; + Bobink.connectToServer(); + } + } + + Label { + text: "Direct Connect" + font.bold: true + visible: root.autoConnectFailed + } + + GridLayout { + columns: 2 + Layout.fillWidth: true + visible: root.autoConnectFailed + + Label { + text: "Security policy:" + } + ComboBox { + id: securityPolicyCombo + Layout.fillWidth: true + textRole: "text" + valueRole: "policy" + model: [ + { + text: "Basic256Sha256", + policy: Bobink.Basic256Sha256 + }, + { + text: "Aes128-Sha256-RsaOaep", + policy: Bobink.Aes128_Sha256_RsaOaep + }, + { + text: "Aes256-Sha256-RsaPss", + policy: Bobink.Aes256_Sha256_RsaPss + } + ] + } + + Label { + text: "Security mode:" + } + ComboBox { + id: securityModeCombo + Layout.fillWidth: true + textRole: "text" + valueRole: "mode" + model: [ + { + text: "Sign & Encrypt", + mode: Bobink.SignAndEncrypt + }, + { + text: "Sign", + mode: Bobink.Sign + }, + { + text: "None", + mode: Bobink.None + } + ] + } + } + + Button { + text: "Direct Connect" + Layout.fillWidth: true + visible: root.autoConnectFailed + onClicked: { + Bobink.auth = auth; + Bobink.serverUrl = serverUrlField.text; + Bobink.connectDirect(securityPolicyCombo.currentValue, securityModeCombo.currentValue); + } + } + + Item { + Layout.fillHeight: true + } + } + } + } + + Rectangle { + id: debugConsole + Layout.fillWidth: true + Layout.preferredHeight: 120 + color: "#1e1e1e" + border.color: "#444" + radius: 4 + + function appendLog(msg) { + let ts = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss"); + debugLog.text += "[" + ts + "] " + msg + "\n"; + debugLog.cursorPosition = debugLog.text.length; + } + + ScrollView { + anchors.fill: parent + anchors.margins: 4 + TextArea { + id: debugLog + readOnly: true + color: "#cccccc" + font.family: "monospace" + font.pointSize: 9 + wrapMode: TextEdit.Wrap + background: null + } + } + } + } +} diff --git a/NodePage.qml b/NodePage.qml new file mode 100644 index 0000000..e089f0e --- /dev/null +++ b/NodePage.qml @@ -0,0 +1,341 @@ +// NodePage.qml — Displays 10 OPC UA nodes per page with tooltips. + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Bobink + +Page { + id: nodePage + + required property StackView stackRef + required property int pageNumber + required property var logFunction + + readonly property var pages: [ + { + title: "Server Info", + description: "Standard OPC UA server nodes (namespace 0)." + + " CurrentTime updates live via monitoring.", + nodes: [ + "ns=0;i=2258", // CurrentTime + "ns=0;i=2257", // StartTime + "ns=0;i=2259", // State + "ns=0;i=2261", // ProductName + "ns=0;i=2264" // SoftwareVersion + ] + }, + { + title: "Read-Write Scalars", + description: "Single-value nodes with read and write access.", + nodes: [ + "ns=1;s=bool_rw_scalar", + "ns=1;s=int16_rw_scalar", + "ns=1;s=uint16_rw_scalar", + "ns=1;s=int32_rw_scalar", + "ns=1;s=uint32_rw_scalar", + "ns=1;s=int64_rw_scalar", + "ns=1;s=uint64_rw_scalar", + "ns=1;s=float_rw_scalar", + "ns=1;s=double_rw_scalar", + "ns=1;s=string_rw_scalar", + "ns=1;s=sbyte_rw_scalar", + "ns=1;s=byte_rw_scalar", + "ns=1;s=datetime_rw_scalar", + "ns=1;s=guid_rw_scalar", + "ns=1;s=bytestring_rw_scalar" + ] + }, + { + title: "Read-Only Scalars", + description: "Single-value nodes with read-only access.", + nodes: [ + "ns=1;s=bool_ro_scalar", + "ns=1;s=int16_ro_scalar", + "ns=1;s=uint16_ro_scalar", + "ns=1;s=int32_ro_scalar", + "ns=1;s=uint32_ro_scalar", + "ns=1;s=int64_ro_scalar", + "ns=1;s=uint64_ro_scalar", + "ns=1;s=float_ro_scalar", + "ns=1;s=double_ro_scalar", + "ns=1;s=string_ro_scalar", + "ns=1;s=sbyte_ro_scalar", + "ns=1;s=byte_ro_scalar", + "ns=1;s=datetime_ro_scalar", + "ns=1;s=guid_ro_scalar", + "ns=1;s=bytestring_ro_scalar" + ] + }, + { + title: "Read-Write Arrays", + description: "Array nodes. Values are displayed comma-separated." + + " To write, enter comma-separated values (e.g. \"1, 2, 3\")." + + " Commas cannot appear inside individual values.", + nodes: [ + "ns=1;s=bool_rw_array", + "ns=1;s=int16_rw_array", + "ns=1;s=uint16_rw_array", + "ns=1;s=int32_rw_array", + "ns=1;s=uint32_rw_array", + "ns=1;s=int64_rw_array", + "ns=1;s=uint64_rw_array", + "ns=1;s=float_rw_array", + "ns=1;s=double_rw_array", + "ns=1;s=string_rw_array", + "ns=1;s=sbyte_rw_array", + "ns=1;s=byte_rw_array", + "ns=1;s=datetime_rw_array", + "ns=1;s=guid_rw_array", + "ns=1;s=bytestring_rw_array" + ] + }, + { + title: "Index Range Write", + description: "Write to specific array elements using OPC UA index" + + " range syntax. Examples: \"0\" = first element," + + " \"0:2\" = elements 0–2. Enter the range and the value(s)" + + " to write (comma-separated for multi-element ranges).", + indexRange: true, + nodes: [ + "ns=1;s=int32_rw_array", + "ns=1;s=float_rw_array", + "ns=1;s=string_rw_array" + ] + }, + { + title: "Non-Existent Nodes", + description: "These node IDs do not exist on the server." + + " The row should show no value and no metadata in the tooltip.", + nodes: [ + "ns=1;s=does_not_exist", + "ns=99;i=12345", + "ns=1;s=also_missing" + ] + }, + { + title: "Empty (Monitoring Test)", + description: "No nodes on this page. All previous pages are inactive," + + " so the server log should show zero active monitored items.", + nodes: [] + } + ] + + readonly property var currentPage: pages[pageNumber - 1] + + // OPC UA ServerState enum (Part 4, Table 120). + readonly property var serverStates: [ + "Running", "Failed", "NoConfiguration", "Suspended", + "Shutdown", "Test", "CommunicationFault", "Unknown" + ] + + function formatValue(node) { + var v = node.value; + if (v === undefined || String(v) === "") + return "—"; + // Map ServerState enum to human-readable string. + if (node.nodeId === "ns=0;i=2259" && !isNaN(v)) + return serverStates[v] || String(v); + return String(v); + } + + Component.onCompleted: nodePage.logFunction( + currentPage.title + " page loaded (" + currentPage.nodes.length + " nodes)") + + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 4 + + // Header + RowLayout { + Label { + text: currentPage.title + font.bold: true + font.pointSize: 14 + } + Label { + text: "(" + nodePage.pageNumber + "/" + nodePage.pages.length + ")" + color: "gray" + } + Item { Layout.fillWidth: true } + Button { + text: "Disconnect" + onClicked: Bobink.disconnectFromServer() + } + } + + Label { + id: pageDescription + visible: currentPage.description !== undefined + text: currentPage.description || "" + wrapMode: Text.WordWrap + color: "gray" + font.italic: true + Layout.fillWidth: true + } + + // Column headers + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 12 + Label { + text: "Identifier" + font.bold: true + Layout.preferredWidth: 160 + } + Label { + text: "Value" + font.bold: true + Layout.preferredWidth: 300 + } + Label { + text: "Write" + font.bold: true + Layout.fillWidth: true + } + } + + Rectangle { + id: separator + Layout.fillWidth: true + height: 1 + color: "#ccc" + } + + // Nodes + Repeater { + model: currentPage.nodes + + ItemDelegate { + id: delegate + required property string modelData + required property int index + Layout.fillWidth: true + padding: 4 + leftPadding: 12 + rightPadding: 12 + + OpcUaMonitoredNode { + id: node + nodeId: delegate.modelData + monitored: nodePage.StackView.status === StackView.Active + onWriteCompleted: (success, message) => { + var short_id = node.nodeId.substring(node.nodeId.indexOf(";s=") + 3); + nodePage.logFunction(short_id + ": " + message); + } + } + + background: Rectangle { + color: delegate.index % 2 === 0 ? "#f8f8f8" : "transparent" + radius: 2 + } + + contentItem: RowLayout { + spacing: 12 + + // Column 1: Display name if available, otherwise short ID. + Label { + text: { + if (node.info.displayName) + return node.info.displayName; + var idx = node.nodeId.indexOf(";s="); + return idx >= 0 ? node.nodeId.substring(idx + 3) : node.nodeId; + } + Layout.preferredWidth: 160 + elide: Text.ElideRight + } + + // Column 2: Live value (always visible) + Label { + text: nodePage.formatValue(node) + Layout.preferredWidth: 300 + elide: Text.ElideRight + } + + // Column 3: Edit area (writable) or READ-ONLY label + + // Normal write controls (non-index-range pages) + TextField { + id: editField + visible: node.writable && !currentPage.indexRange + Layout.fillWidth: true + placeholderText: "Enter value..." + onAccepted: node.writeValue(text) + } + Button { + visible: node.writable && !currentPage.indexRange + text: "Write" + onClicked: node.writeValue(editField.text) + } + + // Index range write controls + TextField { + id: rangeField + visible: node.writable && currentPage.indexRange === true + Layout.preferredWidth: 60 + placeholderText: "Range" + } + TextField { + id: rangeValueField + visible: node.writable && currentPage.indexRange === true + Layout.fillWidth: true + placeholderText: "Value(s)..." + onAccepted: node.writeValueAtRange(text, rangeField.text) + } + Button { + visible: node.writable && currentPage.indexRange === true + text: "Write Range" + onClicked: node.writeValueAtRange(rangeValueField.text, rangeField.text) + } + + Label { + visible: !node.writable + text: "(READ-ONLY)" + color: "gray" + font.italic: true + Layout.fillWidth: true + } + } + + ToolTip.visible: hovered + ToolTip.delay: 400 + ToolTip.text: + "Display Name: " + (node.info.displayName || "—") + + "\nDescription: " + (node.info.description || "—") + + "\nNode Class: " + (node.info.nodeClass || "—") + + "\nData Type: " + (node.info.dataType || "—") + + "\nValue Rank: " + (node.info.valueRank || "—") + + "\nArray Dimensions: " + (node.info.arrayDimensions || "—") + + "\nAccess Level: " + node.info.accessLevel + + "\nStatus: " + (node.info.status || "—") + + "\nSource Time: " + (node.info.sourceTimestamp.toLocaleString() || "—") + + "\nServer Time: " + (node.info.serverTimestamp.toLocaleString() || "—") + } + } + + Item { Layout.fillHeight: true } + + // Navigation + RowLayout { + Layout.fillWidth: true + Button { + text: "← Previous" + visible: nodePage.pageNumber > 1 + onClicked: nodePage.stackRef.pop() + } + Item { Layout.fillWidth: true } + Button { + text: "Next →" + visible: nodePage.pageNumber < nodePage.pages.length + onClicked: nodePage.stackRef.push("NodePage.qml", { + stackRef: nodePage.stackRef, + pageNumber: nodePage.pageNumber + 1, + logFunction: nodePage.logFunction + }) + } + } + } +} diff --git a/deps/BobinkQtOpcUa b/deps/BobinkQtOpcUa new file mode 160000 index 0000000..3e8111a --- /dev/null +++ b/deps/BobinkQtOpcUa @@ -0,0 +1 @@ +Subproject commit 3e8111aaee070dc9b5d51cab3fd44ff88841f63e diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..aa341ad --- /dev/null +++ b/main.cpp @@ -0,0 +1,84 @@ +/** + * @file main.cpp + * @brief Entry point for the Bobink demo application. + */ +#include +#include +#include + +#include +Q_IMPORT_QML_PLUGIN (BobinkPlugin) + +/** @brief Custom log handler matching open62541 server log format. */ +static void +logHandler (QtMsgType type, const QMessageLogContext &ctx, const QString &msg) +{ + // Color only the type/category tag. + const char *color = ""; + const char *label = "debug"; + switch (type) + { + case QtDebugMsg: + label = "debug"; + break; + case QtInfoMsg: + color = "\x1b[32m"; + label = "info"; + break; + case QtWarningMsg: + color = "\x1b[33m"; + label = "warning"; + break; + case QtCriticalMsg: + color = "\x1b[31m"; + label = "critical"; + break; + case QtFatalMsg: + color = "\x1b[1;31m"; + label = "fatal"; + break; + } + + // Shorten "qt.opcua.plugins.open62541.sdk.client" → "client". + QLatin1StringView cat (ctx.category ? ctx.category : "default"); + qsizetype dot = cat.lastIndexOf (QLatin1Char ('.')); + if (dot >= 0) + cat = cat.sliced (dot + 1); + + // "debug/client", "warning/network", etc. — padded to 20 chars. + QByteArray tag + = QByteArray (label) + '/' + QByteArray (cat.data (), cat.size ()); + + // Format UTC offset as "(UTC+0100)" to match open62541 server logs. + QDateTime now = QDateTime::currentDateTime (); + qint32 offset = now.offsetFromUtc (); + QChar sign = offset >= 0 ? u'+' : u'-'; + offset = qAbs (offset); + QString ts = now.toString (u"yyyy-MM-dd HH:mm:ss.zzz") + + QStringLiteral (" (UTC%1%2%3)") + .arg (sign) + .arg (offset / 3600, 2, 10, QLatin1Char ('0')) + .arg ((offset % 3600) / 60, 2, 10, QLatin1Char ('0')); + + fprintf (stderr, "[%s] %s%-20.*s\x1b[0m %s\n", qPrintable (ts), color, + static_cast (tag.size ()), tag.data (), qPrintable (msg)); +} + +int +main (int argc, char *argv[]) +{ + // Load the locally-built OpcUa backend plugin (open62541). + QCoreApplication::addLibraryPath (QStringLiteral (QTOPCUA_PLUGIN_PATH)); + + qInstallMessageHandler (logHandler); + + QGuiApplication app (argc, argv); + + QQmlApplicationEngine engine; + QObject::connect ( + &engine, &QQmlApplicationEngine::objectCreationFailed, &app, + [] () { QCoreApplication::exit (1); }, Qt::QueuedConnection); + + engine.loadFromModule ("BobinkQtOpcUaAppTemplate", "Main"); + return app.exec (); +} -- cgit v1.2.3