From b23ff5f3c98239778f14cc4409501ebe93302815 Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 10:07:26 +0200 Subject: [PATCH 01/16] Fix palette plan suggestion selection flow --- src/ui/MainWindow.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 9994c03..5578739 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1141,8 +1141,6 @@ void MainWindow::showExperimental() void MainWindow::onLoadPaletteClicked() { - hidePalettePlanCompletionPopup(); - QString fileName = ui->EingabePallettenplan->text().trimmed(); if (fileName.isEmpty()) { showMessage("Bitte Palletierplan eingeben"); @@ -1203,9 +1201,15 @@ void MainWindow::onLoadPaletteClicked() updateVisualizationFromPaletteData(*data); emit paletteLoadRequested(m_currentPaletteFile); + // Successful load commits the selection. + hidePalettePlanCompletionPopup(); qDebug() << "Palette loaded successfully:" << m_currentPaletteFile; } else { showMessage("Palletierplan nicht gefunden: " + fileName); + + // A failed load should keep matching suggestions available so the + // operator can immediately correct the entered prefix/value. + updatePalettePlanCompletionPopup(fileName); } } } @@ -2923,6 +2927,11 @@ void MainWindow::setupPalettePlanCompleter() if (value.isEmpty()) { return; } + + // Selecting a suggestion changes the line edit text. Block the + // textChanged callback here so the same suggestion is not shown + // again immediately underneath the selected value. + const QSignalBlocker blocker(ui->EingabePallettenplan); ui->EingabePallettenplan->setText(value); hidePalettePlanCompletionPopup(); ui->EingabePallettenplan->setFocus(Qt::OtherFocusReason); From fa2686c493e362eb20d0bb1fad4604e61e51539d Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 10:08:27 +0200 Subject: [PATCH 02/16] Add network status fields to HMI status page --- include/multipack/ui/MainWindow.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/include/multipack/ui/MainWindow.h b/include/multipack/ui/MainWindow.h index 101dafd..297c36c 100644 --- a/include/multipack/ui/MainWindow.h +++ b/include/multipack/ui/MainWindow.h @@ -198,6 +198,7 @@ private slots: void maybeStartRobotStatusMonitor(); void stopRobotStatusMonitor(); void setupStatusTab(); + void refreshNetworkStatus(); void updateStatusTab(const robot::RobotStatus& status); void onStatusDetailsUpdated(const QString& polyscopeVersion, const QString& serialNumber, @@ -264,10 +265,17 @@ private slots: QWidget* m_centralWidget = nullptr; QTimer* m_zwischenlageTimer = nullptr; QTimer* m_paletteClearTimer = nullptr; + QTimer* m_networkStatusTimer = nullptr; NotificationPopup* m_zwischenlagePopup = nullptr; QPushButton* m_palette1ClearIndicator = nullptr; QPushButton* m_palette2ClearIndicator = nullptr; VisualizationWidget* m_visualizationWidget = nullptr; + QLabel* m_statusHostname = nullptr; + QLabel* m_statusEth0Ip = nullptr; + QLabel* m_statusWlan0Ip = nullptr; + QLabel* m_statusWifiSsid = nullptr; + QLabel* m_statusTailscaleState = nullptr; + QLabel* m_statusTailscaleIp = nullptr; QLabel* m_statusRobotIp = nullptr; QLabel* m_statusConnection = nullptr; QLabel* m_statusRobotMode = nullptr; From 990c849214f6b30c99ad9f09f2e0eb98a52eab1f Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 10:08:51 +0200 Subject: [PATCH 03/16] Show Pi network and Tailscale status in UI --- src/ui/MainWindow.cpp | 115 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 5578739..e83f0b4 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -31,6 +31,8 @@ #include #include #include +#include +#include #include #include #include @@ -84,6 +86,65 @@ QString palettePlanItemFileName(QListWidgetItem* item) const QString storedName = item->data(Qt::UserRole).toString().trimmed(); return storedName.isEmpty() ? item->text().trimmed() : storedName; } + +QString ipv4ForInterface(const QString& interfaceName) +{ + const QNetworkInterface iface = QNetworkInterface::interfaceFromName(interfaceName); + if (!iface.isValid() || !(iface.flags() & QNetworkInterface::IsUp)) { + return QStringLiteral("-"); + } + + for (const QNetworkAddressEntry& entry : iface.addressEntries()) { + const QHostAddress address = entry.ip(); + if (address.protocol() == QAbstractSocket::IPv4Protocol && !address.isLoopback()) { + return address.toString(); + } + } + return QStringLiteral("-"); +} + +QString commandOutput(const QString& program, const QStringList& arguments, int timeoutMs = 500) +{ + QProcess process; + process.start(program, arguments); + if (!process.waitForStarted(timeoutMs)) { + return QString(); + } + if (!process.waitForFinished(timeoutMs)) { + process.kill(); + process.waitForFinished(100); + return QString(); + } + if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { + return QString(); + } + return QString::fromUtf8(process.readAllStandardOutput()).trimmed(); +} + +QString currentWifiSsid() +{ + QString ssid = commandOutput(QStringLiteral("iwgetid"), + {QStringLiteral("wlan0"), QStringLiteral("-r")}); + if (!ssid.isEmpty()) { + return ssid; + } + + const QString nmcli = commandOutput( + QStringLiteral("nmcli"), + {QStringLiteral("-t"), QStringLiteral("-f"), QStringLiteral("ACTIVE,SSID"), + QStringLiteral("dev"), QStringLiteral("wifi")}, + 800); + const QStringList lines = nmcli.split('\n', Qt::SkipEmptyParts); + for (const QString& line : lines) { + if (line.startsWith(QStringLiteral("yes:"))) { + QString value = line.mid(4); + value.replace(QStringLiteral("\\:"), QStringLiteral(":")); + return value.trimmed(); + } + } + + return QStringLiteral("-"); +} } // namespace MainWindow::MainWindow(QWidget* parent) @@ -645,6 +706,12 @@ void MainWindow::setupStatusTab() form->setLabelAlignment(Qt::AlignRight); form->setFormAlignment(Qt::AlignTop); + m_statusHostname = new QLabel("-", statusWidget); + m_statusEth0Ip = new QLabel("-", statusWidget); + m_statusWlan0Ip = new QLabel("-", statusWidget); + m_statusWifiSsid = new QLabel("-", statusWidget); + m_statusTailscaleState = new QLabel("-", statusWidget); + m_statusTailscaleIp = new QLabel("-", statusWidget); m_statusRobotIp = new QLabel("-", statusWidget); m_statusConnection = new QLabel("-", statusWidget); m_statusRobotMode = new QLabel("-", statusWidget); @@ -655,6 +722,23 @@ void MainWindow::setupStatusTab() m_statusSerialNumber = new QLabel("-", statusWidget); m_statusLoadedProgram = new QLabel("-", statusWidget); + auto* networkHeader = new QLabel(tr("Raspberry Pi / Netzwerk"), statusWidget); + QFont networkHeaderFont = networkHeader->font(); + networkHeaderFont.setBold(true); + networkHeader->setFont(networkHeaderFont); + form->addRow(networkHeader); + form->addRow(tr("Hostname:"), m_statusHostname); + form->addRow(tr("eth0 IP:"), m_statusEth0Ip); + form->addRow(tr("wlan0 IP:"), m_statusWlan0Ip); + form->addRow(tr("WLAN SSID:"), m_statusWifiSsid); + form->addRow(tr("Tailscale:"), m_statusTailscaleState); + form->addRow(tr("Tailscale IP:"), m_statusTailscaleIp); + + auto* robotHeader = new QLabel(tr("Roboter"), statusWidget); + QFont robotHeaderFont = robotHeader->font(); + robotHeaderFont.setBold(true); + robotHeader->setFont(robotHeaderFont); + form->addRow(robotHeader); form->addRow(tr("Robot IP:"), m_statusRobotIp); form->addRow(tr("Connection:"), m_statusConnection); form->addRow(tr("Robot Mode:"), m_statusRobotMode); @@ -668,6 +752,37 @@ void MainWindow::setupStatusTab() rootLayout->addLayout(form); ui->tabWidget->addTab(statusWidget, tr("Status")); + + m_networkStatusTimer = new QTimer(this); + m_networkStatusTimer->setInterval(5000); + connect(m_networkStatusTimer, &QTimer::timeout, + this, &MainWindow::refreshNetworkStatus); + m_networkStatusTimer->start(); + refreshNetworkStatus(); +} + +void MainWindow::refreshNetworkStatus() +{ + if (!m_statusHostname) { + return; + } + + const QString eth0Ip = ipv4ForInterface(QStringLiteral("eth0")); + const QString wlan0Ip = ipv4ForInterface(QStringLiteral("wlan0")); + const QString tailscaleIp = ipv4ForInterface(QStringLiteral("tailscale0")); + + m_statusHostname->setText(QHostInfo::localHostName().trimmed().isEmpty() + ? QStringLiteral("-") + : QHostInfo::localHostName()); + m_statusEth0Ip->setText(eth0Ip); + m_statusWlan0Ip->setText(wlan0Ip); + m_statusWifiSsid->setText(wlan0Ip == QStringLiteral("-") + ? QStringLiteral("Not connected") + : currentWifiSsid()); + m_statusTailscaleIp->setText(tailscaleIp); + m_statusTailscaleState->setText(tailscaleIp == QStringLiteral("-") + ? QStringLiteral("Not connected") + : QStringLiteral("Connected")); } void MainWindow::updateStatusTab(const robot::RobotStatus& status) From 2c4b884e130623fafeece3ca7cdd021a3353a16e Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 10:09:45 +0200 Subject: [PATCH 04/16] Include Qt font type explicitly for status headers --- src/ui/MainWindow.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index e83f0b4..b61aae0 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include From 1abfb6ad4410f1e410c99745be923af16421db89 Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 10:09:48 +0200 Subject: [PATCH 05/16] Add palette suggestion load-state regression tests --- tests/test_mainwindow_integration.cpp | 67 +++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/test_mainwindow_integration.cpp b/tests/test_mainwindow_integration.cpp index 5212d21..099587f 100644 --- a/tests/test_mainwindow_integration.cpp +++ b/tests/test_mainwindow_integration.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,8 @@ class MainWindowIntegrationTest : public QObject private slots: void startAndStopServerFromUi(); + void failedPaletteLoadKeepsSuggestionsVisible(); + void successfulPaletteLoadClosesSuggestions(); }; void MainWindowIntegrationTest::startAndStopServerFromUi() @@ -89,6 +92,70 @@ void MainWindowIntegrationTest::startAndStopServerFromUi() QTRY_VERIFY(!window.isVisible()); } + +void MainWindowIntegrationTest::failedPaletteLoadKeepsSuggestionsVisible() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + qputenv("MULTIPACK_DISABLE_ROBOT_STATUS_MONITOR", "1"); + + DatabaseManager database; + QVERIFY(database.open(tempDir.filePath("palettes.db"))); + QCOMPARE(database.savePaletteData(testhelpers::makeSamplePaletteData()), SaveResult::Inserted); + + MainWindow window; + window.setDatabaseManager(&database); + window.show(); + + auto* paletteInput = window.findChild("EingabePallettenplan"); + auto* loadButton = window.findChild("LadePallettenplan"); + auto* popup = window.findChild("PalettePlanCompletionPopup"); + + QVERIFY(paletteInput != nullptr); + QVERIFY(loadButton != nullptr); + QVERIFY(popup != nullptr); + + paletteInput->setText("sam"); + QTRY_VERIFY(popup->isVisible()); + + QTest::mouseClick(loadButton, Qt::LeftButton); + QTRY_VERIFY(popup->isVisible()); +} + +void MainWindowIntegrationTest::successfulPaletteLoadClosesSuggestions() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + qputenv("MULTIPACK_DISABLE_ROBOT_STATUS_MONITOR", "1"); + + GlobalState::instance().clear(); + + DatabaseManager database; + QVERIFY(database.open(tempDir.filePath("palettes.db"))); + QCOMPARE(database.savePaletteData(testhelpers::makeSamplePaletteData()), SaveResult::Inserted); + + MainWindow window; + window.setDatabaseManager(&database); + window.setGlobalState(&GlobalState::instance()); + window.show(); + + auto* paletteInput = window.findChild("EingabePallettenplan"); + auto* loadButton = window.findChild("LadePallettenplan"); + auto* popup = window.findChild("PalettePlanCompletionPopup"); + + QVERIFY(paletteInput != nullptr); + QVERIFY(loadButton != nullptr); + QVERIFY(popup != nullptr); + + paletteInput->setText("sample"); + QTRY_VERIFY(popup->isVisible()); + + QTest::mouseClick(loadButton, Qt::LeftButton); + QTRY_VERIFY(!popup->isVisible()); +} + int main(int argc, char** argv) { #ifdef Q_OS_WIN From 4ac0bb3bf2647d0f3de22d9f1011f2775224923c Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 10:11:34 +0200 Subject: [PATCH 06/16] Add CI test workflow --- .github/workflows/tests.yml | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..693182d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: Tests + +on: + push: + branches: + - "**" + pull_request: + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build and test (Ubuntu) + runs-on: ubuntu-22.04 + timeout-minutes: 30 + + env: + QT_QPA_PLATFORM: offscreen + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install build and Qt dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build pkg-config qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools qt6-multimedia-dev libqt6sql6-sqlite libsqlite3-dev libssl-dev + + - name: Configure tests + run: | + cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTS=ON -DENABLE_VTK=OFF + + - name: Build tests + run: cmake --build build-tests --parallel 2 + + - name: Run tests + run: ctest --test-dir build-tests --output-on-failure --timeout 60 From a1c63069b1da1edca3af5fb71df5fe31043c93e6 Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 10:16:26 +0200 Subject: [PATCH 07/16] Fix CI Qt OpenGL dependencies --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 693182d..5a5f3f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: - name: Install build and Qt dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build pkg-config qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools qt6-multimedia-dev libqt6sql6-sqlite libsqlite3-dev libssl-dev + sudo apt-get install -y build-essential cmake ninja-build pkg-config qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools qt6-multimedia-dev libqt6sql6-sqlite libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev libsqlite3-dev libssl-dev - name: Configure tests run: | From b22a072206e164f30544180b1a77ba6cdd9466a1 Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 10:23:05 +0200 Subject: [PATCH 08/16] Fix failed-load palette suggestion regression test --- tests/test_mainwindow_integration.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_mainwindow_integration.cpp b/tests/test_mainwindow_integration.cpp index 099587f..910c340 100644 --- a/tests/test_mainwindow_integration.cpp +++ b/tests/test_mainwindow_integration.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -119,6 +120,14 @@ void MainWindowIntegrationTest::failedPaletteLoadKeepsSuggestionsVisible() paletteInput->setText("sam"); QTRY_VERIFY(popup->isVisible()); + // Simulate the plan disappearing/failing to load after suggestions were + // already presented. Block the database change signal so the popup model + // remains unchanged and we specifically exercise the failed-load UI path. + { + const QSignalBlocker blocker(&database); + QVERIFY(database.deletePalette("sample.rob")); + } + QTest::mouseClick(loadButton, Qt::LeftButton); QTRY_VERIFY(popup->isVisible()); } From 31087bddac597aee82bbec823cf419e9e14abbaf Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 11:20:43 +0200 Subject: [PATCH 09/16] Centralize persistent data paths --- include/multipack/config/ConfigDefaults.h | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/include/multipack/config/ConfigDefaults.h b/include/multipack/config/ConfigDefaults.h index 649e11c..70965e8 100644 --- a/include/multipack/config/ConfigDefaults.h +++ b/include/multipack/config/ConfigDefaults.h @@ -9,6 +9,8 @@ #define MULTIPACK_CONFIG_CONFIGDEFAULTS_H #include +#include +#include #ifndef MULTIPACK_APP_VERSION #define MULTIPACK_APP_VERSION "2.0.0" @@ -93,9 +95,15 @@ inline QString defaultDatabasePath() * @brief Get default settings file path * @return Settings file path */ +inline QString dataDirectory() +{ + const QString configured = qEnvironmentVariable("MULTIPACK_DATA_DIR").trimmed(); + return configured.isEmpty() ? QDir::currentPath() : QDir(configured).absolutePath(); +} + inline QString defaultSettingsPath() { - return "settings.json"; + return QDir(dataDirectory()).filePath("settings.json"); } /** @@ -104,7 +112,7 @@ inline QString defaultSettingsPath() */ inline QString defaultLogDir() { - return LOG_DIR; + return QDir(dataDirectory()).filePath(LOG_DIR); } } // namespace Defaults From d84fdb681aecb645125db8a12f67e2077fbc0bb0 Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 11:20:48 +0200 Subject: [PATCH 10/16] Resolve runtime files from persistent data directory --- src/core/AppInitializer.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/AppInitializer.cpp b/src/core/AppInitializer.cpp index 5241d79..1ce80ec 100644 --- a/src/core/AppInitializer.cpp +++ b/src/core/AppInitializer.cpp @@ -30,7 +30,7 @@ QString resolveConfiguredPath(const QString& configuredPath, const QString& fall { const QString path = configuredPath.trimmed().isEmpty() ? fallback : configuredPath.trimmed(); QFileInfo info(path); - return info.isAbsolute() ? info.absoluteFilePath() : QDir::current().absoluteFilePath(path); + return info.isAbsolute() ? info.absoluteFilePath() : QDir(config::Defaults::dataDirectory()).absoluteFilePath(path); } } @@ -209,7 +209,7 @@ bool AppInitializer::initializeLogging() { qDebug() << "AppInitializer - initializing logging"; - const QString logPath = QDir::currentPath() + "/logs"; + const QString logPath = config::Defaults::defaultLogDir(); const bool verboseEnabled = qEnvironmentVariable("MULTIPACK_VERBOSE", "0") == "1"; const config::LogLevel level = verboseEnabled ? config::LogLevel::Debug : config::LogLevel::Info; @@ -232,7 +232,7 @@ bool AppInitializer::initializeSettings() m_settingsManager = std::make_unique(); // Try to load settings - QString settingsPath = QDir::currentPath() + "/settings.json"; + const QString settingsPath = config::Defaults::defaultSettingsPath(); if (QFile::exists(settingsPath)) { if (!m_settingsManager->load(settingsPath)) { qWarning() << "Failed to load settings from" << settingsPath; From b8d1f23cae984624342354d8c319b01018bf653e Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 11:20:55 +0200 Subject: [PATCH 11/16] Use shared settings path for USB key config --- src/system/UsbKeyCheck.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/system/UsbKeyCheck.cpp b/src/system/UsbKeyCheck.cpp index 7c2391b..6b974ac 100644 --- a/src/system/UsbKeyCheck.cpp +++ b/src/system/UsbKeyCheck.cpp @@ -246,7 +246,7 @@ void resolveUsbConfig(const multipack::config::SettingsManager* settingsManager, } } - const QString jsonPath = QDir::current().filePath("settings.json"); + const QString jsonPath = config::Defaults::defaultSettingsPath(); if (loadUsbConfigFromJsonFile(jsonPath, usbKey, expectedValue)) { return; } From 090fdf5c11f2e2d58691a130a71367bd38e73f09 Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 11:22:58 +0200 Subject: [PATCH 12/16] Protect immutable deployments from in-place updates --- src/system/AutoUpdater.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/system/AutoUpdater.cpp b/src/system/AutoUpdater.cpp index e133c8f..6ffbf44 100644 --- a/src/system/AutoUpdater.cpp +++ b/src/system/AutoUpdater.cpp @@ -61,7 +61,7 @@ AutoUpdater::AutoUpdater(QObject* parent) , m_downloadReply(nullptr) , m_checkTimer(new QTimer(this)) , m_status(UpdateStatus::Idle) - , m_updateCacheDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/multipack-updates") + , m_updateCacheDir(QDir(config::Defaults::dataDirectory()).filePath("updates")) , m_usbUpdateDir(config::Defaults::defaultUsbPath()) { qDebug() << "AutoUpdater - initialized"; @@ -143,6 +143,13 @@ bool AutoUpdater::checkForUsbUpdates(const QString& usbDirectory) bool AutoUpdater::downloadAndInstallUpdate() { + if (qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1") { + const QString message = tr("Application updates are managed by the system A/B updater"); + qWarning() << "AutoUpdater:" << message; + emit updateFailed(message); + return false; + } + if (m_availableUpdate.version.isEmpty()) { qWarning() << "AutoUpdater: No update available to download"; return false; @@ -987,6 +994,13 @@ void AutoUpdater::onDownloadNetworkReply(QNetworkReply* reply) bool AutoUpdater::installUpdate(const QString& filePath) { + if (qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1") { + const QString message = tr("Refusing in-place update on immutable system"); + qWarning() << "AutoUpdater:" << message; + emit updateFailed(message); + return false; + } + emit installationStarted(); const QString appDir = QCoreApplication::applicationDirPath(); From 4d7b798cefc85ebe94b04d51602a22b3188045e0 Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 11:23:04 +0200 Subject: [PATCH 13/16] Create persistent settings directory before save --- src/config/SettingsManager.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config/SettingsManager.cpp b/src/config/SettingsManager.cpp index 800e41c..c098e63 100644 --- a/src/config/SettingsManager.cpp +++ b/src/config/SettingsManager.cpp @@ -137,6 +137,12 @@ bool SettingsManager::save(const QString& path) QJsonDocument doc(m_settings); + const QFileInfo targetInfo(filePath); + if (!QDir().mkpath(targetInfo.absolutePath())) { + qWarning() << "Cannot create settings directory:" << targetInfo.absolutePath(); + return false; + } + QFile file(filePath); if (!file.open(QIODevice::WriteOnly)) { qWarning() << "Cannot open settings file for writing:" << filePath; From 04dc0ad08a35447e32f5f67345576f93329bb03b Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 13:35:45 +0200 Subject: [PATCH 14/16] Fix enabled labels and commit start layer before robot start --- src/ui/MainWindow.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index b61aae0..e21624c 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1175,6 +1175,11 @@ void MainWindow::updateEnabledStates() ui->EingabeStartlage->setEnabled(paletteLoaded); ui->EingabeKartonhoehe->setEnabled(paletteLoaded); ui->EingabeKartonGewicht->setEnabled(paletteLoaded); + ui->label_Startlage->setEnabled(paletteLoaded); + ui->label_Kartonhoehe->setEnabled(paletteLoaded); + ui->label_Kartonhoehe_mm->setEnabled(paletteLoaded); + ui->label_Gewicht->setEnabled(paletteLoaded); + ui->label_Gewicht_kg->setEnabled(paletteLoaded); ui->checkBoxEinzelpaket->setEnabled(paletteLoaded); ui->checkBoxLabelInvert->setEnabled(paletteLoaded); ui->ButtonOpenParameterRoboter->setEnabled(paletteLoaded); @@ -1481,6 +1486,18 @@ void MainWindow::onStartlageChanged(int value) void MainWindow::onRobotStartClicked() { qDebug() << "Robot start clicked"; + + // Commit the currently visible start layer immediately before starting the + // robot. This guarantees that a subsequent UR_Startlage RPC request sees + // the operator's latest selection even if another state update occurred + // after the spin box valueChanged signal. + if (m_state) { + const int startLayer = ui->EingabeStartlage->value(); + m_state->setStartLayer(startLayer); + m_state->setCurrentLayer(startLayer); + qDebug() << "Robot start - committed Startlage:" << startLayer; + } + if (ensureRobotConnected()) { m_robot->play(); } From 8c244abb3f1b3ab63c1a1cac295fce0265350530 Mon Sep 17 00:00:00 2001 From: Snupai Date: Fri, 2 Oct 2026 20:20:30 +0200 Subject: [PATCH 15/16] Integrate asynchronous system A/B updater and nonblocking SSID status --- CMakeLists.txt | 8 +- ReadMe.md | 18 +++ include/multipack/system/AutoUpdater.h | 5 + .../multipack/system/SystemUpdaterClient.h | 33 +++++ include/multipack/system/WifiSsidProbe.h | 24 ++++ include/multipack/ui/MainWindow.h | 2 + src/system/AutoUpdater.cpp | 80 +++++++++++- src/system/SystemUpdaterClient.cpp | 60 +++++++++ src/system/WifiSsidProbe.cpp | 38 ++++++ src/ui/MainWindow.cpp | 60 +++------ tests/CMakeLists.txt | 4 + tests/test_mainwindow_integration.cpp | 56 ++++++++ tests/test_system_clients.cpp | 121 ++++++++++++++++++ 13 files changed, 458 insertions(+), 51 deletions(-) create mode 100644 include/multipack/system/SystemUpdaterClient.h create mode 100644 include/multipack/system/WifiSsidProbe.h create mode 100644 src/system/SystemUpdaterClient.cpp create mode 100644 src/system/WifiSsidProbe.cpp create mode 100644 tests/test_system_clients.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8deab7a..0ecde0f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -190,6 +190,8 @@ set(MOC_HEADERS include/multipack/config/SettingsManager.h include/multipack/system/UsbKeyCheck.h include/multipack/system/UpdateChecker.h + include/multipack/system/SystemUpdaterClient.h + include/multipack/system/WifiSsidProbe.h include/multipack/system/AutoUpdater.h include/multipack/system/UsbMonitor.h ) @@ -266,6 +268,8 @@ set(SYSTEM_SOURCES src/system/UpdateChecker.cpp src/system/FileOperations.cpp src/system/RobFileParser.cpp + src/system/SystemUpdaterClient.cpp + src/system/WifiSsidProbe.cpp src/system/AutoUpdater.cpp src/system/UsbMonitor.cpp ) @@ -274,7 +278,9 @@ if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64" AND MULTIPACK_SAFE_ARM64_EMULA # GCC 11 under Docker/QEMU can ICE/segfault on these translation units at -O2. # Keep the workaround tightly scoped so the ARM64 release build stays fast. set_source_files_properties( - src/system/AutoUpdater.cpp + src/system/SystemUpdaterClient.cpp + src/system/WifiSsidProbe.cpp + src/system/AutoUpdater.cpp src/ui/UIHelpers.cpp PROPERTIES COMPILE_OPTIONS "-O1") endif() diff --git a/ReadMe.md b/ReadMe.md index 67defb0..8f98ae8 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -198,3 +198,21 @@ available before readiness. ## License Proprietary - Szaidel Cosmetic GmbH + +### Immutable system updates and network status + +With `MULTIPACK_IMMUTABLE=1`, the existing update button reads system status and +asks for a published system release VERSION (for example `rpi-hmi-v0.1.0`). +The asynchronous client sends only `status` or `install` plus that validated +identifier to `/run/hmi-updater/control.sock`. The image grants `hmi-update` group +access. The daemon verifies signatures, writes the inactive slot, and reboots; +once accepted, installation cannot be cancelled by closing the UI. Status includes +the active version, boot phase and download/install progress. Network errors can +be retried. No legacy application/USB installation runs in immutable mode. +Without the environment flag the signed legacy updater is retained. + +SSID probing uses asynchronous `nmcli` with a deadline and no Wi-Fi rescan. +HMI provisioning installs `network-manager`. Portable bundles require a host +NetworkManager installation for SSID display; when it is absent, the UI explicitly +shows SSID unavailable. Interface IP status remains usable. Probe processes belong +to the window and are terminated when it closes. diff --git a/include/multipack/system/AutoUpdater.h b/include/multipack/system/AutoUpdater.h index dc9248a..2b0d005 100644 --- a/include/multipack/system/AutoUpdater.h +++ b/include/multipack/system/AutoUpdater.h @@ -2,6 +2,7 @@ #define MULTIPACK_SYSTEM_AUTOUPDATER_H #include +#include "multipack/system/SystemUpdaterClient.h" #include #include #include @@ -236,6 +237,10 @@ private slots: void onSslErrors(const QList& errors); private: + bool m_immutable = false; + SystemUpdaterClient* m_systemUpdater = nullptr; + QTimer* m_systemPoll = nullptr; + bool m_systemChecking = false; // Core components QNetworkAccessManager* m_networkManager; QNetworkReply* m_checkReply = nullptr; diff --git a/include/multipack/system/SystemUpdaterClient.h b/include/multipack/system/SystemUpdaterClient.h new file mode 100644 index 0000000..81ebc5b --- /dev/null +++ b/include/multipack/system/SystemUpdaterClient.h @@ -0,0 +1,33 @@ +#ifndef MULTIPACK_SYSTEM_SYSTEMUPDATERCLIENT_H +#define MULTIPACK_SYSTEM_SYSTEMUPDATERCLIENT_H +#include +#include +#include +#include +namespace multipack::system { +// One bounded asynchronous request at a time. Production always uses the fixed socket. +class SystemUpdaterClient : public QObject { + Q_OBJECT +public: + explicit SystemUpdaterClient(QObject* parent = nullptr, + const QString& socketPath = QStringLiteral("/run/hmi-updater/control.sock"), + int timeoutMs = 4000); + bool status(); + bool install(const QString& version); + void cancel(); // Cancels observation only; never cancels a privileged installation. + static bool validVersion(const QString& version); +signals: + void response(const QJsonObject& value); + void error(const QString& message); +private: + bool request(const QJsonObject& value); + void fail(const QString& message); + QLocalSocket* m_socket; + QTimer m_timeout; + QString m_path; + QByteArray m_request, m_buffer; + int m_timeoutMs; + bool m_busy = false; +}; +} +#endif diff --git a/include/multipack/system/WifiSsidProbe.h b/include/multipack/system/WifiSsidProbe.h new file mode 100644 index 0000000..eff6e42 --- /dev/null +++ b/include/multipack/system/WifiSsidProbe.h @@ -0,0 +1,24 @@ +#ifndef MULTIPACK_SYSTEM_WIFISSIDPROBE_H +#define MULTIPACK_SYSTEM_WIFISSIDPROBE_H +#include +#include +#include +namespace multipack::system { +class WifiSsidProbe : public QObject { + Q_OBJECT +public: + explicit WifiSsidProbe(QObject* parent = nullptr, const QString& program = "nmcli", int timeoutMs = 1500); + ~WifiSsidProbe() override; + void refresh(); + static QString parse(const QByteArray& output); +signals: + void result(const QString& ssid); +private: + QProcess* m_process; + QTimer m_timer; + QString m_program; + int m_timeoutMs; + bool m_expired = false; +}; +} +#endif diff --git a/include/multipack/ui/MainWindow.h b/include/multipack/ui/MainWindow.h index 297c36c..5506d7a 100644 --- a/include/multipack/ui/MainWindow.h +++ b/include/multipack/ui/MainWindow.h @@ -1,3 +1,4 @@ +#include "multipack/system/WifiSsidProbe.h" /** * @file MainWindow.h * @brief Main application window using Qt Designer UI @@ -265,6 +266,7 @@ private slots: QWidget* m_centralWidget = nullptr; QTimer* m_zwischenlageTimer = nullptr; QTimer* m_paletteClearTimer = nullptr; + system::WifiSsidProbe* m_wifiSsidProbe = nullptr; QTimer* m_networkStatusTimer = nullptr; NotificationPopup* m_zwischenlagePopup = nullptr; QPushButton* m_palette1ClearIndicator = nullptr; diff --git a/src/system/AutoUpdater.cpp b/src/system/AutoUpdater.cpp index 6ffbf44..89840c3 100644 --- a/src/system/AutoUpdater.cpp +++ b/src/system/AutoUpdater.cpp @@ -1,3 +1,5 @@ +#include +#include /** * @file AutoUpdater.cpp * @brief Implementation of comprehensive automatic updater @@ -78,6 +80,50 @@ AutoUpdater::AutoUpdater(QObject* parent) } m_currentVersion = config::Defaults::VERSION; + m_immutable = qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1"; + if (m_immutable) { + m_systemUpdater = new SystemUpdaterClient(this); + m_systemPoll = new QTimer(this); + m_systemPoll->setInterval(2000); + connect(m_systemPoll, &QTimer::timeout, this, [this] { m_systemUpdater->status(); }); + connect(m_systemUpdater, &SystemUpdaterClient::error, this, [this](const QString& error) { + m_progress.statusText = error; + setStatus(UpdateStatus::Idle); // Allow retry after transport/daemon errors. + emit downloadProgress(m_progress); + emit updateFailed(error); + if (m_systemChecking) { + m_systemChecking = false; + emit checkCompleted(false, error); + } + }); + connect(m_systemUpdater, &SystemUpdaterClient::response, this, [this](const QJsonObject& value) { + if (value.value("accepted").toBool()) { + setStatus(UpdateStatus::Downloading); + m_progress.statusText = tr("System update accepted; preparing inactive slot"); + emit downloadStarted(m_availableUpdate.version); + } else { + const QString phase = value.value("phase").toString(); + const auto boot = value.value("boot").toObject(); + const QString active = boot.value("active").toString(); + const QString version = boot.value("version").toObject().value(active).toString(); + if (!version.isEmpty()) m_currentVersion = version; + m_progress.bytesReceived = static_cast(value.value("bytes").toDouble()); + m_progress.bytesTotal = static_cast(value.value("total").toDouble()); + m_progress.statusText = tr("System: %1; active %2 (%3); boot: %4") + .arg(phase, active, m_currentVersion, boot.value("phase").toString()); + setStatus(phase == "downloading" ? UpdateStatus::Downloading + : phase == "installing" || phase == "rebooting" ? UpdateStatus::Installing + : UpdateStatus::Idle); + if (m_systemChecking) { + m_systemChecking = false; + emit checkCompleted(true, m_progress.statusText); + } + } + emit downloadProgress(m_progress); + }); + m_systemPoll->start(); + QTimer::singleShot(0, this, [this] { m_systemUpdater->status(); }); + } } AutoUpdater::~AutoUpdater() @@ -100,6 +146,12 @@ AutoUpdater::~AutoUpdater() bool AutoUpdater::checkForUpdates() { + if (m_immutable) { + if (!m_systemUpdater->status()) return false; + m_systemChecking = true; + emit checkStarted(); + return true; + } if (m_status != UpdateStatus::Idle) { qWarning() << "AutoUpdater: Cannot check for updates, operation in progress"; return false; @@ -111,6 +163,10 @@ bool AutoUpdater::checkForUpdates() bool AutoUpdater::checkForUsbUpdates(const QString& usbDirectory) { + if (m_immutable) { + emit checkFailed(tr("System releases must be installed by VERSION through the A/B updater")); + return false; + } if (m_status != UpdateStatus::Idle) { qWarning() << "AutoUpdater: Cannot check USB updates, operation in progress"; return false; @@ -143,11 +199,9 @@ bool AutoUpdater::checkForUsbUpdates(const QString& usbDirectory) bool AutoUpdater::downloadAndInstallUpdate() { - if (qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1") { - const QString message = tr("Application updates are managed by the system A/B updater"); - qWarning() << "AutoUpdater:" << message; - emit updateFailed(message); - return false; + if (m_immutable) { + if (m_status != UpdateStatus::Idle) return false; + return m_systemUpdater->install(m_availableUpdate.version); } if (m_availableUpdate.version.isEmpty()) { @@ -251,6 +305,10 @@ bool AutoUpdater::downloadAndInstallUpdate() void AutoUpdater::cancelUpdate() { + if (m_immutable) { + emit updateFailed(tr("An accepted system update cannot be cancelled from the HMI")); + return; + } if (m_checkReply) { m_checkReply->abort(); m_checkReply->deleteLater(); @@ -318,6 +376,17 @@ QString AutoUpdater::usbUpdatePath() const void AutoUpdater::showUpdateDialog() { + if (m_immutable) { + bool ok = false; + const QString version = QInputDialog::getText(nullptr, tr("System A/B Update"), + tr("%1\nEnter published system release VERSION. Installation reboots the HMI.") + .arg(m_progress.statusText), QLineEdit::Normal, QString(), &ok); + if (ok) { + m_availableUpdate.version = version; + downloadAndInstallUpdate(); + } + return; + } if (m_availableUpdate.version.isEmpty()) { QMessageBox::information(nullptr, tr("No Updates Available"), @@ -352,6 +421,7 @@ void AutoUpdater::initializeNetwork() void AutoUpdater::startGitHubCheck() { + if (m_immutable) { checkForUpdates(); return; } setStatus(UpdateStatus::Checking); emit checkStarted(); diff --git a/src/system/SystemUpdaterClient.cpp b/src/system/SystemUpdaterClient.cpp new file mode 100644 index 0000000..80cac09 --- /dev/null +++ b/src/system/SystemUpdaterClient.cpp @@ -0,0 +1,60 @@ +#include "multipack/system/SystemUpdaterClient.h" +#include +#include +namespace multipack::system { +SystemUpdaterClient::SystemUpdaterClient(QObject* parent, const QString& path, int timeoutMs) + : QObject(parent), m_socket(new QLocalSocket(this)), m_path(path), m_timeoutMs(timeoutMs) +{ + m_timeout.setSingleShot(true); + connect(&m_timeout, &QTimer::timeout, this, [this] { fail(tr("System updater timed out")); }); + connect(m_socket, &QLocalSocket::connected, this, [this] { m_socket->write(m_request); }); + connect(m_socket, &QLocalSocket::errorOccurred, this, [this] { + if (m_busy) fail(tr("System updater: %1").arg(m_socket->errorString())); + }); + connect(m_socket, &QLocalSocket::disconnected, this, [this] { + if (m_busy) fail(tr("System updater disconnected before replying")); + }); + connect(m_socket, &QLocalSocket::readyRead, this, [this] { + if (!m_busy) return; + m_buffer += m_socket->readAll(); + if (m_buffer.size() > 65536) { fail(tr("System updater reply too large")); return; } + const int end = m_buffer.indexOf('\n'); + if (end < 0) return; + QJsonParseError parseError; + const auto doc = QJsonDocument::fromJson(m_buffer.left(end), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + fail(tr("Invalid system updater reply")); return; + } + const auto value = doc.object(); + cancel(); + if (value.contains("error") && !value.value("error").isNull()) + emit error(tr("System updater: %1").arg(value.value("error").toString())); + else if (value.value("accepted") != true && !value.value("phase").isString()) + emit error(tr("Invalid system updater status")); + else emit response(value); + }); +} +bool SystemUpdaterClient::validVersion(const QString& version) +{ + static const QRegularExpression pattern(QStringLiteral("\\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}\\z")); + return pattern.match(version).hasMatch(); +} +bool SystemUpdaterClient::status() { return request({{"op", "status"}}); } +bool SystemUpdaterClient::install(const QString& version) +{ + if (!validVersion(version)) { emit error(tr("Invalid system release VERSION")); return false; } + return request({{"op", "install"}, {"version", version}}); +} +bool SystemUpdaterClient::request(const QJsonObject& value) +{ + if (m_busy) return false; + m_buffer.clear(); + m_request = QJsonDocument(value).toJson(QJsonDocument::Compact) + '\n'; + m_busy = true; + m_timeout.start(m_timeoutMs); + m_socket->connectToServer(m_path); + return true; +} +void SystemUpdaterClient::cancel() { m_busy = false; m_timeout.stop(); m_socket->abort(); } +void SystemUpdaterClient::fail(const QString& message) { cancel(); emit error(message); } +} diff --git a/src/system/WifiSsidProbe.cpp b/src/system/WifiSsidProbe.cpp new file mode 100644 index 0000000..51e4eaa --- /dev/null +++ b/src/system/WifiSsidProbe.cpp @@ -0,0 +1,38 @@ +#include "multipack/system/WifiSsidProbe.h" +namespace multipack::system { +WifiSsidProbe::WifiSsidProbe(QObject* parent, const QString& program, int timeoutMs) + : QObject(parent), m_process(new QProcess(this)), m_program(program), m_timeoutMs(timeoutMs) +{ + m_timer.setSingleShot(true); + connect(&m_timer, &QTimer::timeout, this, [this] { + m_expired = true; m_process->kill(); emit result(tr("SSID unavailable (timeout)")); + }); + connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) { + m_timer.stop(); emit result(tr("SSID unavailable (nmcli not installed)")); + } + }); + connect(m_process, QOverload::of(&QProcess::finished), this, + [this](int code, QProcess::ExitStatus status) { + m_timer.stop(); + if (m_expired) return; + emit result(code == 0 && status == QProcess::NormalExit + ? parse(m_process->readAllStandardOutput()) : tr("SSID unavailable")); + }); +} +WifiSsidProbe::~WifiSsidProbe() { m_process->disconnect(this); m_process->kill(); } +void WifiSsidProbe::refresh() +{ + if (m_process->state() != QProcess::NotRunning) return; + m_expired = false; + m_process->start(m_program, {"--terse", "--escape", "no", "--fields", "ACTIVE,SSID", + "device", "wifi", "list", "ifname", "wlan0", "--rescan", "no"}); + m_timer.start(m_timeoutMs); +} +QString WifiSsidProbe::parse(const QByteArray& output) +{ + for (const auto& line : QString::fromUtf8(output).split('\n')) + if (line.startsWith("yes:")) return line.mid(4); // Preserve SSID spaces/colons/backslashes. + return tr("Not connected"); +} +} diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index e21624c..c9aca88 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1,3 +1,4 @@ +#include "multipack/system/WifiSsidProbe.h" /** * @file MainWindow.cpp * @brief Implementation of main window using Qt Designer UI @@ -104,48 +105,6 @@ QString ipv4ForInterface(const QString& interfaceName) return QStringLiteral("-"); } -QString commandOutput(const QString& program, const QStringList& arguments, int timeoutMs = 500) -{ - QProcess process; - process.start(program, arguments); - if (!process.waitForStarted(timeoutMs)) { - return QString(); - } - if (!process.waitForFinished(timeoutMs)) { - process.kill(); - process.waitForFinished(100); - return QString(); - } - if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { - return QString(); - } - return QString::fromUtf8(process.readAllStandardOutput()).trimmed(); -} - -QString currentWifiSsid() -{ - QString ssid = commandOutput(QStringLiteral("iwgetid"), - {QStringLiteral("wlan0"), QStringLiteral("-r")}); - if (!ssid.isEmpty()) { - return ssid; - } - - const QString nmcli = commandOutput( - QStringLiteral("nmcli"), - {QStringLiteral("-t"), QStringLiteral("-f"), QStringLiteral("ACTIVE,SSID"), - QStringLiteral("dev"), QStringLiteral("wifi")}, - 800); - const QStringList lines = nmcli.split('\n', Qt::SkipEmptyParts); - for (const QString& line : lines) { - if (line.startsWith(QStringLiteral("yes:"))) { - QString value = line.mid(4); - value.replace(QStringLiteral("\\:"), QStringLiteral(":")); - return value.trimmed(); - } - } - - return QStringLiteral("-"); -} } // namespace MainWindow::MainWindow(QWidget* parent) @@ -754,6 +713,10 @@ void MainWindow::setupStatusTab() ui->tabWidget->addTab(statusWidget, tr("Status")); + m_wifiSsidProbe = new system::WifiSsidProbe(this); + connect(m_wifiSsidProbe, &system::WifiSsidProbe::result, this, [this](const QString& ssid) { + m_statusWifiSsid->setText(ipv4ForInterface("wlan0") == "-" ? tr("Not connected") : ssid); + }); m_networkStatusTimer = new QTimer(this); m_networkStatusTimer->setInterval(5000); connect(m_networkStatusTimer, &QTimer::timeout, @@ -777,9 +740,8 @@ void MainWindow::refreshNetworkStatus() : QHostInfo::localHostName()); m_statusEth0Ip->setText(eth0Ip); m_statusWlan0Ip->setText(wlan0Ip); - m_statusWifiSsid->setText(wlan0Ip == QStringLiteral("-") - ? QStringLiteral("Not connected") - : currentWifiSsid()); + if (wlan0Ip == "-") m_statusWifiSsid->setText(tr("Not connected")); + else m_wifiSsidProbe->refresh(); m_statusTailscaleIp->setText(tailscaleIp); m_statusTailscaleState->setText(tailscaleIp == QStringLiteral("-") ? QStringLiteral("Not connected") @@ -1732,6 +1694,10 @@ void MainWindow::onUpdateCheckCompleted(bool success, const QString& message) return; } + if (qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1") { + m_autoUpdater->showUpdateDialog(); + return; + } auto updateInfo = m_autoUpdater->availableUpdate(); if (updateInfo.version.isEmpty()) { showMessage("Keine Updates verfuegbar. Aktuelle Version: " + m_autoUpdater->currentVersion()); @@ -1757,6 +1723,10 @@ void MainWindow::onUpdateCheckCompleted(bool success, const QString& message) void MainWindow::onUpdateDownloadProgress(const system::UpdateProgress& progress) { + if (qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1") { + showMessage(progress.statusText); + return; + } if (progress.bytesTotal > 0) { qint64 percent = (progress.bytesReceived * 100) / progress.bytesTotal; showMessage(QString("Download: %1%").arg(percent)); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6ff7611..d00dece 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -82,3 +82,7 @@ if(NOT WIN32) ENVIRONMENT "${TEST_QT_ENV}" ) endif() + +add_executable(test_system_clients test_system_clients.cpp) +target_link_libraries(test_system_clients PRIVATE multipack-lib Qt6::Test) +add_test(NAME test_system_clients COMMAND $) diff --git a/tests/test_mainwindow_integration.cpp b/tests/test_mainwindow_integration.cpp index 910c340..7e7b5c9 100644 --- a/tests/test_mainwindow_integration.cpp +++ b/tests/test_mainwindow_integration.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include #include #include @@ -33,6 +35,7 @@ private slots: void startAndStopServerFromUi(); void failedPaletteLoadKeepsSuggestionsVisible(); void successfulPaletteLoadClosesSuggestions(); + void selectedStartLayerReachesRpcAfterRobotStart(); }; void MainWindowIntegrationTest::startAndStopServerFromUi() @@ -165,6 +168,59 @@ void MainWindowIntegrationTest::successfulPaletteLoadClosesSuggestions() QTRY_VERIFY(!popup->isVisible()); } +void MainWindowIntegrationTest::selectedStartLayerReachesRpcAfterRobotStart() +{ + QTemporaryDir tempDir; + qputenv("MULTIPACK_DISABLE_ROBOT_STATUS_MONITOR", "1"); + auto& state = GlobalState::instance(); + state.clear(); + DatabaseManager database; + QVERIFY(database.open(tempDir.filePath("palettes.db"))); + auto palette = testhelpers::makeSamplePaletteData(); + palette.metadata.anzLagen = 2; + palette.layerAssignments = {1, 1}; + palette.intermediaryLayers = {0, 0}; + QCOMPARE(database.savePaletteData(palette), SaveResult::Inserted); + MainWindow window; + window.setDatabaseManager(&database); + window.setGlobalState(&state); + auto* input = window.findChild("EingabePallettenplan"); + auto* load = window.findChild("LadePallettenplan"); + auto* startLayer = window.findChild("EingabeStartlage"); + QVERIFY(input && load && startLayer); + const QStringList labels = {"label_Startlage", "label_Kartonhoehe", "label_Kartonhoehe_mm", + "label_Gewicht", "label_Gewicht_kg"}; + for (const auto& name : labels) { + auto* label = window.findChild(name); + QVERIFY(label); + QVERIFY(!label->isEnabled()); + } + input->setText("sample"); + load->click(); + for (const auto& name : labels) QVERIFY(window.findChild(name)->isEnabled()); + startLayer->setValue(2); + QCOMPARE(state.startLayer(), 2); + // Reproduce a stale state write after valueChanged. The robot-start slot must + // commit the visible value again even without an available physical robot. + state.setStartLayer(1); + QVERIFY(QMetaObject::invokeMethod(&window, "onRobotStartClicked", Qt::DirectConnection)); + QCOMPARE(state.startLayer(), 2); + XmlRpcServer server; + server.setGlobalState(&state); + server.registerStandardMethods(); + QVERIFY(server.start(0)); + QTcpSocket socket; + socket.connectToHost(QHostAddress::LocalHost, server.port()); + QVERIFY(socket.waitForConnected(1000)); + const QByteArray body = "UR_Startlage"; + socket.write("POST /RPC2 HTTP/1.1\r\nHost: localhost\r\nContent-Length: " + + QByteArray::number(body.size()) + "\r\n\r\n" + body); + QTRY_VERIFY_WITH_TIMEOUT(socket.bytesAvailable() > 0, 3000); + const auto reply = socket.readAll(); + QVERIFY2(reply.contains("2"), reply.constData()); + QVERIFY(!reply.contains("")); +} + int main(int argc, char** argv) { #ifdef Q_OS_WIN diff --git a/tests/test_system_clients.cpp b/tests/test_system_clients.cpp new file mode 100644 index 0000000..24cc7c9 --- /dev/null +++ b/tests/test_system_clients.cpp @@ -0,0 +1,121 @@ +#include "multipack/system/SystemUpdaterClient.h" +#include "multipack/system/WifiSsidProbe.h" +#include "multipack/system/AutoUpdater.h" +#include +#include +#include +#include +#include +#include +using namespace multipack::system; +class SystemClientsTest : public QObject { + Q_OBJECT +private slots: + void versionBoundary() { + QVERIFY(SystemUpdaterClient::validVersion("v2.0.0-rc_1")); + QVERIFY(SystemUpdaterClient::validVersion(QString(80, 'a'))); + for (const QString& v : {QString(), QString("../root"), QString("https://host"), + QString("x\n"), QString("x;reboot"), QString(81, 'a')}) + QVERIFY(!SystemUpdaterClient::validVersion(v)); + } + void fakeSocketInstallAndFragmentedStatus() { + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + SystemUpdaterClient client(nullptr, server.fullServerName()); + QSignalSpy replies(&client, &SystemUpdaterClient::response); + QSignalSpy errors(&client, &SystemUpdaterClient::error); + QVERIFY(client.install("v2.0")); + QVERIFY(!client.status()); // Never overlap requests. + QTRY_VERIFY(server.hasPendingConnections()); + auto* peer = server.nextPendingConnection(); + QTRY_VERIFY(peer->canReadLine()); + const auto request = QJsonDocument::fromJson(peer->readLine()).object(); + QCOMPARE(request, (QJsonObject{{"op", "install"}, {"version", "v2.0"}})); + peer->write("{\"accepted\":true}\n"); peer->flush(); + QTRY_COMPARE(replies.count(), 1); + QVERIFY(client.status()); + QTRY_VERIFY(server.hasPendingConnections()); + auto* statusPeer = server.nextPendingConnection(); + QTRY_VERIFY(statusPeer->canReadLine()); + QCOMPARE(QJsonDocument::fromJson(statusPeer->readLine()).object(), (QJsonObject{{"op", "status"}})); + statusPeer->write("{\"phase\":"); statusPeer->flush(); + QTest::qWait(20); + QCOMPARE(replies.count(), 1); + statusPeer->write("\"installing\",\"percent\":42}\n"); statusPeer->flush(); + QTRY_COMPARE(replies.count(), 2); + QCOMPARE(errors.count(), 0); + } + void failures_data() { + QTest::addColumn("reply"); + QTest::newRow("daemon error") << QByteArray("{\"error\":\"update already running\"}\n"); + QTest::newRow("malformed") << QByteArray("bad json\n"); + QTest::newRow("schema") << QByteArray("{}\n"); + QTest::newRow("oversized") << QByteArray(65537, 'x'); + QTest::newRow("timeout") << QByteArray(); + } + void failures() { + QFETCH(QByteArray, reply); + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + SystemUpdaterClient client(nullptr, server.fullServerName(), 200); + QSignalSpy errors(&client, &SystemUpdaterClient::error); + QVERIFY(client.status()); + QTRY_VERIFY(server.hasPendingConnections()); + auto* peer = server.nextPendingConnection(); + QTRY_VERIFY(peer->canReadLine()); peer->readLine(); + peer->write(reply); peer->flush(); + QTRY_COMPARE(errors.count(), 1); + QVERIFY(client.status()); // Retry is safe after failure. + client.cancel(); + } + void missingSocketAndClose() { + QTemporaryDir dir; + SystemUpdaterClient client(nullptr, dir.filePath("absent")); + QSignalSpy errors(&client, &SystemUpdaterClient::error); + QVERIFY(client.status()); + QTRY_COMPARE(errors.count(), 1); + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + auto* closing = new SystemUpdaterClient(nullptr, server.fullServerName()); + QVERIFY(closing->status()); + QTRY_VERIFY(server.hasPendingConnections()); + delete closing; // Pending socket is owned and cancelled on destruction. + } + void wifiParsingAndMissingUtility() { + QCOMPARE(WifiSsidProbe::parse("no:other\nyes: A:B\\C \n"), QString(" A:B\\C ")); + WifiSsidProbe probe(nullptr, "/missing/nmcli"); + QSignalSpy result(&probe, &WifiSsidProbe::result); + probe.refresh(); + QTRY_COMPARE(result.count(), 1); + QVERIFY(result.first().first().toString().contains("unavailable")); + } +#ifndef Q_OS_WIN + void slowToolDoesNotBlockEventLoop() { + QTemporaryDir dir; + QFile script(dir.filePath("slow-nmcli")); + QVERIFY(script.open(QIODevice::WriteOnly)); + script.write("#!/bin/sh\nexec sleep 10\n"); script.close(); + QVERIFY(script.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + WifiSsidProbe probe(nullptr, script.fileName(), 100); + QSignalSpy result(&probe, &WifiSsidProbe::result); + bool heartbeat = false; + QTimer::singleShot(10, &probe, [&] { heartbeat = true; }); + probe.refresh(); + QTRY_VERIFY_WITH_TIMEOUT(heartbeat, 90); + QTRY_COMPARE(result.count(), 1); + QVERIFY(result.first().first().toString().contains("timeout")); + } +#endif + void immutableRejectsLegacyUsbPath() { + qputenv("MULTIPACK_IMMUTABLE", "1"); + AutoUpdater updater; + QSignalSpy errors(&updater, &AutoUpdater::checkFailed); + QVERIFY(!updater.checkForUsbUpdates("/any/path")); + QCOMPARE(errors.count(), 1); + qunsetenv("MULTIPACK_IMMUTABLE"); + } +}; +QTEST_GUILESS_MAIN(SystemClientsTest) +#include "test_system_clients.moc" From 34bc8c28b5c265a415634c957c5b538c544b512b Mon Sep 17 00:00:00 2001 From: Snupai Date: Wed, 7 Oct 2026 14:19:36 +0200 Subject: [PATCH 16/16] Fix localized SSID probing and immutable update retry feedback --- ReadMe.md | 9 ++- include/multipack/system/AutoUpdater.h | 4 +- .../multipack/system/SystemUpdaterClient.h | 2 + src/system/AutoUpdater.cpp | 35 +++++++-- src/system/SystemUpdaterClient.cpp | 6 +- src/system/WifiSsidProbe.cpp | 4 ++ tests/test_mainwindow_integration.cpp | 71 +++++++++++++++++++ tests/test_system_clients.cpp | 71 +++++++++++++++++++ 8 files changed, 192 insertions(+), 10 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index 8f98ae8..70c24f5 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -214,5 +214,12 @@ Without the environment flag the signed legacy updater is retained. SSID probing uses asynchronous `nmcli` with a deadline and no Wi-Fi rescan. HMI provisioning installs `network-manager`. Portable bundles require a host NetworkManager installation for SSID display; when it is absent, the UI explicitly -shows SSID unavailable. Interface IP status remains usable. Probe processes belong +shows SSID unavailable. The probe uses the C locale so translated nmcli output +does not affect SSID detection. Interface IP status remains usable. Probe processes belong to the window and are terminated when it closes. + +A saved installation failure remains readable system status and allows another +VERSION to be submitted. If a status request is still pending when an update is +confirmed, the UI reports that no request was sent and asks the operator to retry. +Background idle/error observations do not replace operator messages; active +update progress and new installation failures remain visible. diff --git a/include/multipack/system/AutoUpdater.h b/include/multipack/system/AutoUpdater.h index 2b0d005..587c22e 100644 --- a/include/multipack/system/AutoUpdater.h +++ b/include/multipack/system/AutoUpdater.h @@ -78,7 +78,8 @@ class AutoUpdater : public QObject * @brief Constructor * @param parent Parent QObject */ - explicit AutoUpdater(QObject* parent = nullptr); + explicit AutoUpdater(QObject* parent = nullptr, + const QString& systemSocketPath = QStringLiteral("/run/hmi-updater/control.sock")); /** * @brief Destructor @@ -241,6 +242,7 @@ private slots: SystemUpdaterClient* m_systemUpdater = nullptr; QTimer* m_systemPoll = nullptr; bool m_systemChecking = false; + bool m_systemInstalling = false; // Core components QNetworkAccessManager* m_networkManager; QNetworkReply* m_checkReply = nullptr; diff --git a/include/multipack/system/SystemUpdaterClient.h b/include/multipack/system/SystemUpdaterClient.h index 81ebc5b..87f4696 100644 --- a/include/multipack/system/SystemUpdaterClient.h +++ b/include/multipack/system/SystemUpdaterClient.h @@ -14,6 +14,7 @@ class SystemUpdaterClient : public QObject { int timeoutMs = 4000); bool status(); bool install(const QString& version); + bool busy() const { return m_busy; } void cancel(); // Cancels observation only; never cancels a privileged installation. static bool validVersion(const QString& version); signals: @@ -28,6 +29,7 @@ class SystemUpdaterClient : public QObject { QByteArray m_request, m_buffer; int m_timeoutMs; bool m_busy = false; + bool m_statusRequest = false; }; } #endif diff --git a/src/system/AutoUpdater.cpp b/src/system/AutoUpdater.cpp index 89840c3..85a9cb7 100644 --- a/src/system/AutoUpdater.cpp +++ b/src/system/AutoUpdater.cpp @@ -56,7 +56,7 @@ QString sanitizeForFileName(const QString& value) } } // namespace -AutoUpdater::AutoUpdater(QObject* parent) +AutoUpdater::AutoUpdater(QObject* parent, const QString& systemSocketPath) : QObject(parent) , m_networkManager(new QNetworkAccessManager(this)) , m_checkReply(nullptr) @@ -82,21 +82,26 @@ AutoUpdater::AutoUpdater(QObject* parent) m_currentVersion = config::Defaults::VERSION; m_immutable = qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1"; if (m_immutable) { - m_systemUpdater = new SystemUpdaterClient(this); + m_systemUpdater = new SystemUpdaterClient(this, systemSocketPath); m_systemPoll = new QTimer(this); m_systemPoll->setInterval(2000); connect(m_systemPoll, &QTimer::timeout, this, [this] { m_systemUpdater->status(); }); connect(m_systemUpdater, &SystemUpdaterClient::error, this, [this](const QString& error) { + const bool reportError = m_systemChecking || m_systemInstalling + || m_status == UpdateStatus::Downloading || m_status == UpdateStatus::Installing; + m_systemInstalling = false; m_progress.statusText = error; setStatus(UpdateStatus::Idle); // Allow retry after transport/daemon errors. - emit downloadProgress(m_progress); - emit updateFailed(error); + if (reportError) emit updateFailed(error); if (m_systemChecking) { m_systemChecking = false; emit checkCompleted(false, error); } }); connect(m_systemUpdater, &SystemUpdaterClient::response, this, [this](const QJsonObject& value) { + const bool wasUpdating = m_status == UpdateStatus::Downloading + || m_status == UpdateStatus::Installing; + m_systemInstalling = false; if (value.value("accepted").toBool()) { setStatus(UpdateStatus::Downloading); m_progress.statusText = tr("System update accepted; preparing inactive slot"); @@ -111,6 +116,11 @@ AutoUpdater::AutoUpdater(QObject* parent) m_progress.bytesTotal = static_cast(value.value("total").toDouble()); m_progress.statusText = tr("System: %1; active %2 (%3); boot: %4") .arg(phase, active, m_currentVersion, boot.value("phase").toString()); + if (phase == "error") { + const QString error = value.value("error").toString(); + m_progress.statusText += tr("; previous update failed: %1").arg(error); + if (wasUpdating) emit updateFailed(error); + } setStatus(phase == "downloading" ? UpdateStatus::Downloading : phase == "installing" || phase == "rebooting" ? UpdateStatus::Installing : UpdateStatus::Idle); @@ -119,7 +129,8 @@ AutoUpdater::AutoUpdater(QObject* parent) emit checkCompleted(true, m_progress.statusText); } } - emit downloadProgress(m_progress); + if (m_status == UpdateStatus::Downloading || m_status == UpdateStatus::Installing) + emit downloadProgress(m_progress); }); m_systemPoll->start(); QTimer::singleShot(0, this, [this] { m_systemUpdater->status(); }); @@ -200,8 +211,18 @@ bool AutoUpdater::checkForUsbUpdates(const QString& usbDirectory) bool AutoUpdater::downloadAndInstallUpdate() { if (m_immutable) { - if (m_status != UpdateStatus::Idle) return false; - return m_systemUpdater->install(m_availableUpdate.version); + if (m_status != UpdateStatus::Idle) { + emit updateFailed(tr("A system update is already running; no new update was submitted.")); + return false; + } + if (m_systemUpdater->busy()) { + emit updateFailed(tr("System updater status request is still pending. Please retry the update.")); + return false; + } + m_systemInstalling = true; + const bool sent = m_systemUpdater->install(m_availableUpdate.version); + if (!sent) m_systemInstalling = false; + return sent; } if (m_availableUpdate.version.isEmpty()) { diff --git a/src/system/SystemUpdaterClient.cpp b/src/system/SystemUpdaterClient.cpp index 80cac09..2ae33e5 100644 --- a/src/system/SystemUpdaterClient.cpp +++ b/src/system/SystemUpdaterClient.cpp @@ -26,8 +26,11 @@ SystemUpdaterClient::SystemUpdaterClient(QObject* parent, const QString& path, i fail(tr("Invalid system updater reply")); return; } const auto value = doc.object(); + const bool statusReply = m_statusRequest && value.value("phase").isString(); cancel(); - if (value.contains("error") && !value.value("error").isNull()) + if (statusReply) + emit response(value); // An earlier installation failure is readable status. + else if (value.contains("error") && !value.value("error").isNull()) emit error(tr("System updater: %1").arg(value.value("error").toString())); else if (value.value("accepted") != true && !value.value("phase").isString()) emit error(tr("Invalid system updater status")); @@ -50,6 +53,7 @@ bool SystemUpdaterClient::request(const QJsonObject& value) if (m_busy) return false; m_buffer.clear(); m_request = QJsonDocument(value).toJson(QJsonDocument::Compact) + '\n'; + m_statusRequest = value.value("op") == "status"; m_busy = true; m_timeout.start(m_timeoutMs); m_socket->connectToServer(m_path); diff --git a/src/system/WifiSsidProbe.cpp b/src/system/WifiSsidProbe.cpp index 51e4eaa..3d81ee1 100644 --- a/src/system/WifiSsidProbe.cpp +++ b/src/system/WifiSsidProbe.cpp @@ -1,4 +1,5 @@ #include "multipack/system/WifiSsidProbe.h" +#include namespace multipack::system { WifiSsidProbe::WifiSsidProbe(QObject* parent, const QString& program, int timeoutMs) : QObject(parent), m_process(new QProcess(this)), m_program(program), m_timeoutMs(timeoutMs) @@ -25,6 +26,9 @@ void WifiSsidProbe::refresh() { if (m_process->state() != QProcess::NotRunning) return; m_expired = false; + auto environment = QProcessEnvironment::systemEnvironment(); + environment.insert("LC_ALL", "C"); + m_process->setProcessEnvironment(environment); m_process->start(m_program, {"--terse", "--escape", "no", "--fields", "ACTIVE,SSID", "device", "wifi", "list", "ifname", "wlan0", "--rescan", "no"}); m_timer.start(m_timeoutMs); diff --git a/tests/test_mainwindow_integration.cpp b/tests/test_mainwindow_integration.cpp index 7e7b5c9..d1f37f0 100644 --- a/tests/test_mainwindow_integration.cpp +++ b/tests/test_mainwindow_integration.cpp @@ -6,6 +6,8 @@ #include "multipack/database/DatabaseManager.h" #include "multipack/network/XmlRpcServer.h" #include "multipack/ui/MainWindow.h" +#include "multipack/system/AutoUpdater.h" +#include "multipack/message/StatusManager.h" #include #include @@ -18,6 +20,9 @@ #include #include #include +#include +#include +#include #include using multipack::config::SettingsManager; @@ -36,8 +41,74 @@ private slots: void failedPaletteLoadKeepsSuggestionsVisible(); void successfulPaletteLoadClosesSuggestions(); void selectedStartLayerReachesRpcAfterRobotStart(); + void immutableUpdateRetryAndOperatorMessages(); }; +void MainWindowIntegrationTest::immutableUpdateRetryAndOperatorMessages() +{ + using namespace multipack::system; + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + qputenv("MULTIPACK_IMMUTABLE", "1"); + AutoUpdater updater(nullptr, server.fullServerName()); + MainWindow window; + window.setAutoUpdater(&updater); + auto* client = updater.findChild(); + QSignalSpy failures(&updater, &AutoUpdater::updateFailed); + QSignalSpy progress(&updater, &AutoUpdater::downloadProgress); + QTRY_VERIFY(server.hasPendingConnections()); + auto* heldStatus = server.nextPendingConnection(); + QTRY_VERIFY(heldStatus->canReadLine()); heldStatus->readLine(); + QTimer acceptDialog; + acceptDialog.setInterval(10); + connect(&acceptDialog, &QTimer::timeout, &window, [] { + for (auto* widget : QApplication::topLevelWidgets()) { + if (auto* dialog = qobject_cast(widget)) { + dialog->setTextValue("rpi-hmi-v2"); + dialog->accept(); + } + } + }); + acceptDialog.start(); + updater.showUpdateDialog(); // User confirms while background status is pending. + QCOMPARE(failures.count(), 1); + QVERIFY(failures.first().first().toString().contains("retry", Qt::CaseInsensitive)); + heldStatus->write("{\"phase\":\"idle\",\"boot\":{}}\n"); heldStatus->flush(); + QTRY_VERIFY(!client->busy()); + auto& messages = multipack::message::StatusManager::instance(); + messages.showError("Operator warning", 10000); + client->response(QJsonObject{{"phase", "idle"}, {"boot", QJsonObject{}}}); + QCOMPARE(messages.currentMessage(), QString("Operator warning")); + QCOMPARE(progress.count(), 0); + int installs = 0; + connect(&server, &QLocalServer::newConnection, &server, [&] { + auto* peer = server.nextPendingConnection(); + connect(peer, &QLocalSocket::readyRead, peer, [&, peer] { + if (!peer->canReadLine()) return; + const auto request = QJsonDocument::fromJson(peer->readLine()).object(); + if (request.value("op") == "install") { + QCOMPARE(request.value("version").toString(), QString("rpi-hmi-v2")); + ++installs; + peer->write("{\"accepted\":true}\n"); + } else { + peer->write("{\"phase\":\"error\",\"error\":\"download failed\",\"boot\":{}}\n"); + } + peer->flush(); + }); + }); + QVERIFY(updater.checkForUpdates()); // Saved failure must still open the VERSION dialog. + QTRY_COMPARE(installs, 1); + QTRY_COMPARE(updater.currentStatus(), UpdateStatus::Downloading); + QVERIFY(progress.count() > 0); // Real update progress is still visible. + acceptDialog.stop(); + client->response(QJsonObject{{"phase", "error"}, {"error", "new download failed"}}); + QCOMPARE(failures.count(), 2); + client->response(QJsonObject{{"phase", "error"}, {"error", "new download failed"}}); + QCOMPARE(failures.count(), 2); // Repeated background failure status stays quiet. + qunsetenv("MULTIPACK_IMMUTABLE"); +} + void MainWindowIntegrationTest::startAndStopServerFromUi() { QTemporaryDir tempDir; diff --git a/tests/test_system_clients.cpp b/tests/test_system_clients.cpp index 24cc7c9..726e474 100644 --- a/tests/test_system_clients.cpp +++ b/tests/test_system_clients.cpp @@ -11,6 +11,59 @@ using namespace multipack::system; class SystemClientsTest : public QObject { Q_OBJECT private slots: + void failedInstallationStatusAllowsAnotherCheckAndInstall() { + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + SystemUpdaterClient client(nullptr, server.fullServerName()); + QSignalSpy replies(&client, &SystemUpdaterClient::response); + QSignalSpy errors(&client, &SystemUpdaterClient::error); + QVERIFY(client.status()); + QTRY_VERIFY(server.hasPendingConnections()); + auto* peer = server.nextPendingConnection(); + QTRY_VERIFY(peer->canReadLine()); peer->readLine(); + peer->write("{\"phase\":\"error\",\"error\":\"download failed\"}\n"); peer->flush(); + QTRY_COMPARE(replies.count(), 1); + QCOMPARE(errors.count(), 0); + QCOMPARE(replies.first().first().toJsonObject().value("error").toString(), QString("download failed")); + QVERIFY(client.install("rpi-hmi-v2")); + QTRY_VERIFY(server.hasPendingConnections()); + auto* installPeer = server.nextPendingConnection(); + QTRY_VERIFY(installPeer->canReadLine()); + QCOMPARE(QJsonDocument::fromJson(installPeer->readLine()).object().value("op").toString(), QString("install")); + // An error rejecting this new install remains an error, even with a phase. + installPeer->write("{\"phase\":\"error\",\"error\":\"update already running\"}\n"); installPeer->flush(); + QTRY_COMPARE(errors.count(), 1); + } + void immutableChecksCanRecoverFromSavedFailure() { + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + connect(&server, &QLocalServer::newConnection, &server, [&] { + auto* peer = server.nextPendingConnection(); + connect(peer, &QLocalSocket::readyRead, peer, [peer] { + if (!peer->canReadLine()) return; + peer->readLine(); + peer->write("{\"phase\":\"error\",\"error\":\"download failed\",\"boot\":{}}\n"); + peer->flush(); + }); + }); + qputenv("MULTIPACK_IMMUTABLE", "1"); + AutoUpdater updater(nullptr, server.fullServerName()); + qunsetenv("MULTIPACK_IMMUTABLE"); + auto* client = updater.findChild(); + QSignalSpy replies(client, &SystemUpdaterClient::response); + QSignalSpy completed(&updater, &AutoUpdater::checkCompleted); + QSignalSpy progress(&updater, &AutoUpdater::downloadProgress); + QTRY_COMPARE(replies.count(), 1); + for (int i = 1; i <= 2; ++i) { + QVERIFY(updater.checkForUpdates()); + QTRY_COMPARE(completed.count(), i); + QVERIFY(completed.last().first().toBool()); + QVERIFY(completed.last().at(1).toString().contains("download failed")); + } + QCOMPARE(progress.count(), 0); // Idle/error observations never overwrite operator messages. + } void versionBoundary() { QVERIFY(SystemUpdaterClient::validVersion("v2.0.0-rc_1")); QVERIFY(SystemUpdaterClient::validVersion(QString(80, 'a'))); @@ -92,6 +145,24 @@ private slots: QVERIFY(result.first().first().toString().contains("unavailable")); } #ifndef Q_OS_WIN + void wifiProbeUsesStableLocaleWithoutChangingParentEnvironment() { + QTemporaryDir dir; + QFile script(dir.filePath("localized-nmcli")); + QVERIFY(script.open(QIODevice::WriteOnly)); + script.write("#!/bin/sh\nif [ \"$LC_ALL\" = C ]; then printf 'yes:Factory\\n'; else printf 'ja:Factory\\n'; fi\n"); + script.close(); + QVERIFY(script.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + const auto previous = qgetenv("LC_ALL"); + const bool wasSet = qEnvironmentVariableIsSet("LC_ALL"); + qputenv("LC_ALL", "de_DE.UTF-8"); + WifiSsidProbe probe(nullptr, script.fileName()); + QSignalSpy results(&probe, &WifiSsidProbe::result); + probe.refresh(); + QTRY_COMPARE(results.count(), 1); + QCOMPARE(results.first().first().toString(), QString("Factory")); + QCOMPARE(qgetenv("LC_ALL"), QByteArray("de_DE.UTF-8")); + if (wasSet) qputenv("LC_ALL", previous); else qunsetenv("LC_ALL"); + } void slowToolDoesNotBlockEventLoop() { QTemporaryDir dir; QFile script(dir.filePath("slow-nmcli"));