From dd90aeb8b80269f9d93d9bfb88131ef8a38fc8af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 3 Nov 2026 20:03:53 +0700 Subject: [PATCH 01/12] feat(firmware): fail-closed v2 dual-revision package selection after ROM probe Keep immutable legacy v1 support, validate and hash two non-overlapping revision images, select v3.2 candidate only on actual ROM evidence, and rehash before write. --- apps/arstack_studio/src/FirmwareManager.cpp | 147 ++++++++++++++++++-- apps/arstack_studio/src/FirmwareManager.hpp | 15 ++ apps/arstack_studio/src/main.cpp | 6 + 3 files changed, 158 insertions(+), 10 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 7ea1a10c0..91d2d6e6f 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -27,6 +27,10 @@ constexpr auto kManifestName = "firmware-manifest.json"; constexpr auto kManifestSchema = "arstack.studio.firmware.v1"; constexpr auto kExpectedChip = "esp32p4"; constexpr auto kPreV3Policy = "pre-v3"; +constexpr auto kV3Policy = "v3.x"; +constexpr auto kCatalogName = "firmware-catalog.json"; +constexpr auto kCatalogSchema = "arstack.studio.firmware.catalog.v2"; +constexpr qint64 kMaxFirmwareSize = 16 * 1024 * 1024; constexpr qsizetype kMaxOperationOutput = 65536; constexpr qsizetype kTrimmedOperationOutput = 49152; constexpr qsizetype kMaxProgressOutputTail = 2048; @@ -147,6 +151,14 @@ bool FirmwareManager::supportsEsp32P4Revision(const int major, const int minor) return major >= 0 && major < 3 && minor >= 0; } +bool FirmwareManager::revisionInRange(const int major, const int minor, + const int minRevision, const int maxRevision) noexcept { + if (major < 0 || minor < 0 || minor >= 100 || major > 99 || + minRevision < 0 || maxRevision < minRevision) return false; + const int revision = major * 100 + minor; + return revision >= minRevision && revision <= maxRevision; +} + int FirmwareManager::parseFlashProgress(const QString& output) { // Keep this parser pure so deterministic fixtures exercise the exact // production token rules. Stream reassembly remains bounded in @@ -205,6 +217,9 @@ void FirmwareManager::refreshBundle() { revisionPolicy_.clear(); firmwareSha256_.clear(); firmwareImagePath_.clear(); + revisionImages_.clear(); + revisionCatalogAvailable_ = false; + targetVerified_ = false; flashProgress_ = -1; progressOutputTail_.clear(); @@ -220,8 +235,11 @@ void FirmwareManager::refreshBundle() { return; } bundleReady_ = true; - bundleStatus_ = QStringLiteral("Ready · firmware v%1 · build %2 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified") - .arg(firmwareVersion_, firmwareBuildId_); + bundleStatus_ = revisionCatalogAvailable_ + ? QStringLiteral("Ready · pre-v3 + v3.2 preview · firmware v%1 · build %2 · SHA-256 verified") + .arg(firmwareVersion_, firmwareBuildId_) + : QStringLiteral("Ready · firmware v%1 · build %2 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified") + .arg(firmwareVersion_, firmwareBuildId_); emit stateChanged(); } @@ -273,7 +291,7 @@ bool FirmwareManager::loadManifest() { if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName || expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 || !QRegularExpression(QStringLiteral("^[0-9a-f]{16}$")).match(buildId).hasMatch() || - !sourceCommit.startsWith(buildId, Qt::CaseInsensitive) || + !QRegularExpression(QStringLiteral("^[0-9a-f]{40}$")).match(sourceCommit).hasMatch() || revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) { bundleStatus_ = QStringLiteral("Firmware manifest fields/capabilities are incomplete or unsafe."); return false; @@ -303,6 +321,107 @@ bool FirmwareManager::loadManifest() { revisionPolicy_ = revisionPolicy; firmwareSha256_ = actualHash; firmwareImagePath_ = QDir::cleanPath(imagePath); + return loadRevisionCatalog(root, imageName, actualHash, sourceCommit); +} + +bool FirmwareManager::loadRevisionCatalog( + const QDir& root, const QString& legacyImage, + const QString& legacyHash, const QString& sourceCommit) { + const QFileInfo catalogInfo{root.filePath(QString::fromLatin1(kCatalogName))}; + if (!catalogInfo.exists()) return true; // Immutable historical v1 bundles remain pre-v3 only. + QFile catalog{catalogInfo.absoluteFilePath()}; + if (catalogInfo.isSymLink() || catalogInfo.size() <= 0 || catalogInfo.size() > 65536 || + !catalog.open(QIODevice::ReadOnly)) { + bundleStatus_ = QStringLiteral("Firmware catalog could not be read safely."); + return false; + } + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(catalog.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + bundleStatus_ = QStringLiteral("Firmware catalog is invalid JSON."); + return false; + } + const QJsonObject object = document.object(); + const QJsonArray images = object.value(QStringLiteral("images")).toArray(); + if (object.value(QStringLiteral("schema")).toString() != QString::fromLatin1(kCatalogSchema) || + object.value(QStringLiteral("chip")).toString() != QString::fromLatin1(kExpectedChip) || + object.value(QStringLiteral("sourceCommit")).toString() != sourceCommit || + object.value(QStringLiteral("version")).toString() != firmwareVersion_ || + object.value(QStringLiteral("protocol")).toInt(-1) != expectedProtocol_.toInt() || + images.size() != 2) { + bundleStatus_ = QStringLiteral("Firmware catalog identity/schema or image count is incompatible."); + return false; + } + QVector validated; + const QString canonicalRoot = QFileInfo(root.absolutePath()).canonicalFilePath(); + if (canonicalRoot.isEmpty()) { + bundleStatus_ = QStringLiteral("Firmware directory could not be resolved."); + return false; + } + for (const auto& value : images) { + if (!value.isObject()) { + bundleStatus_ = QStringLiteral("Firmware catalog contains an invalid image entry."); + return false; + } + const QJsonObject entry = value.toObject(); + const QString policy = entry.value(QStringLiteral("policy")).toString(); + const QString name = entry.value(QStringLiteral("image")).toString(); + const QString hash = normalizedHash(entry.value(QStringLiteral("sha256")).toString()); + const int minRevision = entry.value(QStringLiteral("minRevision")).toInt(-1); + const int maxRevision = entry.value(QStringLiteral("maxRevision")).toInt(-1); + // Qualification is intentionally limited to proven v1.x and the v3.2 + // engineering-preview target. Do not silently permit future revisions. + const bool legacy = policy == QString::fromLatin1(kPreV3Policy) && + minRevision == 100 && maxRevision == 199; + const bool v3 = policy == QString::fromLatin1(kV3Policy) && + minRevision == 302 && maxRevision == 302; + const QFileInfo imageInfo{root.filePath(name)}; + if ((!legacy && !v3) || name.isEmpty() || QFileInfo(name).fileName() != name || + !QRegularExpression(QStringLiteral("^[0-9a-f]{64}$")).match(hash).hasMatch() || + imageInfo.isSymLink() || !imageInfo.isFile() || imageInfo.size() <= 0 || + imageInfo.size() > kMaxFirmwareSize || + imageInfo.canonicalPath() != canonicalRoot || + (legacy && (name != legacyImage || hash != legacyHash))) { + bundleStatus_ = QStringLiteral("Firmware catalog has an unsafe or incompatible image entry."); + return false; + } + for (const RevisionImage& earlier : validated) { + if (earlier.policy == policy || earlier.imagePath == imageInfo.canonicalFilePath() || + !(maxRevision < earlier.minRevision || minRevision > earlier.maxRevision)) { + bundleStatus_ = QStringLiteral("Firmware catalog contains duplicate or overlapping images."); + return false; + } + } + QFile imageFile{imageInfo.canonicalFilePath()}; + QCryptographicHash hasher{QCryptographicHash::Sha256}; + if (!imageFile.open(QIODevice::ReadOnly) || !hasher.addData(&imageFile) || + QString::fromLatin1(hasher.result().toHex()) != hash) { + bundleStatus_ = QStringLiteral("Firmware catalog SHA-256 verification failed."); + return false; + } + validated.push_back({policy, imageInfo.canonicalFilePath(), hash, minRevision, maxRevision}); + } + if (validated.size() != 2 || validated[0].policy == validated[1].policy) { + bundleStatus_ = QStringLiteral("Firmware catalog must contain two disjoint revision policies."); + return false; + } + revisionImages_ = std::move(validated); + revisionCatalogAvailable_ = true; + return true; +} + +bool FirmwareManager::selectFirmwareForRevision(const int major, const int minor) { + if (!revisionCatalogAvailable_) return supportsEsp32P4Revision(major, minor); + const RevisionImage* selected = nullptr; + for (const RevisionImage& image : revisionImages_) { + if (!revisionInRange(major, minor, image.minRevision, image.maxRevision)) continue; + if (selected != nullptr) return false; // Multiple matches must never be resolved by array order. + selected = ℑ + } + if (selected == nullptr) return false; + revisionPolicy_ = selected->policy; + firmwareImagePath_ = selected->imagePath; + firmwareSha256_ = selected->sha256; return true; } @@ -347,7 +466,17 @@ bool FirmwareManager::installFirmware(const QString& portName) { return false; } if (!targetVerified_ || port.isEmpty() || port != selectedPort_) { - fail(QStringLiteral("Check and verify a supported ESP32-P4 pre-v3 board before flashing.")); + fail(QStringLiteral("Identify and verify a supported ESP32-P4 board before installing firmware.")); + return false; + } + // Re-hash immediately before the write. A file replaced after preflight + // must never be trusted because the catalog was valid earlier. + QFile image{firmwareImagePath_}; + QCryptographicHash hasher{QCryptographicHash::Sha256}; + if (!image.open(QIODevice::ReadOnly) || !hasher.addData(&image) || + QString::fromLatin1(hasher.result().toHex()) != firmwareSha256_) { + targetVerified_ = false; + fail(QStringLiteral("Firmware image changed after verification. Installation is blocked.")); return false; } if (sessionGeneration_ == 0) { @@ -606,9 +735,7 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) const bool p4WithRevision = success && parseEsp32P4Revision(operationOutput_, major, minor); const bool anyChipAnswered = success && operationOutput_.contains( QRegularExpression{QStringLiteral(R"(Chip\s+type:)"), QRegularExpression::CaseInsensitiveOption}); - const bool supportedRevision = p4WithRevision && - revisionPolicy_ == QString::fromLatin1(kPreV3Policy) && - supportsEsp32P4Revision(major, minor); + const bool supportedRevision = p4WithRevision && selectFirmwareForRevision(major, minor); targetVerified_ = supportedRevision; bootloaderHelpNeeded_ = !success; @@ -621,10 +748,10 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) } if (supportedRevision) { - setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 verified on %3. Ready to install ARStack firmware.") - .arg(major).arg(minor).arg(selectedPort_)); + setStatus(QStringLiteral("ESP32-P4 v%1.%2 verified. Matching %3 firmware selected.") + .arg(major).arg(minor).arg(revisionPolicy_)); } else if (p4WithRevision) { - setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 is outside this firmware package (pre-v3). Installation is blocked.") + setStatus(QStringLiteral("ESP32-P4 v%1.%2 is not supported by this firmware package. Installation is blocked.") .arg(major).arg(minor)); } else if (anyChipAnswered) { setStatus(QStringLiteral("A chip answered on %1, but it is not an ESP32-P4. Installation is blocked.") diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 1616b42cc..9157a3496 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -5,9 +5,11 @@ #include #include #include +#include class DeterministicSessionHarness; class FirmwareWorker; +class QDir; class FirmwareManager : public QObject { Q_OBJECT @@ -52,6 +54,7 @@ class FirmwareManager : public QObject { [[nodiscard]] static bool parseEsp32P4Revision(const QString& output, int& major, int& minor); [[nodiscard]] static bool supportsEsp32P4Revision(int major, int minor) noexcept; + [[nodiscard]] static bool revisionInRange(int major, int minor, int minRevision, int maxRevision) noexcept; [[nodiscard]] static int parseFlashProgress(const QString& output); [[nodiscard]] static constexpr int launchTimeoutMs() noexcept { return 15000; } [[nodiscard]] static constexpr int probeTimeoutMs() noexcept { return 30000; } @@ -89,10 +92,20 @@ class FirmwareManager : public QObject { private: friend class DeterministicSessionHarness; enum class Operation { none, probe, flash, reset }; + struct RevisionImage { + QString policy; + QString imagePath; + QString sha256; + int minRevision{-1}; + int maxRevision{-1}; + }; [[nodiscard]] QString bundleRoot() const; [[nodiscard]] QString flasherPath() const; bool loadManifest(); + bool loadRevisionCatalog(const QDir& root, const QString& legacyImage, + const QString& legacyHash, const QString& sourceCommit); + bool selectFirmwareForRevision(int major, int minor); bool startEspflash(const QStringList& arguments, Operation operation); int operationTimeoutMs(Operation operation) const noexcept; void connectWorkerSignals(); @@ -122,6 +135,8 @@ class FirmwareManager : public QObject { QString revisionPolicy_; QString firmwareSha256_; QString firmwareImagePath_; + QVector revisionImages_; + bool revisionCatalogAvailable_{false}; QString status_{QStringLiteral("Firmware setup ready")}; QString bundleStatus_{QStringLiteral("Checking firmware package...")}; QString logText_; diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index c421dc0f3..994d5af27 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -267,6 +267,12 @@ int checkFirmwareContract(int argc, char* argv[]) { FirmwareManager::supportsEsp32P4Revision(1, 3) && FirmwareManager::supportsEsp32P4Revision(2, 99) && !FirmwareManager::supportsEsp32P4Revision(3, 0) && + FirmwareManager::revisionInRange(1, 3, 100, 199) && + !FirmwareManager::revisionInRange(1, 3, 302, 302) && + FirmwareManager::revisionInRange(3, 2, 302, 302) && + !FirmwareManager::revisionInRange(3, 1, 302, 302) && + !FirmwareManager::revisionInRange(3, 99, 302, 302) && + !FirmwareManager::revisionInRange(3, 100, 300, 399) && !FirmwareManager::supportsEsp32P4Revision(-1, 0) && !FirmwareManager::supportsEsp32P4Revision(1, -1); From f96470eb9879e711f825091a8425e69220b85b93 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 3 Nov 2026 20:05:57 +0700 Subject: [PATCH 02/12] feat(esp32p4): build isolated v3.2 candidate and package revision catalog Use maintained IDF 5.5.6, preserve v1 legacy manifest, compile v3.x from isolated defaults and SDKCONFIG, validate merged image revision headers and source SHA, ship v3.2 engineering preview only. --- .github/workflows/esp32p4-smv-injector.yml | 80 +++++++++++++++++- scripts/validate_esp32p4_revision_catalog.py | 87 ++++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 scripts/validate_esp32p4_revision_catalog.py diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 443ef47a6..014b5e38e 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -9,6 +9,11 @@ on: default: false required: false workflow_dispatch: + inputs: + dual_revision_preview: + description: 'Also build the v3.2 engineering-preview image and catalog' + type: boolean + default: false push: branches: [main] paths: @@ -69,7 +74,9 @@ jobs: CCACHE_MAXSIZE: 350M CCACHE_COMPILERCHECK: content container: - image: espressif/idf:v5.5.4 + # v5.5.6 contains later P4 v3.2 watchdog fixes; both profiles use the + # same locked toolchain but separate generated sdkconfig/build outputs. + image: espressif/idf:v5.5.6 defaults: run: shell: bash @@ -289,7 +296,7 @@ jobs: "flashOffset": 0, "image": image, "sha256": sha256, - "espIdf": "5.5.4", + "espIdf": "5.5.6", "sourceCommit": source_commit, "buildId": source_commit[:16], "phaseSyncPreview": candidate, @@ -330,13 +337,80 @@ jobs: print(f"Firmware package: PASS · {manifest['image']} · PTP-P2 · pre-v3 · SHA-256 {actual}") PY + # Only purpose-built engineering previews pay for a second IDF compile. + # Historical v1 single-image release pipelines stay unchanged. + - name: Build independent ESP32-P4 revision v3.2 preview image + if: ${{ inputs.phase_sync_preview == true || inputs.dual_revision_preview == true }} + run: | + set -euo pipefail + source /opt/esp/idf/export.sh + python3 - <<'PY' + from pathlib import Path + cfg = Path('sdkconfig.defaults').read_text(encoding='utf-8') + old = 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' + assert cfg.count(old) == 1, 'legacy silicon selector changed' + cfg = cfg.replace(old, '# CONFIG_ESP32P4_SELECTS_REV_LESS_V3 is not set\nCONFIG_ESP32P4_REV_MIN_300=y') + Path('sdkconfig.v3x.defaults').write_text(cfg, encoding='utf-8') + PY + # Never allow the first profile's sdkconfig/build cache to be reused. + test ! -e sdkconfig.v3x + idf.py -B build-v3x -DSDKCONFIG="$PWD/sdkconfig.v3x" \ + -DSDKCONFIG_DEFAULTS="$PWD/sdkconfig.v3x.defaults" set-target esp32p4 + grep -qx '# CONFIG_ESP32P4_SELECTS_REV_LESS_V3 is not set' sdkconfig.v3x + grep -qx 'CONFIG_ESP32P4_REV_MIN_300=y' sdkconfig.v3x + idf.py -B build-v3x build + # Fail if the effective revision configuration was silently reset. + grep -qx '# CONFIG_ESP32P4_SELECTS_REV_LESS_V3 is not set' sdkconfig.v3x + grep -qx 'CONFIG_ESP32P4_REV_MIN_300=y' sdkconfig.v3x + image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}-v3x.bin" + idf.py -B build-v3x merge-bin -o "$image" -f raw + test -s "build-v3x/$image" + + - name: Generate revision-aware catalog with distinct exact-SHA images + if: ${{ inputs.phase_sync_preview == true || inputs.dual_revision_preview == true }} + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib, json, os, pathlib + root = pathlib.Path('build') + legacy = json.loads((root/'firmware-manifest.json').read_text(encoding='utf-8')) + image = f"arstack-esp32p4-smv-{os.environ['ARSTACK_FIRMWARE_VERSION']}-v3x.bin" + payload = (pathlib.Path('build-v3x')/image).read_bytes() + old = (root/legacy['image']).read_bytes() + assert payload != old, 'two revisions accidentally contain identical firmware' + assert legacy['sourceCommit'][:16].encode('ascii') in payload, 'v3x build-id does not match checkout' + assert legacy['espIdf'] == '5.5.6' + catalog = { + 'schema': 'arstack.studio.firmware.catalog.v2', + 'chip': 'esp32p4', + 'version': legacy['version'], + 'protocol': legacy['protocol'], + 'sourceCommit': legacy['sourceCommit'], + 'releaseChannel': 'engineering-preview', + 'images': [ + {'policy': 'pre-v3', 'minRevision': 100, 'maxRevision': 199, + 'image': legacy['image'], 'sha256': legacy['sha256']}, + {'policy': 'v3.x', 'minRevision': 302, 'maxRevision': 302, + 'image': image, 'sha256': hashlib.sha256(payload).hexdigest()} + ] + } + assert hashlib.sha256(old).hexdigest() == legacy['sha256'] + (root/'firmware-catalog.json').write_text( + json.dumps(catalog, sort_keys=True, indent=2)+'\n', encoding='utf-8') + PY + - name: Stage Studio firmware package run: | set -euo pipefail mkdir -p build/studio-firmware cp "build/arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" build/studio-firmware/ cp build/firmware-manifest.json build/studio-firmware/ - sha256sum build/studio-firmware/* > build/studio-firmware/SHA256SUMS.txt + if [[ -f build/firmware-catalog.json ]]; then + cp "build-v3x/arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}-v3x.bin" build/studio-firmware/ + cp build/firmware-catalog.json build/studio-firmware/ + python3 "$GITHUB_WORKSPACE/scripts/validate_esp32p4_revision_catalog.py" build/studio-firmware + fi + (cd build/studio-firmware && sha256sum *.bin *.json > SHA256SUMS.txt) - name: Upload ESP32-P4 firmware package uses: actions/upload-artifact@v4 diff --git a/scripts/validate_esp32p4_revision_catalog.py b/scripts/validate_esp32p4_revision_catalog.py new file mode 100644 index 000000000..db23790fa --- /dev/null +++ b/scripts/validate_esp32p4_revision_catalog.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Fail-closed CI/package validation for ESP32-P4 silicon revision artifacts. + +The Studio runtime separately validates the catalog and SHA before a write. +This check prevents shipping a catalog with a wrong bootloader image header. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + +CAP = 16 * 1024 * 1024 +SCHEMA = "arstack.studio.firmware.catalog.v2" + + +def header_revision_bounds(image: bytes) -> tuple[int, int]: + # ESP-IDF esp_image_header_t: min_chip_rev_full @15, max @17, + # little-endian; merged raw images contain the bootloader at offset 0. + if len(image) < 24 or image[0] != 0xE9: + raise ValueError("missing ESP image bootloader header at offset zero") + minimum = int.from_bytes(image[15:17], "little") + maximum = int.from_bytes(image[17:19], "little") + if minimum > maximum: + raise ValueError("inverted chip revision bounds in bootloader") + return minimum, maximum + + +def verify(root: Path) -> None: + root = root.resolve(strict=True) + legacy = json.loads((root / "firmware-manifest.json").read_text()) + catalog = json.loads((root / "firmware-catalog.json").read_text()) + if (catalog.get("schema") != SCHEMA or catalog.get("chip") != "esp32p4" + or legacy.get("chip") != "esp32p4" + or catalog.get("sourceCommit") != legacy.get("sourceCommit") + or catalog.get("version") != legacy.get("version") + or catalog.get("protocol") != legacy.get("protocol")): + raise ValueError("catalog identity mismatch") + images = catalog.get("images") + if not isinstance(images, list) or len(images) != 2: + raise ValueError("catalog must include exactly two images") + policies = set() + names = set() + for entry in images: + if not isinstance(entry, dict): + raise ValueError("invalid image entry") + policy = entry.get("policy") + name = entry.get("image") + sha = entry.get("sha256") + if (policy not in ("pre-v3", "v3.x") + or not isinstance(name, str) or Path(name).name != name + or name in names or policy in policies + or not isinstance(sha, str) or len(sha) != 64): + raise ValueError("unsafe image or duplicate revision selection") + policies.add(policy) + names.add(name) + low, high = (100, 199) if policy == "pre-v3" else (302, 302) + if (entry.get("minRevision"), entry.get("maxRevision")) != (low, high): + raise ValueError("unexpected qualification range") + path = root / name + if path.is_symlink() or not path.is_file() or path.resolve().parent != root: + raise ValueError("unsafe image path") + if not (24 <= path.stat().st_size <= CAP): + raise ValueError("invalid image size") + payload = path.read_bytes() + if hashlib.sha256(payload).hexdigest() != sha: + raise ValueError("image SHA256 mismatch") + if catalog["sourceCommit"][:16].encode("ascii") not in payload: + raise ValueError("image does not identify the exact source commit") + minimum, maximum = header_revision_bounds(payload) + if not (minimum <= low <= high <= maximum): + raise ValueError(f"{policy}: bootloader incompatible with approved revision {low}..{high} (header {minimum}..{maximum})") + if policy == "pre-v3" and (name != legacy["image"] or sha != legacy["sha256"] or maximum >= 300): + raise ValueError("legacy header incorrectly enables v3 silicon") + if policy == "v3.x" and minimum < 300: + raise ValueError("v3 bootloader incorrectly enables pre-v3 silicon") + if policies != {"pre-v3", "v3.x"}: + raise ValueError("missing silicon variant") + print("Revision-aware firmware package: PASS (two disjoint images, headers, source and SHA)") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("firmware_directory", type=Path) + arguments = parser.parse_args() + verify(arguments.firmware_directory) From 8237f7331b959d539988aa0e28f3bcc0778f4323 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 3 Nov 2026 20:09:55 +0700 Subject: [PATCH 03/12] fix(studio): bound complete flashing attempt, stop PTP/SV safely and pin postboot board identity Add 300s monotonic Qt transaction deadline, ACK-verified STOP preflight, and exact board/boot continuity verification. Make running-test interruption visible in primary dialog. --- apps/arstack_studio/qml/FirmwarePanel.qml | 4 +- apps/arstack_studio/qml/WorkflowBar.qml | 12 ++-- .../src/SmartSessionController.cpp | 63 ++++++++++++++++--- .../src/SmartSessionController.hpp | 9 ++- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 552ba4f11..bd25ac991 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -249,7 +249,9 @@ SurfacePanel { : "Reinstall current" visible: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable || panel.session.firmwareReinstallAvailable) - enabled: visible && !panel.firmware.busy + enabled: visible && !panel.firmware.busy && + !panel.device.running && !panel.device.ptpRunning && !panel.device.ptpStarting + toolTipText: enabled ? "Install the matched firmware" : "Stop active SV/PTP in the main firmware update dialog first" tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal" onClicked: { if (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 6f41d798d..3819eccb3 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -545,9 +545,11 @@ SurfacePanel { spacing: 12 Label { Layout.fillWidth: true - text: smartSession.firmwareUpdateRequired - ? "Update is required before SV injection." - : "Current firmware works. Updating is optional." + text: (device.running || device.ptpRunning || device.ptpStarting) + ? "This will stop the active SV/PTP test before installing firmware. Continue only when the test may be interrupted." + : (smartSession.firmwareUpdateRequired + ? "Update is required before SV injection." + : "Current firmware works. Updating is optional.") color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 11 @@ -567,7 +569,9 @@ SurfacePanel { onClicked: { ribbon.updatePromptDeferred = true; updateDialog.close() } } CalmButton { - theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Update firmware"; tone: "accent"; implicitWidth: 138 + theme: ribbon.theme; uiFont: ribbon.uiFont + text: (device.running || device.ptpRunning || device.ptpStarting) ? "Stop test & update" : "Update firmware" + tone: "accent"; implicitWidth: 154 onClicked: { if (smartSession.beginFirmwareUpdate()) { ribbon.updatePromptDeferred = false diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index b4ba1e05d..760ea855f 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -105,6 +105,16 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent reconcile(); }); + firmwareTotalTimer_.setInterval(firmwareTotalDeadlineMs()); + firmwareTotalTimer_.setSingleShot(true); + connect(&firmwareTotalTimer_, &QTimer::timeout, this, [this] { + if (!updateRequested_) return; + if (firmware_ != nullptr && firmware_->busy()) firmware_->cancel(); + latchFirmwareFailure(QStringLiteral("Firmware setup reached its safety deadline. The board is not verified; reconnect it and choose Recovery.")); + emit firmwareUpdateFinished(false); + reconcile(); + }); + reconnectTimer_.setInterval(3000); reconnectTimer_.setSingleShot(true); connect(&reconnectTimer_, &QTimer::timeout, this, [this] { @@ -142,6 +152,7 @@ void SmartSessionController::shutdown() { discoveryTimer_.stop(); prepareTimer_.stop(); reconnectTimer_.stop(); + firmwareTotalTimer_.stop(); profileSyncTimer_.stop(); profileRetryTimer_.stop(); profileSyncTotalTimer_.stop(); @@ -252,7 +263,7 @@ QString SmartSessionController::updateStatus() const { if (firmware_ == nullptr) return {}; switch (updateStage_) { case UpdateStage::stopping: - return QStringLiteral("Stopping SMV output safely…"); + return QStringLiteral("Stopping output safely before firmware setup…"); case UpdateStage::releasingPort: return QStringLiteral("Releasing the device serial session before firmware access…"); case UpdateStage::probing: @@ -599,7 +610,11 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { if (device_ == nullptr || firmware_ == nullptr || port.isEmpty() || firmware_->busy()) return false; updatePort_ = port; + updateExpectedDeviceId_ = device_->deviceVerified() ? device_->deviceId().trimmed() : QString{}; + updateExpectedBootId_ = device_->deviceVerified() ? device_->bootId().trimmed() : QString{}; + stoppingPtpForFirmware_ = false; updateRequested_ = true; + firmwareTotalTimer_.start(); updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; profileSyncBootId_.clear(); @@ -608,16 +623,22 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { setupErrorStatus_.clear(); lastFirmwareUpdateResult_.clear(); - if (device_->running()) { + if (device_->running() || device_->ptpRunning() || device_->ptpStarting()) { updateStage_ = UpdateStage::stopping; + const bool stopSv = device_->running(); + stoppingPtpForFirmware_ = device_->ptpRunning() || device_->ptpStarting(); setPresentation( QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Stopping SMV output before firmware installation…"), + QStringLiteral("Stopping active SV/PTP test before firmware installation…"), false, false); - if (!device_->stop()) { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; + // Both STOP paths are ACK/readback verified by the existing device owner. + // An asynchronous STOP acceptance is not evidence of a stopped board. + const bool svAccepted = !stopSv || device_->stop(); + const bool ptpAccepted = !stoppingPtpForFirmware_ || (svAccepted && device_->stopPtp()); + if (!svAccepted || !ptpAccepted) { + latchFirmwareFailure(QStringLiteral("Could not safely stop active SV/PTP output. Firmware access was blocked.")); + emit firmwareUpdateFinished(false); reconcile(); return false; } @@ -635,6 +656,8 @@ bool SmartSessionController::retryFirmwareUpdate() { lastFirmwareUpdateResult_.clear(); blankBoardDetected_ = false; updateRequested_ = true; + stoppingPtpForFirmware_ = false; + firmwareTotalTimer_.start(); updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; continueFirmwareUpdate(); @@ -644,8 +667,10 @@ bool SmartSessionController::retryFirmwareUpdate() { bool SmartSessionController::cancelFirmwareUpdate() { if (!firmwareUpdateCanCancel() || device_ == nullptr) return false; reconnectTimer_.stop(); + firmwareTotalTimer_.stop(); updateRequested_ = false; updateWasOptional_ = false; + stoppingPtpForFirmware_ = false; updateStage_ = UpdateStage::idle; updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; @@ -726,7 +751,7 @@ bool SmartSessionController::startFirmwareProbe() { void SmartSessionController::continueFirmwareUpdate() { if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; - if (device_->running()) { + if (device_->running() || device_->ptpRunning() || device_->ptpStarting()) { updateStage_ = UpdateStage::stopping; return; } @@ -849,7 +874,16 @@ void SmartSessionController::reconnectDeviceSignals() { }); connect(device_, &DeviceController::runningChanged, this, [this] { if (updateRequested_ && updateStage_ == UpdateStage::stopping && - device_ != nullptr && !device_->running()) { + device_ != nullptr && !device_->running() && + !device_->ptpRunning() && !device_->ptpStarting()) { + QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); + } + reconcile(); + }); + connect(device_, &DeviceController::ptpStateChanged, this, [this] { + if (updateRequested_ && updateStage_ == UpdateStage::stopping && + device_ != nullptr && !device_->running() && + !device_->ptpRunning() && !device_->ptpStarting()) { QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); } reconcile(); @@ -1151,6 +1185,8 @@ QString SmartSessionController::diagnosticsText() const { void SmartSessionController::latchFirmwareFailure(QString message) { reconnectTimer_.stop(); + firmwareTotalTimer_.stop(); + stoppingPtpForFirmware_ = false; const bool optionalOperation = updateWasOptional_; updateRequested_ = false; updateWasOptional_ = false; @@ -1436,6 +1472,15 @@ void SmartSessionController::reconcile() { // A completed write must verify the exact bundled build. This remains // fail-closed even though a pre-existing compatible build may be used. if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { + if ((!updateExpectedDeviceId_.isEmpty() && + !recoveryIdentityMatches(updateExpectedDeviceId_, device_->deviceId())) || + (!updateExpectedBootId_.isEmpty() && + updateExpectedBootId_ == device_->bootId().trimmed())) { + latchFirmwareFailure(QStringLiteral("A different or stale device identity appeared after firmware setup. Verification was blocked.")); + emit firmwareUpdateFinished(false); + reconcile(); + return; + } if (!firmwareIsCurrent()) { const QString observed = deviceFirmwareVersion_.isEmpty() ? QStringLiteral("legacy/unknown firmware") @@ -1453,6 +1498,8 @@ void SmartSessionController::reconcile() { reconcile(); return; } + firmwareTotalTimer_.stop(); + stoppingPtpForFirmware_ = false; updateRequested_ = false; updateWasOptional_ = false; updateStage_ = UpdateStage::idle; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 516783333..d43b23c84 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -144,6 +144,7 @@ class SmartSessionController : public QObject { [[nodiscard]] static constexpr int firmwareStopAckTimeoutMs() noexcept { return 3500; } [[nodiscard]] static constexpr int firmwareReleaseAckTimeoutMs() noexcept { return 2000; } + [[nodiscard]] static constexpr int firmwareTotalDeadlineMs() noexcept { return 300000; } [[nodiscard]] static constexpr bool generationIsCurrent( const quint64 activeGeneration, @@ -265,7 +266,9 @@ class SmartSessionController : public QObject { return owner_->updateStage_ == UpdateStage::releasingPort && owner_->pendingReleaseGeneration_ != 0; } [[nodiscard]] int timeoutForStage(const UpdateStage stage) const noexcept { - return stage == UpdateStage::stopping ? firmwareStopAckTimeoutMs() : firmwareReleaseAckTimeoutMs(); + return stage == UpdateStage::stopping + ? (owner_ != nullptr && owner_->stoppingPtpForFirmware_ ? 12000 : firmwareStopAckTimeoutMs()) + : firmwareReleaseAckTimeoutMs(); } void synchronize() { if (!waitingForAck()) { @@ -405,6 +408,7 @@ class SmartSessionController : public QObject { QTimer discoveryTimer_; QTimer prepareTimer_; QTimer reconnectTimer_; + QTimer firmwareTotalTimer_; QTimer profileSyncTimer_; QTimer profileRetryTimer_; QTimer profileSyncTotalTimer_; @@ -413,6 +417,8 @@ class SmartSessionController : public QObject { QString deviceFirmwareVersion_; QString deviceFirmwareBuildId_; QString updatePort_; + QString updateExpectedDeviceId_; + QString updateExpectedBootId_; QString blankBoardPort_; QString setupErrorStatus_; QString lastFirmwareUpdateResult_; @@ -429,6 +435,7 @@ class SmartSessionController : public QObject { bool m4Mode_{false}; // suppress V1 automatic profile sync after M4 operator intent bool updateRequested_{false}; bool updateWasOptional_{false}; + bool stoppingPtpForFirmware_{false}; bool blankBoardDetected_{false}; bool manualRecoveryArmed_{false}; bool setupError_{false}; From 16473365962b085a2e6f0c3335de16c9b9186df6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 3 Nov 2026 20:12:43 +0700 Subject: [PATCH 04/12] fix(firmware): validate P4 bootloader and app revision headers at physical flash offsets ESP32-P4 uses 0x2000 second-stage bootloader and default 0x10000 factory app. Fail closed when either disagrees with qualified pre-v3 or v3.2 catalog. --- apps/arstack_studio/src/FirmwareManager.cpp | 29 +++++++++++++++++- scripts/validate_esp32p4_revision_catalog.py | 32 +++++++++++--------- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 91d2d6e6f..4d4c1a214 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -31,6 +31,28 @@ constexpr auto kV3Policy = "v3.x"; constexpr auto kCatalogName = "firmware-catalog.json"; constexpr auto kCatalogSchema = "arstack.studio.firmware.catalog.v2"; constexpr qint64 kMaxFirmwareSize = 16 * 1024 * 1024; + +// ESP32-P4 ROM bootloader and the default factory application locations. +// Verify *both* headers, not the 0x0 fill in idf.py's merged raw image. +bool imageHeadersAcceptRevision(const QString& imagePath, const int minRevision, + const int maxRevision, const bool isV3) { + QFile file{imagePath}; + if (!file.open(QIODevice::ReadOnly)) return false; + for (const qint64 offset : {qint64{0x2000}, qint64{0x10000}}) { + if (!file.seek(offset)) return false; + const QByteArray header = file.read(24); + if (header.size() != 24 || static_cast(header[0]) != 0xE9) return false; + const auto word = [&header](const int at) -> int { + return static_cast(header[at]) | + (static_cast(static_cast(header[at + 1])) << 8); + }; + const int declaredMin = word(15); + const int declaredMax = word(17); + if (declaredMin > minRevision || declaredMax < maxRevision || + (isV3 && declaredMin < 300) || (!isV3 && declaredMax >= 300)) return false; + } + return true; +} constexpr qsizetype kMaxOperationOutput = 65536; constexpr qsizetype kTrimmedOperationOutput = 49152; constexpr qsizetype kMaxProgressOutputTail = 2048; @@ -395,7 +417,8 @@ bool FirmwareManager::loadRevisionCatalog( QFile imageFile{imageInfo.canonicalFilePath()}; QCryptographicHash hasher{QCryptographicHash::Sha256}; if (!imageFile.open(QIODevice::ReadOnly) || !hasher.addData(&imageFile) || - QString::fromLatin1(hasher.result().toHex()) != hash) { + QString::fromLatin1(hasher.result().toHex()) != hash || + !imageHeadersAcceptRevision(imageInfo.canonicalFilePath(), minRevision, maxRevision, v3)) { bundleStatus_ = QStringLiteral("Firmware catalog SHA-256 verification failed."); return false; } @@ -432,6 +455,10 @@ bool FirmwareManager::probeTarget(const QString& portName) { fail(QStringLiteral("Bundled espflash executable is unavailable.")); return false; } + if (!bundleReady_) { + fail(bundleStatus_); + return false; + } const QString port = portName.trimmed(); if (port.isEmpty()) { fail(QStringLiteral("Select the ESP32-P4 USB serial port first.")); diff --git a/scripts/validate_esp32p4_revision_catalog.py b/scripts/validate_esp32p4_revision_catalog.py index db23790fa..bf6757797 100644 --- a/scripts/validate_esp32p4_revision_catalog.py +++ b/scripts/validate_esp32p4_revision_catalog.py @@ -15,13 +15,14 @@ SCHEMA = "arstack.studio.firmware.catalog.v2" -def header_revision_bounds(image: bytes) -> tuple[int, int]: - # ESP-IDF esp_image_header_t: min_chip_rev_full @15, max @17, - # little-endian; merged raw images contain the bootloader at offset 0. - if len(image) < 24 or image[0] != 0xE9: - raise ValueError("missing ESP image bootloader header at offset zero") - minimum = int.from_bytes(image[15:17], "little") - maximum = int.from_bytes(image[17:19], "little") +def header_revision_bounds(image: bytes, offset: int) -> tuple[int, int]: + # ESP32-P4 ROM loads bootloader from flash 0x2000; the default factory + # application lives at 0x10000. The merged raw image begins at flash 0. + # ESP-IDF esp_image_header_t: min_chip_rev_full @15, max @17. + if len(image) < offset + 24 or image[offset] != 0xE9: + raise ValueError(f"missing ESP32-P4 image header at flash 0x{offset:x}") + minimum = int.from_bytes(image[offset + 15:offset + 17], "little") + maximum = int.from_bytes(image[offset + 17:offset + 19], "little") if minimum > maximum: raise ValueError("inverted chip revision bounds in bootloader") return minimum, maximum @@ -68,13 +69,16 @@ def verify(root: Path) -> None: raise ValueError("image SHA256 mismatch") if catalog["sourceCommit"][:16].encode("ascii") not in payload: raise ValueError("image does not identify the exact source commit") - minimum, maximum = header_revision_bounds(payload) - if not (minimum <= low <= high <= maximum): - raise ValueError(f"{policy}: bootloader incompatible with approved revision {low}..{high} (header {minimum}..{maximum})") - if policy == "pre-v3" and (name != legacy["image"] or sha != legacy["sha256"] or maximum >= 300): - raise ValueError("legacy header incorrectly enables v3 silicon") - if policy == "v3.x" and minimum < 300: - raise ValueError("v3 bootloader incorrectly enables pre-v3 silicon") + for offset, label in ((0x2000, "bootloader"), (0x10000, "application")): + minimum, maximum = header_revision_bounds(payload, offset) + if not (minimum <= low <= high <= maximum): + raise ValueError(f"{policy}: {label} incompatible with approved revision {low}..{high} (header {minimum}..{maximum})") + if policy == "pre-v3" and maximum >= 300: + raise ValueError(f"legacy {label} incorrectly enables v3 silicon") + if policy == "v3.x" and minimum < 300: + raise ValueError(f"v3 {label} incorrectly enables pre-v3 silicon") + if policy == "pre-v3" and (name != legacy["image"] or sha != legacy["sha256"]): + raise ValueError("legacy manifest and catalog disagree") if policies != {"pre-v3", "v3.x"}: raise ValueError("missing silicon variant") print("Revision-aware firmware package: PASS (two disjoint images, headers, source and SHA)") From 3fd26aa9624f6f0ba93dc39dab9aeef1dc59bf1c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 3 Nov 2026 20:15:15 +0700 Subject: [PATCH 05/12] test(provisioning): negative firmware catalog fixtures and real Studio selection regression --- .github/workflows/esp32p4-smv-injector.yml | 4 + .../src/DeterministicSessionHarness.cpp | 24 +++++ tests/test_esp32p4_revision_catalog.py | 93 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 tests/test_esp32p4_revision_catalog.py diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 014b5e38e..e3d451161 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -98,6 +98,10 @@ jobs: restore-keys: | esp32p4-idf554-ccache-v1-${{ runner.os }}- + - name: Validate dual-revision package verifier negative fixtures + shell: bash + run: python3 "$GITHUB_WORKSPACE/tests/test_esp32p4_revision_catalog.py" + - name: Guard autonomous publisher and explicit Ethernet STOP policy shell: bash run: python3 "$GITHUB_WORKSPACE/tests/test_board_autonomous_publisher_contract.py" diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 8603b3b16..550a2d895 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -27,6 +27,7 @@ class DeterministicSessionHarness final { }; const CaseResult cases[] = { + {"dual-revision catalog selects only qualified silicon", revisionCatalogFailClosed()}, {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, {"same version missing build ID -> READY with update suggestion", sameVersionMissingBuildOffersUpdate()}, @@ -128,6 +129,29 @@ class DeterministicSessionHarness final { } private: + static bool revisionCatalogFailClosed() { + FirmwareManager firmware; + firmware.revisionCatalogAvailable_ = true; + firmware.revisionImages_ = { + {QStringLiteral("pre-v3"), QStringLiteral("legacy.bin"), QStringLiteral("a"), 100, 199}, + {QStringLiteral("v3.x"), QStringLiteral("v3.bin"), QStringLiteral("b"), 302, 302}, + }; + const bool legacy = firmware.selectFirmwareForRevision(1, 3) && + firmware.revisionPolicy_ == QStringLiteral("pre-v3") && + firmware.firmwareImagePath_ == QStringLiteral("legacy.bin"); + const bool v3 = firmware.selectFirmwareForRevision(3, 2) && + firmware.revisionPolicy_ == QStringLiteral("v3.x") && + firmware.firmwareImagePath_ == QStringLiteral("v3.bin"); + const bool rejectsUnknown = !firmware.selectFirmwareForRevision(3, 1) && + !firmware.selectFirmwareForRevision(4, 0); + firmware.revisionImages_.push_back( + {QStringLiteral("overlap"), QStringLiteral("duplicate.bin"), QStringLiteral("c"), 302, 302}); + const bool rejectsOverlap = !firmware.selectFirmwareForRevision(3, 2); + const bool deadline = SmartSessionController::firmwareTotalDeadlineMs() == 300000; + firmware.shutdown(); + return legacy && v3 && rejectsUnknown && rejectsOverlap && deadline; + } + struct Fixture { SclProfileModel profiles; FirmwareManager firmware; diff --git a/tests/test_esp32p4_revision_catalog.py b/tests/test_esp32p4_revision_catalog.py new file mode 100644 index 000000000..8ec181510 --- /dev/null +++ b/tests/test_esp32p4_revision_catalog.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Synthetic ESP32-P4 merged-image contract failures, no hardware or SDK required.""" +import hashlib +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from validate_esp32p4_revision_catalog import verify + +SOURCE = "0123456789abcdef0123456789abcdef01234567" + + +def image(minimum, maximum): + raw = bytearray(bytes([255]) * 0x11000) + for off in (0x2000, 0x10000): + raw[off] = 0xE9 + raw[off + 15:off + 17] = minimum.to_bytes(2, "little") + raw[off + 17:off + 19] = maximum.to_bytes(2, "little") + raw[0x8000:0x8010] = SOURCE[:16].encode("ascii") + return bytes(raw) + + +class CatalogTest(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + self.root = Path(self.folder.name) + self.a, self.b = image(100, 299), image(300, 399) + self.root.joinpath("old.bin").write_bytes(self.a) + self.root.joinpath("new.bin").write_bytes(self.b) + a, b = hashlib.sha256(self.a).hexdigest(), hashlib.sha256(self.b).hexdigest() + self.manifest = dict(chip="esp32p4", version="0.1.1", protocol=1, + sourceCommit=SOURCE, image="old.bin", sha256=a) + self.catalog = dict(schema="arstack.studio.firmware.catalog.v2", + chip="esp32p4", version="0.1.1", protocol=1, + sourceCommit=SOURCE, images=[ + dict(policy="pre-v3", image="old.bin", sha256=a, minRevision=100, maxRevision=199), + dict(policy="v3.x", image="new.bin", sha256=b, minRevision=302, maxRevision=302), + ]) + + def run_verifier(self): + self.root.joinpath("firmware-manifest.json").write_text(json.dumps(self.manifest)) + self.root.joinpath("firmware-catalog.json").write_text(json.dumps(self.catalog)) + verify(self.root) + + def reject(self): + with self.assertRaises(ValueError): + self.run_verifier() + + def test_valid(self): + self.run_verifier() + + def test_v3_header_not_legacy(self): + bad = image(100, 299) + self.root.joinpath("new.bin").write_bytes(bad) + self.catalog["images"][1]["sha256"] = hashlib.sha256(bad).hexdigest() + self.reject() + + def test_legacy_header_never_v3(self): + bad = image(100, 399) + self.root.joinpath("old.bin").write_bytes(bad) + self.manifest["sha256"] = self.catalog["images"][0]["sha256"] = hashlib.sha256(bad).hexdigest() + self.reject() + + def test_corrupt_image(self): + self.root.joinpath("new.bin").write_bytes(self.b + b"corrupt") + self.reject() + + def test_revision_policy_range(self): + self.catalog["images"][1]["maxRevision"] = 399 + self.reject() + + def test_duplicate_image(self): + self.catalog["images"][1]["image"] = "old.bin" + self.reject() + + def test_wrong_provenance(self): + self.catalog["sourceCommit"] = "f" * 40 + self.reject() + + def test_wrong_application_header(self): + bad = bytearray(self.b) + bad[0x10000 + 15:0x10000 + 17] = (100).to_bytes(2, "little") + self.root.joinpath("new.bin").write_bytes(bad) + self.catalog["images"][1]["sha256"] = hashlib.sha256(bad).hexdigest() + self.reject() + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 96d2e1aef5b2820da30af7fcbea673686fee9315 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 3 Nov 2026 20:22:59 +0700 Subject: [PATCH 06/12] fix(provisioning): bind ROM eFuse MAC to original and postflash board identities Require unambiguous espflash ROM MAC; block wrong board before write and require same physical device after reset even for blank boards. Add MAC parser negatives and correct IDF provenance. --- .../arstack-studio-native-preview.yml | 2 +- apps/arstack_studio/src/FirmwareManager.cpp | 27 ++++++++++++++++++- apps/arstack_studio/src/FirmwareManager.hpp | 5 ++++ .../src/SmartSessionController.cpp | 13 +++++++++ apps/arstack_studio/src/main.cpp | 13 ++++++++- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index ae6b52943..dce254b11 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -249,7 +249,7 @@ jobs: guiVersion = '0.1.1-preview' firmwareVersion = '0.1.1' firmwareSourceCommit = $env:PREVIEW_SHA - firmwareCompile = 'ESP-IDF v5.5.4 / exact PR HEAD' + firmwareCompile = 'ESP-IDF v5.5.6 / exact PR HEAD' pinnedFlasherBasePackageSha256 = $env:BASE_PORTABLE_SHA256 embeddedExperimentalM3 = $false ptpHardwareQualified = $false diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 4d4c1a214..952274df6 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -169,6 +169,24 @@ bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, in return true; } +QString FirmwareManager::parseEsp32P4Mac(const QString& output) { + // espflash v4.5 board-info reports "MAC address: aa:bb:cc:dd:ee:ff". + // Require one unambiguous ROM eFuse MAC; unrelated lines are diagnostics, + // and a recycled COM number is never a physical identity. + static const QRegularExpression macExpression{ + QStringLiteral(R"(^[ \t]*MAC address:[ \t]*((?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})[ \t]*$)"), + QRegularExpression::CaseInsensitiveOption | QRegularExpression::MultilineOption}; + auto matches = macExpression.globalMatch(output); + if (!matches.hasNext()) return {}; + QString mac = matches.next().captured(1); + if (matches.hasNext()) return {}; // Contradictory/duplicated evidence. + mac.remove(QLatin1Char(':')); + mac = mac.toUpper(); + if (mac == QStringLiteral("000000000000") || mac == QStringLiteral("FFFFFFFFFFFF")) + return {}; + return mac; +} + bool FirmwareManager::supportsEsp32P4Revision(const int major, const int minor) noexcept { return major >= 0 && major < 3 && minor >= 0; } @@ -242,6 +260,7 @@ void FirmwareManager::refreshBundle() { revisionImages_.clear(); revisionCatalogAvailable_ = false; targetVerified_ = false; + targetDeviceMac_.clear(); flashProgress_ = -1; progressOutputTail_.clear(); @@ -471,6 +490,7 @@ bool FirmwareManager::probeTarget(const QString& portName) { selectedPort_ = port; targetVerified_ = false; + targetDeviceMac_.clear(); bootloaderHelpNeeded_ = false; flashProgress_ = -1; progressOutputTail_.clear(); @@ -762,8 +782,11 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) const bool p4WithRevision = success && parseEsp32P4Revision(operationOutput_, major, minor); const bool anyChipAnswered = success && operationOutput_.contains( QRegularExpression{QStringLiteral(R"(Chip\s+type:)"), QRegularExpression::CaseInsensitiveOption}); - const bool supportedRevision = p4WithRevision && selectFirmwareForRevision(major, minor); + const QString romMac = p4WithRevision ? parseEsp32P4Mac(operationOutput_) : QString{}; + const bool supportedRevision = p4WithRevision && !romMac.isEmpty() && + selectFirmwareForRevision(major, minor); + targetDeviceMac_ = supportedRevision ? romMac : QString{}; targetVerified_ = supportedRevision; bootloaderHelpNeeded_ = !success; if (p4WithRevision) { @@ -777,6 +800,8 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) if (supportedRevision) { setStatus(QStringLiteral("ESP32-P4 v%1.%2 verified. Matching %3 firmware selected.") .arg(major).arg(minor).arg(revisionPolicy_)); + } else if (p4WithRevision && romMac.isEmpty()) { + setStatus(QStringLiteral("The board responded, but its factory identity could not be verified. Installation is blocked.")); } else if (p4WithRevision) { setStatus(QStringLiteral("ESP32-P4 v%1.%2 is not supported by this firmware package. Installation is blocked.") .arg(major).arg(minor)); diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 9157a3496..04607c33e 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -41,6 +41,9 @@ class FirmwareManager : public QObject { [[nodiscard]] int flashProgress() const noexcept { return flashProgress_; } [[nodiscard]] QString selectedPort() const { return selectedPort_; } [[nodiscard]] QString targetChip() const { return targetChip_; } + // Factory eFuse MAC from the authorized ROM probe. Used to pin a physical + // device across COM renumbering and exact-build postboot verification. + [[nodiscard]] QString targetDeviceMac() const { return targetDeviceMac_; } [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; } [[nodiscard]] QString firmwareBuildId() const { return firmwareBuildId_; } [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; } @@ -53,6 +56,7 @@ class FirmwareManager : public QObject { [[nodiscard]] bool workerAffinityValid() const noexcept { return workerAffinityValid_; } [[nodiscard]] static bool parseEsp32P4Revision(const QString& output, int& major, int& minor); + [[nodiscard]] static QString parseEsp32P4Mac(const QString& output); [[nodiscard]] static bool supportsEsp32P4Revision(int major, int minor) noexcept; [[nodiscard]] static bool revisionInRange(int major, int minor, int minRevision, int maxRevision) noexcept; [[nodiscard]] static int parseFlashProgress(const QString& output); @@ -128,6 +132,7 @@ class FirmwareManager : public QObject { // operation log or creating another process/parser owner. QString progressOutputTail_; QString selectedPort_; + QString targetDeviceMac_; QString targetChip_{QStringLiteral("Not checked")}; QString firmwareVersion_{QStringLiteral("-")}; QString firmwareBuildId_; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 760ea855f..fd4602bba 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -951,6 +951,19 @@ void SmartSessionController::reconnectFirmwareSignals() { if (updateStage_ == UpdateStage::probing && !firmware_->busy()) { if (firmware_->targetVerified()) { + const QString romMac = firmware_->targetDeviceMac(); + if (romMac.isEmpty() || + (!updateExpectedDeviceId_.isEmpty() && + !recoveryIdentityMatches(updateExpectedDeviceId_, romMac))) { + latchFirmwareFailure(QStringLiteral( + "ROM identity does not match the selected ARStack board. No firmware was written.")); + emit firmwareUpdateFinished(false); + reconcile(); + return; + } + // A fresh board has no application identity yet. This ROM + // eFuse MAC pins its identity for the subsequent boot proof. + updateExpectedDeviceId_ = romMac; updateStage_ = UpdateStage::flashing; setPresentation( QStringLiteral("UPDATING FIRMWARE"), diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 994d5af27..cf408afed 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -262,6 +262,16 @@ int checkFirmwareContract(int argc, char* argv[]) { wrongMajor, wrongMinor); + const bool macIdentityContract = + FirmwareManager::parseEsp32P4Mac(QStringLiteral( + "Chip type: esp32p4 (revision v3.2)\n" + "MAC address: c8:f0:9e:50:8a:d0\n")) == + QStringLiteral("C8F09E508AD0") && + FirmwareManager::parseEsp32P4Mac(QStringLiteral("MAC address: 00:00:00:00:00:00")).isEmpty() && + FirmwareManager::parseEsp32P4Mac(QStringLiteral("MAC address: bad")).isEmpty() && + FirmwareManager::parseEsp32P4Mac(QStringLiteral( + "MAC address: c8:f0:9e:50:8a:d0\nMAC address: aa:bb:cc:dd:ee:ff")).isEmpty(); + const bool revisionPolicy = FirmwareManager::supportsEsp32P4Revision(0, 0) && FirmwareManager::supportsEsp32P4Revision(1, 3) && @@ -311,7 +321,7 @@ int checkFirmwareContract(int argc, char* argv[]) { firmware.expectedProtocol() == QStringLiteral("1") && firmware.firmwareBuildId().size() == 16 && firmware.firmwareSha256().size() == 64 && - realEspflashFormat && dashedFormat && rejectsWrongChip && revisionPolicy && + realEspflashFormat && dashedFormat && rejectsWrongChip && macIdentityContract && revisionPolicy && flashProgressFormat && recoverySelection && firmwareTimeoutPolicy && firmwareWorkerBoundary; if (!valid) { qCritical().noquote() @@ -321,6 +331,7 @@ int checkFirmwareContract(int argc, char* argv[]) { << "dashed-format=" << dashedFormat << "wrong-chip-rejected=" << rejectsWrongChip << "revision-policy=" << revisionPolicy + << "rom-mac-contract=" << macIdentityContract << "progress-format=" << flashProgressFormat << "recovery-selection=" << recoverySelection << "timeout-policy=" << firmwareTimeoutPolicy From 66547f121e60cd44b1d5ef8e1e30afc28532bf63 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 3 Nov 2026 20:26:32 +0700 Subject: [PATCH 07/12] fix(provisioning): recovery retry uses safe preflight and preview-only catalog qualification Prevent retry from bypassing SV/PTP STOP, pin last ROM identity across failed attempts, require lab-board acknowledgement for blank boards, reject contradictory chip probe and prohibit unqualified v3 stable catalogs. --- apps/arstack_studio/qml/WorkflowBar.qml | 11 +++++++- apps/arstack_studio/src/FirmwareManager.cpp | 8 ++++++ .../src/SmartSessionController.cpp | 26 ++++++++++++++++--- apps/arstack_studio/src/main.cpp | 6 ++++- scripts/validate_esp32p4_revision_catalog.py | 3 ++- tests/test_esp32p4_revision_catalog.py | 2 +- 6 files changed, 49 insertions(+), 7 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 3819eccb3..4673ce704 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -507,10 +507,18 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "Keep USB connected until verification finishes." + text: "Keep USB connected. Use only Waveshare ESP32-P4-ETH-M, isolated from energized process-bus equipment." color: ribbon.theme.muted font.family: ribbon.uiFont font.pixelSize: 10 + wrapMode: Text.WordWrap + } + CheckBox { + id: recoveryBoardConfirmed + text: "I confirmed the supported board and safe lab connection" + font.family: ribbon.uiFont + font.pixelSize: 11 + checked: false } RowLayout { Layout.fillWidth: true @@ -521,6 +529,7 @@ SurfacePanel { } CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Install firmware"; tone: "accent"; implicitWidth: 142 + enabled: recoveryBoardConfirmed.checked onClicked: { if (smartSession.beginFirmwareInstall()) { ribbon.installPromptDeferred = false diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 952274df6..f589ec74f 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -164,6 +164,13 @@ bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, in minor = -1; return false; } + // Multiple chip-identity lines must not permit cherry-picking a + // compatible revision from contradictory/flaky ROM probe output. + if (expression.match(output, match.capturedEnd()).hasMatch()) { + major = -1; + minor = -1; + return false; + } major = match.captured(1).toInt(); minor = match.captured(2).toInt(); return true; @@ -389,6 +396,7 @@ bool FirmwareManager::loadRevisionCatalog( object.value(QStringLiteral("sourceCommit")).toString() != sourceCommit || object.value(QStringLiteral("version")).toString() != firmwareVersion_ || object.value(QStringLiteral("protocol")).toInt(-1) != expectedProtocol_.toInt() || + object.value(QStringLiteral("releaseChannel")).toString() != QStringLiteral("engineering-preview") || images.size() != 2) { bundleStatus_ = QStringLiteral("Firmware catalog identity/schema or image count is incompatible."); return false; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index fd4602bba..84fd299bc 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -650,13 +650,33 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { } bool SmartSessionController::retryFirmwareUpdate() { - if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false; + if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || + firmware_->busy() || (updateRequested_ && updateStage_ != UpdateStage::waitingForBootloader)) + return false; + + const QString preservedMac = updateExpectedDeviceId_; + if (!preservedMac.isEmpty() && device_->deviceVerified() && + !recoveryIdentityMatches(preservedMac, device_->deviceId())) { + // An unrelated board might have appeared on the same COM number. + return false; + } + + if (!updateRequested_) { + // A terminal failure restarts through the *same* safe SV/PTP STOP and + // port-owner preflight as an initial operator-authorized installation. + // Do not bypass active PTP/SV simply because this button says Retry. + if (!beginFirmwareOperation(updatePort_)) return false; + if (updateExpectedDeviceId_.isEmpty()) + updateExpectedDeviceId_ = preservedMac; + return true; + } + + // Explicit user retry after Download-mode guidance. No implicit replay, + // and a fresh ROM probe is still required before every write attempt. setupError_ = false; setupErrorStatus_.clear(); lastFirmwareUpdateResult_.clear(); blankBoardDetected_ = false; - updateRequested_ = true; - stoppingPtpForFirmware_ = false; firmwareTotalTimer_.start(); updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index cf408afed..88b954179 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -260,7 +260,11 @@ int checkFirmwareContract(int argc, char* argv[]) { const bool rejectsWrongChip = !FirmwareManager::parseEsp32P4Revision( QStringLiteral("Chip type: esp32s3 (revision v0.2)\n"), wrongMajor, - wrongMinor); + wrongMinor) && + !FirmwareManager::parseEsp32P4Revision( + QStringLiteral("Chip type: esp32p4 (revision v1.3)\nChip type: esp32p4 (revision v3.2)"), + wrongMajor, + wrongMinor); const bool macIdentityContract = FirmwareManager::parseEsp32P4Mac(QStringLiteral( diff --git a/scripts/validate_esp32p4_revision_catalog.py b/scripts/validate_esp32p4_revision_catalog.py index bf6757797..ea62112c2 100644 --- a/scripts/validate_esp32p4_revision_catalog.py +++ b/scripts/validate_esp32p4_revision_catalog.py @@ -36,7 +36,8 @@ def verify(root: Path) -> None: or legacy.get("chip") != "esp32p4" or catalog.get("sourceCommit") != legacy.get("sourceCommit") or catalog.get("version") != legacy.get("version") - or catalog.get("protocol") != legacy.get("protocol")): + or catalog.get("protocol") != legacy.get("protocol") + or catalog.get("releaseChannel") != "engineering-preview"): raise ValueError("catalog identity mismatch") images = catalog.get("images") if not isinstance(images, list) or len(images) != 2: diff --git a/tests/test_esp32p4_revision_catalog.py b/tests/test_esp32p4_revision_catalog.py index 8ec181510..e9a784058 100644 --- a/tests/test_esp32p4_revision_catalog.py +++ b/tests/test_esp32p4_revision_catalog.py @@ -36,7 +36,7 @@ def setUp(self): sourceCommit=SOURCE, image="old.bin", sha256=a) self.catalog = dict(schema="arstack.studio.firmware.catalog.v2", chip="esp32p4", version="0.1.1", protocol=1, - sourceCommit=SOURCE, images=[ + sourceCommit=SOURCE, releaseChannel="engineering-preview", images=[ dict(policy="pre-v3", image="old.bin", sha256=a, minRevision=100, maxRevision=199), dict(policy="v3.x", image="new.bin", sha256=b, minRevision=302, maxRevision=302), ]) From 97252ab166a31820c0ad0e8b7c89c7330cf0d383 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 3 Nov 2026 20:27:39 +0700 Subject: [PATCH 08/12] fix(studio): make board/isolation confirmation a C++ authorization gate for blank-board firmware setup --- apps/arstack_studio/qml/WorkflowBar.qml | 3 ++- apps/arstack_studio/src/SmartSessionController.cpp | 6 +++++- apps/arstack_studio/src/SmartSessionController.hpp | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 4673ce704..d96969bb1 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -488,6 +488,7 @@ SurfacePanel { Dialog { id: installDialog + onOpened: recoveryBoardConfirmed.checked = false modal: true anchors.centerIn: Overlay.overlay width: 408 @@ -531,7 +532,7 @@ SurfacePanel { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Install firmware"; tone: "accent"; implicitWidth: 142 enabled: recoveryBoardConfirmed.checked onClicked: { - if (smartSession.beginFirmwareInstall()) { + if (smartSession.beginFirmwareInstall(recoveryBoardConfirmed.checked)) { ribbon.installPromptDeferred = false installDialog.close() } else controller.showMessage("Firmware installation could not start. Open Tools > Advanced for recovery details.", true) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 84fd299bc..5661aa8b6 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -595,7 +595,11 @@ bool SmartSessionController::beginFirmwareReinstall() { return beginFirmwareOperation(device_->portName()); } -bool SmartSessionController::beginFirmwareInstall() { +bool SmartSessionController::beginFirmwareInstall(const bool supportedBoardAndIsolationConfirmed) { + // A blank ROM cannot attest the PCB/PHY model or physical isolation. + // A confirmation token is required by the sole C++ authorization owner, + // not merely used to disable a button in the presentation layer. + if (!supportedBoardAndIsolationConfirmed) return false; updateWasOptional_ = false; refreshRecoveryOfferFromIdentity(); if (device_ == nullptr || firmware_ == nullptr || !blankBoardDetected_ || diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index d43b23c84..305935fa6 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -230,7 +230,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool beginFirmwareUpdate(); Q_INVOKABLE bool beginFirmwareReinstall(); - Q_INVOKABLE bool beginFirmwareInstall(); + Q_INVOKABLE bool beginFirmwareInstall(bool supportedBoardAndIsolationConfirmed); Q_INVOKABLE bool retryFirmwareUpdate(); Q_INVOKABLE bool cancelFirmwareUpdate(); Q_INVOKABLE bool retryFirmwareSetup(); From 3767c5da9bec23c015154bb4f28b7bd1e9459362 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 3 Nov 2026 20:32:37 +0700 Subject: [PATCH 09/12] fix(rom): reject conflicting chip/MAC labels and handle Windows CRLF board-info output --- apps/arstack_studio/src/FirmwareManager.cpp | 25 ++++++++++++++++++++- apps/arstack_studio/src/main.cpp | 8 ++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index f589ec74f..5beeec99c 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -158,6 +158,22 @@ bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, in static const QRegularExpression expression{ QStringLiteral(R"(Chip\s+type:\s*ESP32[-_ ]?P4\s*\(revision\s+v(\d+)\.(\d+)\))"), QRegularExpression::CaseInsensitiveOption}; + // Ignore no chip or contradictory chip labels. Only one ROM identity is + // admissible, even if a second line has a different ESP chip name. + static const QRegularExpression chipLabel{ + QStringLiteral(R"(Chip\s+type:)"), QRegularExpression::CaseInsensitiveOption}; + auto labels = chipLabel.globalMatch(output); + if (!labels.hasNext()) { + major = -1; + minor = -1; + return false; + } + labels.next(); + if (labels.hasNext()) { + major = -1; + minor = -1; + return false; + } const auto match = expression.match(output); if (!match.hasMatch()) { major = -1; @@ -180,8 +196,15 @@ QString FirmwareManager::parseEsp32P4Mac(const QString& output) { // espflash v4.5 board-info reports "MAC address: aa:bb:cc:dd:ee:ff". // Require one unambiguous ROM eFuse MAC; unrelated lines are diagnostics, // and a recycled COM number is never a physical identity. + static const QRegularExpression macLabel{ + QStringLiteral(R"(^[ \t]*MAC address:)"), + QRegularExpression::CaseInsensitiveOption | QRegularExpression::MultilineOption}; + auto labels = macLabel.globalMatch(output); + if (!labels.hasNext()) return {}; + labels.next(); + if (labels.hasNext()) return {}; // Conflicting valid/invalid MAC labels. static const QRegularExpression macExpression{ - QStringLiteral(R"(^[ \t]*MAC address:[ \t]*((?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})[ \t]*$)"), + QStringLiteral(R"(^[ \t]*MAC address:[ \t]*((?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})[ \t\r]*$)"), QRegularExpression::CaseInsensitiveOption | QRegularExpression::MultilineOption}; auto matches = macExpression.globalMatch(output); if (!matches.hasNext()) return {}; diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 88b954179..ea10567e9 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -268,13 +268,15 @@ int checkFirmwareContract(int argc, char* argv[]) { const bool macIdentityContract = FirmwareManager::parseEsp32P4Mac(QStringLiteral( - "Chip type: esp32p4 (revision v3.2)\n" - "MAC address: c8:f0:9e:50:8a:d0\n")) == + "Chip type: esp32p4 (revision v3.2)\r\n" + "MAC address: c8:f0:9e:50:8a:d0\r\n")) == QStringLiteral("C8F09E508AD0") && FirmwareManager::parseEsp32P4Mac(QStringLiteral("MAC address: 00:00:00:00:00:00")).isEmpty() && FirmwareManager::parseEsp32P4Mac(QStringLiteral("MAC address: bad")).isEmpty() && FirmwareManager::parseEsp32P4Mac(QStringLiteral( - "MAC address: c8:f0:9e:50:8a:d0\nMAC address: aa:bb:cc:dd:ee:ff")).isEmpty(); + "MAC address: c8:f0:9e:50:8a:d0\nMAC address: aa:bb:cc:dd:ee:ff")).isEmpty() && + FirmwareManager::parseEsp32P4Mac(QStringLiteral( + "MAC address: c8:f0:9e:50:8a:d0\nMAC address: invalid")).isEmpty(); const bool revisionPolicy = FirmwareManager::supportsEsp32P4Revision(0, 0) && From caf8e4b96f34ee3dd641ba8cf47ea4d90df6c157 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 4 Nov 2026 01:00:13 +0700 Subject: [PATCH 10/12] fix(packaging): deliver matched dual-revision firmware in Linux engineering preview Reuse same-workflow exact-HEAD firmware artifact from Windows Native Preview without another IDF build. Stage two verified images, provenance and pinned espflash into Linux AppImage; smoke extracted AppImage with production firmware contract; preserve stable tag flow and standalone GUI-only CI. --- .../arstack-studio-linux-appimage.yml | 100 ++++++++++++++++-- .../arstack-studio-native-preview.yml | 9 ++ 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/.github/workflows/arstack-studio-linux-appimage.yml b/.github/workflows/arstack-studio-linux-appimage.yml index 9b04d33a0..901a53755 100644 --- a/.github/workflows/arstack-studio-linux-appimage.yml +++ b/.github/workflows/arstack-studio-linux-appimage.yml @@ -6,6 +6,14 @@ on: - 'apps/arstack_studio/**' - '.github/workflows/arstack-studio-linux-appimage.yml' - 'scripts/install-linux.sh' + # When invoked by Native Preview, reuse the exact PR-head firmware + # artifact already built in that run. Do not compile a third copy of IDF. + workflow_call: + inputs: + paired_preview: + type: boolean + required: false + default: false workflow_dispatch: inputs: release_tag: @@ -26,7 +34,9 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag != '' && inputs.release_tag || github.sha }} + # A PR's github.sha is usually a synthetic merge commit. The paired + # firmware job uses the real PR HEAD: compile the matching GUI. + ref: ${{ (inputs.release_tag != '' && inputs.release_tag) || github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 - name: Confirm release identity before packaging @@ -106,12 +116,84 @@ jobs: shutil.copy2(root/'firmware-manifest.json',output/'firmware-manifest.json') (output/'SHA256SUMS.txt').write_text(f"{manifest['sha256']} {manifest['image']}\n") PY - curl -fLsS https://github.com/esp-rs/espflash/releases/download/v4.5.0/espflash-x86_64-unknown-linux-gnu.zip -o espflash.zip - echo 'dcd7fd822f247df18966935bbb10c7cb4b700c87172349633e9e323ee2542717 espflash.zip' | sha256sum -c - - unzip -j espflash.zip 'espflash' -d AppDir/usr/bin/tools - chmod +x AppDir/usr/bin/tools/espflash fi + - name: Download matched dual-revision firmware from this exact workflow + if: ${{ inputs.paired_preview == true }} + uses: actions/download-artifact@v4 + with: + name: arstack-esp32p4-smv-firmware + path: firmware-package + + - name: Verify and stage pre-v3 + v3.2 preview firmware + if: ${{ inputs.paired_preview == true }} + env: + EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + test -z "${{ inputs.release_tag }}" + test -n "$EXPECTED_SOURCE_SHA" + test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + (cd firmware-package && sha256sum -c SHA256SUMS.txt) + python3 scripts/validate_esp32p4_revision_catalog.py firmware-package + python3 - <<'PY' + import hashlib, json, os, pathlib, shutil + root = pathlib.Path('firmware-package') + destination = pathlib.Path('AppDir/usr/bin/firmware') + destination.mkdir(parents=True, exist_ok=True) + legacy = json.loads((root/'firmware-manifest.json').read_text(encoding='utf-8')) + catalog = json.loads((root/'firmware-catalog.json').read_text(encoding='utf-8')) + expected = os.environ['EXPECTED_SOURCE_SHA'] + if not (legacy['sourceCommit'] == catalog['sourceCommit'] == expected): + raise SystemExit('Linux preview firmware/GUI commits differ') + if (not legacy.get('phaseSyncPreview') or + not legacy.get('bootSourceAutostartEnabled') or + legacy.get('globalGmTraceabilityPhysicallyProven') is not False or + catalog.get('releaseChannel') != 'engineering-preview'): + raise SystemExit('Unqualified preview firmware or misleading timing evidence') + allowed = {entry['image'] for entry in catalog['images']} + allowed.update(('firmware-manifest.json', 'firmware-catalog.json', 'SHA256SUMS.txt')) + if any(p.name not in allowed for p in root.iterdir()): + raise SystemExit('Unexpected files in firmware preview artifact') + for name in sorted(allowed): + shutil.copy2(root/name, destination/name) + provenance = { + 'schema': 'arstack.studio.linux.preview-provenance.v1', + 'sourceCommit': expected, + 'firmwareBuildId': expected[:16], + 'revisionProfiles': ['pre-v3', 'v3.2'], + 'firmwareReleaseChannel': 'engineering-preview', + 'siliconV3PhysicallyQualified': False, + 'globalGmTraceabilityPhysicallyProven': False, + 'stableRelease': False, + } + (pathlib.Path('AppDir/usr/bin')/'PREVIEW-PROVENANCE.json').write_text( + json.dumps(provenance, indent=2, sort_keys=True)+'\\n', encoding='utf-8') + print('Linux paired preview: exact source + both revision images + provenance PASS') + PY + + # Both immutable stable-tag and paired-preview paths share one verified + # flasher source. The GUI-only PR AppImage never claims firmware support. + - name: Bundle verified Linux espflash v4.5.0 + if: ${{ inputs.release_tag != '' || inputs.paired_preview == true }} + run: | + set -euo pipefail + mkdir -p AppDir/usr/bin/tools + curl -fLsS https://github.com/esp-rs/espflash/releases/download/v4.5.0/espflash-x86_64-unknown-linux-gnu.zip -o espflash.zip + echo 'dcd7fd822f247df18966935bbb10c7cb4b700c87172349633e9e323ee2542717 espflash.zip' | sha256sum -c - + unzip -j espflash.zip 'espflash' -d AppDir/usr/bin/tools + chmod +x AppDir/usr/bin/tools/espflash + AppDir/usr/bin/tools/espflash --version + + - name: Check matched Linux preview with the production Studio + if: ${{ inputs.paired_preview == true }} + env: + ARSTACK_STUDIO_FIRMWARE_DIR: ${{ github.workspace }}/AppDir/usr/bin/firmware + ARSTACK_STUDIO_ESPFLASH: ${{ github.workspace }}/AppDir/usr/bin/tools/espflash + run: | + set -euo pipefail + QT_QPA_PLATFORM=offscreen xvfb-run -a build-studio-linux/arstack_studio --check-firmware-contract + - name: Bundle Qt and create single-file AppImage uses: miurahr/install-linuxdeploy-action@v1.8.0 id: linuxdeploy @@ -140,8 +222,10 @@ jobs: xvfb-run -a ./ARStack-Studio-linux-x86_64.AppImage --check-reference-template APPIMAGE_EXTRACT_AND_RUN=1 QT_QPA_PLATFORM=offscreen \ xvfb-run -a ./ARStack-Studio-linux-x86_64.AppImage --check-p0-policy - if [ -n "${{ inputs.release_tag }}" ]; then - APPIMAGE_EXTRACT_AND_RUN=1 QT_QPA_PLATFORM=offscreen \ + if [ -n "${{ inputs.release_tag }}" ] || [ "${{ inputs.paired_preview }}" = "true" ]; then + # Test real extracted AppImage paths, not a developer-env override. + env -u ARSTACK_STUDIO_FIRMWARE_DIR -u ARSTACK_STUDIO_ESPFLASH \ + APPIMAGE_EXTRACT_AND_RUN=1 QT_QPA_PLATFORM=offscreen \ xvfb-run -a ./ARStack-Studio-linux-x86_64.AppImage --check-firmware-contract fi sha256sum ARStack-Studio-linux-x86_64.AppImage > ARStack-Studio-linux-x86_64.sha256 @@ -149,7 +233,7 @@ jobs: - name: Upload reviewed Linux artifacts uses: actions/upload-artifact@v4 with: - name: arstack-studio-linux-x86_64 + name: ${{ inputs.paired_preview && 'arstack-studio-linux-esp32p4-dual-preview' || 'arstack-studio-linux-x86_64' }} path: | ARStack-Studio-linux-x86_64.AppImage ARStack-Studio-linux-x86_64.sha256 diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index dce254b11..7fdbca9a1 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -29,6 +29,15 @@ jobs: # Stable release workflow keeps ordinary firmware until board validation. phase_sync_preview: true + # Windows and Linux must package the SAME firmware artifact. This job + # runs after the existing exact-HEAD firmware builder, and adds no IDF jobs. + linux-preview: + needs: firmware + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + uses: ./.github/workflows/arstack-studio-linux-appimage.yml + with: + paired_preview: true + windows-preview: needs: firmware if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} From a4dc24964f59da615ee5ba999a593e0495e0b4e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 4 Nov 2026 01:04:14 +0700 Subject: [PATCH 11/12] fix(ci): isolate privileged Linux release publishing from read-only reusable preview builder Use one shared exact-head AppImage builder for paired preview and stable tag builds; move stable uploader behind separately authorized workflow_dispatch. Keep existing published-release auto-dispatch path and immutable historical tags protected. --- .../arstack-studio-linux-appimage.yml | 42 ++---------- .../arstack-studio-linux-auto-release.yml | 2 +- .../arstack-studio-linux-release-publish.yml | 68 +++++++++++++++++++ 3 files changed, 75 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/arstack-studio-linux-release-publish.yml diff --git a/.github/workflows/arstack-studio-linux-appimage.yml b/.github/workflows/arstack-studio-linux-appimage.yml index 901a53755..62b094a2d 100644 --- a/.github/workflows/arstack-studio-linux-appimage.yml +++ b/.github/workflows/arstack-studio-linux-appimage.yml @@ -14,10 +14,14 @@ on: type: boolean required: false default: false + release_tag: + type: string + required: false + default: '' workflow_dispatch: inputs: release_tag: - description: 'Existing GitHub release tag to build, test and attach Linux AppImage; blank = build artifact only' + description: 'Build and test exact-tag Linux package; publishing is owned by the separate release workflow' required: false type: string @@ -168,7 +172,7 @@ jobs: 'stableRelease': False, } (pathlib.Path('AppDir/usr/bin')/'PREVIEW-PROVENANCE.json').write_text( - json.dumps(provenance, indent=2, sort_keys=True)+'\\n', encoding='utf-8') + json.dumps(provenance, indent=2, sort_keys=True)+'\n', encoding='utf-8') print('Linux paired preview: exact source + both revision images + provenance PASS') PY @@ -240,37 +244,3 @@ jobs: if-no-files-found: error retention-days: 14 - attach-verified-release: - name: Attach tested Linux package to exact release tag - needs: appimage - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag != '' }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.release_tag }} - fetch-depth: 0 - - name: Download Linux build that passed the AppImage checks - uses: actions/download-artifact@v4 - with: - name: arstack-studio-linux-x86_64 - - name: Verify tag and release before publishing - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.release_tag }} - run: | - set -euo pipefail - test "$(git rev-parse HEAD)" = "$(git rev-parse "$TAG^{commit}")" - git fetch --no-tags origin main - git merge-base --is-ancestor "$TAG^{commit}" origin/main || { - echo "Release source is not integrated into main; refusing Linux package" >&2 - exit 1 - } - gh release view "$TAG" --json isDraft,isPrerelease -q 'select(.isDraft==false and .isPrerelease==false)' > release-check.json - test -s release-check.json - sha256sum -c ARStack-Studio-linux-x86_64.sha256 - gh release upload "$TAG" \ - ARStack-Studio-linux-x86_64.AppImage \ - ARStack-Studio-linux-x86_64.sha256 diff --git a/.github/workflows/arstack-studio-linux-auto-release.yml b/.github/workflows/arstack-studio-linux-auto-release.yml index 131405d7a..25f7efc4f 100644 --- a/.github/workflows/arstack-studio-linux-auto-release.yml +++ b/.github/workflows/arstack-studio-linux-auto-release.yml @@ -74,7 +74,7 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} run: | - gh workflow run arstack-studio-linux-appimage.yml \ + gh workflow run arstack-studio-linux-release-publish.yml \ --repo "$GITHUB_REPOSITORY" --ref main \ --field release_tag="$TAG" echo "Dispatched Linux build for release tag: $TAG" diff --git a/.github/workflows/arstack-studio-linux-release-publish.yml b/.github/workflows/arstack-studio-linux-release-publish.yml new file mode 100644 index 000000000..a30544277 --- /dev/null +++ b/.github/workflows/arstack-studio-linux-release-publish.yml @@ -0,0 +1,68 @@ +name: Publish Verified Linux AppImage to Stable Release + +# The read-only reusable AppImage builder MUST NOT request contents:write on +# pull requests. This separate trusted dispatch is the sole release uploader. +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Existing published latest stable GitHub release tag' + type: string + required: true + +permissions: + contents: read + +jobs: + build-verified-linux: + uses: ./.github/workflows/arstack-studio-linux-appimage.yml + with: + release_tag: ${{ inputs.release_tag }} + paired_preview: false + + attach-verified-release: + name: Attach exact-tag Linux AppImage after independent release checks + needs: build-verified-linux + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.release_tag }} + fetch-depth: 0 + + - name: Download the same-run tested Linux build + uses: actions/download-artifact@v4 + with: + name: arstack-studio-linux-x86_64 + + - name: Verify exact source, release identity and checksum before upload + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + test -n "$TAG" + test "$TAG" != "processbus-sim-m3h-20261024" + test "$TAG" != "processbus-sim-m3h3-20261025" + test "$(git rev-parse HEAD)" = "$(git rev-parse "$TAG^{commit}")" + git fetch --no-tags origin main + git merge-base --is-ancestor "$TAG^{commit}" origin/main || { + echo "Release source is not integrated in main" >&2 + exit 1 + } + gh release view "$TAG" --json tagName,isDraft,isPrerelease -q \ + 'select(.tagName == env.TAG and .isDraft == false and .isPrerelease == false)' > release-check.json + test -s release-check.json + gh api "repos/$GITHUB_REPOSITORY/releases/latest" > latest-release.json + python3 - <<'PY' + import json, os + latest=json.load(open('latest-release.json', encoding='utf-8')) + assert latest['tag_name'] == os.environ['TAG'], 'not current stable release' + assert not latest['draft'] and not latest['prerelease'] + PY + sha256sum -c ARStack-Studio-linux-x86_64.sha256 + gh release upload "$TAG" \ + ARStack-Studio-linux-x86_64.AppImage \ + ARStack-Studio-linux-x86_64.sha256 From 75b16341ac1f56f8b2ed6c0a560edc406a9a818e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 4 Nov 2026 01:17:55 +0700 Subject: [PATCH 12/12] fix(studio): keep exclusive serial ownership through asynchronous flash cancellation Prevent QProcess timeout from releasing COM lease before worker exit; close the discovery ownership loophole; add deterministic timeout/port-retirement regression and concise v1.3/v3.2 physical acceptance guide. --- .../src/DeterministicSessionHarness.cpp | 22 +++++++++++++ .../src/SmartSessionController.cpp | 31 ++++++++++++++--- .../src/SmartSessionController.hpp | 3 ++ ...P1_ESP32P4_DUAL_REVISION_HIL_ACCEPTANCE.md | 33 +++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 docs/releases/P1_ESP32P4_DUAL_REVISION_HIL_ACCEPTANCE.md diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 550a2d895..d9d956417 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -28,6 +28,7 @@ class DeterministicSessionHarness final { const CaseResult cases[] = { {"dual-revision catalog selects only qualified silicon", revisionCatalogFailClosed()}, + {"firmware timeout retains port until flasher exits", firmwarePortReleaseWaitsForWorker()}, {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, {"same version missing build ID -> READY with update suggestion", sameVersionMissingBuildOffersUpdate()}, @@ -174,6 +175,27 @@ class DeterministicSessionHarness final { } }; + static bool firmwarePortReleaseWaitsForWorker() { + Fixture fixture; + quiesce(fixture.session, fixture.device); + auto& s = fixture.session; + auto& f = fixture.firmware; + s.updateRequested_ = true; + s.updateStage_ = SmartSessionController::UpdateStage::flashing; + s.portOwner_ = SmartSessionController::PortOwner::firmwareTool; + f.busy_ = true; + s.latchFirmwareFailure(QStringLiteral("Deadline reached")); + const bool retained = s.portOwner_ == SmartSessionController::PortOwner::firmwareTool && + s.firmwareProcessRetirementPending_ && !s.updateRequested_ && + !s.startDeviceDiscovery(); + f.busy_ = false; + emit f.stateChanged(); // Emulate QProcess cancellation acknowledgement. + const bool released = !s.firmwareProcessRetirementPending_ && + s.portOwner_ == SmartSessionController::PortOwner::none && + s.setupError_; + return retained && released; + } + static void quiesce(SmartSessionController& session, StudioDeviceController& device) { session.discoveryTimer_.stop(); session.prepareTimer_.stop(); diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 5661aa8b6..950fda184 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -109,8 +109,13 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent firmwareTotalTimer_.setSingleShot(true); connect(&firmwareTotalTimer_, &QTimer::timeout, this, [this] { if (!updateRequested_) return; - if (firmware_ != nullptr && firmware_->busy()) firmware_->cancel(); - latchFirmwareFailure(QStringLiteral("Firmware setup reached its safety deadline. The board is not verified; reconnect it and choose Recovery.")); + if (firmware_ != nullptr && firmware_->busy()) { + firmwareProcessRetirementPending_ = true; + firmware_->cancel(); + } + latchFirmwareFailure(QStringLiteral( + "Firmware setup timed out. Studio is stopping the firmware tool; " + "the board is not verified. Use Recovery once the operation ends.")); emit firmwareUpdateFinished(false); reconcile(); }); @@ -304,6 +309,7 @@ void SmartSessionController::setPortOwner(const PortOwner owner) { bool SmartSessionController::startDeviceDiscovery() { if (device_ == nullptr || portOwner_ == PortOwner::firmwareTool || + firmwareProcessRetirementPending_ || (firmware_ != nullptr && firmware_->busy()) || device_->connected() || device_->discovering()) { return false; } @@ -611,7 +617,12 @@ bool SmartSessionController::beginFirmwareInstall(const bool supportedBoardAndIs bool SmartSessionController::beginFirmwareOperation(const QString& portName) { const QString port = portName.trimmed(); - if (device_ == nullptr || firmware_ == nullptr || port.isEmpty() || firmware_->busy()) return false; + if (firmwareProcessRetirementPending_ && firmware_ != nullptr && !firmware_->busy()) { + firmwareProcessRetirementPending_ = false; + if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); + } + if (device_ == nullptr || firmware_ == nullptr || port.isEmpty() || + firmware_->busy() || firmwareProcessRetirementPending_) return false; updatePort_ = port; updateExpectedDeviceId_ = device_->deviceVerified() ? device_->deviceId().trimmed() : QString{}; @@ -963,6 +974,10 @@ void SmartSessionController::reconnectFirmwareSignals() { if (firmware_ == nullptr) return; connect(firmware_, &FirmwareManager::stateChanged, this, [this] { + if (firmwareProcessRetirementPending_ && !firmware_->busy()) { + firmwareProcessRetirementPending_ = false; + if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); + } if (!updateRequested_) { reconcile(); return; @@ -1231,7 +1246,15 @@ void SmartSessionController::latchFirmwareFailure(QString message) { updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; blankBoardDetected_ = false; - if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); + if (portOwner_ == PortOwner::firmwareTool) { + // QProcess::kill is asynchronous. Busy remains true until its finish + // signal; releasing the COM lease earlier would permit another owner + // while the flasher can still be touching the board. + if (firmware_ != nullptr && firmware_->busy()) + firmwareProcessRetirementPending_ = true; + else + setPortOwner(PortOwner::none); + } message = message.trimmed(); lastFirmwareUpdateResult_ = message.isEmpty() ? QStringLiteral("Firmware verification failed; inspect the firmware-service log.") diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 305935fa6..12a351f17 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -436,6 +436,9 @@ class SmartSessionController : public QObject { bool updateRequested_{false}; bool updateWasOptional_{false}; bool stoppingPtpForFirmware_{false}; + // Do not transfer serial authority until an asynchronously cancelled + // flasher has actually exited and FirmwareManager has cleared busy(). + bool firmwareProcessRetirementPending_{false}; bool blankBoardDetected_{false}; bool manualRecoveryArmed_{false}; bool setupError_{false}; diff --git a/docs/releases/P1_ESP32P4_DUAL_REVISION_HIL_ACCEPTANCE.md b/docs/releases/P1_ESP32P4_DUAL_REVISION_HIL_ACCEPTANCE.md new file mode 100644 index 000000000..43096621b --- /dev/null +++ b/docs/releases/P1_ESP32P4_DUAL_REVISION_HIL_ACCEPTANCE.md @@ -0,0 +1,33 @@ +# P1 ESP32-P4 dual-revision — physical acceptance (engineering preview) + +**Scope:** Waveshare ESP32-P4-ETH-M v3.2 and previously accepted ESP32-P4 v1.3. **Status:** PHYSICAL TESTING PENDING. No energized process bus, no live protection system, no externally traceable PTP claim. Existing M3.H.3 stable evidence is not v3.2 evidence. + +Use the **matched Windows Native Preview** or **paired Linux Preview AppImage** from the same PR build run, not the standalone GUI-only Linux artifact. A valid test package contains Studio, bundled espflash v4.5.0, `firmware-manifest.json`, `firmware-catalog.json`, separate `pre-v3` / `v3.x` binaries, and `SHA256SUMS.txt`. Record the manifest `sourceCommit` and both image digests. + +## Operator procedure (one isolated board at a time) + +1. Disconnect from energized equipment. Connect the board USB; keep Ethernet connected only to an isolated bench switch/capture port. Use external board power if required by vendor instructions. Record PCB model and actual silicon revision. +2. Launch Studio. Confirm that passive detection does not reset, write firmware, stop an autonomous board or start SV/PTP. For a fresh device, use **Set up board** and acknowledge the qualified PCB/isolation check. For an existing verified device, choose **Update firmware**. +3. Studio performs an authorized ROM probe. On v1.3 it must select **pre-v3**; on v3.2 it must select **v3.x**. Check ROM factory eFuse MAC against the application `device_id` in copied diagnostics. Unknown/ambiguous revision, invalid MAC or unsupported board must be rejected before write. +4. Keep USB connected until installation ends. Verify that Studio resets/reacquires the *same* physical board and reports a fresh `boot_id`, expected firmware version/protocol/capabilities and the **exact** manifest build ID. A 100% flash bar by itself is not Ready. +5. Check PHY link and standalone board behavior. With isolation maintained, explicitly START the canonical 4I+4V/4000fps test. Capture Ethernet SV packets and PTP Layer-2 frames. Verify 4000 frames/s per configured MU, correct `smpCnt` sequence (including wrap), expected VLAN/APPID/quality, no loss/duplicate frames, and truthful sync state. Do not interpret simulated GM as physical UTC/GNSS traceability. +6. Verify STOP, cold boot, USB disconnect/reconnect, COM renumbering, and bounded recovery. No silent SV/PTP restart or cached SCD/profile deployment after reconnect. +7. Exercise controlled negative scenarios on isolated hardware: unexpected USB removal, explicit Download mode, failed flash, and recovery. Record whether content may be partially written; never force-write a deliberately incompatible revision or change eFuses/security to make a test pass. + +## Evidence sheet (complete separately for **each** silicon revision) + +| Evidence | v1.3 | v3.2 | +| --- | --- | --- | +| Board model / PCB rev / silicon ROM rev | PENDING | PENDING | +| ROM factory MAC = postboot device ID | PENDING | PENDING | +| Tool version + OS + preview artifact ID / sourceCommit | PENDING | PENDING | +| Selected image name + manifest SHA + image-header bounds | PENDING | PENDING | +| Fresh boot ID + exact postboot firmware build/protocol | PENDING | PENDING | +| PHY link + captured 4kHz SV with `smpCnt`/quality continuity | PENDING | PENDING | +| PTP Layer-2 capture + defensible `smpSynch` evidence | PENDING | PENDING | +| STOP/START, reset/reconnect, no silent restart | PENDING | PENDING | +| Interrupted write and bounded recovery | PENDING | PENDING | + +Attach Wireshark PCAP/PCAPNG, serial diagnostics, ROM/flash tool output, OS/build SHA and notes about any nonconformance. Mark **PASS** only after reproducible measured evidence; CI artifacts and simulations are never physical evidence. v3.2 remains an **engineering preview** until qualified in hardware on both lanes. + +See [PRD](../PRD_REVISION_AWARE_ESP32P4_FIRMWARE.md) and [ARD](../ARD_REVISION_AWARE_ESP32P4_FIRMWARE.md) for mandatory negative tests and release gates.