aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--.gitmodules3
-rw-r--r--CMakeLists.txt47
-rw-r--r--Main.qml438
-rw-r--r--NodePage.qml341
m---------deps/BobinkQtOpcUa0
-rw-r--r--main.cpp84
7 files changed, 915 insertions, 0 deletions
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
+Subproject 3e8111aaee070dc9b5d51cab3fd44ff88841f63
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 <QDateTime>
+#include <QGuiApplication>
+#include <QQmlApplicationEngine>
+
+#include <QtQml/QQmlExtensionPlugin>
+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<int> (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 ();
+}