aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.qmlformat.ini10
-rw-r--r--CMakeLists.txt19
-rw-r--r--README.md23
-rw-r--r--cmake/BuildDeps.cmake6
-rw-r--r--demo/CMakeLists.txt4
-rw-r--r--demo/Main.qml222
-rw-r--r--demo/NodePage.qml339
-rw-r--r--docs/guide-qml.md6
8 files changed, 384 insertions, 245 deletions
diff --git a/.qmlformat.ini b/.qmlformat.ini
new file mode 100644
index 0000000..f32994e
--- /dev/null
+++ b/.qmlformat.ini
@@ -0,0 +1,10 @@
+[General]
+IndentWidth=4
+MaxColumnWidth=80
+NewlineType=unix
+NormalizeOrder=true
+ObjectsSpacing=true
+FunctionsSpacing=true
+SemicolonRule=always
+SortImports=false
+UseTabs=false
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 51b90e8..0be515b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -10,14 +10,15 @@ endif()
# Build external dependencies (open62541, qtopcua) if not already built
include(cmake/BuildDeps.cmake)
-# Point CMake directly at the locally-built QtOpcUa packages.
-# Qt6Config.cmake uses NO_DEFAULT_PATH when searching for components, so
-# QT_ADDITIONAL_PACKAGES_PREFIX_PATH should work but is fragile in practice
-# (Qt Creator can interfere). Setting <Package>_DIR is always checked first.
+# Point CMake directly at the locally-built QtOpcUa packages. Qt6Config.cmake
+# uses NO_DEFAULT_PATH when searching for components, so
+# QT_ADDITIONAL_PACKAGES_PREFIX_PATH should work but is fragile in practice (Qt
+# Creator can interfere). Setting <Package>_DIR is always checked first.
set(Qt6OpcUa_DIR "${QTOPCUA_INSTALL_DIR}/lib/cmake/Qt6OpcUa")
set(Qt6OpcUaPrivate_DIR "${QTOPCUA_INSTALL_DIR}/lib/cmake/Qt6OpcUaPrivate")
set(Qt6OpcUaTools_DIR "${QTOPCUA_INSTALL_DIR}/lib/cmake/Qt6OpcUaTools")
-set(Qt6DeclarativeOpcua_DIR "${QTOPCUA_INSTALL_DIR}/lib/cmake/Qt6DeclarativeOpcua")
+set(Qt6DeclarativeOpcua_DIR
+ "${QTOPCUA_INSTALL_DIR}/lib/cmake/Qt6DeclarativeOpcua")
list(PREPEND CMAKE_PREFIX_PATH "${OPEN62541_INSTALL_DIR}")
find_package(Qt6 6.10.2 REQUIRED COMPONENTS Core Qml Quick OpcUa)
@@ -29,10 +30,10 @@ if(PROJECT_IS_TOP_LEVEL)
set(QML_IMPORT_PATH
"${CMAKE_CURRENT_BINARY_DIR}/qml"
CACHE STRING "Path to locally built qml")
- # Generate .qmlls.ini for QML Language Server. Useful once QtOpcUa is installed
- # globally (qt-cmake --install build/deps/qtopcua-build) so qmlls can resolve
- # all Qt QML imports without extra importPaths. set(QT_QML_GENERATE_QMLLS_INI ON
- # CACHE BOOL "")
+ # Generate .qmlls.ini for QML Language Server
+ set(QT_QML_GENERATE_QMLLS_INI
+ ON
+ CACHE BOOL "")
# Ensure the local QtOpcUa and open62541 libs are findable at runtime (needed
# because the Qt plugin loader dlopen's the open62541 backend).
diff --git a/README.md b/README.md
index a396945..0d679bf 100644
--- a/README.md
+++ b/README.md
@@ -183,6 +183,29 @@ target_compile_definitions(YourApp PRIVATE
QTOPCUA_PLUGIN_PATH="${QTOPCUA_INSTALL_DIR}/plugins")
```
+## Editor setup (QML Language Server)
+
+The build generates `.qmlls.ini` files so that qmlls can resolve both Qt and local Bobink QML imports.
+
+**Neovim** — works out of the box if your LSP config picks up qmlls.
+
+**Qt Creator** — enable the language server under Edit > Preferences > Language Client > QML Language Server.
+
+**QML formatting** — a `.qmlformat.ini` is included at the project root. `qmlformat` picks it up automatically from the terminal (`qmlformat -i file.qml`). Qt Creator does not respect `.qmlformat.ini` or its own Code Style settings when formatting ([QTCREATORBUG-29668](https://bugreports.qt.io/browse/QTCREATORBUG-29668)) — use the command line instead.
+
+**Submodule users** — since `QT_QML_GENERATE_QMLLS_INI` and the demo are only enabled for top-level builds, add these to your own project:
+
+```cmake
+# Enable .qmlls.ini generation
+set(QT_QML_GENERATE_QMLLS_INI ON CACHE BOOL "")
+
+# Add the build QML directory so qmlls can resolve the Bobink import
+qt_add_qml_module(YourApp
+ URI YourApp
+ ...
+ IMPORT_PATH "${CMAKE_BINARY_DIR}/qml")
+```
+
## Project structure
```
diff --git a/cmake/BuildDeps.cmake b/cmake/BuildDeps.cmake
index 83791a2..942ea1d 100644
--- a/cmake/BuildDeps.cmake
+++ b/cmake/BuildDeps.cmake
@@ -10,11 +10,13 @@
set(OPEN62541_SOURCE_DIR "${PROJECT_SOURCE_DIR}/deps/open62541")
set(OPEN62541_BUILD_DIR "${PROJECT_BINARY_DIR}/deps/open62541-build")
-set(OPEN62541_INSTALL_DIR "${PROJECT_BINARY_DIR}/deps/open62541-install"
+set(OPEN62541_INSTALL_DIR
+ "${PROJECT_BINARY_DIR}/deps/open62541-install"
CACHE INTERNAL "open62541 install prefix")
set(QTOPCUA_SOURCE_DIR "${PROJECT_SOURCE_DIR}/deps/qtopcua")
set(QTOPCUA_BUILD_DIR "${PROJECT_BINARY_DIR}/deps/qtopcua-build")
-set(QTOPCUA_INSTALL_DIR "${PROJECT_BINARY_DIR}/deps/qtopcua-install"
+set(QTOPCUA_INSTALL_DIR
+ "${PROJECT_BINARY_DIR}/deps/qtopcua-install"
CACHE INTERNAL "QtOpcUa install prefix")
# Verify submodules are initialized
diff --git a/demo/CMakeLists.txt b/demo/CMakeLists.txt
index d2158cb..dd208e8 100644
--- a/demo/CMakeLists.txt
+++ b/demo/CMakeLists.txt
@@ -8,7 +8,9 @@ qt_add_qml_module(
1.0
QML_FILES
Main.qml
- NodePage.qml)
+ NodePage.qml
+ IMPORT_PATH
+ "${PROJECT_BINARY_DIR}/qml")
# Executable goes to bin/ to avoid clashing with the QML module directory
set_target_properties(BobinkDemo PROPERTIES RUNTIME_OUTPUT_DIRECTORY
diff --git a/demo/Main.qml b/demo/Main.qml
index 361e3bd..2cfa577 100644
--- a/demo/Main.qml
+++ b/demo/Main.qml
@@ -9,61 +9,72 @@ import Bobink
ApplicationWindow {
id: root
- width: 800
- height: 900
- visible: true
- title: "Bobink Demo"
property bool autoConnectFailed: false
property bool showPkiSettings: false
+ height: 900
+ title: "Bobink Demo"
+ visible: true
+ width: 800
+
Connections {
- target: Bobink
- function onServersChanged() {
- debugConsole.appendLog("Discovered server list updated");
+ function onCertificateTrustRequested(certInfo) {
+ certTrustDialog.certInfo = certInfo;
+ certTrustDialog.open();
}
+
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
- });
+ 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();
+
+ function onServersChanged() {
+ debugConsole.appendLog("Discovered server list updated");
}
+
+ function onStatusMessage(message) {
+ debugConsole.appendLog(message);
+ }
+
+ target: Bobink
}
Dialog {
id: certTrustDialog
+
property string certInfo
+
anchors.centerIn: parent
- title: "Certificate Trust"
modal: true
standardButtons: Dialog.Yes | Dialog.No
+ title: "Certificate Trust"
+
+ onAccepted: Bobink.acceptCertificate()
+ onRejected: Bobink.rejectCertificate()
+
Label {
text: certTrustDialog.certInfo
}
- onAccepted: Bobink.acceptCertificate()
- onRejected: Bobink.rejectCertificate()
}
ColumnLayout {
@@ -72,11 +83,13 @@ ApplicationWindow {
StackView {
id: stack
- Layout.fillWidth: true
+
Layout.fillHeight: true
+ Layout.fillWidth: true
initialItem: Page {
id: connectionPage
+
Component.onCompleted: {
Bobink.discoveryUrl = discoveryUrlField.text;
Bobink.startDiscovery();
@@ -84,32 +97,44 @@ ApplicationWindow {
OpcUaAuth {
id: auth
- mode: authModeCombo.currentValue
- username: usernameField.text
- password: passwordField.text
+
certPath: Bobink.certFile
keyPath: Bobink.keyFile
+ mode: authModeCombo.currentValue
+ password: passwordField.text
+ username: usernameField.text
}
FileDialog {
id: certFileDialog
- title: "Select Certificate"
+
currentFolder: "file://" + trustFolderField.text
nameFilters: ["DER certificates (*.der)", "All files (*)"]
- onAccepted: certFileField.text = selectedFile.toString().replace("file://", "")
+ title: "Select Certificate"
+
+ 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://", "")
+ title: "Select Private Key"
+
+ 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://", "")
+ title: "Select Trust Folder"
+
+ onAccepted: trustFolderField.text = selectedFolder.toString(
+ ).replace("file://", "")
}
ColumnLayout {
@@ -118,23 +143,27 @@ ApplicationWindow {
spacing: 12
Label {
- text: "Discovery URL"
font.bold: true
+ text: "Discovery URL"
}
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.discoveryUrl
+ = discoveryUrlField.text;
Bobink.startDiscovery();
}
}
@@ -142,114 +171,148 @@ ApplicationWindow {
}
Label {
- text: Bobink.discovering ? "Discovering... (" + Bobink.servers.length + " found)" : Bobink.servers.length + " server(s)"
font.italic: true
+ text: Bobink.discovering ? "Discovering... ("
+ + Bobink.servers.length
+ + " found)" :
+ Bobink.servers.length
+ + " server(s)"
}
ListView {
id: serverListView
+
Layout.fillWidth: true
Layout.preferredHeight: 100
clip: true
model: Bobink.servers
+
+ ScrollBar.vertical: ScrollBar {
+ policy: ScrollBar.AsNeeded
+ }
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
+ Layout.fillWidth: true
color: "gray"
+ elide: Text.ElideRight
font.italic: true
font.pointSize: 8
- elide: Text.ElideRight
- Layout.fillWidth: true
+ text: serverDelegate.modelData.applicationUri
}
}
+
onClicked: {
if (modelData.discoveryUrls.length > 0)
- serverUrlField.text = modelData.discoveryUrls[0];
+ serverUrlField.text
+ = modelData.discoveryUrls[0];
}
}
- ScrollBar.vertical: ScrollBar {
- policy: ScrollBar.AsNeeded
- }
}
RowLayout {
Label {
- text: "PKI"
font.bold: true
+ text: "PKI"
}
+
Label {
- text: Bobink.certFile ? " (" + Bobink.certFile.split("/").pop() + ")" : " (no certificate found)"
- font.italic: true
color: Bobink.certFile ? "green" : "gray"
+ font.italic: true
+ text: Bobink.certFile ? " ("
+ + Bobink.certFile.split(
+ "/").pop() + ")" :
+ " (no certificate found)"
}
+
Item {
Layout.fillWidth: true
}
+
Button {
text: root.showPkiSettings ? "Hide" : "Configure"
- onClicked: root.showPkiSettings = !root.showPkiSettings
+
+ onClicked: root.showPkiSettings =
+ !root.showPkiSettings
}
}
GridLayout {
- columns: 3
Layout.fillWidth: true
+ columns: 3
visible: root.showPkiSettings
Label {
text: "Certificate:"
}
+
TextField {
id: certFileField
+
Layout.fillWidth: true
- text: Bobink.certFile
placeholderText: "Client certificate (.der)"
+ text: Bobink.certFile
}
+
Button {
text: "Browse"
+
onClicked: certFileDialog.open()
}
Label {
text: "Private key:"
}
+
TextField {
id: keyFileField
+
Layout.fillWidth: true
- text: Bobink.keyFile
placeholderText: "Private key (.pem, .crt)"
+ text: Bobink.keyFile
}
+
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
+ text: "Apply PKI"
visible: root.showPkiSettings
+
onClicked: {
Bobink.pkiDir = trustFolderField.text;
Bobink.certFile = certFileField.text;
@@ -259,26 +322,26 @@ ApplicationWindow {
}
Label {
- text: "Server URL"
font.bold: true
+ text: "Server URL"
}
TextField {
id: serverUrlField
+
Layout.fillWidth: true
placeholderText: "opc.tcp://..."
}
Label {
- text: "Authentication"
font.bold: true
+ text: "Authentication"
}
ComboBox {
id: authModeCombo
+
Layout.fillWidth: true
- textRole: "text"
- valueRole: "mode"
model: [
{
text: "Anonymous",
@@ -293,33 +356,42 @@ ApplicationWindow {
mode: OpcUaAuth.Certificate
}
]
+ textRole: "text"
+ valueRole: "mode"
}
GridLayout {
- columns: 2
- visible: authModeCombo.currentValue === OpcUaAuth.UserPass
Layout.fillWidth: true
+ columns: 2
+ visible: authModeCombo.currentValue
+ === OpcUaAuth.UserPass
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
+ text: "Connect"
+
onClicked: {
root.autoConnectFailed = false;
Bobink.auth = auth;
@@ -329,24 +401,24 @@ ApplicationWindow {
}
Label {
- text: "Direct Connect"
font.bold: true
+ text: "Direct Connect"
visible: root.autoConnectFailed
}
GridLayout {
- columns: 2
Layout.fillWidth: true
+ columns: 2
visible: root.autoConnectFailed
Label {
text: "Security policy:"
}
+
ComboBox {
id: securityPolicyCombo
+
Layout.fillWidth: true
- textRole: "text"
- valueRole: "policy"
model: [
{
text: "Basic256Sha256",
@@ -361,16 +433,18 @@ ApplicationWindow {
policy: Bobink.Aes256_Sha256_RsaPss
}
]
+ textRole: "text"
+ valueRole: "policy"
}
Label {
text: "Security mode:"
}
+
ComboBox {
id: securityModeCombo
+
Layout.fillWidth: true
- textRole: "text"
- valueRole: "mode"
model: [
{
text: "Sign & Encrypt",
@@ -385,17 +459,22 @@ ApplicationWindow {
mode: Bobink.None
}
]
+ textRole: "text"
+ valueRole: "mode"
}
}
Button {
- text: "Direct Connect"
Layout.fillWidth: true
+ text: "Direct Connect"
visible: root.autoConnectFailed
+
onClicked: {
Bobink.auth = auth;
Bobink.serverUrl = serverUrlField.text;
- Bobink.connectDirect(securityPolicyCombo.currentValue, securityModeCombo.currentValue);
+ Bobink.connectDirect(
+ securityPolicyCombo.currentValue,
+ securityModeCombo.currentValue);
}
}
@@ -408,11 +487,6 @@ ApplicationWindow {
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");
@@ -420,17 +494,25 @@ ApplicationWindow {
debugLog.cursorPosition = debugLog.text.length;
}
+ Layout.fillWidth: true
+ Layout.preferredHeight: 120
+ border.color: "#444"
+ color: "#1e1e1e"
+ radius: 4
+
ScrollView {
anchors.fill: parent
anchors.margins: 4
+
TextArea {
id: debugLog
- readOnly: true
+
+ background: null
color: "#cccccc"
font.family: "monospace"
font.pointSize: 9
+ readOnly: true
wrapMode: TextEdit.Wrap
- background: null
}
}
}
diff --git a/demo/NodePage.qml b/demo/NodePage.qml
index e089f0e..97c99ef 100644
--- a/demo/NodePage.qml
+++ b/demo/NodePage.qml
@@ -1,5 +1,4 @@
// NodePage.qml — Displays 10 OPC UA nodes per page with tooltips.
-
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
@@ -8,126 +7,90 @@ import Bobink
Page {
id: nodePage
- required property StackView stackRef
- required property int pageNumber
+ readonly property var currentPage: pages[pageNumber - 1]
required property var logFunction
-
+ required property int pageNumber
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": "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-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-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": "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": "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": "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: []
+ "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"
- ]
+ readonly property var serverStates: ["Running", "Failed", "NoConfiguration",
+ "Suspended", "Shutdown", "Test", "CommunicationFault", "Unknown"]
+ required property StackView stackRef
function formatValue(node) {
var v = node.value;
@@ -139,8 +102,10 @@ Page {
return String(v);
}
- Component.onCompleted: nodePage.logFunction(
- currentPage.title + " page loaded (" + currentPage.nodes.length + " nodes)")
+ Component.onCompleted: nodePage.logFunction(currentPage.title
+ + " page loaded ("
+ + currentPage.nodes.length
+ + " nodes)")
ColumnLayout {
anchors.fill: parent
@@ -150,29 +115,37 @@ Page {
// Header
RowLayout {
Label {
- text: currentPage.title
font.bold: true
font.pointSize: 14
+ text: currentPage.title
}
+
Label {
- text: "(" + nodePage.pageNumber + "/" + nodePage.pages.length + ")"
color: "gray"
+ text: "(" + nodePage.pageNumber + "/" + nodePage.pages.length
+ + ")"
+ }
+
+ Item {
+ Layout.fillWidth: true
}
- Item { Layout.fillWidth: true }
+
Button {
text: "Disconnect"
+
onClicked: Bobink.disconnectFromServer()
}
}
Label {
id: pageDescription
- visible: currentPage.description !== undefined
- text: currentPage.description || ""
- wrapMode: Text.WordWrap
+
+ Layout.fillWidth: true
color: "gray"
font.italic: true
- Layout.fillWidth: true
+ text: currentPage.description || ""
+ visible: currentPage.description !== undefined
+ wrapMode: Text.WordWrap
}
// Column headers
@@ -181,28 +154,32 @@ Page {
Layout.leftMargin: 12
Layout.rightMargin: 12
spacing: 12
+
Label {
- text: "Identifier"
- font.bold: true
Layout.preferredWidth: 160
+ font.bold: true
+ text: "Identifier"
}
+
Label {
- text: "Value"
- font.bold: true
Layout.preferredWidth: 300
+ font.bold: true
+ text: "Value"
}
+
Label {
- text: "Write"
- font.bold: true
Layout.fillWidth: true
+ font.bold: true
+ text: "Write"
}
}
Rectangle {
id: separator
+
Layout.fillWidth: true
- height: 1
color: "#ccc"
+ height: 1
}
// Nodes
@@ -211,48 +188,57 @@ Page {
ItemDelegate {
id: delegate
- required property string modelData
+
required property int index
+ required property string modelData
+
Layout.fillWidth: true
- padding: 4
+ 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()
+ || "—")
+ ToolTip.visible: hovered
leftPadding: 12
+ padding: 4
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 {
+ Layout.preferredWidth: 160
+ elide: Text.ElideRight
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;
+ 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
+ text: nodePage.formatValue(node)
}
// Column 3: Edit area (writable) or READ-ONLY label
@@ -260,81 +246,108 @@ Page {
// Normal write controls (non-index-range pages)
TextField {
id: editField
- visible: node.writable && !currentPage.indexRange
+
Layout.fillWidth: true
placeholderText: "Enter value..."
+ visible: node.writable && !currentPage.indexRange
+
onAccepted: node.writeValue(text)
}
+
Button {
- visible: node.writable && !currentPage.indexRange
text: "Write"
+ visible: node.writable && !currentPage.indexRange
+
onClicked: node.writeValue(editField.text)
}
// Index range write controls
TextField {
id: rangeField
- visible: node.writable && currentPage.indexRange === true
+
Layout.preferredWidth: 60
placeholderText: "Range"
+ visible: node.writable && currentPage.indexRange
+ === true
}
+
TextField {
id: rangeValueField
- visible: node.writable && currentPage.indexRange === true
+
Layout.fillWidth: true
placeholderText: "Value(s)..."
- onAccepted: node.writeValueAtRange(text, rangeField.text)
+ visible: node.writable && currentPage.indexRange
+ === true
+
+ onAccepted: node.writeValueAtRange(text,
+ rangeField.text)
}
+
Button {
- visible: node.writable && currentPage.indexRange === true
text: "Write Range"
- onClicked: node.writeValueAtRange(rangeValueField.text, rangeField.text)
+ visible: node.writable && currentPage.indexRange
+ === true
+
+ onClicked: node.writeValueAtRange(rangeValueField.text,
+ rangeField.text)
}
Label {
- visible: !node.writable
- text: "(READ-ONLY)"
+ Layout.fillWidth: true
color: "gray"
font.italic: true
- Layout.fillWidth: true
+ text: "(READ-ONLY)"
+ visible: !node.writable
}
}
- 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() || "—")
+ OpcUaMonitoredNode {
+ id: node
+
+ monitored: nodePage.StackView.status === StackView.Active
+ nodeId: delegate.modelData
+
+ onWriteCompleted: (success, message) => {
+ var short_id = node.nodeId.substring(
+ node.nodeId.indexOf(";s=") + 3);
+ nodePage.logFunction(short_id + ": "
+ + message);
+ }
+ }
}
}
- Item { Layout.fillHeight: true }
+ Item {
+ Layout.fillHeight: true
+ }
// Navigation
RowLayout {
Layout.fillWidth: true
+
Button {
text: "← Previous"
visible: nodePage.pageNumber > 1
+
onClicked: nodePage.stackRef.pop()
}
- Item { Layout.fillWidth: true }
+
+ 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
- })
+ "stackRef":
+ nodePage.stackRef,
+ "pageNumber":
+ nodePage.pageNumber + 1,
+ "logFunction":
+ nodePage.logFunction
+ })
}
}
}
diff --git a/docs/guide-qml.md b/docs/guide-qml.md
index 1bbe0f5..6c92b64 100644
--- a/docs/guide-qml.md
+++ b/docs/guide-qml.md
@@ -316,3 +316,9 @@ Les propriétés `Bobink.pkiDir`, `Bobink.certFile` et `Bobink.keyFile` sont aus
| Lister les serveurs | `Bobink.startDiscovery()` puis `Bobink.servers` |
Pour un exemple concret et complet, consultez les fichiers `demo/Main.qml` et `demo/NodePage.qml`.
+
+## Configuration de Qt Creator
+
+**Autocomplétion et diagnostics QML** — activez le serveur de langage QML : Édition > Préférences > Client de langage > QML Language Server.
+
+**Formatage QML** — un fichier `.qmlformat.ini` est inclus à la racine du projet. Depuis le terminal, `qmlformat` le détecte automatiquement : `qmlformat -i fichier.qml`. Qt Creator ne respecte pas ce fichier ni ses propres réglages de style lors du formatage ([QTCREATORBUG-29668](https://bugreports.qt.io/browse/QTCREATORBUG-29668)) — utilisez la ligne de commande à la place (désolé les gars).