Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .github/workflows/esp32p4-smv-injector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ concurrency:
env:
ARSTACK_FIRMWARE_VERSION: '0.1.1'
ARSTACK_PROTOCOL_VERSION: '1'
# Reusable PR workflows expose github.sha as a synthetic merge SHA.
# Compile identity and manifest must use the exact checked-out PR HEAD.
ARSTACK_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
Comment thread
masarray marked this conversation as resolved.

jobs:
build-esp32p4:
Expand All @@ -63,6 +66,21 @@ jobs:
# synthetic merge SHA that may change between queue and build.
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Pin semantic firmware build ID to the exact checkout
run: |
set -euo pipefail
if [[ ! "$ARSTACK_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
echo 'Missing exact firmware source commit' >&2
exit 1
fi
checkout_commit="$(git -c safe.directory="$GITHUB_WORKSPACE" rev-parse HEAD)"
if [[ "$checkout_commit" != "$ARSTACK_SOURCE_COMMIT" ]]; then
echo "Firmware checkout drift: $checkout_commit != $ARSTACK_SOURCE_COMMIT" >&2
exit 1
fi
echo "ARSTACK_FIRMWARE_BUILD_ID=${ARSTACK_SOURCE_COMMIT:0:16}" >> "$GITHUB_ENV"
echo "Exact firmware build identity pinned to ${ARSTACK_SOURCE_COMMIT:0:16}"

- name: Select ESP32-P4 target
run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4

Expand Down Expand Up @@ -134,10 +152,7 @@ jobs:
test -s "build/${image}"

- name: Create release manifest and SHA-256 contract
env:
# Container git may lack safe.directory or even be unavailable.
# The checkout explicitly selected this verified PR-head SHA.
ARSTACK_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
# Job-scoped ARSTACK_SOURCE_COMMIT is the exact checkout identity.
run: |
set -euo pipefail
image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin"
Expand All @@ -163,6 +178,7 @@ jobs:
"sha256": sha256,
"espIdf": "5.5.4",
"sourceCommit": source_commit,
"buildId": source_commit[:16],
}
with open("build/firmware-manifest.json", "w", encoding="utf-8", newline="\n") as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
Expand All @@ -183,6 +199,11 @@ jobs:
assert 'SMPSYNCH-AUTO' in manifest['capabilities']
assert 'PROFILE-BINARY-V1' in manifest['capabilities']
assert __import__('re').fullmatch(r'[0-9a-f]{40}', manifest['sourceCommit'])
expected_build_id = manifest['sourceCommit'][:16]
assert manifest['buildId'] == expected_build_id
# The app itself must advertise the identity in IDENTIFY. A valid SHA
# and sourceCommit in manifest alone cannot prove which ELF was built.
assert expected_build_id.encode('ascii') in payload, 'Firmware image does not contain the promised semantic build identity'
assert actual == manifest['sha256']
print(f"Firmware package: PASS · {manifest['image']} · PTP-P2 · pre-v3 · SHA-256 {actual}")
PY
Expand Down
6 changes: 3 additions & 3 deletions apps/arstack_studio/qml/FirmwarePanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ SurfacePanel {
Label {
Layout.fillWidth: true
text: panel.device.deviceVerified
? panel.device.deviceProduct + " · " + panel.device.deviceTarget + " · firmware v" + panel.device.firmwareVersion
? panel.device.deviceProduct + " · " + panel.device.deviceTarget + " · firmware v" + panel.device.firmwareVersion + " · build " + (panel.device.firmwareBuildId.length > 0 ? panel.device.firmwareBuildId : "unknown")
: (panel.hasPort
? firmwarePort.currentText + " is visible. Device identity and recovery decisions remain owned by the guided session."
: "Connect the ESP32-P4 programming USB cable, then refresh the port list.")
Expand Down Expand Up @@ -274,7 +274,7 @@ SurfacePanel {
text: panel.firmware.busy
? "The session supervisor currently owns the firmware tool. Keep USB connected until Studio reports a terminal result."
: (panel.session && panel.session.firmwareUpdateAvailable
? "The board firmware is compatible and remains usable. A newer build is available as an optional guided update."
? "Device firmware is compatible, but its build ID differs from this package. Keep using it or install the bundled build when convenient. Copy diagnostics if verification fails."
: "When Studio reports Firmware required, use the guided action. It will stop output, obtain an acknowledged serial release, verify the ESP32-P4, then write.")
color: panel.theme.textSoft
font.family: panel.uiFont
Expand All @@ -289,7 +289,7 @@ SurfacePanel {
uiFont: panel.uiFont
implicitWidth: 172
text: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable)
? (panel.session.firmwareUpdateRequired ? "Update firmware" : "Update available")
? (panel.session.firmwareUpdateRequired ? "Update firmware" : "Install bundled build")
: "Reinstall current"
visible: panel.session &&
(panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable || panel.session.firmwareReinstallAvailable)
Expand Down
9 changes: 9 additions & 0 deletions apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,16 @@ SurfacePanel {
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Item { Layout.fillWidth: true }
CalmButton {
theme: ribbon.theme
uiFont: ribbon.uiFont
text: "Copy diagnostics"
visible: !ribbon.firmwareResultSuccess
implicitWidth: 145
onClicked: StudioClipboard.copyText(smartSession.diagnosticsText())
}
CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "OK"; tone: ribbon.firmwareResultSuccess ? "success" : "normal"; implicitWidth: 100; onClicked: resultDialog.close() }
Item { Layout.fillWidth: true }
}
Expand Down
23 changes: 23 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class DeterministicSessionHarness final {
{"optional bootloader wait -> cancel restores supervisor", optionalBootloaderWaitCanCancel()},
{"firmware stage projection -> supervisor-owned", firmwareStageProjectionIsExplicit()},
{"firmware verification failure remains copyable after READY", firmwareFailureRemainsDiagnosable()},
{"firmware build mismatch explains expected and observed IDs", reconnectBuildMismatchPreservesEvidence()},
{"fragmented espflash progress -> monotonic", fragmentedEspflashProgressIsMonotonic()},
{"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()},
{"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()},
Expand Down Expand Up @@ -320,6 +321,28 @@ class DeterministicSessionHarness final {
fixture.session.startReady();
}

static bool reconnectBuildMismatchPreservesEvidence() {
Fixture fixture;
if (!fixture.profileReady) return false;
auto stale = identity();
stale.buildId = QStringLiteral("fedcba9876543210");
seedVerified(fixture, stale, QStringLiteral("COM7"), false);
auto& session = fixture.session;
session.started_ = false;
session.updateRequested_ = true;
session.updateWasOptional_ = true;
session.updateStage_ = SmartSessionController::UpdateStage::reconnecting;
session.reconcile();
const QString evidence = session.firmwareUpdateResult();
return !session.updateRequested_ &&
session.state() == QStringLiteral("READY") &&
session.firmwareUpdateAvailable() &&
evidence.contains(QStringLiteral("fedcba9876543210")) &&
evidence.contains(QStringLiteral("0123456789abcdef")) &&
evidence.contains(QStringLiteral("Copy diagnostics")) &&
session.diagnosticsText().contains(evidence);
}

static bool optionalBootloaderWaitCanCancel() {
Fixture fixture;
if (!fixture.profileReady) return false;
Expand Down
10 changes: 8 additions & 2 deletions apps/arstack_studio/src/SmartSessionController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1209,9 +1209,15 @@ void SmartSessionController::reconcile() {
const QString observed = deviceFirmwareVersion_.isEmpty()
? QStringLiteral("legacy/unknown firmware")
: QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_);
const QString installedBuild = deviceFirmwareBuildId_.isEmpty()
? QStringLiteral("missing/unknown") : deviceFirmwareBuildId_;
const QString expectedBuild = expectedFirmwareBuildId().isEmpty()
? QStringLiteral("missing/unknown") : expectedFirmwareBuildId();
latchFirmwareFailure(QStringLiteral(
"Firmware was written, but reconnect verification reported %1 instead of the exact bundled build for v%2.")
.arg(observed, expectedFirmwareVersion()));
"Firmware write/reset completed and ESP32-P4 reconnected, but exact build verification failed: "
"%1, installed build %2; package expects v%3, build %4. "
"A compatible existing firmware may still run. Copy diagnostics before retrying.")
.arg(observed, installedBuild, expectedFirmwareVersion(), expectedBuild));
emit firmwareUpdateFinished(false);
reconcile();
return;
Expand Down
Loading