diff --git a/.github/scripts/p0_ribbon_phase1.py b/.github/scripts/p0_ribbon_phase1.py new file mode 100644 index 000000000..792138b59 --- /dev/null +++ b/.github/scripts/p0_ribbon_phase1.py @@ -0,0 +1,137 @@ +from pathlib import Path +import re + +ROOT = Path('.') + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + if old not in text: + raise SystemExit(f'{label}: expected source block not found') + return text.replace(old, new, 1) + +# Register QML components. +cmake_path = ROOT / 'apps/arstack_studio/CMakeLists.txt' +cmake = cmake_path.read_text(encoding='utf-8') +needle = ' qml/WorkflowBar.qml\n qml/CalmButton.qml\n' +replacement = (' qml/WorkflowBar.qml\n' + ' qml/ModernRibbon.qml\n' + ' qml/RibbonGroup.qml\n' + ' qml/RibbonAction.qml\n' + ' qml/CalmButton.qml\n') +cmake = replace_once(cmake, needle, replacement, 'CMake ribbon registration') +cmake_path.write_text(cmake, encoding='utf-8') + +# Theme: define every surface token used by QML. This fixes the white PTP card. +theme_path = ROOT / 'apps/arstack_studio/qml/StudioTheme.qml' +theme = theme_path.read_text(encoding='utf-8') +theme = replace_once(theme, + ' readonly property color surface2: "#0e141b"\n readonly property color raised: "#151d27"\n', + ' readonly property color surface2: "#0e141b"\n readonly property color panelAlt: "#101820"\n readonly property color raised: "#151d27"\n', + 'StudioTheme panelAlt') +theme_path.write_text(theme, encoding='utf-8') + +# WorkflowBar keeps the existing session/dialog implementation, but presentation becomes ribbon-native. +workflow_path = ROOT / 'apps/arstack_studio/qml/WorkflowBar.qml' +workflow = workflow_path.read_text(encoding='utf-8') +anchor = ''' function resetDockLayout() { + controller.phasorDetached = false + controller.waveformDetached = false + controller.phasorDockVisible = true + controller.waveformDockVisible = false + controller.telemetryDockVisible = true + controller.telemetryExpanded = false + controller.showMessage("Dock layout reset.", false) + } +''' +helpers = anchor + ''' + function openUpdatePrompt() { + updatePromptDeferred = false + updateDialog.open() + } + + function openInstallPrompt() { + installPromptDeferred = false + installDialog.open() + } + + function retryProfileSync() { + if (smartSession.retryProfileSync()) + controller.showMessage("Retrying 4I+4V profile synchronization.", false) + else + controller.showMessage(smartSession.statusText, true) + } +''' +workflow = replace_once(workflow, anchor, helpers, 'WorkflowBar ribbon helpers') +pattern = re.compile(r'\n implicitHeight: 72\n color: theme\.surface2\n border\.color: theme\.line\n.*\n\}', re.S) +modern = ''' + implicitHeight: ribbon.compact ? 70 : 96 + color: "transparent" + border.width: 0 + + ModernRibbon { + anchors.fill: parent + theme: ribbon.theme + controller: ribbon.controller + workflow: ribbon + session: smartSession + device: ribbon.device + uiFont: ribbon.uiFont + compact: ribbon.compact + } +}''' +workflow, count = pattern.subn(modern, workflow, count=1) +if count != 1: + raise SystemExit(f'WorkflowBar presentation replacement count={count}') +workflow_path.write_text(workflow, encoding='utf-8') + +# Main shell: denser desktop instrument proportions, transient-only toast, and 0 Hz = DC. +main_path = ROOT / 'apps/arstack_studio/qml/Main.qml' +main = main_path.read_text(encoding='utf-8') +main = replace_once(main, + ' width: 1480\n height: 900\n minimumWidth: 1080\n minimumHeight: 720\n', + ' width: 1240\n height: 760\n minimumWidth: 960\n minimumHeight: 620\n', + 'Main default geometry') +main = replace_once(main, + ' readonly property bool compactLayout: width < 1300\n', + ' readonly property bool compactLayout: width < 1120\n', + 'Main compact breakpoint') +main = replace_once(main, + ' readonly property string toastMessage: transientMessage.length ? transientMessage : device.lastError\n readonly property bool toastError: transientMessage.length ? transientError : device.lastError.length > 0\n', + ' readonly property string toastMessage: transientMessage\n readonly property bool toastError: transientError\n', + 'Main transient toast policy') +main = replace_once(main, + ' Layout.preferredHeight: 72\n Layout.minimumHeight: 72\n', + ' Layout.preferredHeight: workflowBar.implicitHeight\n Layout.minimumHeight: workflowBar.implicitHeight\n', + 'Main ribbon height') +main = replace_once(main, + ' CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "AC"; tone: root.signalFrequency > 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("AC") }\n CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "DC"; tone: root.signalFrequency === 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("DC") }\n Rectangle { width: 1; height: 24; color: studioTheme.lineSoft }\n\n', + '', + 'Remove AC/DC mode buttons') +main = replace_once(main, + ' enabled: root.signalFrequency > 0\n validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 }\n', + ' enabled: true\n validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 }\n', + 'Frequency accepts DC zero') +main = main.replace('root.showMessage("AC frequency must be greater than 0 and not exceed 1000 Hz.", true)', + 'root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true)') +main = main.replace('CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "50";', + 'CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50";', 1) +main = main.replace('CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "60";', + 'CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60";', 1) +quick_50 = ' CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) }\n' +if quick_50 not in main: + raise SystemExit('Quick frequency 50 button not found') +main = main.replace(quick_50, + ' CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "0"; implicitWidth: 44; toolTipText: "DC"; onClicked: root.setFrequencyValue(0) }\n' + quick_50, + 1) +main_path.write_text(main, encoding='utf-8') + +# Matrix density: desktop instrument rows, not dashboard cards. +matrix_path = ROOT / 'apps/arstack_studio/qml/SignalMatrix.qml' +matrix = matrix_path.read_text(encoding='utf-8') +matrix = replace_once(matrix, + ' Layout.preferredHeight: matrix.compact ? 54 : 60\n', + ' Layout.preferredHeight: matrix.compact ? 46 : 50\n', + 'SignalMatrix row density') +matrix_path.write_text(matrix, encoding='utf-8') + +print('Phase 1 ribbon + compact UX patch applied successfully') diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index e13de6a97..761bc5044 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -209,18 +209,20 @@ jobs: root=/tmp/arstack-firmware-contract rm -rf "$root" mkdir -p "$root/firmware" "$root/tools" - printf 'ARStack-P0-firmware-contract\n' > "$root/firmware/arstack-esp32p4-smv-0.1.0.bin" - sha="$(sha256sum "$root/firmware/arstack-esp32p4-smv-0.1.0.bin" | awk '{print $1}')" + printf 'ARStack-P0-firmware-contract\n' > "$root/firmware/arstack-esp32p4-smv-0.1.1.bin" + sha="$(sha256sum "$root/firmware/arstack-esp32p4-smv-0.1.1.bin" | awk '{print $1}')" cat > "$root/firmware/firmware-manifest.json" < process exit -> immediate reopen + runs-on: windows-2022 + timeout-minutes: 12 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.10.3' + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + - name: Configure Studio + shell: powershell + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON + if ($LASTEXITCODE -ne 0) { throw "Studio configure failed: $LASTEXITCODE" } + - name: Build actual Studio executable + shell: powershell + run: | + cmake --build build-arstack-studio --config Release --target arstack_studio --parallel + if ($LASTEXITCODE -ne 0) { throw "Studio build failed: $LASTEXITCODE" } + - name: Two-cycle normal-close lifecycle regression + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $env:QT_QPA_PLATFORM = 'offscreen' + $env:QT_QUICK_BACKEND = 'software' + $candidate = Get-ChildItem -Path (Join-Path $PWD 'build-arstack-studio') -Recurse -File | + Where-Object { $_.Name -ieq 'arstack_studio.exe' -or $_.Name -ieq 'ARStackStudio.exe' } | + Select-Object -First 1 + if ($null -eq $candidate) { throw 'Built ARStack Studio executable was not found.' } + $exe = $candidate.FullName + function Invoke-LifecycleCycle([int]$Cycle) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $exe + $startInfo.Arguments = '--check-app-lifecycle' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { + $process.Dispose() + throw "Cycle ${Cycle}: failed to start Studio process." + } + $pidValue = $process.Id + if (-not $process.WaitForExit(12000)) { + Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue + $process.Dispose() + throw "Cycle ${Cycle}: Studio process did not exit within 12 seconds after normal primary-window close." + } + $exitCode = $process.ExitCode + $process.Dispose() + if ($exitCode -ne 0) { throw "Cycle ${Cycle}: Studio lifecycle coordinator exited with code $exitCode." } + if (Get-Process -Id $pidValue -ErrorAction SilentlyContinue) { throw "Cycle ${Cycle}: Studio PID $pidValue remained alive after native WaitForExit." } + Write-Host "Cycle $Cycle PASS: normal close -> clean worker retirement -> PID exit." + } + Invoke-LifecycleCycle 1 + Start-Sleep -Milliseconds 250 + Invoke-LifecycleCycle 2 + Write-Host 'Windows lifecycle gate: PASS · normal close -> process exit -> lock release -> immediate reopen -> clean exit' diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index b7c7c3583..1b7c9c967 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -21,6 +21,10 @@ on: permissions: contents: read +concurrency: + group: esp32p4-ptp-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build-esp32p4-ptp: strategy: @@ -33,6 +37,7 @@ jobs: role_value: 1 name: build-esp32p4-ptp-${{ matrix.role_name }} runs-on: ubuntu-latest + timeout-minutes: 25 container: image: espressif/idf:v5.5.4 defaults: @@ -45,6 +50,54 @@ jobs: - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + - name: Verify PTP source readiness and ESP-IDF 5.5 TX contract + shell: bash + run: | + python - <<'PY' + from pathlib import Path + eth = Path('main/ethernet_port.c').read_text(encoding='utf-8') + app = Path('main/app_main.cpp').read_text(encoding='utf-8') + ptp = Path('main/ptp_lab_task.cpp').read_text(encoding='utf-8') + receiver = Path('main/ptp_receiver_task.cpp').read_text(encoding='utf-8') + + # Never allow the old premature boot path back into Ethernet init. + if 'ar_ptp_lab_try_start(handle)' in eth: + raise SystemExit('PTP must not auto-start inside Ethernet initialization before app_main starts Ethernet') + if 'ESP_ERROR_CHECK(esp_eth_start(eth_handle));' not in app: + raise SystemExit('app_main must explicitly start Ethernet before operator-controlled PTP can run') + + # ESP-IDF 5.5 esp_eth_transmit_ctrl_vargs forwards argc as the number + # of variadic arguments. ESP32-P4 EMAC computes buf_num = argc / 2, + # so one frame is two varargs: buffer pointer + buffer length. + tx_signature = '2U,\n frame.data(),\n frame.size()' + if tx_signature not in ptp: + raise SystemExit('PTP SOURCE timestamp TX must pass argc=2 for one buffer/length frame on ESP-IDF 5.5') + if tx_signature not in receiver: + raise SystemExit('PTP RECEIVER timestamp TX must pass argc=2 for one buffer/length frame on ESP-IDF 5.5') + if 'argc counts buffer/length pairs' in ptp or 'argc counts buffer/length pairs' in receiver: + raise SystemExit('stale/incorrect esp_eth_transmit_ctrl_vargs argc documentation remains') + if 'PTP hardware-timestamp TX failed:' not in ptp or 'without a valid hardware timestamp' not in ptp: + raise SystemExit('PTP SOURCE must distinguish driver TX errors from invalid timestamp evidence') + + # Admission truth is the stronger bounded runtime contract: explicit + # operator start is accepted only after real Announce + Sync + Follow_Up TX. + required = [ + 'const bool started = ar_ptp_lab_try_start(s_eth_handle);', + 'ar_esp32p4_restore_normal_emac();', + 'g_ptp_ready', + 'PTP source readiness timeout', + 'g_announce_sent.load', + 'g_sync_sent.load', + 'g_follow_up_sent.load', + 'return g_ptp_ready.load', + ] + combined = eth + '\n' + ptp + missing = [token for token in required if token not in combined] + if missing: + raise SystemExit('PTP verified-start contract missing: ' + ', '.join(missing)) + print('PTP contract: post-Ethernet start + ESP-IDF 5.5 argc=2 timestamp TX + verified core timing + rollback') + PY + - name: Enable guarded PTP-P2 profile shell: bash run: | diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index b43450b0c..5a734db97 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -8,10 +8,12 @@ on: - 'include/ariec61850/asn1/**' - 'include/ariec61850/ethernet/**' - 'include/ariec61850/mms/utc_time.hpp' + - 'include/ariec61850/time_sync/**' - 'include/ariec61850/sampled_values/**' - 'src/asn1/**' - 'src/ethernet/**' - 'src/mms/utc_time.cpp' + - 'src/time_sync/**' - 'src/sampled_values/**' - '.github/workflows/esp32p4-smv-injector.yml' pull_request: @@ -20,23 +22,30 @@ on: - 'include/ariec61850/asn1/**' - 'include/ariec61850/ethernet/**' - 'include/ariec61850/mms/utc_time.hpp' + - 'include/ariec61850/time_sync/**' - 'include/ariec61850/sampled_values/**' - 'src/asn1/**' - 'src/ethernet/**' - 'src/mms/utc_time.cpp' + - 'src/time_sync/**' - 'src/sampled_values/**' - '.github/workflows/esp32p4-smv-injector.yml' permissions: contents: read +concurrency: + group: esp32p4-smv-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: - ARSTACK_FIRMWARE_VERSION: '0.1.0' + ARSTACK_FIRMWARE_VERSION: '0.1.1' ARSTACK_PROTOCOL_VERSION: '1' jobs: build-esp32p4: runs-on: ubuntu-latest + timeout-minutes: 25 container: image: espressif/idf:v5.5.4 defaults: @@ -55,7 +64,20 @@ jobs: grep -qx 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' sdkconfig echo 'ESP32-P4 pre-v3 build policy: PASS' - - name: Verify realtime START and telemetry boundary policy + - name: Verify production PTP-P2 profile + run: | + set -euo pipefail + grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig + grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig + grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig + grep -qx 'CONFIG_AR_PTP_DOMAIN=0' sdkconfig + grep -qx 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0' sdkconfig + grep -qx 'CONFIG_AR_PTP_RESPOND_PDELAY=y' sdkconfig + grep -qx 'CONFIG_AR_PTP_SYNC_INTERVAL_MS=250' sdkconfig + grep -qx 'CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000' sdkconfig + echo 'Production timing profile: PASS · serialized shared EMAC TX · PTP-P2 LAB_SOURCE · HW timestamp adapter compiled' + + - name: Verify realtime START, transport health and telemetry boundary policy run: | set -euo pipefail python3 - <<'PY' @@ -74,7 +96,10 @@ jobs: assert 'g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed);' in source assert 'elapsed_ticks) * 1000000ULL' in source assert 'rate_baseline_valid = true;' in source - print('Realtime boundary policy: PASS - timer starts after formatting and telemetry rate is elapsed-time normalized') + assert 'kTransportFailureStopThreshold = 400U' in source + assert 'live_control_force_stop();' in source + assert 'SV transport fault:' in source + print('Realtime boundary policy: PASS - post-log timer start + elapsed-time telemetry + 100 ms fail-closed canonical TX') PY - name: Build firmware @@ -107,7 +132,7 @@ jobs: "chipRevisionPolicy": "pre-v3", "version": os.environ["ARSTACK_FIRMWARE_VERSION"], "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]), - "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS"], + "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], "flashOffset": 0, "image": image, "sha256": sha256, @@ -129,8 +154,10 @@ jobs: assert manifest['chipRevisionPolicy'] == 'pre-v3' assert manifest['flashOffset'] == 0 assert manifest['protocol'] == 1 + assert 'PTP-P2' in manifest['capabilities'] + assert 'SMPSYNCH-AUTO' in manifest['capabilities'] assert actual == manifest['sha256'] - print(f"Firmware package: PASS · {manifest['image']} · pre-v3 · SHA-256 {actual}") + print(f"Firmware package: PASS · {manifest['image']} · PTP-P2 · pre-v3 · SHA-256 {actual}") PY - name: Stage Studio firmware package @@ -147,4 +174,4 @@ jobs: name: arstack-esp32p4-smv-firmware path: embedded/esp32p4_smv_injector/build/studio-firmware/ if-no-files-found: error - retention-days: 14 + retention-days: 14 \ No newline at end of file diff --git a/.github/workflows/p0-ribbon-phase1-windows.yml b/.github/workflows/p0-ribbon-phase1-windows.yml new file mode 100644 index 000000000..fbbcf747e --- /dev/null +++ b/.github/workflows/p0-ribbon-phase1-windows.yml @@ -0,0 +1,39 @@ +name: P0 Ribbon Phase 1 Apply Windows + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + paths: + - '.github/workflows/p0-ribbon-phase1-windows.yml' + +permissions: + contents: write + +jobs: + apply: + runs-on: windows-2025 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + - name: Apply phase 1 ribbon patch + shell: powershell + run: | + python .github/scripts/p0_ribbon_phase1.py + if ($LASTEXITCODE -ne 0) { throw "Ribbon patch failed: $LASTEXITCODE" } + - name: Commit source changes + shell: powershell + run: | + git diff --quiet + if ($LASTEXITCODE -eq 0) { + Write-Host 'Phase 1 patch already present.' + exit 0 + } + git config user.name 'ARStack CI Repair' + git config user.email 'actions@users.noreply.github.com' + git add apps/arstack_studio + git commit -m 'ui: adopt modern tabbed ribbon and compact instrument shell' + git push origin HEAD:feature/ptp-production-v0.1.1 diff --git a/.github/workflows/p0-ribbon-phase1.yml b/.github/workflows/p0-ribbon-phase1.yml new file mode 100644 index 000000000..d2c848a4f --- /dev/null +++ b/.github/workflows/p0-ribbon-phase1.yml @@ -0,0 +1,35 @@ +name: P0 Ribbon Phase 1 Apply + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + paths: + - '.github/workflows/p0-ribbon-phase1.yml' + - '.github/scripts/p0_ribbon_phase1.py' + +permissions: + contents: write + +jobs: + apply: + runs-on: macos-15 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + - name: Apply phase 1 ribbon patch + run: python3 .github/scripts/p0_ribbon_phase1.py + - name: Commit source changes + run: | + if git diff --quiet; then + echo 'Phase 1 patch already present.' + exit 0 + fi + git config user.name 'ARStack CI Repair' + git config user.email 'actions@users.noreply.github.com' + git add apps/arstack_studio + git commit -m 'ui: adopt modern tabbed ribbon and compact instrument shell' + git push origin HEAD:feature/ptp-production-v0.1.1 diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 05f47d343..be5234e8f 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.24) -project(ARStackStudio VERSION 0.1.0 LANGUAGES CXX) +project(ARStackStudio VERSION 0.1.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -86,6 +86,9 @@ qt_add_qml_module(arstack_studio qml/DarkToolButton.qml qml/TelemetryDock.qml qml/WorkflowBar.qml + qml/ModernRibbon.qml + qml/RibbonGroup.qml + qml/RibbonAction.qml qml/CalmButton.qml qml/NumericField.qml qml/StateBadge.qml diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 4ce687be9..417f86d58 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -160,6 +160,7 @@ Item { } PtpExpertPanel { theme: hub.theme + controller: hub.controller device: hub.device session: hub.session uiFont: hub.uiFont diff --git a/apps/arstack_studio/qml/DockFrame.qml b/apps/arstack_studio/qml/DockFrame.qml index c559e5ca9..bc492d874 100644 --- a/apps/arstack_studio/qml/DockFrame.qml +++ b/apps/arstack_studio/qml/DockFrame.qml @@ -21,7 +21,7 @@ Rectangle { color: theme.surface radius: theme.panelRadius border.width: 1 - border.color: theme.line + border.color: theme.lineSoft clip: true ColumnLayout { @@ -30,8 +30,8 @@ Rectangle { Rectangle { Layout.fillWidth: true - Layout.preferredHeight: 34 - color: "#131b24" + Layout.preferredHeight: 30 + color: theme.surface2 MouseArea { anchors.fill: parent @@ -44,15 +44,9 @@ Rectangle { RowLayout { anchors.fill: parent anchors.leftMargin: 10 - anchors.rightMargin: 6 - spacing: 7 + anchors.rightMargin: 5 + spacing: 6 - Rectangle { - width: 3 - height: 16 - radius: 2 - color: theme.accent - } Label { Layout.fillWidth: true text: frame.titleText @@ -64,46 +58,35 @@ Rectangle { verticalAlignment: Text.AlignVCenter } - Rectangle { + Label { visible: frame.statusText.length > 0 - implicitWidth: statusLabel.implicitWidth + 12 - implicitHeight: 20 - radius: 5 - color: "#0d141c" - border.width: 1 - border.color: theme.lineSoft - - Label { - id: statusLabel - anchors.centerIn: parent - text: frame.statusText - color: theme.muted - font.family: frame.monoFont - font.pixelSize: theme.captionSize - 2 - font.weight: Font.DemiBold - verticalAlignment: Text.AlignVCenter - } + text: frame.statusText + color: theme.muted + font.family: frame.monoFont + font.pixelSize: Math.max(7, theme.captionSize - 1) + font.weight: Font.Medium + verticalAlignment: Text.AlignVCenter } DarkToolButton { visible: frame.detachable iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") - iconSize: 17 + iconSize: 15 theme: frame.theme uiFont: frame.uiFont onClicked: frame.detachRequested() ToolTip.visible: hovered - ToolTip.text: "Float this dock (double-click the title also works)" + ToolTip.text: "Float view" } DarkToolButton { visible: frame.closable iconSource: Qt.resolvedUrl("../assets/lucide/x.svg") - iconSize: 18 + iconSize: 16 theme: frame.theme uiFont: frame.uiFont onClicked: frame.closeRequested() ToolTip.visible: hovered - ToolTip.text: "Hide this dock; restore it from View > Docks" + ToolTip.text: "Hide view" } } diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 64a813992..a151ce9db 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -145,7 +145,7 @@ SurfacePanel { spacing: 1 Label { text: panel.firmware.bundleReady - ? "ARStack firmware v" + panel.firmware.firmwareVersion + " is verified in this Studio package" + ? "ARStack firmware v" + panel.firmware.firmwareVersion + " · build " + panel.firmware.firmwareBuildId + " is verified in this Studio package" : "Firmware package is not ready" color: panel.theme.text font.family: panel.uiFont @@ -265,7 +265,9 @@ SurfacePanel { Layout.fillWidth: true text: panel.firmware.busy ? "The session supervisor currently owns the firmware tool. Keep USB connected until Studio reports a terminal result." - : "When Studio reports Firmware required or Firmware update, use that guided action from the main workflow. It will stop output, obtain an acknowledged serial release, verify the ESP32-P4, then write." + : (panel.session && panel.session.firmwareUpdateAvailable + ? "The board firmware is compatible and remains usable. A newer build is available as an optional guided update." + : "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 font.pixelSize: 10 @@ -273,7 +275,28 @@ SurfacePanel { } } + CalmButton { + id: firmwareAction + theme: panel.theme + uiFont: panel.uiFont + implicitWidth: 172 + text: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable) + ? (panel.session.firmwareUpdateRequired ? "Update firmware" : "Update available") + : "Reinstall current" + visible: panel.session && + (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable || panel.session.firmwareReinstallAvailable) + enabled: visible && !panel.firmware.busy + tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal" + onClicked: { + if (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable) + panel.session.beginFirmwareUpdate() + else + panel.session.beginFirmwareReinstall() + } + } + Rectangle { + visible: !firmwareAction.visible implicitWidth: 150 implicitHeight: 34 radius: 7 diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index af7fd321e..775ac6adf 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -6,12 +6,12 @@ import ARStack.Studio 1.0 ApplicationWindow { id: root - width: 1480 - height: 900 - minimumWidth: 1080 - minimumHeight: 720 + width: 1240 + height: 760 + minimumWidth: 960 + minimumHeight: 620 visible: true - title: "ARStack Studio · SMV Injector" + title: "ARStack Studio · SMV + PTP · v" + Qt.application.version color: studioTheme.bg StudioTheme { id: studioTheme } @@ -44,14 +44,21 @@ ApplicationWindow { property bool waveformDetached: false property bool telemetryDockVisible: true property bool telemetryExpanded: false + property bool applicationShutdownRequested: false font.family: root.uiFont - // Graceful window close is still a safety boundary. QML submits only the - // Stop intent; the supervisor owns whether the current session may act. + // Primary-window close owns application exit. C++ teardown owns STOP, COM + // close and worker retirement; QML must not race it with another async STOP. onClosing: function(close) { - if (workflowBar.session && workflowBar.session.state === "RUNNING") - workflowBar.session.requestStop() + close.accepted = true + if (root.applicationShutdownRequested) return + root.applicationShutdownRequested = true + configurationWindow.hide() + detachedPhasorWindow.hide() + detachedWaveformWindow.hide() + // C++ owns process termination and bounded worker retirement. Do not + // race it with a second QML quit path. } FontLoader { @@ -59,11 +66,11 @@ ApplicationWindow { source: Qt.resolvedUrl("../assets/InterVariable.ttf") } - readonly property bool compactLayout: width < 1300 + readonly property bool compactLayout: width < 1120 readonly property bool canDeploy: workflowBar.session ? workflowBar.session.canDeployProfile : false readonly property bool canStart: workflowBar.session ? workflowBar.session.startReady : false - readonly property string toastMessage: transientMessage.length ? transientMessage : device.lastError - readonly property bool toastError: transientMessage.length ? transientError : device.lastError.length > 0 + readonly property string toastMessage: transientMessage + readonly property bool toastError: transientError SclProfileModel { id: sclProfiles } DeviceController { id: device } @@ -422,121 +429,42 @@ ApplicationWindow { } } - header: Rectangle { - height: 50 - color: studioTheme.chrome - border.width: 1 - border.color: studioTheme.lineSoft - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 15 - anchors.rightMargin: 15 - spacing: 9 - - Rectangle { - width: 30 - height: 30 - radius: 6 - color: "#101b27" - border.width: 1 - border.color: "#315071" - Image { - anchors.centerIn: parent - width: 17 - height: 17 - source: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") - sourceSize.width: 34 - sourceSize.height: 34 - } - } - - ColumnLayout { - spacing: 0 - Layout.alignment: Qt.AlignVCenter - Label { text: "ARSTACK61850"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.Bold; font.letterSpacing: 1.0; verticalAlignment: Text.AlignVCenter } - Label { text: "SMV Injector"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } - } - - Item { Layout.fillWidth: true } - Rectangle { - Layout.preferredWidth: root.compactLayout ? 170 : 210 - implicitHeight: 32 - radius: 7 - color: studioTheme.surface2 - border.width: 1 - border.color: workflowBar.smartStateColor - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 8 - Rectangle { width: 8; height: 8; radius: 4; color: workflowBar.smartStateColor } - Label { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - text: workflowBar.displayState - color: workflowBar.smartStateColor - font.family: root.uiFont - font.pixelSize: 9 - font.weight: Font.DemiBold - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - Label { - text: device.deviceVerified ? "•••" : "↻" - color: studioTheme.accent - font.family: root.uiFont - font.pixelSize: 13 - font.weight: Font.Bold - verticalAlignment: Text.AlignVCenter - } - } - MouseArea { - id: identityMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - enabled: !device.discovering - onClicked: device.deviceVerified - ? root.openConfiguration() - : workflowBar.session.requestConnect() - } - ToolTip.visible: identityMouse.containsMouse - ToolTip.text: workflowBar.session.statusText - ToolTip.delay: 450 - } - } - } footer: Rectangle { - height: 30 + height: 28 color: studioTheme.chrome border.width: 1 border.color: studioTheme.lineSoft RowLayout { anchors.fill: parent - anchors.leftMargin: 15 - anchors.rightMargin: 15 - spacing: 9 - Rectangle { width: 6; height: 6; radius: 3; color: workflowBar.smartStateColor } + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + Label { - Layout.fillWidth: true - text: workflowBar.session.statusText + text: device.deviceVerified ? device.portName + " · ESP32-P4" : "No device" + color: device.deviceVerified ? studioTheme.textSoft : studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 + font.weight: Font.Medium + } + Rectangle { width: 1; height: 12; color: studioTheme.lineSoft } + Label { + text: "4I + 4V · 4000 samples/s" color: studioTheme.muted font.family: root.uiFont - font.pixelSize: studioTheme.captionSize - elide: Text.ElideRight + font.pixelSize: 9 } + Item { Layout.fillWidth: true } Label { - visible: device.running - text: "FPS " + device.fps + " · MISSED " + device.missed + " · TX FAIL " + device.txFailures + visible: device.deviceVerified + text: "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures color: (Number(device.missed) > 0 || Number(device.txFailures) > 0) ? studioTheme.amber : studioTheme.textSoft font.family: root.monoFont - font.pixelSize: studioTheme.captionSize + font.pixelSize: 9 + font.weight: Font.Medium } } } @@ -666,14 +594,14 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent - anchors.margins: root.compactLayout ? 9 : 11 - spacing: root.compactLayout ? 9 : 11 + anchors.margins: 8 + spacing: 8 WorkflowBar { id: workflowBar Layout.fillWidth: true - Layout.preferredHeight: 72 - Layout.minimumHeight: 72 + Layout.preferredHeight: 54 + Layout.minimumHeight: 54 theme: studioTheme controller: root device: device @@ -729,26 +657,24 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent - anchors.margins: root.compactLayout ? 12 : 15 - spacing: 9 + anchors.margins: root.compactLayout ? 11 : 13 + spacing: 8 RowLayout { Layout.fillWidth: true ColumnLayout { spacing: 1 - Label { text: "INJECTION"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } - Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold } + Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } } Rectangle { Layout.fillWidth: true - height: 53 - radius: 7 - color: studioTheme.surface2 - border.width: 1 - border.color: studioTheme.lineSoft + height: 48 + radius: 8 + color: "#0b1219" + border.width: 0 RowLayout { anchors.fill: parent @@ -756,50 +682,48 @@ ApplicationWindow { anchors.rightMargin: 11 spacing: 8 - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "AC"; tone: root.signalFrequency > 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("AC") } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "DC"; tone: root.signalFrequency === 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("DC") } - Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } - - ColumnLayout { - spacing: 0 - Label { text: root.signalFrequency === 0 ? "DC MODE" : "FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } - RowLayout { - spacing: 5 - NumericField { - id: frequencyField - theme: studioTheme - monoFont: root.monoFont - compact: root.compactLayout - implicitWidth: 84 - text: "50.000" - suffixText: "Hz" - enabled: root.signalFrequency > 0 - validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } - onTextEdited: { - var value = root.parseOperatorNumber(text) - if (root.validFrequency(value)) { - invalidInput = false - root.signalFrequency = value - if (value > 0) root.previousAcFrequency = value - root.refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) - workflowBar.session.requestSetFrequency(value) - } else invalidInput = true - } - onEditingFinished: { - var value = root.parseOperatorNumber(text) - if (!root.validFrequency(value)) { - text = root.signalFrequency.toFixed(3) - invalidInput = false - root.showMessage("AC frequency must be greater than 0 and not exceed 1000 Hz.", true) - } else text = value.toFixed(3) - } - } + Label { + text: "Frequency" + color: studioTheme.textSoft + font.family: root.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + NumericField { + id: frequencyField + theme: studioTheme + monoFont: root.monoFont + compact: root.compactLayout + implicitWidth: 90 + text: "50.000" + suffixText: "Hz" + enabled: true + validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 } + onTextEdited: { + var value = root.parseOperatorNumber(text) + if (root.validFrequency(value)) { + invalidInput = false + root.signalFrequency = value + if (value > 0) root.previousAcFrequency = value + root.refreshPreview() + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestSetFrequency(value) + } else invalidInput = true + } + onEditingFinished: { + var value = root.parseOperatorNumber(text) + if (!root.validFrequency(value)) { + text = root.signalFrequency.toFixed(3) + invalidInput = false + root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true) + } else text = value.toFixed(3) } } - CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } - CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "0"; implicitWidth: 44; toolTipText: "DC"; onClicked: root.setFrequencyValue(0) } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } CheckBox { enabled: root.signalFrequency > 0 @@ -911,7 +835,7 @@ ApplicationWindow { SplitView.preferredHeight: 300 theme: studioTheme titleText: "Phasor" - statusText: "GENERATED" + statusText: "" uiFont: root.uiFont monoFont: root.monoFont detachable: true @@ -982,7 +906,7 @@ ApplicationWindow { } TelemetryDock { - visible: root.telemetryDockVisible + visible: root.telemetryDockVisible && root.telemetryExpanded SplitView.fillWidth: true SplitView.minimumHeight: root.telemetryExpanded ? 88 : 32 SplitView.maximumHeight: root.telemetryExpanded ? 320 : 32 @@ -1005,7 +929,7 @@ ApplicationWindow { StatusToast { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: 48 + anchors.bottomMargin: 38 theme: studioTheme uiFont: root.uiFont message: root.toastMessage diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml new file mode 100644 index 000000000..31f809305 --- /dev/null +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: bar + + property var theme + property var controller + property var workflow + property var session + property var device + property string uiFont: "Inter" + property bool compact: false + + implicitHeight: 52 + radius: 0 + color: "transparent" + border.width: 0 + + function reconnectOrOpenDevice() { + if (device.deviceVerified) + controller.openConfiguration() + else + session.requestConnect() + } + + function conciseState() { + if (session.state === "RUNNING") return "Running" + if (session.state === "READY") return "Ready" + if (session.state === "WAITING FOR DEVICE") return "Offline" + if (session.state === "DEVICE FOUND" || session.state === "CONNECTING" || session.state === "CHECKING DEVICE") return "Connecting" + if (session.state === "FIRMWARE REQUIRED") return "Firmware required" + if (session.state === "UPDATING FIRMWARE") return "Updating" + if (session.state === "UPDATE NEEDS BOOT") return "Bootloader" + if (session.state === "PREPARING 4I+4V") return "Preparing" + if (session.state === "PROFILE BLOCKED" || session.state === "PROFILE SYNC ERROR" || session.state === "SETUP ERROR") return "Attention" + return workflow.displayState + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 6 + + RowLayout { + Layout.preferredWidth: bar.compact ? 112 : 132 + Layout.alignment: Qt.AlignVCenter + spacing: 8 + + Rectangle { + width: 28 + height: 28 + radius: 7 + color: "#0d151d" + border.width: 1 + border.color: bar.theme.lineSoft + Image { + anchors.centerIn: parent + width: 15 + height: 15 + source: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") + sourceSize.width: 30 + sourceSize.height: 30 + } + } + + Label { + Layout.fillWidth: true + text: "SMV Injector" + color: bar.theme.text + font.family: bar.uiFont + font.pixelSize: 11 + font.weight: Font.DemiBold + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + } + + Rectangle { width: 1; height: 24; color: bar.theme.lineSoft } + + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Start" + tone: bar.session.startReady ? "success" : "neutral" + iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") + enabled: !bar.workflow.injectionRunning && !bar.session.updatingFirmware && !bar.session.updateNeedsBootloaderHelp + toolTipText: bar.workflow.startReason() + onClicked: bar.workflow.requestStart() + } + + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Stop" + tone: bar.workflow.injectionRunning ? "danger" : "neutral" + iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") + enabled: bar.workflow.injectionRunning && !bar.session.updatingFirmware + toolTipText: "Stop Sampled Values output" + onClicked: bar.workflow.requestStop() + } + + Rectangle { width: 1; height: 24; color: bar.theme.lineSoft } + + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: bar.compact ? "SCL" : "Open SCL" + iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") + enabled: bar.session.engineeringEditable + toolTipText: "Open IEC 61850 engineering configuration" + onClicked: bar.workflow.openEngineeringDialog() + } + + RibbonAction { + visible: !bar.compact + theme: bar.theme + uiFont: bar.uiFont + text: "4I+4V" + iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + enabled: bar.session.engineeringEditable + toolTipText: "Use the built-in 4I + 4V / 4000 samples/s profile" + onClicked: bar.workflow.useBuiltInProfile() + } + + RibbonAction { + visible: bar.session.firmwareUpdateRequired || bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable + theme: bar.theme + uiFont: bar.uiFont + tone: "warning" + text: bar.session.firmwareInstallRequired ? "Install firmware" + : bar.session.firmwareUpdateRequired ? "Firmware required" + : "Retry profile" + toolTipText: bar.session.firmwareInstallRequired ? "Install the bundled ARStack firmware" + : bar.session.firmwareUpdateRequired ? "Firmware update is required before injection" + : "Retry the 4I + 4V profile synchronization" + onClicked: { + if (bar.session.firmwareInstallRequired) bar.workflow.openInstallPrompt() + else if (bar.session.firmwareUpdateRequired) bar.workflow.openUpdatePrompt() + else bar.workflow.retryProfileSync() + } + } + + Item { Layout.fillWidth: true; Layout.minimumWidth: 8 } + + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Phasor" + checkable: true + checked: bar.controller.phasorDockVisible || bar.controller.phasorDetached + tone: checked ? "accent" : "neutral" + toolTipText: "Show or hide phasor preview" + onClicked: { + if (bar.controller.phasorDetached) bar.controller.phasorDetached = false + bar.controller.phasorDockVisible = checked + } + } + + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Wave" + visible: !bar.compact + checkable: true + checked: bar.controller.waveformDockVisible || bar.controller.waveformDetached + tone: checked ? "accent" : "neutral" + toolTipText: "Show or hide waveform preview" + onClicked: { + if (bar.controller.waveformDetached) bar.controller.waveformDetached = false + bar.controller.waveformDockVisible = checked + } + } + + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: bar.compact ? "" : "Advanced" + iconOnly: bar.compact + iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + tone: "neutral" + toolTipText: bar.session.firmwareUpdateAvailable + ? "Advanced controls · optional firmware update available" + : "Advanced device, firmware, waveform and timing controls" + onClicked: bar.controller.openConfiguration() + } + + Rectangle { + Layout.preferredWidth: bar.compact ? 92 : 112 + Layout.preferredHeight: 30 + Layout.alignment: Qt.AlignVCenter + radius: 15 + color: "#0a1016" + border.width: 1 + border.color: Qt.rgba(bar.workflow.smartStateColor.r, + bar.workflow.smartStateColor.g, + bar.workflow.smartStateColor.b, 0.48) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 9 + spacing: 6 + + Rectangle { + width: 6 + height: 6 + radius: 3 + color: bar.workflow.smartStateColor + } + + Label { + Layout.fillWidth: true + text: bar.conciseState() + color: bar.workflow.smartStateColor + font.family: bar.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + } + + MouseArea { + id: stateMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !bar.device.discovering + onClicked: bar.reconnectOrOpenDevice() + } + ToolTip.visible: stateMouse.containsMouse + ToolTip.text: bar.device.deviceVerified + ? bar.device.portName + " · ESP32-P4\n" + bar.session.statusText + : bar.session.statusText + ToolTip.delay: 420 + } + } +} diff --git a/apps/arstack_studio/qml/NumericField.qml b/apps/arstack_studio/qml/NumericField.qml index 91cf9c538..12c1887cc 100644 --- a/apps/arstack_studio/qml/NumericField.qml +++ b/apps/arstack_studio/qml/NumericField.qml @@ -18,11 +18,11 @@ TextField { selectionColor: theme.accent selectedTextColor: theme.bg font.family: monoFont - font.pixelSize: compact ? 12 : 14 + font.pixelSize: compact ? 12 : 13 font.weight: Font.DemiBold leftPadding: 10 - rightPadding: suffixText.length > 0 ? 34 : 10 - implicitHeight: compact ? 35 : 39 + rightPadding: suffixText.length > 0 ? 31 : 10 + implicitHeight: compact ? 34 : 36 Label { visible: field.suffixText.length > 0 @@ -32,15 +32,15 @@ TextField { text: field.suffixText color: field.activeFocus ? field.theme.textSoft : field.theme.muted font.family: field.monoFont - font.pixelSize: field.compact ? 9 : 10 + font.pixelSize: field.compact ? 9 : 9 font.weight: Font.Medium Behavior on color { ColorAnimation { duration: 90 } } } background: Rectangle { - radius: 7 - color: field.activeFocus ? "#0b141d" : (field.hovered ? "#121c26" : "#10171f") - border.width: field.activeFocus ? 2 : 1 + radius: 6 + color: field.activeFocus ? "#0b141d" : (field.hovered ? "#101922" : "#0d141b") + border.width: 1 border.color: field.invalidInput ? field.theme.red : field.activeFocus ? field.theme.accent : field.hovered ? "#334252" : field.theme.lineSoft diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index bb9f7a4d6..852e301c6 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -5,19 +5,14 @@ import QtQuick.Layouts SurfacePanel { id: panel + property var controller property var device property var session property string uiFont: "Inter" property string monoFont: "Inter" - property var syncValues: ["AUTO", "0", "1", "2"] property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] - - function syncIndex(mode) { - if (mode === "FORCE_0") return 1 - if (mode === "FORCE_1_LOCAL") return 2 - if (mode === "FORCE_2_GLOBAL") return 3 - return 0 - } + property bool advancedVisible: false + property bool startPending: false function roleIndex(role) { if (role === "RECEIVER") return 1 @@ -25,8 +20,11 @@ SurfacePanel { return 0 } - function showMetric(value, suffix) { - return value === "NA" ? "—" : value + suffix + function syncIndex(mode) { + if (mode === "FORCE_0") return 1 + if (mode === "FORCE_1_LOCAL") return 2 + if (mode === "FORCE_2_GLOBAL") return 3 + return 0 } function applyProfile() { @@ -43,230 +41,346 @@ SurfacePanel { }) } - readonly property string role: panel.device.ptpRole - readonly property bool receiverRole: role === "RECEIVER" - readonly property bool monitorRole: role === "MONITOR" - readonly property bool sourceRole: role === "SOURCE" + function startSelectedMode() { + if (!panel.session || !panel.device.ptpAvailable) return + var role = panel.roleValues[modeCombo.currentIndex] + if (!panel.session.requestPtpRole(role)) { + if (panel.controller) panel.controller.showMessage("PTP mode could not be selected.", true) + return + } + if (!panel.applyProfile()) { + if (panel.controller) panel.controller.showMessage("PTP settings were rejected before start.", true) + return + } + panel.startPending = true + if (!panel.session.requestStartPtp()) { + panel.startPending = false + if (panel.controller) panel.controller.showMessage("PTP start command could not be sent.", true) + } + } - ColumnLayout { - anchors.fill: parent - anchors.margins: 14 - spacing: 9 + readonly property string activeRole: panel.device.ptpRole + readonly property bool sourceRole: activeRole === "SOURCE" + readonly property bool receiverRole: activeRole === "RECEIVER" + readonly property bool sourceEvidence: Number(panel.device.ptpAnnounceSent) > 0 + && Number(panel.device.ptpSyncSent) > 0 + && Number(panel.device.ptpFollowUpSent) > 0 + && Number(panel.device.ptpTxFailures) === 0 + readonly property string headlineState: panel.startPending ? "STARTING" + : panel.device.ptpRunning + ? (panel.receiverRole ? panel.device.ptpDiscipline : panel.activeRole + " ACTIVE") + : panel.device.ptpStatus === "PTP start failed" ? "START FAILED" : "OFF" - RowLayout { - Layout.fillWidth: true - ColumnLayout { - spacing: 1 - Label { text: "PTP LAB TIMING"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } - Label { text: "Source, measured receiver & synchronization stimulus"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } - } - Item { Layout.fillWidth: true } - StateBadge { - theme: panel.theme - monoFont: panel.uiFont - state: panel.device.ptpRunning - ? (panel.receiverRole ? panel.device.ptpDiscipline : panel.role + " ACTIVE") - : panel.role + " STOPPED" - stateColor: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green - : panel.device.ptpDiscipline === "FAULT" ? panel.theme.red - : panel.device.ptpRunning ? panel.theme.amber : panel.theme.muted - } + Timer { + interval: 700 + repeat: true + running: panel.startPending || panel.device.ptpRunning + onTriggered: { + if (panel.session) panel.session.requestPtpRefresh() + if (panel.device.ptpRunning) panel.startPending = false } + } - Rectangle { - Layout.fillWidth: true - implicitHeight: 47 - radius: 7 - color: panel.theme.amberSoft - border.width: 1 - border.color: "#6a5529" - Label { - anchors.fill: parent - anchors.margins: 9 - text: "SOURCE emits lab timing. RECEIVER disciplines the ESP32-P4 IEEE1588 clock from external PTP. MONITOR is passive. Forced smpSynch 0/1/2 remain explicit lab simulation." - wrapMode: Text.WordWrap - color: panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 9 - } + Timer { + interval: 3500 + repeat: false + running: panel.startPending + onTriggered: { + panel.startPending = false + if (panel.session) panel.session.requestPtpRefresh() + if (!panel.device.ptpRunning && panel.controller) + panel.controller.showMessage("PTP did not reach verified transmit state. Check Ethernet link and retry.", true) } + } - RowLayout { - Layout.fillWidth: true - spacing: 10 - Label { text: "Operating role"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold } - ComboBox { - Layout.fillWidth: true - model: ["Lab source — emit timing", "Time receiver — measured lock", "Monitor — passive"] - currentIndex: panel.roleIndex(panel.role) - enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.session.updatingFirmware - font.family: panel.uiFont - font.pixelSize: 9 - onActivated: panel.session.requestPtpRole(panel.roleValues[currentIndex]) - } - Label { - Layout.preferredWidth: 230 - text: panel.receiverRole ? "AUTO follows measured lock evidence." - : panel.monitorRole ? "No clock discipline or AUTO promotion." - : "Conservative lab source; no traceability claim." - wrapMode: Text.WordWrap - color: panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 8 + Connections { + target: panel.device + function onPtpStateChanged() { + if (panel.device.ptpRunning) { + panel.startPending = false + if (panel.controller && panel.sourceRole && panel.sourceEvidence) + panel.controller.showMessage("PTP Source active · Announce + Sync + Follow_Up verified.", false) } } + } - GridLayout { - Layout.fillWidth: true - columns: 4 - columnSpacing: 10 - rowSpacing: 7 - Label { text: "Domain"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: domainField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpDomain === "-" ? "0" : panel.device.ptpDomain; validator: IntValidator { bottom: 0; top: 255 } } - Label { text: "transportSpecific"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: transportField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpTransportSpecific === "-" ? "0" : panel.device.ptpTransportSpecific; validator: IntValidator { bottom: 0; top: 15 } } - Label { text: "Announce interval"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } - Label { text: "Sync interval"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "250"; validator: IntValidator { bottom: 20; top: 5000 } } - CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } - Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } - } + ScrollView { + anchors.fill: parent + contentWidth: availableWidth + clip: true - CheckBox { - id: pdelayCheck - visible: panel.sourceRole - text: "Respond to peer-delay requests using hardware timestamps" - checked: true - font.family: panel.uiFont - font.pixelSize: 9 - } + ColumnLayout { + width: parent.width + spacing: 12 - Rectangle { - visible: !panel.sourceRole - Layout.fillWidth: true - implicitHeight: 79 - radius: 7 - color: panel.theme.panelAlt - border.width: 1 - border.color: panel.theme.lineSoft - GridLayout { - anchors.fill: parent - anchors.margins: 9 - columns: 4 - rowSpacing: 4 - columnSpacing: 12 - Label { text: "Discipline"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.device.ptpDiscipline; color: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green : panel.device.ptpDiscipline === "FAULT" ? panel.theme.red : panel.theme.amber; font.family: panel.monoFont; font.pixelSize: 10; font.weight: Font.DemiBold } - Label { text: "Selected source"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.device.ptpSource === "NONE" ? "—" : panel.device.ptpSource; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 9; elide: Text.ElideMiddle } - Label { text: "Offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.showMetric(panel.device.ptpOffsetNs, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } - Label { text: "Mean path delay"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.showMetric(panel.device.ptpPathDelayNs, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } - Label { text: "Path jitter"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.showMetric(panel.device.ptpJitterNs, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } - Label { text: "Clock correction"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.device.ptpFrequencyPpb + " ppb"; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 2 + Label { + text: "PTP TIMING" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + font.weight: Font.DemiBold + font.letterSpacing: 0.9 + } + Label { + text: "Network timing control" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 18 + font.weight: Font.DemiBold + } + } + Item { Layout.fillWidth: true } + StateBadge { + theme: panel.theme + monoFont: panel.uiFont + state: panel.headlineState + stateColor: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green + : panel.headlineState === "START FAILED" ? panel.theme.red + : panel.device.ptpRunning ? panel.theme.green + : panel.startPending ? panel.theme.amber : panel.theme.muted + } } - } - Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + Rectangle { + Layout.fillWidth: true + implicitHeight: 82 + radius: 8 + color: panel.theme.panelAlt + border.width: 1 + border.color: panel.theme.lineSoft - RowLayout { - Layout.fillWidth: true - spacing: 12 - ColumnLayout { + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 14 + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + Label { + text: "Timing mode" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + } + ComboBox { + id: modeCombo + Layout.fillWidth: true + model: ["Provide PTP timing", "Follow external PTP", "Monitor PTP only"] + currentIndex: panel.roleIndex(panel.activeRole) + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending && !panel.session.updatingFirmware + font.family: panel.uiFont + font.pixelSize: 10 + } + } + + ColumnLayout { + Layout.preferredWidth: 310 + spacing: 3 + Label { + text: modeCombo.currentIndex === 0 + ? "Board sends Announce + hardware-timestamped two-step Sync/Follow_Up." + : modeCombo.currentIndex === 1 + ? "Board follows an external PTP source and reports measured clock lock." + : "Board listens to PTP without transmitting or disciplining its clock." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + } + Label { + text: modeCombo.currentIndex === 0 + ? "PTP TX does not by itself change SV smpSynch." + : modeCombo.currentIndex === 1 + ? "AUTO smpSynch follows measured receiver evidence only." + : "Useful for observing timing traffic without changing the network." + wrapMode: Text.WordWrap + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + } + } + } + } + + RowLayout { Layout.fillWidth: true - spacing: 4 - Label { text: "SV smpSynch stimulus"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold } - ComboBox { - Layout.fillWidth: true - model: ["AUTO — measured PTP only", "0 — Not synchronized", "1 — Local synchronized (lab)", "2 — Global synchronized (lab)"] - currentIndex: panel.syncIndex(panel.device.smpSynchMode) + spacing: 8 + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + tone: "accent" + text: panel.startPending ? "Starting…" + : modeCombo.currentIndex === 0 ? "Start PTP Source" + : modeCombo.currentIndex === 1 ? "Start PTP Receiver" : "Start Monitor" + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending && !panel.session.updatingFirmware + onClicked: panel.startSelectedMode() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + tone: "danger" + text: "Stop PTP" + enabled: panel.session && (panel.device.ptpRunning || panel.startPending) && !panel.session.updatingFirmware + onClicked: { + panel.startPending = false + panel.session.requestStopPtp() + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Refresh" enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware + onClicked: panel.session.requestPtpRefresh() + } + Item { Layout.fillWidth: true } + CheckBox { + text: "Advanced settings" + checked: panel.advancedVisible + onToggled: panel.advancedVisible = checked font.family: panel.uiFont font.pixelSize: 9 - onActivated: panel.session.requestSmpSynch(panel.syncValues[currentIndex]) } } - StateBadge { - theme: panel.theme - monoFont: panel.uiFont - state: "smpSynch=" + panel.device.smpSynchValue - + (panel.device.smpSynchSimulated ? " SIMULATED" : panel.device.smpSynchMeasured ? " MEASURED" : " SAFE") - stateColor: panel.device.smpSynchSimulated ? panel.theme.amber - : panel.device.smpSynchMeasured ? panel.theme.green : panel.theme.muted - } - } - Label { - Layout.fillWidth: true - text: panel.device.smpSynchMode === "AUTO" && panel.receiverRole - ? "AUTO: LOCKED non-traceable → 1, LOCKED traceable → 2, bounded HOLDOVER → 1, otherwise 0." - : panel.device.smpSynchMode === "AUTO" - ? "AUTO remains 0 in SOURCE/MONITOR because those roles do not prove local clock discipline." - : "Forced 0/1/2 is a live receiver-condition stimulus independent of measured clock lock." - wrapMode: Text.WordWrap - color: panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 8 - } + Rectangle { + Layout.fillWidth: true + implicitHeight: 102 + radius: 8 + color: panel.sourceRole && panel.device.ptpRunning && panel.sourceEvidence ? "#10251d" : panel.theme.surface2 + border.width: 1 + border.color: panel.sourceRole && panel.device.ptpRunning && panel.sourceEvidence ? "#347a59" : panel.theme.lineSoft - GridLayout { - Layout.fillWidth: true - columns: 5 - columnSpacing: 12 - rowSpacing: 4 - Label { text: panel.sourceRole ? "Announce TX" : "Announce RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? "Sync TX" : "Sync RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? "FollowUp TX" : "FollowUp RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? "Pdelay TX" : "Pdelay RX / Req"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? "TX failures" : "Accepted / rejected"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? panel.device.ptpAnnounceSent : panel.device.ptpRxAnnounce; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.device.ptpSyncSent : panel.device.ptpRxSync; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.device.ptpFollowUpSent : panel.device.ptpRxFollowUp; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.device.ptpPdelayFrames : panel.device.ptpRxPdelay + " / " + panel.device.ptpPdelayRequests; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.device.ptpTxFailures : panel.device.ptpAccepted + " / " + panel.device.ptpRejected; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - } + GridLayout { + anchors.fill: parent + anchors.margins: 11 + columns: 5 + columnSpacing: 14 + rowSpacing: 6 - Item { Layout.fillHeight: true } + Label { text: panel.sourceRole ? "Announce TX" : "Announce RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.sourceRole ? "Sync TX" : "Sync RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.sourceRole ? "Follow_Up TX" : "Follow_Up RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.sourceRole ? "Pdelay TX" : "Pdelay RX / Req"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.sourceRole ? "TX failures" : "Accepted / rejected"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - RowLayout { - Layout.fillWidth: true - CalmButton { - theme: panel.theme - uiFont: panel.uiFont - text: "Refresh" - enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware - onClicked: panel.session.requestPtpRefresh() - } - Item { Layout.fillWidth: true } - CalmButton { - theme: panel.theme - uiFont: panel.uiFont - text: "Apply profile" - enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.session.updatingFirmware - onClicked: panel.applyProfile() + Label { text: panel.sourceRole ? panel.device.ptpAnnounceSent : panel.device.ptpRxAnnounce; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 15; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpSyncSent : panel.device.ptpRxSync; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 15; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpFollowUpSent : panel.device.ptpRxFollowUp; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 15; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpPdelayFrames : panel.device.ptpRxPdelay + " / " + panel.device.ptpPdelayRequests; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 15; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpTxFailures : panel.device.ptpAccepted + " / " + panel.device.ptpRejected; color: panel.sourceRole && Number(panel.device.ptpTxFailures) > 0 ? panel.theme.red : panel.theme.text; font.family: panel.monoFont; font.pixelSize: 15; font.weight: Font.DemiBold } + + Label { + Layout.columnSpan: 5 + text: panel.sourceRole && panel.device.ptpRunning && panel.sourceEvidence + ? "Verified transmit path: Announce + Sync + Follow_Up counters are advancing with zero TX failures." + : panel.sourceRole && panel.startPending + ? "Waiting for the first verified Announce + Sync + Follow_Up transmission…" + : panel.device.ptpStatus + color: panel.sourceRole && panel.device.ptpRunning && panel.sourceEvidence ? panel.theme.green : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + } + } } - CalmButton { - theme: panel.theme - uiFont: panel.uiFont - tone: "danger" - text: "Stop PTP" - enabled: panel.session && panel.device.ptpRunning && !panel.session.updatingFirmware - onClicked: panel.session.requestStopPtp() + + Rectangle { + visible: panel.receiverRole + Layout.fillWidth: true + implicitHeight: 88 + radius: 8 + color: panel.theme.panelAlt + border.width: 1 + border.color: panel.theme.lineSoft + + GridLayout { + anchors.fill: parent + anchors.margins: 10 + columns: 4 + Label { text: "Discipline"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.ptpDiscipline; color: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green : panel.theme.amber; font.family: panel.monoFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { text: "Selected source"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.ptpSource === "NONE" ? "—" : panel.device.ptpSource; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 9 } + Label { text: "Offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.ptpOffsetNs === "NA" ? "—" : panel.device.ptpOffsetNs + " ns"; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + Label { text: "Path delay"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.ptpPathDelayNs === "NA" ? "—" : panel.device.ptpPathDelayNs + " ns"; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + } } - CalmButton { - theme: panel.theme - uiFont: panel.uiFont - tone: "accent" - text: panel.receiverRole ? "Start receiver" : panel.monitorRole ? "Start monitor" : "Start source" - enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.session.updatingFirmware - onClicked: panel.session.requestStartPtp() + + ColumnLayout { + visible: panel.advancedVisible + Layout.fillWidth: true + spacing: 10 + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + Label { text: "Advanced PTP profile"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + + GridLayout { + Layout.fillWidth: true + columns: 4 + columnSpacing: 10 + rowSpacing: 7 + Label { text: "Domain"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: domainField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpDomain === "-" ? "0" : panel.device.ptpDomain; validator: IntValidator { bottom: 0; top: 255 } } + Label { text: "transportSpecific"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: transportField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpTransportSpecific === "-" ? "0" : String(Number(panel.device.ptpTransportSpecific)); validator: IntValidator { bottom: 0; top: 15 } } + Label { text: "Announce interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } + Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "250"; validator: IntValidator { bottom: 20; top: 5000 } } + CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } + Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } + CheckBox { id: pdelayCheck; text: "Peer-delay response"; checked: true; font.family: panel.uiFont; font.pixelSize: 9 } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Apply advanced settings" + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning + onClicked: panel.applyProfile() + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + Label { text: "SV synchronization flag · lab / interoperability"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + RowLayout { + Layout.fillWidth: true + ComboBox { + Layout.fillWidth: true + model: ["AUTO — measured PTP only", "0 — Not synchronized", "1 — Local synchronized (simulation)", "2 — Global synchronized (simulation)"] + currentIndex: panel.syncIndex(panel.device.smpSynchMode) + enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware + font.family: panel.uiFont + font.pixelSize: 9 + onActivated: panel.session.requestSmpSynch(["AUTO", "0", "1", "2"][currentIndex]) + } + StateBadge { + theme: panel.theme + monoFont: panel.uiFont + state: "smpSynch=" + panel.device.smpSynchValue + + (panel.device.smpSynchSimulated ? " SIMULATED" : panel.device.smpSynchMeasured ? " MEASURED" : " SAFE") + stateColor: panel.device.smpSynchSimulated ? panel.theme.amber : panel.device.smpSynchMeasured ? panel.theme.green : panel.theme.muted + } + } + Label { + Layout.fillWidth: true + text: "PTP Source transmission is not proof that the SV clock is externally synchronized. AUTO stays fail-closed unless receiver discipline is measured." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 8 + } } + + Item { Layout.minimumHeight: 8 } } } } diff --git a/apps/arstack_studio/qml/QualityEditor.qml b/apps/arstack_studio/qml/QualityEditor.qml index 72750e269..6864a3d0f 100644 --- a/apps/arstack_studio/qml/QualityEditor.qml +++ b/apps/arstack_studio/qml/QualityEditor.qml @@ -13,7 +13,7 @@ RowLayout { property real qualityValue: 0 property bool compact: false - spacing: 7 + spacing: 6 function hexText(value) { return "0x" + (Number(value) >>> 0).toString(16).padStart(8, "0").toUpperCase() @@ -41,7 +41,7 @@ RowLayout { if (q & (1 << 9)) parts.push("inaccurate") if (q & (1 << 11)) parts.push("test") if (q & (1 << 12)) parts.push("operator-blocked") - return parts.length ? parts.join(" · ") : "no detail flags" + return parts.length ? parts.join(" · ") : "" } function presetIndex(value) { @@ -71,17 +71,16 @@ RowLayout { } Label { - text: "QUALITY" + text: "Quality" color: editor.theme.muted font.family: editor.uiFont - font.pixelSize: 8 + font.pixelSize: 9 font.weight: Font.DemiBold - font.letterSpacing: 0.8 } Rectangle { - implicitHeight: 22 - implicitWidth: validityText.implicitWidth + 16 + implicitHeight: 20 + implicitWidth: validityText.implicitWidth + 14 radius: 5 color: editor.validityName(editor.qualityValue) === "GOOD" ? editor.theme.greenSoft : editor.validityName(editor.qualityValue) === "QUESTIONABLE" ? editor.theme.amberSoft : editor.theme.redSoft @@ -102,7 +101,7 @@ RowLayout { ComboBox { id: presetBox - implicitWidth: editor.compact ? 104 : 118 + implicitWidth: editor.compact ? 98 : 108 model: ["Good", "Invalid", "Questionable", "Test"] currentIndex: editor.presetIndex(editor.qualityValue) font.family: editor.uiFont @@ -115,7 +114,7 @@ RowLayout { theme: editor.theme monoFont: editor.monoFont compact: true - implicitWidth: editor.compact ? 106 : 120 + implicitWidth: editor.compact ? 100 : 112 Component.onCompleted: text = editor.hexText(editor.qualityValue) onEditingFinished: { var trimmed = text.trim() @@ -131,15 +130,17 @@ RowLayout { controller.showMessage("Quality must be a valid 32-bit value.", true) } } + ToolTip.visible: hovered && editor.flagSummary(editor.qualityValue).length > 0 + ToolTip.text: editor.flagSummary(editor.qualityValue) } Label { Layout.fillWidth: true - visible: !editor.compact + visible: !editor.compact && editor.flagSummary(editor.qualityValue).length > 0 text: editor.flagSummary(editor.qualityValue) elide: Text.ElideRight color: editor.theme.muted font.family: editor.uiFont - font.pixelSize: 9 + font.pixelSize: 8 } } diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml new file mode 100644 index 000000000..5826cd4f7 --- /dev/null +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Button { + id: control + + property var theme + property string uiFont: "Inter" + property string tone: "neutral" + property url iconSource + property bool large: false + property bool iconOnly: false + property string toolTipText: "" + + implicitWidth: iconOnly ? 32 : large ? Math.max(74, label.implicitWidth + 40) : Math.max(54, label.implicitWidth + (icon.visible ? 32 : 20)) + implicitHeight: large ? 38 : 32 + hoverEnabled: true + activeFocusOnTab: true + font.family: uiFont + font.pixelSize: large ? 11 : 9 + font.weight: Font.DemiBold + + readonly property color fillColor: !enabled ? "transparent" + : tone === "success" ? (hovered ? "#194433" : "#14372a") + : tone === "danger" ? (hovered ? "#46252d" : "#351e24") + : tone === "accent" ? (hovered ? "#15324d" : "#11283d") + : tone === "warning" ? (hovered ? "#41351f" : "#312919") + : checked ? "#132b42" + : hovered ? theme.raisedHover : "transparent" + readonly property color edgeColor: !enabled ? "transparent" + : tone === "success" ? "#2b684e" + : tone === "danger" ? "#713843" + : tone === "accent" ? "#2b567f" + : tone === "warning" ? "#715a2f" + : checked ? "#294f74" + : activeFocus ? theme.accent : "transparent" + readonly property color textColor: !enabled ? theme.muted2 + : tone === "success" ? "#d9f7e9" + : tone === "danger" ? "#ffdce1" + : tone === "accent" ? "#ddebff" + : tone === "warning" ? "#f0d49a" + : checked ? theme.text : theme.textSoft + + contentItem: Item { + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + + RowLayout { + id: row + anchors.centerIn: parent + spacing: control.iconOnly ? 0 : 6 + + Image { + id: icon + visible: control.iconSource.toString().length > 0 + source: control.iconSource + Layout.preferredWidth: control.large ? 17 : 14 + Layout.preferredHeight: Layout.preferredWidth + sourceSize.width: Layout.preferredWidth * 2 + sourceSize.height: Layout.preferredHeight * 2 + opacity: control.enabled ? 0.92 : 0.28 + } + + Text { + id: label + visible: !control.iconOnly + text: control.text + color: control.textColor + font: control.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + + background: Rectangle { + radius: 6 + color: control.fillColor + border.width: control.edgeColor === "transparent" ? 0 : 1 + border.color: control.edgeColor + Behavior on color { ColorAnimation { duration: 90 } } + Behavior on border.color { ColorAnimation { duration: 90 } } + } + + ToolTip.visible: hovered && toolTipText.length > 0 + ToolTip.text: toolTipText + ToolTip.delay: 420 +} diff --git a/apps/arstack_studio/qml/RibbonGroup.qml b/apps/arstack_studio/qml/RibbonGroup.qml new file mode 100644 index 000000000..b8b138aac --- /dev/null +++ b/apps/arstack_studio/qml/RibbonGroup.qml @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: group + + property var theme + property string uiFont: "Inter" + property string title: "" + default property alias content: actionRow.data + + implicitHeight: 66 + implicitWidth: Math.max(92, actionRow.implicitWidth + 18) + color: "transparent" + + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 1 + color: group.theme.lineSoft + opacity: 0.8 + } + + RowLayout { + id: actionRow + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: caption.top + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 4 + } + + Label { + id: caption + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 15 + text: group.title + color: group.theme.muted + font.family: group.uiFont + font.pixelSize: 8 + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } +} diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index dae10bc78..f00843460 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -18,10 +18,10 @@ Rectangle { property string unitText: "A RMS" property bool compact: false - color: theme.surface2 - radius: 9 + color: matrix.theme.surface2 + radius: 7 border.width: 1 - border.color: theme.lineSoft + border.color: matrix.theme.lineSoft function phaseColorFor(index) { return [theme.phaseA, theme.phaseB, theme.phaseC, theme.phaseN][index] @@ -42,32 +42,15 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 44 - Layout.leftMargin: 12 - Layout.rightMargin: 12 - spacing: 9 - - Rectangle { - width: 23 - height: 23 - radius: 6 - color: "#122033" - border.width: 1 - border.color: "#223955" - Label { - anchors.centerIn: parent - text: matrix.symbolText - color: matrix.theme.accent - font.family: matrix.monoFont - font.pixelSize: 11 - font.weight: Font.Bold - } - } + Layout.preferredHeight: 32 + Layout.leftMargin: 11 + Layout.rightMargin: 11 + spacing: 7 Label { text: matrix.titleText color: matrix.theme.text font.family: matrix.uiFont - font.pixelSize: 13 + font.pixelSize: 12 font.weight: Font.DemiBold verticalAlignment: Text.AlignVCenter } @@ -76,7 +59,7 @@ Rectangle { text: matrix.unitText color: matrix.theme.muted font.family: matrix.uiFont - font.pixelSize: 10 + font.pixelSize: 8 font.weight: Font.Medium verticalAlignment: Text.AlignVCenter } @@ -86,14 +69,40 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 28 - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 8 - Item { Layout.preferredWidth: 28 } - Label { text: "Channel"; Layout.preferredWidth: 46; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } - Label { text: "Value"; Layout.fillWidth: true; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } - Label { text: "Phase"; Layout.preferredWidth: matrix.compact ? 92 : 108; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Layout.preferredHeight: 20 + Layout.leftMargin: 9 + Layout.rightMargin: 9 + spacing: 6 + Item { Layout.preferredWidth: 25 } + Label { + text: "CH" + Layout.preferredWidth: 46 + color: matrix.theme.muted2 + font.family: matrix.monoFont + font.pixelSize: 7 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + Label { + text: "MAGNITUDE" + Layout.fillWidth: true + color: matrix.theme.muted2 + font.family: matrix.uiFont + font.pixelSize: 7 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + Label { + text: "ANGLE" + Layout.preferredWidth: matrix.compact ? 84 : 100 + color: matrix.theme.muted2 + font.family: matrix.uiFont + font.pixelSize: 7 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } } Repeater { @@ -116,10 +125,10 @@ Rectangle { property real pendingPhase: angle Layout.fillWidth: true - Layout.preferredHeight: matrix.compact ? 54 : 60 - color: signalRow.selected ? "#111c27" - : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#131f2b" - : rowHover.hovered ? "#101820" : "transparent" + Layout.preferredHeight: matrix.compact ? 40 : 42 + color: signalRow.selected ? "#101c27" + : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#0f1922" + : rowHover.hovered ? "#0d161e" : "transparent" Behavior on color { ColorAnimation { duration: 90 } } HoverHandler { id: rowHover } @@ -142,7 +151,8 @@ Rectangle { Rectangle { visible: signalRow.selected - width: 3 + width: 2 + opacity: 0.86 anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom @@ -151,12 +161,12 @@ Rectangle { RowLayout { anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 8 + anchors.leftMargin: 9 + anchors.rightMargin: 9 + spacing: 6 CheckBox { - Layout.preferredWidth: 28 + Layout.preferredWidth: 25 Layout.alignment: Qt.AlignVCenter checked: signalRow.channelEnabled ToolTip.visible: hovered @@ -174,12 +184,12 @@ Rectangle { Layout.preferredWidth: 46 Layout.alignment: Qt.AlignVCenter spacing: 6 - Rectangle { width: 8; height: 8; radius: 4; color: signalRow.phaseColor } + Rectangle { width: 6; height: 6; radius: 3; color: signalRow.phaseColor } Label { text: signalRow.sid color: signalRow.selected ? matrix.theme.text : matrix.theme.textSoft font.family: matrix.monoFont - font.pixelSize: 11 + font.pixelSize: 10 font.weight: Font.Bold verticalAlignment: Text.AlignVCenter } @@ -190,7 +200,7 @@ Rectangle { Layout.fillWidth: true theme: matrix.theme monoFont: matrix.monoFont - compact: matrix.compact + compact: true suffixText: matrix.groupIndex === 0 ? "A" : "V" text: signalRow.mag.toFixed(3) invalidInput: false @@ -237,10 +247,10 @@ Rectangle { NumericField { id: phaseField enabled: matrix.controller.signalFrequency > 0 - Layout.preferredWidth: matrix.compact ? 92 : 108 + Layout.preferredWidth: matrix.compact ? 84 : 100 theme: matrix.theme monoFont: matrix.monoFont - compact: matrix.compact + compact: true suffixText: "°" text: signalRow.angle.toFixed(2) invalidInput: false @@ -286,8 +296,11 @@ Rectangle { } Rectangle { + visible: signalRow.rowIndex < 3 anchors.left: parent.left + anchors.leftMargin: 9 anchors.right: parent.right + anchors.rightMargin: 9 anchors.bottom: parent.bottom height: 1 color: matrix.theme.lineSoft diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml index 5318ca9c2..3f26d8c3d 100644 --- a/apps/arstack_studio/qml/StudioTheme.qml +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -6,6 +6,7 @@ QtObject { readonly property color chrome: "#0c1117" readonly property color surface: "#11171f" readonly property color surface2: "#0e141b" + readonly property color panelAlt: "#101820" readonly property color raised: "#151d27" readonly property color raisedHover: "#1a2430" readonly property color line: "#26313d" @@ -34,14 +35,14 @@ QtObject { readonly property color plotGridStrong: "#334353" readonly property color plotSurface: "#0b1219" - // Shared density and typography tokens. Keep engineering data compact, - // but never depend on tiny text to make the workspace fit. - readonly property int captionSize: 10 - readonly property int labelSize: 11 - readonly property int bodySize: 12 - readonly property int subtitleSize: 15 - readonly property int titleSize: 20 - readonly property int controlHeight: 36 - readonly property int panelRadius: 10 + // Premium demo density: compact enough to read as a desktop instrument, + // while keeping engineering values comfortably legible at 100–150% DPI. + readonly property int captionSize: 9 + readonly property int labelSize: 10 + readonly property int bodySize: 11 + readonly property int subtitleSize: 14 + readonly property int titleSize: 18 + readonly property int controlHeight: 34 + readonly property int panelRadius: 8 readonly property int controlRadius: 7 } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index b99626fb6..7ad54c4df 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -26,6 +26,28 @@ SurfacePanel { smartSession.firmwareInstallRequired || smartSession.firmwareUpdateRequired || smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp readonly property bool injectionRunning: smartSession.state === "RUNNING" + readonly property int firmwareStageIndex: { + switch (smartSession.firmwareUpdateStage) { + case "prepare": return 0 + case "verify": return 1 + case "write": return 2 + case "reconnect": return 3 + default: return -1 + } + } + readonly property var firmwareStages: [ + { title: "Prepare session", detail: "Stop output and release the serial port" }, + { title: "Verify board", detail: "Confirm ESP32-P4 ROM identity" }, + { title: "Write & restart", detail: "Program the verified image and reset the board" }, + { title: "Reconnect & verify", detail: "Confirm the new ARStack semantic identity" } + ] + + function firmwareStageState(index) { + if (firmwareStageIndex < 0) return "pending" + if (index < firmwareStageIndex) return "done" + if (index === firmwareStageIndex) return "active" + return "pending" + } Settings { id: operatorSettings @@ -162,7 +184,7 @@ SurfacePanel { if (smartSession.state === "UPDATING FIRMWARE") return "Installing firmware" if (smartSession.state === "UPDATE NEEDS BOOT") return "Download mode required" if (smartSession.state === "PREPARING 4I+4V") return "Preparing" - if (smartSession.state === "READY") return "Ready to inject" + if (smartSession.state === "READY") return smartSession.firmwareUpdateAvailable ? "Ready · update available" : "Ready to inject" if (smartSession.state === "RUNNING") return "Injection running" if (smartSession.state === "PROFILE BLOCKED") return "Profile unavailable" if (smartSession.state === "PROFILE SYNC ERROR") return "Profile sync failed" @@ -277,6 +299,23 @@ SurfacePanel { controller.showMessage("Dock layout reset.", false) } + function openUpdatePrompt() { + updatePromptDeferred = false + updateDialog.open() + } + + function openInstallPrompt() { + installPromptDeferred = false + installDialog.open() + } + + function retryProfileSync() { + if (smartSession.retryProfileSync()) + controller.showMessage("Retrying 4I+4V profile synchronization.", false) + else + controller.showMessage(smartSession.statusText, true) + } + FileDialog { id: engineeringFileDialog title: "Open IEC 61850 engineering configuration" @@ -346,14 +385,14 @@ SurfacePanel { modal: true anchors.centerIn: Overlay.overlay width: 410 - title: "Firmware update required" + title: smartSession.firmwareUpdateRequired ? "Firmware update required" : "Firmware update available" closePolicy: Popup.NoAutoClose background: Rectangle { color: ribbon.theme.surface; radius: 10; border.width: 1; border.color: ribbon.theme.line } contentItem: ColumnLayout { spacing: 12 Label { Layout.fillWidth: true - text: "Update firmware to start injection" + text: smartSession.firmwareUpdateRequired ? "Update firmware to start injection" : "A newer firmware build is available" color: ribbon.theme.text font.family: ribbon.uiFont font.pixelSize: 14 @@ -362,7 +401,9 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "This ESP32-P4 is connected, but its ARStack firmware is older than this Studio build. Update now? Studio will flash, restart, reconnect, and verify it automatically." + text: smartSession.firmwareUpdateRequired + ? "This ESP32-P4 does not satisfy the current runtime contract. Update is required before injection." + : "The connected firmware is compatible and can be used now. A newer bundled build is available; update only when convenient. Studio will flash, restart, reconnect, and verify it automatically." color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 11 @@ -392,9 +433,26 @@ SurfacePanel { id: progressDialog modal: true anchors.centerIn: Overlay.overlay - width: 450 - title: smartSession.updateNeedsBootloaderHelp ? "Device needs Download mode" : "Installing firmware" + width: 500 closePolicy: Popup.NoAutoClose + padding: 18 + header: Rectangle { + implicitHeight: 46 + color: ribbon.theme.surface2 + border.width: 1 + border.color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.line + Label { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + text: smartSession.updateNeedsBootloaderHelp ? "Device needs Download mode" : "Firmware update" + color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 13 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + } background: Rectangle { color: ribbon.theme.surface radius: 10 @@ -402,18 +460,101 @@ SurfacePanel { border.color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.line } contentItem: ColumnLayout { - spacing: 12 + spacing: 13 + Label { Layout.fillWidth: true text: smartSession.updateNeedsBootloaderHelp - ? "Studio cannot reach the ESP32-P4 bootloader yet." - : "Keep USB connected. Studio verifies the board before reporting success." + ? "Studio cannot reach the ESP32-P4 ROM bootloader yet." + : "Keep USB connected. Studio reports success only after the board restarts and reconnects with the expected ARStack identity." color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.text font.family: ribbon.uiFont - font.pixelSize: 13 + font.pixelSize: 12 font.weight: Font.DemiBold wrapMode: Text.WordWrap } + + Rectangle { + Layout.fillWidth: true + implicitHeight: stageColumn.implicitHeight + 18 + radius: 8 + color: ribbon.theme.panelAlt + border.width: 1 + border.color: ribbon.theme.lineSoft + + ColumnLayout { + id: stageColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 9 + spacing: 2 + + Repeater { + model: ribbon.firmwareStages + delegate: RowLayout { + required property int index + required property var modelData + Layout.fillWidth: true + Layout.preferredHeight: 38 + spacing: 10 + readonly property string stageState: ribbon.firmwareStageState(index) + + Rectangle { + width: 20 + height: 20 + radius: 10 + color: parent.stageState === "done" ? ribbon.theme.greenSoft + : parent.stageState === "active" ? ribbon.theme.accentSoft + : "transparent" + border.width: 1 + border.color: parent.stageState === "done" ? ribbon.theme.green + : parent.stageState === "active" ? ribbon.theme.accent + : ribbon.theme.line + Label { + anchors.centerIn: parent + text: parent.parent.stageState === "done" ? "✓" : parent.parent.stageState === "active" ? "•" : "" + color: parent.parent.stageState === "done" ? ribbon.theme.green : ribbon.theme.accent + font.family: ribbon.uiFont + font.pixelSize: parent.parent.stageState === "active" ? 16 : 11 + font.weight: Font.Bold + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + Layout.fillWidth: true + text: modelData.title + color: stageState === "pending" ? ribbon.theme.muted : ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 10 + font.weight: stageState === "active" ? Font.DemiBold : Font.Medium + } + Label { + Layout.fillWidth: true + text: modelData.detail + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 9 + elide: Text.ElideRight + } + } + + Label { + visible: index === 2 && stageState === "active" && smartSession.firmwareProgress >= 0 + text: smartSession.firmwareProgress + "%" + color: ribbon.theme.accent + font.family: ribbon.monoFont + font.pixelSize: 11 + font.weight: Font.DemiBold + } + } + } + } + } + Label { Layout.fillWidth: true text: smartSession.updateStatus.length > 0 ? smartSession.updateStatus : "Preparing firmware installation…" @@ -422,26 +563,78 @@ SurfacePanel { font.pixelSize: 10 wrapMode: Text.WordWrap } - ProgressBar { - visible: !smartSession.updateNeedsBootloaderHelp - Layout.fillWidth: true - from: 0; to: 100 - indeterminate: smartSession.firmwareProgress < 0 - value: smartSession.firmwareProgress < 0 ? 0 : smartSession.firmwareProgress - } - Label { - visible: !smartSession.updateNeedsBootloaderHelp && smartSession.firmwareProgress >= 0 + + ColumnLayout { + visible: !smartSession.updateNeedsBootloaderHelp && ribbon.firmwareStageIndex === 2 Layout.fillWidth: true - text: smartSession.firmwareProgress + "%" - color: ribbon.theme.text - font.family: ribbon.monoFont - font.pixelSize: 12 - font.weight: Font.DemiBold - horizontalAlignment: Text.AlignHCenter + spacing: 6 + + RowLayout { + Layout.fillWidth: true + Label { + text: "Writing firmware" + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 9 + } + Item { Layout.fillWidth: true } + Label { + text: smartSession.firmwareProgress >= 0 ? smartSession.firmwareProgress + "%" : "Starting write…" + color: smartSession.firmwareProgress >= 0 ? ribbon.theme.accent : ribbon.theme.muted + font.family: ribbon.monoFont + font.pixelSize: 10 + font.weight: Font.DemiBold + } + } + + Rectangle { + id: flashTrack + Layout.fillWidth: true + height: 8 + radius: 4 + color: ribbon.theme.lineSoft + clip: true + Rectangle { + visible: smartSession.firmwareProgress >= 0 + height: parent.height + radius: parent.radius + color: ribbon.theme.accent + width: parent.width * Math.max(0, Math.min(1, smartSession.firmwareProgress / 100.0)) + Behavior on width { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + Rectangle { + id: indeterminateFlash + visible: smartSession.firmwareProgress < 0 + height: parent.height + width: Math.max(28, parent.width * 0.24) + radius: parent.radius + color: ribbon.theme.accent + opacity: 0.72 + NumberAnimation on x { + running: indeterminateFlash.visible + loops: Animation.Infinite + from: -indeterminateFlash.width + to: flashTrack.width + duration: 900 + easing.type: Easing.InOutQuad + } + } + } } + RowLayout { visible: smartSession.updateNeedsBootloaderHelp Layout.fillWidth: true + CalmButton { + visible: smartSession.firmwareUpdateCanCancel + theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Use current firmware"; implicitWidth: 150 + onClicked: { + if (smartSession.cancelFirmwareUpdate()) { + progressDialog.close() + controller.showMessage("Firmware update skipped. Reconnecting to the compatible firmware already on the board.", false) + } + } + } Item { Layout.fillWidth: true } CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Retry"; tone: "accent"; implicitWidth: 110; onClicked: smartSession.retryFirmwareUpdate() } } @@ -453,8 +646,25 @@ SurfacePanel { modal: true anchors.centerIn: Overlay.overlay width: 430 - title: ribbon.firmwareResultSuccess ? "Success" : "Firmware result" closePolicy: Popup.NoAutoClose + padding: 18 + header: Rectangle { + implicitHeight: 44 + color: ribbon.theme.surface2 + border.width: 1 + border.color: ribbon.firmwareResultSuccess ? ribbon.theme.green : ribbon.theme.red + Label { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + text: ribbon.firmwareResultSuccess ? "Firmware verified" : "Firmware result" + color: ribbon.firmwareResultSuccess ? ribbon.theme.green : ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 13 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + } background: Rectangle { color: ribbon.theme.surface radius: 10 @@ -500,211 +710,18 @@ SurfacePanel { } } - implicitHeight: 72 - color: theme.surface2 - border.color: theme.line - - component MenuButton: CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - implicitHeight: 24 - implicitWidth: 66 - font.pixelSize: 10 - } + implicitHeight: 54 + color: "transparent" + border.width: 0 - component RunButton: CalmButton { + ModernRibbon { + anchors.fill: parent theme: ribbon.theme + controller: ribbon.controller + workflow: ribbon + session: smartSession + device: ribbon.device uiFont: ribbon.uiFont - implicitHeight: 36 - implicitWidth: 112 - iconSize: 15 - font.pixelSize: 10 - } - - ColumnLayout { - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - anchors.topMargin: 5 - anchors.bottomMargin: 5 - spacing: 4 - - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 24 - spacing: 3 - - MenuButton { - text: "File" - onClicked: fileMenu.open() - Menu { - id: fileMenu - y: parent.height - MenuItem { text: "Open Engineering File…"; enabled: smartSession.engineeringEditable; onTriggered: ribbon.openEngineeringDialog() } - MenuItem { - text: "Reopen Last Configuration" - enabled: smartSession.engineeringEditable && operatorSettings.lastEngineeringUrl.length > 0 - onTriggered: ribbon.loadEngineeringFile(operatorSettings.lastEngineeringUrl) - } - MenuSeparator {} - MenuItem { text: "Use Built-in 4I+4V Profile"; enabled: smartSession.engineeringEditable; onTriggered: ribbon.useBuiltInProfile() } - MenuSeparator {} - MenuItem { text: "Exit"; onTriggered: Qt.quit() } - } - } - - MenuButton { - text: "Injection" - implicitWidth: 82 - onClicked: injectionMenu.open() - Menu { - id: injectionMenu - y: parent.height - MenuItem { text: "Start Injection F5"; enabled: !ribbon.injectionRunning; onTriggered: ribbon.requestStart() } - MenuItem { text: "Stop Injection F6"; enabled: ribbon.injectionRunning; onTriggered: ribbon.requestStop() } - MenuSeparator {} - MenuItem { text: "Balanced 3-Phase"; onTriggered: controller.balanced() } - MenuItem { text: "Zero All"; onTriggered: controller.zeroAll() } - } - } - - MenuButton { - text: "View" - onClicked: viewMenu.open() - Menu { - id: viewMenu - y: parent.height - Menu { - title: "Docks" - MenuItem { - text: (controller.phasorDockVisible || controller.phasorDetached ? "✓ " : " ") + "Phasor" - onTriggered: { - if (controller.phasorDetached) controller.phasorDetached = false - controller.phasorDockVisible = !controller.phasorDockVisible - } - } - MenuItem { - text: (controller.waveformDockVisible || controller.waveformDetached ? "✓ " : " ") + "Waveform" - onTriggered: { - if (controller.waveformDetached) controller.waveformDetached = false - controller.waveformDockVisible = !controller.waveformDockVisible - } - } - MenuItem { - text: (controller.telemetryDockVisible ? "✓ " : " ") + "Status Monitor" - onTriggered: controller.telemetryDockVisible = !controller.telemetryDockVisible - } - MenuSeparator {} - MenuItem { - text: "Dock All Floating Views" - enabled: controller.phasorDetached || controller.waveformDetached - onTriggered: { - if (controller.phasorDetached) { controller.phasorDetached = false; controller.phasorDockVisible = true } - if (controller.waveformDetached) { controller.waveformDetached = false; controller.waveformDockVisible = true } - } - } - MenuItem { text: "Reset Dock Layout"; onTriggered: ribbon.resetDockLayout() } - } - } - } - - MenuButton { - text: "Tools" - onClicked: toolsMenu.open() - Menu { - id: toolsMenu - y: parent.height - MenuItem { text: "Advanced…"; onTriggered: controller.openConfiguration() } - MenuItem { text: "Diagnostics…"; onTriggered: controller.openDiagnostics() } - } - } - - Item { Layout.fillWidth: true } - - Label { - visible: !ribbon.compact - text: operatorSettings.lastEngineeringUrl.length > 0 ? "Last configuration auto-opens" : "Built-in 4I+4V default" - color: ribbon.theme.muted - font.family: ribbon.uiFont - font.pixelSize: 9 - } - } - - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 8 - - Rectangle { width: 8; height: 8; radius: 4; color: ribbon.smartStateColor } - Label { - text: ribbon.displayState - color: ribbon.smartStateColor - font.family: ribbon.uiFont - font.pixelSize: 11 - font.weight: Font.DemiBold - } - Label { - Layout.fillWidth: true - visible: !ribbon.compact - text: smartSession.state === "READY" ? "4I + 4V · 4000 samples/s" : smartSession.statusText - color: ribbon.theme.muted - font.family: ribbon.uiFont - font.pixelSize: 10 - elide: Text.ElideRight - } - - CalmButton { - visible: smartSession.firmwareUpdateRequired && !smartSession.updatingFirmware - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "Update firmware" - tone: "accent" - implicitHeight: 32 - implicitWidth: 128 - onClicked: { ribbon.updatePromptDeferred = false; updateDialog.open() } - } - CalmButton { - visible: smartSession.firmwareInstallRequired && !smartSession.updatingFirmware - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "Install firmware" - tone: "accent" - implicitHeight: 32 - implicitWidth: 128 - onClicked: { ribbon.installPromptDeferred = false; installDialog.open() } - } - CalmButton { - visible: smartSession.profileSyncRetryAvailable - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "Retry profile" - tone: "accent" - implicitHeight: 32 - implicitWidth: 112 - onClicked: { - if (smartSession.retryProfileSync()) - controller.showMessage("Retrying 4I+4V profile synchronization.", false) - else - controller.showMessage(smartSession.statusText, true) - } - } - - RunButton { - text: "Start" - iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") - tone: smartSession.startReady ? "success" : "neutral" - enabled: !ribbon.injectionRunning && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp - toolTipText: ribbon.startReason() - onClicked: ribbon.requestStart() - } - RunButton { - text: "Stop" - iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") - tone: ribbon.injectionRunning ? "danger" : "neutral" - enabled: ribbon.injectionRunning && !smartSession.updatingFirmware - toolTipText: ribbon.injectionRunning ? "Stop Sampled Values output" : "Injection is not running" - onClicked: ribbon.requestStop() - } - } + compact: ribbon.compact } } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index fd0140d73..5c72ebe0a 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -28,7 +28,14 @@ class DeterministicSessionHarness final { const CaseResult cases[] = { {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, - {"auto identity timeout -> UNIDENTIFIED", automaticIdentityTimeoutStaysUnidentified()}, + {"same version missing build ID -> READY with update suggestion", sameVersionMissingBuildOffersUpdate()}, + {"same version stale build -> READY with update suggestion", sameVersionStaleBuildOffersUpdate()}, + {"optional bootloader wait -> cancel restores supervisor", optionalBootloaderWaitCanCancel()}, + {"firmware stage projection -> supervisor-owned", firmwareStageProjectionIsExplicit()}, + {"fragmented espflash progress -> monotonic", fragmentedEspflashProgressIsMonotonic()}, + {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, + {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, + {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, {"manual recovery intent survives IDENTIFY -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, @@ -70,6 +77,9 @@ class DeterministicSessionHarness final { bool profileReady{false}; Fixture() { + firmware.firmwareVersion_ = QStringLiteral(ARSTACK_STUDIO_VERSION); + firmware.firmwareBuildId_ = QStringLiteral("0123456789abcdef"); + firmware.bundleReady_ = true; profileReady = profiles.loadReferenceTemplate(); session.setProfiles(&profiles); session.setFirmware(&firmware); @@ -93,18 +103,23 @@ class DeterministicSessionHarness final { static DeviceIdentity identity( const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), - const QString& bootId = QStringLiteral("0123456789ABCDEF")) { + const QString& bootId = QStringLiteral("0123456789ABCDEF"), + const QString& buildId = QStringLiteral("0123456789abcdef")) { DeviceIdentity result; result.product = QStringLiteral("SMV-INJECTOR"); result.target = QStringLiteral("ESP32-P4"); result.protocolVersion = QStringLiteral("1"); result.deviceId = deviceId; result.firmwareVersion = firmware; + result.buildId = buildId; result.bootId = bootId; result.capabilities = { QStringLiteral("SMV-4I4V"), + QStringLiteral("PROFILE"), QStringLiteral("LIVE-SETPOINTS"), - QStringLiteral("SESSION-LEASE")}; + QStringLiteral("SESSION-LEASE"), + QStringLiteral("PTP-P2"), + QStringLiteral("SMPSYNCH-AUTO")}; return result; } @@ -123,6 +138,7 @@ class DeterministicSessionHarness final { session.blankBoardDetected_ = false; session.blankBoardPort_.clear(); session.updateRequested_ = false; + session.updateWasOptional_ = false; session.updateStage_ = SmartSessionController::UpdateStage::idle; session.portOwner_ = SmartSessionController::PortOwner::deviceSession; session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::idle; @@ -245,6 +261,7 @@ class DeterministicSessionHarness final { seedVerified(fixture, identity(), QStringLiteral("COM7"), false); return fixture.session.state() == QStringLiteral("READY") && fixture.session.startReady() && + fixture.session.firmwareReinstallAvailable() && !fixture.session.firmwareInstallVisible() && !fixture.session.firmwareUpdateRequired(); } @@ -262,15 +279,168 @@ class DeterministicSessionHarness final { !fixture.session.startReady(); } - static bool automaticIdentityTimeoutStaysUnidentified() { + static bool sameVersionMissingBuildOffersUpdate() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto stale = identity(); + stale.buildId.clear(); + seedVerified(fixture, stale, QStringLiteral("COM7"), false); + return fixture.session.state() == QStringLiteral("READY") && + !fixture.session.firmwareUpdateRequired() && + fixture.session.firmwareUpdateAvailable() && + !fixture.session.firmwareReinstallAvailable() && + fixture.session.startReady(); + } + + static bool sameVersionStaleBuildOffersUpdate() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto stale = identity(); + stale.buildId = QStringLiteral("fedcba9876543210"); + seedVerified(fixture, stale, QStringLiteral("COM7"), false); + return fixture.session.state() == QStringLiteral("READY") && + !fixture.session.firmwareUpdateRequired() && + fixture.session.firmwareUpdateAvailable() && + !fixture.session.firmwareReinstallAvailable() && + fixture.session.startReady(); + } + + static bool optionalBootloaderWaitCanCancel() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto stale = identity(); + stale.buildId = QStringLiteral("fedcba9876543210"); + seedVerified(fixture, stale, QStringLiteral("COM7"), false); + auto& session = fixture.session; + auto& device = fixture.device; + + session.started_ = false; // deterministic: exercise state recovery without real COM discovery. + session.updateRequested_ = true; + session.updateWasOptional_ = true; + session.updateStage_ = SmartSessionController::UpdateStage::waitingForBootloader; + session.portOwner_ = SmartSessionController::PortOwner::firmwareTool; + device.connected_ = false; + device.deviceVerified_ = false; + + const bool cancelled = session.cancelFirmwareUpdate(); + return cancelled && !session.updateRequested_ && !session.updateWasOptional_ && + session.updateStage_ == SmartSessionController::UpdateStage::idle && + session.portOwner_ == SmartSessionController::PortOwner::none && + !session.setupError_; + } + + static bool firmwareStageProjectionIsExplicit() { + Fixture fixture; + auto& session = fixture.session; + session.updateStage_ = SmartSessionController::UpdateStage::idle; + const bool idle = session.firmwareUpdateStage() == QStringLiteral("idle"); + session.updateStage_ = SmartSessionController::UpdateStage::stopping; + const bool stopping = session.firmwareUpdateStage() == QStringLiteral("prepare"); + session.updateStage_ = SmartSessionController::UpdateStage::releasingPort; + const bool releasing = session.firmwareUpdateStage() == QStringLiteral("prepare"); + session.updateStage_ = SmartSessionController::UpdateStage::probing; + const bool probing = session.firmwareUpdateStage() == QStringLiteral("verify"); + session.updateStage_ = SmartSessionController::UpdateStage::flashing; + const bool flashing = session.firmwareUpdateStage() == QStringLiteral("write"); + session.updateStage_ = SmartSessionController::UpdateStage::reconnecting; + const bool reconnecting = session.firmwareUpdateStage() == QStringLiteral("reconnect"); + session.updateStage_ = SmartSessionController::UpdateStage::waitingForBootloader; + const bool bootloader = session.firmwareUpdateStage() == QStringLiteral("verify"); + return idle && stopping && releasing && probing && flashing && reconnecting && bootloader; + } + + static bool fragmentedEspflashProgressIsMonotonic() { + Fixture fixture; + auto& firmware = fixture.firmware; + firmware.operation_ = FirmwareManager::Operation::flash; + firmware.flashProgress_ = -1; + firmware.progressOutputTail_.clear(); + + firmware.updateProgressFromOutput(QString::fromLatin1("\x1B[2K\r[00:00:01] [================] 4")); + const bool remainsUnknown = firmware.flashProgress_ == -1; + firmware.updateProgressFromOutput(QStringLiteral("2/100 segment 0x0\r")); + const bool reconstructsSplitCounter = firmware.flashProgress_ == 42; + firmware.updateProgressFromOutput(QStringLiteral("[00:00:02] [========] 21/100 segment 0x0\r")); + const bool neverMovesBackward = firmware.flashProgress_ == 42; + firmware.updateProgressFromOutput(QStringLiteral("[00:00:03] [========================================] 100/100 segment 0x0\r")); + const bool reachesCompletion = firmware.flashProgress_ == 100; + + return remainsUnknown && reconstructsSplitCounter && neverMovesBackward && reachesCompletion && + FirmwareManager::parseFlashProgress(QStringLiteral("Writing 64%\r")) == 64 && + FirmwareManager::parseFlashProgress(QStringLiteral("[00:00:02] [================] 17/20 segment 0x10000")) == 85 && + FirmwareManager::parseFlashProgress(QStringLiteral("21/0 segment 0x0")) == -1; + } + + static bool automaticIdentityTimeoutOffersFirmwareRecovery() { Fixture fixture; if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; - return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && - !fixture.session.firmwareInstallVisible() && - !fixture.session.blankBoardDetected_ && + return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && + fixture.session.firmwareInstallVisible() && + fixture.session.blankBoardDetected_ && + fixture.session.blankBoardPort_ == QStringLiteral("COM7") && !fixture.session.startReady(); } + static bool singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto& device = fixture.device; + auto& session = fixture.session; + quiesce(session, device); + session.clearBlankBoardContext(); + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.recoveryPending_ = false; + + device.ports_ = {QStringLiteral("COM7")}; + device.recommendedPort_.clear(); + device.recoveryCandidatePort_ = QStringLiteral("COM7"); + device.portName_ = QStringLiteral("COM7"); + device.connected_ = false; + device.discovering_ = false; + device.deviceVerified_ = false; + device.identificationState_ = DeviceController::IdentificationState::Unidentified; + device.identifyAttempts_ = DeviceController::identityMaxAttempts(); + device.identity_ = {}; + emit device.portsChanged(); + emit device.identificationStateChanged(); + session.reconcile(); + + return session.state() == QStringLiteral("FIRMWARE REQUIRED") && + session.firmwareInstallVisible() && + session.blankBoardDetected_ && + session.blankBoardPort_ == QStringLiteral("COM7") && + !session.startReady(); + } + + static bool ambiguousIdentityTimeoutStaysUnidentified() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto& device = fixture.device; + auto& session = fixture.session; + quiesce(session, device); + session.clearBlankBoardContext(); + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.recoveryPending_ = false; + + device.ports_ = {QStringLiteral("COM7"), QStringLiteral("COM8")}; + device.recommendedPort_.clear(); + device.portName_ = QStringLiteral("COM7"); + device.connected_ = false; + device.discovering_ = false; + device.deviceVerified_ = false; + device.identificationState_ = DeviceController::IdentificationState::Unidentified; + device.identifyAttempts_ = DeviceController::identityMaxAttempts(); + device.identity_ = {}; + emit device.portsChanged(); + emit device.identificationStateChanged(); + session.reconcile(); + + return session.state() == QStringLiteral("UNIDENTIFIED") && + !session.firmwareInstallVisible() && + !session.blankBoardDetected_ && + !session.startReady(); + } + static bool manualRecoveryIntentArmsFirmwareOffer() { Fixture fixture; if (!fixture.profileReady || !seedIdentityTimeout(fixture, true)) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index ae72cf0e9..ad6eb65a1 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -5,8 +5,11 @@ #include "DeviceIoWorker.hpp" #include +#include #include #include +#include +#include #include #include @@ -33,6 +36,7 @@ const QRegularExpression kIdentityExpression{ QStringLiteral( "ARSTACK identity product=([A-Z0-9_-]+) target=([A-Z0-9_-]+) protocol=(\\d+) " "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\-]+) " + "(?:build=([A-Fa-f0-9]{16}) )?" "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\-]+)"), QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kPtpStatusExpression{ @@ -73,19 +77,77 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { } DeviceController::~DeviceController() { - if (ioWorker_ == nullptr || !ioThread_.isRunning()) return; + static_cast(shutdown()); +} + +bool DeviceController::shutdown() { + if (shutdownComplete_) return true; + if (shuttingDown_) return !ioThread_.isRunning(); + shuttingDown_ = true; + pendingAutoDetect_ = false; + pendingConnectPort_.clear(); + + if (ioWorker_ == nullptr || !ioThread_.isRunning()) { + ioWorker_ = nullptr; + ioWorkerReady_ = false; + ioWorkerAffinityValid_ = false; + shutdownComplete_ = true; + shuttingDown_ = false; + return true; + } const bool requestStop = running_; if (QThread::currentThread() == &ioThread_) { ioWorker_->shutdown(requestStop); - } else { - QMetaObject::invokeMethod( - ioWorker_, - [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); }, - Qt::BlockingQueuedConnection); + ioThread_.quit(); + shuttingDown_ = false; + return false; + } + + auto acknowledged = QSharedPointer::create(); + DeviceIoWorker* const worker = ioWorker_; + const bool queued = QMetaObject::invokeMethod( + worker, + [worker, requestStop, acknowledged] { + worker->shutdown(requestStop); + acknowledged->release(); + }, + Qt::QueuedConnection); + bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs()); + if (!graceful) { + qCritical().noquote() + << "Device I/O shutdown acknowledgement timed out; entering bounded retirement fallback."; } + ioThread_.quit(); - ioThread_.wait(); + bool joined = ioThread_.wait(shutdownJoinTimeoutMs()); + if (!joined) { + graceful = false; + qCritical().noquote() + << "Device I/O thread missed primary join deadline; retrying bounded retirement."; + ioThread_.requestInterruption(); + ioThread_.quit(); + joined = ioThread_.wait(shutdownRetryTimeoutMs()); + } + if (!joined) { + // Emergency process-exit containment only. Normal CI must never reach this. + graceful = false; + qCritical().noquote() + << "Device I/O thread still alive; emergency terminate fallback engaged."; + ioThread_.terminate(); + joined = ioThread_.wait(shutdownForceTimeoutMs()); + } + + ioWorker_ = nullptr; + ioWorkerReady_ = false; + ioWorkerAffinityValid_ = false; + connected_ = false; + running_ = false; + profileDeploying_ = false; + shutdownComplete_ = joined; + shuttingDown_ = false; + if (!joined) qCritical().noquote() << "Device I/O thread could not be retired before exit."; + return graceful && joined; } void DeviceController::connectWorkerSignals() { @@ -183,6 +245,14 @@ void DeviceController::connectWorkerSignals() { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; lastIdentificationPort_ = port; identifyAttempts_ = attempts; + const QString timedOutPort = port.trimmed(); + const bool singleVisiblePort = ports_.size() == 1 && + ports_.constFirst().compare(timedOutPort, Qt::CaseInsensitive) == 0; + const bool recommendedCandidate = !recommendedPort_.isEmpty() && + recommendedPort_.compare(timedOutPort, Qt::CaseInsensitive) == 0; + if (!timedOutPort.isEmpty() && (singleVisiblePort || recommendedCandidate)) { + recoveryCandidatePort_ = timedOutPort; + } if (continuing) { setIdentificationState(IdentificationState::Identifying); setDiscoveryState(QStringLiteral("Checking connected devices for an ARStack injector..."), true); @@ -243,6 +313,7 @@ void DeviceController::connectWorkerSignals() { QStringList DeviceController::ports() const { return ports_; } QString DeviceController::recommendedPort() const { return recommendedPort_; } +QString DeviceController::recoveryCandidatePort() const { return recoveryCandidatePort_; } QString DeviceController::discoveryStatus() const { return discoveryStatus_; } bool DeviceController::discovering() const noexcept { return discovering_; } bool DeviceController::deviceVerified() const noexcept { return deviceVerified_; } @@ -253,6 +324,7 @@ QString DeviceController::deviceTarget() const { return identity_.target; } QString DeviceController::deviceId() const { return identity_.deviceId; } QString DeviceController::protocolVersion() const { return identity_.protocolVersion; } QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; } +QString DeviceController::firmwareBuildId() const { return identity_.buildId; } QString DeviceController::bootId() const { return identity_.bootId; } QStringList DeviceController::capabilities() const { return identity_.capabilities; } DeviceIdentity DeviceController::deviceIdentity() const { return identity_; } @@ -295,8 +367,9 @@ bool DeviceController::parseIdentityLine(const QString& line, DeviceIdentity& id parsed.protocolVersion = match.captured(3); parsed.deviceId = match.captured(4).toUpper(); parsed.firmwareVersion = match.captured(5); - parsed.bootId = match.captured(6).toUpper(); - parsed.capabilities = match.captured(7).split(QLatin1Char(','), Qt::SkipEmptyParts); + parsed.buildId = match.captured(6).toLower(); + parsed.bootId = match.captured(7).toUpper(); + parsed.capabilities = match.captured(8).split(QLatin1Char(','), Qt::SkipEmptyParts); for (QString& capability : parsed.capabilities) capability = capability.trimmed().toUpper(); parsed.capabilities.removeDuplicates(); @@ -313,15 +386,21 @@ bool DeviceController::parseIdentityLine(const QString& line, DeviceIdentity& id bool DeviceController::identitySupportsCurrentContract( const DeviceIdentity& identity, - const QString& expectedFirmwareVersion) { + const QString& expectedFirmwareVersion, + const QString& expectedBuildId) { static const QStringList requiredCapabilities{ QStringLiteral("SMV-4I4V"), + QStringLiteral("PROFILE"), QStringLiteral("LIVE-SETPOINTS"), - QStringLiteral("SESSION-LEASE")}; + QStringLiteral("SESSION-LEASE"), + QStringLiteral("PTP-P2"), + QStringLiteral("SMPSYNCH-AUTO")}; + const QString wantedBuild = expectedBuildId.trimmed().toLower(); if (identity.product != QStringLiteral("SMV-INJECTOR") || identity.target != QStringLiteral("ESP32-P4") || identity.protocolVersion != QStringLiteral("1") || identity.firmwareVersion != expectedFirmwareVersion || + (!wantedBuild.isEmpty() && identity.buildId.compare(wantedBuild, Qt::CaseInsensitive) != 0) || identity.bootId.size() != 16) { return false; } @@ -406,6 +485,9 @@ void DeviceController::handlePortSnapshot( ports_ = ports; recommendedPort_ = recommendedPort; highConfidenceCount_ = highConfidenceCount; + if (!recoveryCandidatePort_.isEmpty() && !ports_.contains(recoveryCandidatePort_)) { + recoveryCandidatePort_.clear(); + } if (identificationState_ == IdentificationState::Unidentified && !lastIdentificationPort_.isEmpty() && !ports_.contains(lastIdentificationPort_)) { @@ -438,6 +520,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto connected_ = true; portName_ = portName; lastIdentificationPort_ = portName; + recoveryCandidatePort_.clear(); identifyAttempts_ = 0; deviceVerified_ = false; clearIdentity(); @@ -749,6 +832,7 @@ void DeviceController::clearIdentity() { void DeviceController::markDeviceVerified() { if (deviceVerified_) return; deviceVerified_ = true; + recoveryCandidatePort_.clear(); lastIdentificationPort_ = portName_; setIdentificationState(IdentificationState::Verified); setDiscoveryState(QStringLiteral("ARStack ESP32-P4 identity verified."), false); @@ -924,6 +1008,21 @@ void DeviceController::processLine(const QString& rawLine) { emit deviceMessage(QStringLiteral("PTP expert profile accepted.")); static_cast(sendPtpShow()); } + if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) { + ptpStatus_ = ptpRole_ == QStringLiteral("SOURCE") ? QStringLiteral("Source TX verified") : QStringLiteral("Timing runtime started"); + emit deviceMessage(ptpRole_ == QStringLiteral("SOURCE") + ? QStringLiteral("PTP source start verified; reading live TX counters.") + : QStringLiteral("PTP %1 runtime started.").arg(ptpRole_.toLower())); + static_cast(sendPtpShow()); + } + if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) { + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("PTP start failed"); + emit ptpStateChanged(); + setError(ptpRole_ == QStringLiteral("SOURCE") + ? QStringLiteral("PTP source did not emit Announce/Sync/Follow_Up. Check the Ethernet link and retry Start PTP Source.") + : QStringLiteral("PTP %1 could not start. Check the Ethernet link and retry.").arg(ptpRole_.toLower())); + } if (line.contains(QStringLiteral("PROFILE commit rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PROFILE rejected"), Qt::CaseInsensitive)) { diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 661f64df0..ec4bd733f 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -15,12 +15,13 @@ struct DeviceIdentity final { QString protocolVersion; QString deviceId; QString firmwareVersion; + QString buildId; QString bootId; QStringList capabilities; [[nodiscard]] bool empty() const noexcept { return product.isEmpty() && target.isEmpty() && protocolVersion.isEmpty() && - deviceId.isEmpty() && firmwareVersion.isEmpty() && bootId.isEmpty() && + deviceId.isEmpty() && firmwareVersion.isEmpty() && buildId.isEmpty() && bootId.isEmpty() && capabilities.isEmpty(); } @@ -41,6 +42,7 @@ class DeviceController : public QObject { Q_PROPERTY(QString deviceId READ deviceId NOTIFY deviceIdentityChanged) Q_PROPERTY(QString protocolVersion READ protocolVersion NOTIFY deviceIdentityChanged) Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY deviceIdentityChanged) Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged) Q_PROPERTY(QStringList capabilities READ capabilities NOTIFY deviceIdentityChanged) Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) @@ -100,8 +102,17 @@ class DeviceController : public QObject { explicit DeviceController(QObject* parent = nullptr); ~DeviceController() override; + // Application-lifetime boundary required by AGENTS.md section 11. + // Returns false if an emergency retirement fallback was required. + bool shutdown(); + [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 1200; } + [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; } + [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; } + [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; } + [[nodiscard]] QStringList ports() const; [[nodiscard]] QString recommendedPort() const; + [[nodiscard]] QString recoveryCandidatePort() const; [[nodiscard]] QString discoveryStatus() const; [[nodiscard]] bool discovering() const noexcept; [[nodiscard]] bool deviceVerified() const noexcept; @@ -112,6 +123,7 @@ class DeviceController : public QObject { [[nodiscard]] QString deviceId() const; [[nodiscard]] QString protocolVersion() const; [[nodiscard]] QString firmwareVersion() const; + [[nodiscard]] QString firmwareBuildId() const; [[nodiscard]] QString bootId() const; [[nodiscard]] QStringList capabilities() const; [[nodiscard]] DeviceIdentity deviceIdentity() const; @@ -174,7 +186,8 @@ class DeviceController : public QObject { [[nodiscard]] static bool parseIdentityLine(const QString& line, DeviceIdentity& identity); [[nodiscard]] static bool identitySupportsCurrentContract( const DeviceIdentity& identity, - const QString& expectedFirmwareVersion); + const QString& expectedFirmwareVersion, + const QString& expectedBuildId = {}); [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } @@ -304,6 +317,7 @@ class DeviceController : public QObject { QThread ioThread_; QStringList ports_; QString recommendedPort_; + QString recoveryCandidatePort_; QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; DeviceIdentity identity_; QString lastError_; @@ -330,6 +344,8 @@ class DeviceController : public QObject { bool ptpRunning_{false}; bool ioWorkerReady_{false}; bool ioWorkerAffinityValid_{false}; + bool shuttingDown_{false}; + bool shutdownComplete_{false}; bool pendingAutoDetect_{false}; double signalFrequencyHz_{50.0}; QString ptpStatus_{QStringLiteral("Waiting for device")}; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 42a9b3778..70230eec7 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -23,16 +23,14 @@ struct PortCandidate final { }; [[nodiscard]] int portConfidence(const QSerialPortInfo& info) { - int score = 0; - if (info.hasVendorIdentifier() && info.vendorIdentifier() == 0x303AU) score += 100; - const QString identity = QStringLiteral("%1 %2 %3") - .arg(info.description(), info.manufacturer(), info.serialNumber()).toLower(); - if (identity.contains(QStringLiteral("esp32-p4"))) score += 90; - else if (identity.contains(QStringLiteral("esp32"))) score += 65; - if (identity.contains(QStringLiteral("espressif"))) score += 60; - if (identity.contains(QStringLiteral("usb jtag")) || - identity.contains(QStringLiteral("usb serial"))) score += 15; - return score; + return DeviceIoWorker::portConfidenceForMetadata( + info.hasVendorIdentifier(), + info.hasVendorIdentifier() ? info.vendorIdentifier() : 0U, + info.hasProductIdentifier(), + info.hasProductIdentifier() ? info.productIdentifier() : 0U, + info.description(), + info.manufacturer(), + info.serialNumber()); } } // namespace diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index df5005cc0..66bca1cf8 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -29,6 +29,48 @@ class DeviceIoWorker final : public QObject { [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } + // Fast discovery policy: USB bridge metadata may prioritize a port, but it + // never verifies device identity. The opened port must still answer the + // semantic IDENTIFY contract before Studio can become READY. + [[nodiscard]] static int portConfidenceForMetadata( + const bool hasVendorIdentifier, + const quint16 vendorIdentifier, + const bool hasProductIdentifier, + const quint16 productIdentifier, + const QString& description, + const QString& manufacturer, + const QString& serialNumber) { + int score = 0; + if (hasVendorIdentifier && vendorIdentifier == 0x303AU) score += 100; + + const QString identity = QStringLiteral("%1 %2 %3") + .arg(description, manufacturer, serialNumber).toLower(); + + // The current ARStack ESP32-P4 board exposes its console through a WCH + // CH343 bridge on Windows. Treat that bridge as a fast transport + // candidate, not as proof that the device is ARStack. + const bool saysCh343 = identity.contains(QStringLiteral("ch343")); + const bool wchCh343 = hasVendorIdentifier && vendorIdentifier == 0x1A86U && + hasProductIdentifier && productIdentifier == 0x55D3U; + if (wchCh343) score += 95; + else if (saysCh343) score += 85; + + if (identity.contains(QStringLiteral("esp32-p4"))) score += 90; + else if (identity.contains(QStringLiteral("esp32"))) score += 65; + if (identity.contains(QStringLiteral("espressif"))) score += 60; + if (identity.contains(QStringLiteral("usb jtag")) || + identity.contains(QStringLiteral("usb serial")) || + identity.contains(QStringLiteral("usb-enhanced-serial"))) { + score += 15; + } + + // Windows can expose many virtual Bluetooth COM ports. They are poor + // automatic injector candidates and must never outrank a physical USB + // bridge. Manual selection remains available. + if (identity.contains(QStringLiteral("bluetooth"))) score -= 120; + return score; + } + // S8B Windows ownership contract: access/device/resource/read/write errors // are session-fatal and must release the transport instead of leaving a // stale COM handle alive. Keep this pure so Windows CI can prove the exact diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 3814c25c2..f59cb7b92 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -7,14 +7,18 @@ #include #include #include +#include #include #include #include +#include #include #include #include #include #include +#include +#include #include @@ -25,12 +29,26 @@ constexpr auto kExpectedChip = "esp32p4"; constexpr auto kPreV3Policy = "pre-v3"; constexpr qsizetype kMaxOperationOutput = 65536; constexpr qsizetype kTrimmedOperationOutput = 49152; +constexpr qsizetype kMaxProgressOutputTail = 2048; +constexpr qsizetype kTrimmedProgressOutputTail = 1024; QString normalizedHash(const QString& text) { QString hash = text.trimmed().toLower(); hash.remove(QLatin1Char(' ')); return hash; } + +QString normalizedTerminalProgress(QString text) { + // espflash renders progress as terminal output. QProcess can deliver ANSI + // control sequences, carriage-return rewrites and arbitrary chunk splits. + // Strip presentation controls here; the full operation log remains intact. + static const QRegularExpression ansiCsi{ + QStringLiteral(R"(\x1B\[[0-?]*[ -/]*[@-~])")}; + text.remove(ansiCsi); + text.replace(QLatin1Char('\r'), QLatin1Char('\n')); + text.remove(QLatin1Char('\b')); + return text; +} } // namespace FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { @@ -45,7 +63,7 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { } FirmwareManager::~FirmwareManager() { - shutdown(); + static_cast(shutdown()); } void FirmwareManager::connectWorkerSignals() { @@ -102,6 +120,7 @@ void FirmwareManager::connectWorkerSignals() { cancelRequested_ = false; busy_ = false; flashProgress_ = -1; + progressOutputTail_.clear(); setStatus(QStringLiteral("Firmware operation cancelled.")); emit stateChanged(); if (cancelledOperation == Operation::reset) emit installationFinished(false); @@ -129,13 +148,35 @@ bool FirmwareManager::supportsEsp32P4Revision(const int major, const int minor) } int FirmwareManager::parseFlashProgress(const QString& output) { - static const QRegularExpression expression{QStringLiteral(R"((\d{1,3})\s*%)")}; - auto matches = expression.globalMatch(output); + // Keep this parser pure so deterministic fixtures exercise the exact + // production token rules. Stream reassembly remains bounded in + // updateProgressFromOutput(). espflash has used both percentage output and + // indicatif-style "current/total segment ..." progress bars. + const QString normalized = normalizedTerminalProgress(output); int latest = -1; - while (matches.hasNext()) { - const int value = matches.next().captured(1).toInt(); + + static const QRegularExpression percentExpression{ + QStringLiteral(R"((?= 0 && value <= 100) latest = value; } + + static const QRegularExpression segmentExpression{ + QStringLiteral(R"((? total) continue; + const int value = static_cast((current * 100 + total / 2) / total); + latest = std::clamp(value, 0, 100); + } return latest; } @@ -159,11 +200,13 @@ void FirmwareManager::refreshBundle() { if (busy_ || shuttingDown_) return; bundleReady_ = false; firmwareVersion_ = QStringLiteral("-"); + firmwareBuildId_.clear(); expectedProtocol_ = QStringLiteral("-"); revisionPolicy_.clear(); firmwareSha256_.clear(); firmwareImagePath_.clear(); flashProgress_ = -1; + progressOutputTail_.clear(); const QFileInfo flasherInfo{flasherPath()}; flasherAvailable_ = flasherInfo.exists() && flasherInfo.isFile() && flasherInfo.isExecutable(); @@ -177,8 +220,8 @@ void FirmwareManager::refreshBundle() { return; } bundleReady_ = true; - bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · SHA-256 verified") - .arg(firmwareVersion_); + bundleStatus_ = QStringLiteral("Ready · firmware v%1 · build %2 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified") + .arg(firmwareVersion_, firmwareBuildId_); emit stateChanged(); } @@ -207,13 +250,31 @@ bool FirmwareManager::loadManifest() { const QString expectedHash = normalizedHash(object.value(QStringLiteral("sha256")).toString()); const int protocol = object.value(QStringLiteral("protocol")).toInt(-1); const QString version = object.value(QStringLiteral("version")).toString().trimmed(); + const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower(); + const QString buildId = sourceCommit.left(16); const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower(); const qint64 flashOffset = object.value(QStringLiteral("flashOffset")).toVariant().toLongLong(); + const QJsonArray capabilities = object.value(QStringLiteral("capabilities")).toArray(); + const auto hasCapability = [&capabilities](const QString& wanted) { + for (const auto& value : capabilities) { + if (value.toString().compare(wanted, Qt::CaseInsensitive) == 0) return true; + } + return false; + }; + const bool productionCapabilities = + hasCapability(QStringLiteral("SMV-4I4V")) && + hasCapability(QStringLiteral("PROFILE")) && + hasCapability(QStringLiteral("LIVE-SETPOINTS")) && + hasCapability(QStringLiteral("SESSION-LEASE")) && + hasCapability(QStringLiteral("PTP-P2")) && + hasCapability(QStringLiteral("SMPSYNCH-AUTO")); if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName || expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 || - revisionPolicy != QString::fromLatin1(kPreV3Policy)) { - bundleStatus_ = QStringLiteral("Firmware manifest fields are incomplete or unsafe."); + !QRegularExpression(QStringLiteral("^[0-9a-f]{16}$")).match(buildId).hasMatch() || + !sourceCommit.startsWith(buildId, Qt::CaseInsensitive) || + revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) { + bundleStatus_ = QStringLiteral("Firmware manifest fields/capabilities are incomplete or unsafe."); return false; } @@ -236,6 +297,7 @@ bool FirmwareManager::loadManifest() { } firmwareVersion_ = version; + firmwareBuildId_ = buildId; expectedProtocol_ = QString::number(protocol); revisionPolicy_ = revisionPolicy; firmwareSha256_ = actualHash; @@ -264,6 +326,7 @@ bool FirmwareManager::probeTarget(const QString& portName) { targetVerified_ = false; bootloaderHelpNeeded_ = false; flashProgress_ = -1; + progressOutputTail_.clear(); targetChip_ = QStringLiteral("Checking %1...").arg(port); busy_ = true; cancelRequested_ = false; @@ -292,7 +355,10 @@ bool FirmwareManager::installFirmware(const QString& portName) { } bootloaderHelpNeeded_ = false; - flashProgress_ = 0; + // Do not invent 0%. Progress remains unknown until espflash emits real + // write telemetry. The UI renders this as an indeterminate write state. + flashProgress_ = -1; + progressOutputTail_.clear(); busy_ = true; cancelRequested_ = false; setStatus(QStringLiteral("Installing ARStack firmware v%1 on %2...").arg(firmwareVersion_, port)); @@ -318,23 +384,65 @@ void FirmwareManager::cancel() { Qt::QueuedConnection); } -void FirmwareManager::shutdown() { - if (shuttingDown_) return; +bool FirmwareManager::shutdown() { + if (shuttingDown_) return !workerThread_.isRunning(); shuttingDown_ = true; operation_ = Operation::none; activeOperationGeneration_ = 0; busy_ = false; cancelRequested_ = true; + progressOutputTail_.clear(); - if (worker_ != nullptr && workerThread_.isRunning()) { - if (QThread::currentThread() == &workerThread_) { - worker_->shutdown(); - } else { - QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection); - } + if (worker_ == nullptr || !workerThread_.isRunning()) { + worker_ = nullptr; + workerReady_ = false; + workerAffinityValid_ = false; + return true; + } + if (QThread::currentThread() == &workerThread_) { + worker_->shutdown(); workerThread_.quit(); - workerThread_.wait(); + return false; } + + auto acknowledged = QSharedPointer::create(); + FirmwareWorker* const worker = worker_; + const bool queued = QMetaObject::invokeMethod( + worker, + [worker, acknowledged] { + worker->shutdown(); + acknowledged->release(); + }, + Qt::QueuedConnection); + bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs()); + if (!graceful) { + qCritical().noquote() + << "Firmware worker shutdown acknowledgement timed out; entering bounded retirement fallback."; + } + + workerThread_.quit(); + bool joined = workerThread_.wait(shutdownJoinTimeoutMs()); + if (!joined) { + graceful = false; + qCritical().noquote() + << "Firmware worker missed primary join deadline; retrying bounded retirement."; + workerThread_.requestInterruption(); + workerThread_.quit(); + joined = workerThread_.wait(shutdownRetryTimeoutMs()); + } + if (!joined) { + graceful = false; + qCritical().noquote() + << "Firmware worker still alive; emergency terminate fallback engaged."; + workerThread_.terminate(); + joined = workerThread_.wait(shutdownForceTimeoutMs()); + } + + worker_ = nullptr; + workerReady_ = false; + workerAffinityValid_ = false; + if (!joined) qCritical().noquote() << "Firmware worker could not be retired before exit."; + return graceful && joined; } void FirmwareManager::clearLog() { @@ -345,7 +453,7 @@ void FirmwareManager::clearLog() { bool FirmwareManager::startEspflash(const QStringList& arguments, const Operation operation) { if (shuttingDown_ || worker_ == nullptr || !workerThread_.isRunning() || - !workerReady_ || !workerAffinityValid_ || sessionGeneration_ == 0) { + !workerReady_ || !workerAffinityValid() || sessionGeneration_ == 0) { fail(QStringLiteral("Firmware worker is unavailable or not affinity-safe.")); busy_ = false; return false; @@ -354,6 +462,7 @@ bool FirmwareManager::startEspflash(const QStringList& arguments, const Operatio operation_ = operation; activeOperationGeneration_ = sessionGeneration_; operationOutput_.clear(); + if (operation == Operation::flash) progressOutputTail_.clear(); QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); environment.insert(QStringLiteral("ESPFLASH_PORT"), selectedPort_); environment.insert(QStringLiteral("ESPFLASH_SKIP_UPDATE_CHECK"), QStringLiteral("true")); @@ -398,6 +507,7 @@ void FirmwareManager::handleOperationRejected(const QString& message) { activeOperationGeneration_ = 0; busy_ = false; flashProgress_ = -1; + progressOutputTail_.clear(); cancelRequested_ = false; if (rejected == Operation::reset) { @@ -420,6 +530,7 @@ void FirmwareManager::handleLaunchFailure(const QString& message, const bool tim activeOperationGeneration_ = 0; busy_ = false; flashProgress_ = -1; + progressOutputTail_.clear(); cancelRequested_ = false; if (failedOperation == Operation::reset) { @@ -446,6 +557,7 @@ void FirmwareManager::handleOperationTimeout() { activeOperationGeneration_ = 0; busy_ = false; flashProgress_ = -1; + progressOutputTail_.clear(); cancelRequested_ = false; if (timedOut == Operation::reset) { @@ -476,6 +588,7 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) cancelRequested_ = false; busy_ = false; flashProgress_ = -1; + progressOutputTail_.clear(); setStatus(QStringLiteral("Firmware operation cancelled.")); emit stateChanged(); if (completed == Operation::reset) emit installationFinished(false); @@ -527,6 +640,7 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) if (!success) { busy_ = false; flashProgress_ = -1; + progressOutputTail_.clear(); bootloaderHelpNeeded_ = true; fail(QStringLiteral("Firmware installation failed. Put the board in Download mode and retry. The device is not considered ready.")); emit stateChanged(); @@ -551,6 +665,7 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) if (completed == Operation::reset) { busy_ = false; + progressOutputTail_.clear(); if (success) { targetVerified_ = false; bootloaderHelpNeeded_ = false; @@ -567,8 +682,19 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) void FirmwareManager::updateProgressFromOutput(const QString& text) { if (operation_ != Operation::flash || text.isEmpty()) return; - const int latest = parseFlashProgress(text); - if (latest < 0 || flashProgress_ == latest) return; + + // QProcess signal boundaries are not protocol boundaries. Preserve a small + // rolling suffix so tokens such as "6" + "4%" are reconstructed without + // allowing terminal output to grow an unbounded presentation buffer. + progressOutputTail_ += text; + if (progressOutputTail_.size() > kMaxProgressOutputTail) { + progressOutputTail_ = progressOutputTail_.right(kTrimmedProgressOutputTail); + } + + const int latest = parseFlashProgress(progressOutputTail_); + // espflash can repaint older terminal lines; operator progress must never + // move backwards. Completion still owns the authoritative 100% transition. + if (latest < 0 || latest <= flashProgress_) return; flashProgress_ = std::clamp(latest, 0, 100); emit stateChanged(); } diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 6261ffe55..1616b42cc 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -6,6 +6,7 @@ #include #include +class DeterministicSessionHarness; class FirmwareWorker; class FirmwareManager : public QObject { @@ -19,6 +20,7 @@ class FirmwareManager : public QObject { Q_PROPERTY(QString selectedPort READ selectedPort NOTIFY stateChanged) Q_PROPERTY(QString targetChip READ targetChip NOTIFY stateChanged) Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged) + Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY stateChanged) Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged) Q_PROPERTY(QString firmwareSha256 READ firmwareSha256 NOTIFY stateChanged) Q_PROPERTY(QString status READ status NOTIFY stateChanged) @@ -38,6 +40,7 @@ class FirmwareManager : public QObject { [[nodiscard]] QString selectedPort() const { return selectedPort_; } [[nodiscard]] QString targetChip() const { return targetChip_; } [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; } + [[nodiscard]] QString firmwareBuildId() const { return firmwareBuildId_; } [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; } [[nodiscard]] QString firmwareSha256() const { return firmwareSha256_; } [[nodiscard]] QString status() const { return status_; } @@ -71,7 +74,11 @@ class FirmwareManager : public QObject { bool probeTarget(const QString& portName); bool installFirmware(const QString& portName); void cancel(); - void shutdown(); + bool shutdown(); + [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 2200; } + [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; } + [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; } + [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; } signals: void stateChanged(); @@ -80,6 +87,7 @@ class FirmwareManager : public QObject { void operationFailed(const QString& message, bool bootloaderHelpNeeded); private: + friend class DeterministicSessionHarness; enum class Operation { none, probe, flash, reset }; [[nodiscard]] QString bundleRoot() const; @@ -102,9 +110,14 @@ class FirmwareManager : public QObject { QThread workerThread_; Operation operation_{Operation::none}; QString operationOutput_; + // Bounded rolling tail used only for terminal-style espflash progress. + // It preserves tokens split across QProcess chunks without duplicating the + // operation log or creating another process/parser owner. + QString progressOutputTail_; QString selectedPort_; QString targetChip_{QStringLiteral("Not checked")}; QString firmwareVersion_{QStringLiteral("-")}; + QString firmwareBuildId_; QString expectedProtocol_{QStringLiteral("-")}; QString revisionPolicy_; QString firmwareSha256_; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index e35551617..b6d7a1076 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -24,7 +24,9 @@ QString SmartSessionController::chooseRecoveryPort( const QString& recommendedPort, const QStringList& visiblePorts) { const QString recommended = recommendedPort.trimmed(); - if (!recommended.isEmpty()) return recommended; + if (!recommended.isEmpty() && visiblePorts.contains(recommended, Qt::CaseInsensitive)) { + return recommended; + } if (visiblePorts.size() != 1) return {}; return visiblePorts.front().trimmed(); } @@ -98,6 +100,31 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent }); } +SmartSessionController::~SmartSessionController() { + shutdown(); +} + +void SmartSessionController::shutdown() { + if (shuttingDown_) return; + shuttingDown_ = true; + started_ = false; + discoveryTimer_.stop(); + prepareTimer_.stop(); + reconnectTimer_.stop(); + profileSyncTimer_.stop(); + firmwareHandoffWatchdog_.shutdown(); + deviceRecoveryMonitor_.shutdown(); + updateRequested_ = false; + pendingReleaseGeneration_ = 0; + profileSyncStage_ = ProfileSyncStage::idle; + if (device_ != nullptr) QObject::disconnect(device_, nullptr, this, nullptr); + if (profiles_ != nullptr) QObject::disconnect(profiles_, nullptr, this, nullptr); + if (firmware_ != nullptr) QObject::disconnect(firmware_, nullptr, this, nullptr); + device_ = nullptr; + profiles_ = nullptr; + firmware_ = nullptr; +} + QObject* SmartSessionController::device() const noexcept { return device_; } QObject* SmartSessionController::profiles() const noexcept { return profiles_; } QObject* SmartSessionController::firmware() const noexcept { return firmware_; } @@ -121,6 +148,19 @@ bool SmartSessionController::engineeringEditable() const noexcept { portOwner_ != PortOwner::firmwareTool && (firmware_ == nullptr || !firmware_->busy()); } bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } +bool SmartSessionController::firmwareUpdateAvailable() const noexcept { + return device_ != nullptr && firmware_ != nullptr && device_->deviceVerified() && + firmware_->bundleReady() && !firmware_->busy() && !updateRequested_ && + firmwareIsCompatible() && !firmwareIsCurrent(); +} +bool SmartSessionController::firmwareUpdateCanCancel() const noexcept { + return updateRequested_ && updateWasOptional_ && updateStage_ == UpdateStage::waitingForBootloader && + firmware_ != nullptr && !firmware_->busy(); +} +bool SmartSessionController::firmwareReinstallAvailable() const noexcept { + return device_ != nullptr && firmware_ != nullptr && device_->deviceVerified() && + firmware_->bundleReady() && !firmware_->busy() && !updateRequested_ && firmwareIsCurrent(); +} bool SmartSessionController::firmwareInstallRequired() const noexcept { return blankBoardDetected_ && !updateRequested_; } @@ -166,7 +206,11 @@ QString SmartSessionController::updateStatus() const { } QString SmartSessionController::firmwareSetupPort() const { return blankBoardPort_; } QString SmartSessionController::expectedFirmwareVersion() const { return QStringLiteral(ARSTACK_STUDIO_VERSION); } +QString SmartSessionController::expectedFirmwareBuildId() const { + return firmware_ != nullptr ? firmware_->firmwareBuildId() : QString{}; +} QString SmartSessionController::deviceFirmwareVersion() const { return deviceFirmwareVersion_; } +QString SmartSessionController::deviceFirmwareBuildId() const { return deviceFirmwareBuildId_; } quint64 SmartSessionController::advanceSessionGeneration() { sessionGeneration_ = nextSessionGeneration(sessionGeneration_); @@ -237,7 +281,7 @@ void SmartSessionController::setFirmware(QObject* object) { } void SmartSessionController::start() { - if (started_) return; + if (started_ || shuttingDown_) return; started_ = true; if (profiles_ != nullptr) static_cast(ensureDefaultProfile()); discoveryTimer_.start(); @@ -395,10 +439,18 @@ bool SmartSessionController::beginFirmwareUpdate() { !firmware_->bundleReady() || firmware_->busy()) { return false; } + updateWasOptional_ = firmwareUpdateAvailable() && !firmwareUpdateRequired_; + return beginFirmwareOperation(device_->portName()); +} + +bool SmartSessionController::beginFirmwareReinstall() { + if (!firmwareReinstallAvailable() || device_ == nullptr) return false; + updateWasOptional_ = true; return beginFirmwareOperation(device_->portName()); } bool SmartSessionController::beginFirmwareInstall() { + updateWasOptional_ = false; refreshRecoveryOfferFromIdentity(); if (device_ == nullptr || firmware_ == nullptr || !blankBoardDetected_ || !firmware_->bundleReady() || firmware_->busy() || blankBoardPort_.isEmpty()) { @@ -452,6 +504,31 @@ bool SmartSessionController::retryFirmwareUpdate() { return updateRequested_; } +bool SmartSessionController::cancelFirmwareUpdate() { + if (!firmwareUpdateCanCancel() || device_ == nullptr) return false; + reconnectTimer_.stop(); + updateRequested_ = false; + updateWasOptional_ = false; + updateStage_ = UpdateStage::idle; + updateReconnectAttempts_ = 0; + pendingReleaseGeneration_ = 0; + setupError_ = false; + setupErrorStatus_.clear(); + blankBoardDetected_ = false; + blankBoardPort_.clear(); + if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); + if (started_ && !device_->connected() && !device_->discovering()) { + QTimer::singleShot(0, this, [this] { + if (!started_ || updateRequested_ || device_ == nullptr || device_->connected() || device_->discovering()) return; + static_cast(startDeviceDiscovery()); + reconcile(); + }); + } else { + reconcile(); + } + return true; +} + bool SmartSessionController::retryFirmwareSetup() { if (!firmwareRetryAvailable()) return false; return retryFirmwareUpdate(); @@ -552,6 +629,7 @@ void SmartSessionController::reconnectDeviceSignals() { prepareTimer_.start(); } else { deviceFirmwareVersion_.clear(); + deviceFirmwareBuildId_.clear(); profileSyncBootId_.clear(); resetProfileSync(true); } @@ -732,31 +810,34 @@ void SmartSessionController::refreshRecoveryOfferFromIdentity() { return; } - // Preserve explicit recovery intent while the selected port moves through - // CONNECTING/IDENTIFYING. Only the terminal Unidentified state can turn - // that intent into a firmware offer. Automatic discovery always clears the - // flag in startDeviceDiscovery(), so a transient handshake failure alone - // can never imply blank firmware. + // A terminal semantic-identification timeout on the *single* high-confidence + // Espressif/ESP32 candidate is a safe legacy/blank-firmware recovery signal. + // This does not authorize a write: FirmwareManager still releases the serial + // session and requires espflash ROM identity + ESP32-P4 pre-v3 verification + // before write-bin can start. Generic or ambiguous serial ports remain closed. if (device_->identificationState() != DeviceController::IdentificationState::Unidentified) { blankBoardDetected_ = false; blankBoardPort_.clear(); return; } - if (!manualRecoveryArmed_ || recoveryPending_) { + if (recoveryPending_) { blankBoardDetected_ = false; blankBoardPort_.clear(); return; } - const QString recommended = device_->recommendedPort().trimmed(); - if (recommended.isEmpty() || !device_->ports().contains(recommended)) { + QString recoveryPort = device_->recoveryCandidatePort().trimmed(); + if (recoveryPort.isEmpty()) { + recoveryPort = chooseRecoveryPort(device_->recommendedPort(), device_->ports()); + } + if (recoveryPort.isEmpty() || !device_->ports().contains(recoveryPort, Qt::CaseInsensitive)) { blankBoardDetected_ = false; blankBoardPort_.clear(); return; } - blankBoardPort_ = recommended; + blankBoardPort_ = recoveryPort; blankBoardDetected_ = true; } @@ -770,17 +851,26 @@ void SmartSessionController::clearBlankBoardContext() { void SmartSessionController::latchFirmwareFailure(QString message) { reconnectTimer_.stop(); + const bool optionalOperation = updateWasOptional_; updateRequested_ = false; + updateWasOptional_ = false; updateStage_ = UpdateStage::idle; updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; blankBoardDetected_ = false; if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); - setupError_ = true; message = message.trimmed(); - setupErrorStatus_ = message.isEmpty() + setupError_ = !optionalOperation; + setupErrorStatus_ = optionalOperation ? QString{} : (message.isEmpty() ? QStringLiteral("Firmware setup did not complete. Retry explicitly when the board is ready.") - : std::move(message); + : std::move(message)); + if (optionalOperation && started_ && device_ != nullptr && !device_->connected() && !device_->discovering()) { + QTimer::singleShot(0, this, [this] { + if (!started_ || updateRequested_ || device_ == nullptr || device_->connected() || device_->discovering()) return; + static_cast(startDeviceDiscovery()); + reconcile(); + }); + } } void SmartSessionController::resetProfileSync(const bool requireSync) { @@ -908,23 +998,31 @@ void SmartSessionController::setPresentation( } void SmartSessionController::refreshFirmwareIdentity() { - deviceFirmwareVersion_ = - (device_ != nullptr && device_->deviceVerified()) ? device_->firmwareVersion() : QString{}; + const bool verified = device_ != nullptr && device_->deviceVerified(); + deviceFirmwareVersion_ = verified ? device_->firmwareVersion() : QString{}; + deviceFirmwareBuildId_ = verified ? device_->firmwareBuildId() : QString{}; } bool SmartSessionController::firmwareIsCurrent() const { return device_ != nullptr && device_->deviceVerified() && DeviceController::identitySupportsCurrentContract( - device_->deviceIdentity(), expectedFirmwareVersion()); + device_->deviceIdentity(), expectedFirmwareVersion(), expectedFirmwareBuildId()); +} + +bool SmartSessionController::firmwareIsCompatible() const { + return device_ != nullptr && device_->deviceVerified() && + DeviceController::identitySupportsCurrentContract( + device_->deviceIdentity(), expectedFirmwareVersion(), QString{}); } bool SmartSessionController::deviceControlAvailable() const noexcept { - return device_ != nullptr && device_->deviceVerified() && firmwareIsCurrent() && + return device_ != nullptr && device_->deviceVerified() && firmwareIsCompatible() && portOwner_ == PortOwner::deviceSession && !updateRequested_ && (firmware_ == nullptr || !firmware_->busy()); } void SmartSessionController::reconcile() { + if (shuttingDown_) return; if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) { setPresentation( QStringLiteral("INITIALIZING"), @@ -987,7 +1085,7 @@ void SmartSessionController::reconcile() { if (blankBoardDetected_) { setPresentation( QStringLiteral("FIRMWARE REQUIRED"), - QStringLiteral("Manual recovery selected for %1 after bounded semantic identification failed. Studio will verify chip and revision before any firmware write.") + QStringLiteral("ESP32-P4 candidate %1 did not answer the current semantic identity after bounded attempts. Studio can install the bundled firmware after ROM chip/revision verification.") .arg(blankBoardPort_), false, false); @@ -997,7 +1095,7 @@ void SmartSessionController::reconcile() { if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) { setPresentation( QStringLiteral("UNIDENTIFIED"), - QStringLiteral("No ARStack semantic identity was received after %1 bounded attempts. Retry identification, or use Tools > Advanced > Verify selected port to enter explicit firmware recovery.") + QStringLiteral("No ARStack semantic identity was received after %1 bounded attempts and the visible serial devices are ambiguous. Disconnect unrelated serial devices or select the intended ESP32-P4 port, then retry.") .arg(device_->identifyAttempts()), false, false); @@ -1015,41 +1113,50 @@ void SmartSessionController::reconcile() { } refreshFirmwareIdentity(); - if (!firmwareIsCurrent()) { - if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { + + // 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 (!firmwareIsCurrent()) { const QString observed = deviceFirmwareVersion_.isEmpty() ? QStringLiteral("legacy/unknown firmware") : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); latchFirmwareFailure(QStringLiteral( - "Firmware was written, but reconnect verification reported %1 instead of the current semantic identity contract for v%2. Retry firmware setup explicitly.") + "Firmware was written, but reconnect verification reported %1 instead of the exact bundled build for v%2.") .arg(observed, expectedFirmwareVersion())); emit firmwareUpdateFinished(false); - setPresentation(QStringLiteral("SETUP ERROR"), setupErrorStatus_, false, false); + reconcile(); return; } + updateRequested_ = false; + updateWasOptional_ = false; + updateStage_ = UpdateStage::idle; + clearBlankBoardContext(); + emit firmwareUpdateFinished(true); + } + + // Runtime compatibility and package freshness are intentionally separate. + // Same-version firmware with the required protocol/capabilities remains + // usable; build provenance mismatch is an advisory update, not a lockout. + if (!firmwareIsCompatible()) { const QString versionText = deviceFirmwareVersion_.isEmpty() ? QStringLiteral("legacy firmware") : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); setPresentation( QStringLiteral("FIRMWARE UPDATE"), - QStringLiteral("%1 detected. ARStack Studio v%2 is ready to update it.") - .arg(versionText, expectedFirmwareVersion()), + QStringLiteral("%1 does not satisfy the current protocol/capability contract. Update is required before injection.") + .arg(versionText), false, true); return; } - if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; - clearBlankBoardContext(); - emit firmwareUpdateFinished(true); - } - if (device_->running()) { setPresentation( QStringLiteral("RUNNING"), - QStringLiteral("4I + 4V · 4000 samples/s · live value apply"), + firmwareUpdateAvailable() + ? QStringLiteral("4I + 4V · 4000 samples/s · live value apply · firmware update available") + : QStringLiteral("4I + 4V · 4000 samples/s · live value apply"), false, false); return; @@ -1115,7 +1222,9 @@ void SmartSessionController::reconcile() { setPresentation( QStringLiteral("READY"), - QStringLiteral("4I + 4V · 4000 samples/s · ready to start"), + firmwareUpdateAvailable() + ? QStringLiteral("4I + 4V · 4000 samples/s · ready · firmware update available") + : QStringLiteral("4I + 4V · 4000 samples/s · ready to start"), true, false); } diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index e6b6268a1..f75cf1b93 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -25,6 +25,9 @@ class SmartSessionController : public QObject { Q_PROPERTY(bool liveControlReady READ liveControlReady NOTIFY stateChanged) Q_PROPERTY(bool engineeringEditable READ engineeringEditable NOTIFY stateChanged) Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) + Q_PROPERTY(bool firmwareUpdateAvailable READ firmwareUpdateAvailable NOTIFY stateChanged) + Q_PROPERTY(bool firmwareUpdateCanCancel READ firmwareUpdateCanCancel NOTIFY stateChanged) + Q_PROPERTY(bool firmwareReinstallAvailable READ firmwareReinstallAvailable NOTIFY stateChanged) Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged) Q_PROPERTY(bool firmwareRetryAvailable READ firmwareRetryAvailable NOTIFY stateChanged) Q_PROPERTY(bool profileSyncRetryAvailable READ profileSyncRetryAvailable NOTIFY stateChanged) @@ -32,10 +35,13 @@ class SmartSessionController : public QObject { Q_PROPERTY(bool updateNeedsBootloaderHelp READ updateNeedsBootloaderHelp NOTIFY stateChanged) Q_PROPERTY(bool recoveryPending READ recoveryPending NOTIFY stateChanged) Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged) + Q_PROPERTY(QString firmwareUpdateStage READ firmwareUpdateStage NOTIFY stateChanged) Q_PROPERTY(QString updateStatus READ updateStatus NOTIFY stateChanged) Q_PROPERTY(QString firmwareSetupPort READ firmwareSetupPort NOTIFY stateChanged) Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT) + Q_PROPERTY(QString expectedFirmwareBuildId READ expectedFirmwareBuildId NOTIFY stateChanged) Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged) + Q_PROPERTY(QString deviceFirmwareBuildId READ deviceFirmwareBuildId NOTIFY stateChanged) Q_PROPERTY(qulonglong sessionGeneration READ sessionGeneration NOTIFY stateChanged) Q_PROPERTY(PortOwner portOwner READ portOwner NOTIFY stateChanged) @@ -47,7 +53,21 @@ class SmartSessionController : public QObject { }; Q_ENUM(PortOwner) + enum class UpdateStage { + idle, + stopping, + releasingPort, + probing, + flashing, + reconnecting, + waitingForBootloader, + }; + Q_ENUM(UpdateStage) + explicit SmartSessionController(QObject* parent = nullptr); + ~SmartSessionController() override; + + void shutdown(); [[nodiscard]] QObject* device() const noexcept; [[nodiscard]] QObject* profiles() const noexcept; @@ -59,6 +79,9 @@ class SmartSessionController : public QObject { [[nodiscard]] bool liveControlReady() const noexcept; [[nodiscard]] bool engineeringEditable() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; + [[nodiscard]] bool firmwareUpdateAvailable() const noexcept; + [[nodiscard]] bool firmwareUpdateCanCancel() const noexcept; + [[nodiscard]] bool firmwareReinstallAvailable() const noexcept; [[nodiscard]] bool firmwareInstallRequired() const noexcept; [[nodiscard]] bool firmwareInstallVisible() const noexcept { return !recoveryPending_ && firmwareInstallRequired(); @@ -69,10 +92,29 @@ class SmartSessionController : public QObject { [[nodiscard]] bool updateNeedsBootloaderHelp() const noexcept; [[nodiscard]] bool recoveryPending() const noexcept { return recoveryPending_; } [[nodiscard]] int firmwareProgress() const noexcept; + [[nodiscard]] QString firmwareUpdateStage() const { + switch (updateStage_) { + case UpdateStage::stopping: + case UpdateStage::releasingPort: + return QStringLiteral("prepare"); + case UpdateStage::probing: + case UpdateStage::waitingForBootloader: + return QStringLiteral("verify"); + case UpdateStage::flashing: + return QStringLiteral("write"); + case UpdateStage::reconnecting: + return QStringLiteral("reconnect"); + case UpdateStage::idle: + return QStringLiteral("idle"); + } + return QStringLiteral("idle"); + } [[nodiscard]] QString updateStatus() const; [[nodiscard]] QString firmwareSetupPort() const; [[nodiscard]] QString expectedFirmwareVersion() const; + [[nodiscard]] QString expectedFirmwareBuildId() const; [[nodiscard]] QString deviceFirmwareVersion() const; + [[nodiscard]] QString deviceFirmwareBuildId() const; [[nodiscard]] quint64 sessionGeneration() const noexcept { return sessionGeneration_; } [[nodiscard]] PortOwner portOwner() const noexcept { return portOwner_; } @@ -155,8 +197,10 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool requestStopPtp(); Q_INVOKABLE bool beginFirmwareUpdate(); + Q_INVOKABLE bool beginFirmwareReinstall(); Q_INVOKABLE bool beginFirmwareInstall(); Q_INVOKABLE bool retryFirmwareUpdate(); + Q_INVOKABLE bool cancelFirmwareUpdate(); Q_INVOKABLE bool retryFirmwareSetup(); Q_INVOKABLE bool retryIdentification(); Q_INVOKABLE bool retryProfileSync(); @@ -170,15 +214,6 @@ class SmartSessionController : public QObject { private: friend class DeterministicSessionHarness; - enum class UpdateStage { - idle, - stopping, - releasingPort, - probing, - flashing, - reconnecting, - waitingForBootloader, - }; enum class ProfileSyncStage { idle, deploying, failed }; class FirmwareHandoffWatchdog final { @@ -188,6 +223,7 @@ class SmartSessionController : public QObject { QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); }); QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); }); } + void shutdown() { timer_.stop(); owner_ = nullptr; } private: [[nodiscard]] bool waitingForAck() const noexcept { @@ -229,6 +265,12 @@ class SmartSessionController : public QObject { explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) { QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); }); } + void shutdown() { + QObject::disconnect(verifiedConnection_); + QObject::disconnect(identificationConnection_); + device_ = nullptr; + owner_ = nullptr; + } private: void bindDevice() { @@ -315,6 +357,7 @@ class SmartSessionController : public QObject { quint64 advanceSessionGeneration(); void setPortOwner(PortOwner owner); bool firmwareIsCurrent() const; + bool firmwareIsCompatible() const; bool deviceControlAvailable() const noexcept; void resetProfileSync(bool requireSync); bool beginProfileSync(const QVariantMap& profile); @@ -332,6 +375,7 @@ class SmartSessionController : public QObject { QString state_{QStringLiteral("WAITING FOR DEVICE")}; QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; QString deviceFirmwareVersion_; + QString deviceFirmwareBuildId_; QString updatePort_; QString blankBoardPort_; QString setupErrorStatus_; @@ -346,10 +390,12 @@ class SmartSessionController : public QObject { bool wasReady_{false}; bool needsProfileSync_{true}; bool updateRequested_{false}; + bool updateWasOptional_{false}; bool blankBoardDetected_{false}; bool manualRecoveryArmed_{false}; bool setupError_{false}; bool recoveryPending_{false}; + bool shuttingDown_{false}; int updateReconnectAttempts_{0}; int profileSyncAttempts_{0}; PortOwner portOwner_{PortOwner::none}; diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 19d115b42..0d1335928 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -3,7 +3,6 @@ #include "DeviceController.hpp" -#include #include #include @@ -79,17 +78,6 @@ class StudioDeviceController : public DeviceController { } }); - if (auto* app = QCoreApplication::instance(); app != nullptr) { - connect(app, &QCoreApplication::aboutToQuit, this, [this] { - controlHealthTimer_.stop(); - healthProbeOutstanding_ = false; - setSessionHeartbeatEnabled(false); - clearPendingLiveCommands(); - if (connected() && running()) { - static_cast(DeviceController::stop()); - } - }); - } } [[nodiscard]] bool currentFirmwareIdentitySeen() const { diff --git a/apps/arstack_studio/src/WindowsOwnershipHarness.cpp b/apps/arstack_studio/src/WindowsOwnershipHarness.cpp index e7edb6d32..5aa3396d3 100644 --- a/apps/arstack_studio/src/WindowsOwnershipHarness.cpp +++ b/apps/arstack_studio/src/WindowsOwnershipHarness.cpp @@ -76,6 +76,27 @@ bool serialTransportLossPolicy() { !DeviceIoWorker::serialErrorForcesTransportLoss(Error::UnknownError); } +bool fastUsbDiscoveryPolicy() { + const int ch343Exact = DeviceIoWorker::portConfidenceForMetadata( + true, 0x1A86U, true, 0x55D3U, + QStringLiteral("USB-Enhanced-SERIAL CH343"), {}, {}); + const int ch343ByName = DeviceIoWorker::portConfidenceForMetadata( + false, 0U, false, 0U, + QStringLiteral("USB-Enhanced-SERIAL CH343"), {}, {}); + const int bluetooth = DeviceIoWorker::portConfidenceForMetadata( + false, 0U, false, 0U, + QStringLiteral("Standard Serial over Bluetooth link"), {}, {}); + const int generic = DeviceIoWorker::portConfidenceForMetadata( + false, 0U, false, 0U, QStringLiteral("USB Serial Port"), {}, {}); + const int espressif = DeviceIoWorker::portConfidenceForMetadata( + true, 0x303AU, false, 0U, QStringLiteral("USB JTAG/serial debug unit"), + QStringLiteral("Espressif"), {}); + + return ch343Exact >= 60 && ch343ByName >= 60 && espressif >= 60 && + ch343Exact > generic && ch343ByName > generic && + ch343Exact > bluetooth && bluetooth < generic; +} + bool crossProcessOwnershipAndCrashRecovery() { QTemporaryDir temp; if (!temp.isValid()) return false; @@ -123,23 +144,28 @@ int main(int argc, char* argv[]) { } const bool serialPolicy = serialTransportLossPolicy(); + const bool fastDiscovery = fastUsbDiscoveryPolicy(); const bool processOwnership = crossProcessOwnershipAndCrashRecovery(); qInfo().noquote() << "S8B Windows ownership:" << (serialPolicy ? "PASS" : "FAIL") << "· Permission/Resource/DeviceNotFound/Read/Write errors force transport loss"; + qInfo().noquote() + << "S8B Windows discovery:" + << (fastDiscovery ? "PASS" : "FAIL") + << "· CH343/Espressif USB outrank generic and Bluetooth COM ports; IDENTIFY remains authoritative"; qInfo().noquote() << "S8B Windows ownership:" << (processOwnership ? "PASS" : "FAIL") << "· second process blocked with exit 23 and stale lock reclaimed after hard crash"; - if (!serialPolicy || !processOwnership) { - qCritical().noquote() << "S8B Windows ownership integration harness: FAIL"; + if (!serialPolicy || !fastDiscovery || !processOwnership) { + qCritical().noquote() << "S8B Windows ownership/discovery integration harness: FAIL"; return 22; } qInfo().noquote() - << "S8B Windows ownership integration harness: PASS · OS process lock + crash recovery + serial transport-loss policy locked"; + << "S8B Windows ownership/discovery integration harness: PASS · fast CH343 discovery + OS process lock + crash recovery + serial transport-loss policy locked"; return 0; } diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 31c8e981d..45bb96be0 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -27,7 +27,7 @@ #include #ifndef ARSTACK_STUDIO_VERSION -#define ARSTACK_STUDIO_VERSION "0.1.0" +#define ARSTACK_STUDIO_VERSION "0.1.1" #endif namespace { @@ -49,7 +49,11 @@ class PrimaryWindowCloseFilter final : public QObject { [[nodiscard]] bool eventFilter(QObject* watched, QEvent* event) override { if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { closeObserved_ = true; - app_->quit(); + // QCoreApplication::quit() can be ignored by application/window + // policy. A primary-window close is an explicit process-exit request, + // so terminate the main event loop deterministically. aboutToQuit() + // remains the single cleanup boundary for serial/firmware workers. + QCoreApplication::exit(0); } return QObject::eventFilter(watched, event); } @@ -155,6 +159,7 @@ int checkFirmwareContract(int argc, char* argv[]) { const bool valid = firmware.bundleReady() && firmware.flasherAvailable() && firmware.firmwareVersion() == QStringLiteral(ARSTACK_STUDIO_VERSION) && firmware.expectedProtocol() == QStringLiteral("1") && + firmware.firmwareBuildId().size() == 16 && firmware.firmwareSha256().size() == 64 && realEspflashFormat && dashedFormat && rejectsWrongChip && revisionPolicy && flashProgressFormat && recoverySelection && firmwareTimeoutPolicy && firmwareWorkerBoundary; @@ -189,23 +194,23 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { DeviceIdentity currentIdentity; const QString currentLine = QStringLiteral( "I (412) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " - "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF " - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE") + "device_id=A1B2C3D4E5F6 firmware=%1 build=0123456789abcdef boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); const bool currentParsed = DeviceController::parseIdentityLine(currentLine, currentIdentity); const bool currentAccepted = currentParsed && DeviceController::identitySupportsCurrentContract( - currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION)); + currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef")); DeviceIdentity legacyIdentity; const QString legacyLine = QStringLiteral( "I (417) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " - "device_id=A1B2C3D4E5F6 firmware=%1 capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE") + "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); const bool legacyParsed = DeviceController::parseIdentityLine(legacyLine, legacyIdentity); const bool legacyRejectedAsCurrent = legacyParsed && !DeviceController::identitySupportsCurrentContract( - legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION)); + legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef")); DeviceIdentity protocolLegacy; const bool protocolLegacyParsed = DeviceController::parseIdentityLine( @@ -220,8 +225,8 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { const bool capabilityFailClosed = DeviceController::parseIdentityLine( QStringLiteral( "I (425) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " - "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF " - "capabilities=SMV-4I4V,LIVE-SETPOINTS"), + "device_id=A1B2C3D4E5F6 firmware=0.1.1 boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,SMPSYNCH-AUTO"), reducedCapabilities) && !DeviceController::identitySupportsCurrentContract( reducedCapabilities, QStringLiteral(ARSTACK_STUDIO_VERSION)); @@ -231,19 +236,19 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { QStringLiteral( "I (430) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-S3 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF " - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"), rejectedIdentity); const bool rejectsMissingFirmware = !DeviceController::parseIdentityLine( QStringLiteral( "I (435) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 boot_id=0123456789ABCDEF " - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"), rejectedIdentity); const bool rejectsMalformedBoot = !DeviceController::parseIdentityLine( QStringLiteral( "I (440) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=NOT-A-BOOT-ID " - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"), rejectedIdentity); const bool boundedIdentifyPolicy = @@ -445,12 +450,6 @@ int main(int argc, char* argv[]) { } FirmwareManager firmwareService; - QObject::connect( - &app, - &QCoreApplication::aboutToQuit, - &firmwareService, - &FirmwareManager::shutdown, - Qt::DirectConnection); qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); @@ -476,6 +475,37 @@ int main(int argc, char* argv[]) { mainWindow->installEventFilter(&primaryCloseFilter); } + QPointer lifecycleDevice; + QPointer lifecycleSession; + if (mainWindow != nullptr) { + lifecycleDevice = mainWindow->findChild(); + lifecycleSession = mainWindow->findChild(); + } + if (mainWindow != nullptr && (lifecycleDevice.isNull() || lifecycleSession.isNull())) { + qCritical().noquote() + << "Application lifecycle owner discovery failed; refusing an unowned worker/session lifetime."; + return 14; + } + + bool lifecycleCoordinatorInvoked = false; + bool lifecycleShutdownClean = false; + QObject::connect( + &app, + &QCoreApplication::aboutToQuit, + &app, + [&] { + lifecycleCoordinatorInvoked = true; + if (!lifecycleSession.isNull()) lifecycleSession->shutdown(); + const bool deviceClean = lifecycleDevice.isNull() || lifecycleDevice->shutdown(); + const bool firmwareClean = firmwareService.shutdown(); + lifecycleShutdownClean = deviceClean && firmwareClean; + if (!lifecycleShutdownClean) { + qCritical().noquote() + << "Application shutdown required an emergency worker-retirement fallback."; + } + }, + Qt::DirectConnection); + if (lifecycleCheck) { if (mainWindow == nullptr) { qCritical().noquote() << "Application lifecycle regression: main window was not created."; @@ -490,15 +520,14 @@ int main(int argc, char* argv[]) { return; } - QCloseEvent closeEvent; - QCoreApplication::sendEvent(lifecycleTarget.data(), &closeEvent); + lifecycleTarget->close(); if (!primaryCloseFilter.closeObserved()) { - qCritical().noquote() << "Application lifecycle regression: primary QEvent::Close bypassed the lifetime filter."; + qCritical().noquote() << "Application lifecycle regression: normal primary-window close bypassed the lifetime filter."; QCoreApplication::exit(10); return; } - - QCoreApplication::exit(0); + // Do not call exit() here. The regression must prove that the real + // primary-window close path itself terminates the process. }); QTimer::singleShot(3500, &app, [] { qCritical().noquote() << "Application lifecycle regression: primary close was not observed in time."; @@ -507,8 +536,14 @@ int main(int argc, char* argv[]) { } const int result = app.exec(); + if (!lifecycleCoordinatorInvoked || !lifecycleShutdownClean) { + qCritical().noquote() + << "Application lifecycle regression: shutdown coordinator did not retire all workers cleanly."; + return result == 0 ? 15 : result; + } if (lifecycleCheck && result == 0) { - qInfo().noquote() << "Application lifecycle regression: PASS · primary window close reached the lifetime filter"; + qInfo().noquote() + << "Application lifecycle regression: PASS · close -> supervisor stop -> worker join -> process exit"; } return result; } \ No newline at end of file diff --git a/docs/PTP_LAB_TIMING.md b/docs/PTP_LAB_TIMING.md index eecf1ccf2..623d47f35 100644 --- a/docs/PTP_LAB_TIMING.md +++ b/docs/PTP_LAB_TIMING.md @@ -105,7 +105,7 @@ The PTP task runs separately from the Sampled Values realtime task. It does not ## Configuration -PTP transmission is **off by default**. +The portable PTP feature remains Kconfig-guarded, but the ARStack Studio **v0.1.1 production firmware profile enables it by default** in `LAB_SOURCE` mode. Custom/experimental firmware may still disable `CONFIG_AR_PTP_LAB_TX` explicitly. Relevant ESP32-P4 configuration options: diff --git a/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md index fea433a76..3e5e53cc4 100644 --- a/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md +++ b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md @@ -122,7 +122,7 @@ Recommended firmware response/announcement: ```text ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 \ device_id= firmware= boot_id= \ - capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE + capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO ``` The existing EFUSE-derived `device_id` remains the stable physical identity. Add a per-boot `boot_id` so Studio can distinguish a reboot/reflash from a delayed line belonging to the previous session. diff --git a/docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md b/docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md new file mode 100644 index 000000000..100e16eea --- /dev/null +++ b/docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md @@ -0,0 +1,51 @@ +# ARStack Studio v0.1.1 — PTP production integration + +Status: **release candidate integration**. + +## Purpose + +v0.1.1 promotes the already-implemented ARIEC61850/ARSVIN-derived PTP stack into the normal ESP32-P4 production firmware instead of shipping it compiled out. No second PTP engine is introduced. + +## Production default + +The normal firmware image compiles `CONFIG_AR_PTP_LAB_TX=y` and starts in `LAB_SOURCE` mode with conservative ARSVIN-compatible defaults: + +- Layer-2 PTPv2 (`0x88F7`) +- domain 0 +- `transportSpecific=0` +- untagged PTP by default +- Announce every 1000 ms +- two-step Sync every 250 ms +- Pdelay response enabled +- clockClass 248 +- clockAccuracy Unknown +- internal-oscillator time source + +The ESP32-P4 adapter uses its IEEE1588 hardware clock and hardware TX/RX descriptor timestamps for the existing two-step Sync and peer-delay paths. + +## Roles retained + +- `LAB_SOURCE` — broadcasts Announce / Sync / Follow_Up and answers peer delay. +- `TIME_RECEIVER` — external-source selection, hardware timestamp correlation, path/offset measurement and bounded clock discipline. +- `MONITOR` — passive timing observation. + +Role changes remain controlled by the existing stopped-only profile path. + +## smpSynch truth boundary + +`LAB_SOURCE` does **not** make the locally generated SMV clock externally disciplined. `AUTO` therefore remains `smpSynch=0` in source/monitor mode. Only measured `TIME_RECEIVER` discipline may promote AUTO to 1/2 according to the existing P2 evidence policy. Forced 0/1/2 values remain explicit laboratory stimuli. + +## Product capability contract + +The v0.1.1 semantic identity and firmware manifest advertise `PTP-P2` and `SMPSYNCH-AUTO`. Studio v0.1.1 requires these capabilities in addition to the existing SMV/live/session contract, so an older PTP-less production image cannot silently present as current. + +## Release gate + +Software CI must prove: + +1. the normal production firmware build has PTP-P2 enabled; +2. the dedicated PTP matrix still builds SOURCE and TIME_RECEIVER; +3. the Windows Studio/package contract accepts the v0.1.1 firmware identity and manifest; +4. no IED Simulator source is changed by this integration. + +Physical closeout is intentionally short and product-focused: verify visible Announce/Sync/Follow_Up, matching Pdelay response when requested, and simultaneous SMV 4000 fps with zero missed/TX failures over a practical bench interval. This is an interoperability/product claim, not GPS traceability or formal IEEE/IEC conformance certification. diff --git a/embedded/esp32p4_smv_injector/CMakeLists.txt b/embedded/esp32p4_smv_injector/CMakeLists.txt index d1ac04ce5..4a750412c 100644 --- a/embedded/esp32p4_smv_injector/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/CMakeLists.txt @@ -2,7 +2,35 @@ cmake_minimum_required(VERSION 3.16) # Keep the device identity version aligned with the Studio P0 release bundle. # ESP-IDF exports this as the application version through esp_app_desc. -set(PROJECT_VER "0.1.0") +set(PROJECT_VER "0.1.1") + +# Semantic firmware provenance. Explicit overrides are useful for controlled +# local builds. GitHub Actions uses the exact workflow commit, which is also +# written to the release manifest as sourceCommit. Local repository builds +# fall back to HEAD. Unknown provenance must never produce a flashable binary. +if(DEFINED ENV{ARSTACK_FIRMWARE_BUILD_ID} AND NOT "$ENV{ARSTACK_FIRMWARE_BUILD_ID}" STREQUAL "") + set(ARSTACK_FIRMWARE_BUILD_ID "$ENV{ARSTACK_FIRMWARE_BUILD_ID}") +elseif(DEFINED ENV{GITHUB_SHA} AND NOT "$ENV{GITHUB_SHA}" STREQUAL "") + string(SUBSTRING "$ENV{GITHUB_SHA}" 0 16 ARSTACK_FIRMWARE_BUILD_ID) +else() + execute_process( + COMMAND git rev-parse --short=16 HEAD + WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/../.." + OUTPUT_VARIABLE ARSTACK_FIRMWARE_BUILD_ID + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE ARSTACK_FIRMWARE_BUILD_ID_RESULT) + if(NOT ARSTACK_FIRMWARE_BUILD_ID_RESULT EQUAL 0) + message(FATAL_ERROR "Unable to establish semantic firmware build provenance") + endif() +endif() +string(TOLOWER "${ARSTACK_FIRMWARE_BUILD_ID}" ARSTACK_FIRMWARE_BUILD_ID) +string(LENGTH "${ARSTACK_FIRMWARE_BUILD_ID}" ARSTACK_FIRMWARE_BUILD_ID_LENGTH) +if(NOT ARSTACK_FIRMWARE_BUILD_ID_LENGTH EQUAL 16 OR NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]+$") + message(FATAL_ERROR "ARSTACK_FIRMWARE_BUILD_ID must be exactly 16 hexadecimal characters") +endif() +set(ARSTACK_FIRMWARE_BUILD_ID "${ARSTACK_FIRMWARE_BUILD_ID}" CACHE INTERNAL "ARStack semantic firmware build ID") +message(STATUS "ARStack semantic firmware build ID: ${ARSTACK_FIRMWARE_BUILD_ID}") include($ENV{IDF_PATH}/tools/cmake/project.cmake) -project(arstack_esp32p4_smv_injector) +project(arstack_esp32p4_smv_injector) \ No newline at end of file diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index 48e478b6b..8cf84571a 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -20,6 +20,8 @@ idf_component_register( PRIV_REQUIRES esp_app_format esp_hw_support) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) +target_compile_definitions(${COMPONENT_LIB} PRIVATE + ARSTACK_FIRMWARE_BUILD_ID="${ARSTACK_FIRMWARE_BUILD_ID}") if(CONFIG_AR_PTP_LAB_TX) # P1.5's proven lab-source implementation remains intact. Rename only its diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index ee41f223d..2e49e16d4 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -34,6 +34,7 @@ namespace { using ar::esp32p4::smv::RuntimePublisherProfile; using ar::esp32p4::smv::live_control_bind_publisher_task; +using ar::esp32p4::smv::live_control_force_stop; using ar::esp32p4::smv::live_control_initialize; using ar::esp32p4::smv::live_control_task; using ar::esp32p4::smv::live_signal_snapshot; @@ -58,6 +59,7 @@ constexpr char kMirrorSvId[] = "AR_DIAG_SV1"; constexpr std::uint16_t kSampleModeSamplesPerSecond = 1U; constexpr std::uint16_t kDiagnosticEtherType = 0x88B5U; constexpr std::uint32_t kStatsMergeEvery = 400U; +constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz constexpr std::uint32_t kTimerResolutionHz = 1000000U; EventGroupHandle_t g_link_events = nullptr; @@ -429,6 +431,8 @@ void publisher_task(void* argument) { TimingStats local{}; bool schedule_anchored = false; std::int64_t expected_wake_us = 0; + std::uint32_t consecutive_canonical_tx_failures = 0U; + esp_err_t last_canonical_tx_error = ESP_OK; #if CONFIG_AR_SMV_DIAGNOSTIC_PROBES std::array probe_broadcast{}; @@ -466,6 +470,8 @@ void publisher_task(void* argument) { local = TimingStats{}; reset_interval_stats(); schedule_anchored = false; + consecutive_canonical_tx_failures = 0U; + last_canonical_tx_error = ESP_OK; expected_schedule.reset(active_profile.publisher_rate_hz); static_cast(expected_schedule.next_ticks()); // first alarm interval ESP_LOGI(kTag, @@ -529,6 +535,8 @@ void publisher_task(void* argument) { if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { ++local.canonical_fail; + last_canonical_tx_error = ESP_ERR_INVALID_STATE; + ++consecutive_canonical_tx_failures; #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; #endif @@ -536,8 +544,15 @@ void publisher_task(void* argument) { patch_packet(canonical, canonical_sample_count, row, signal_state); const esp_err_t canonical_result = esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()); - if (canonical_result == ESP_OK) ++local.canonical_ok; - else ++local.canonical_fail; + if (canonical_result == ESP_OK) { + ++local.canonical_ok; + consecutive_canonical_tx_failures = 0U; + last_canonical_tx_error = ESP_OK; + } else { + ++local.canonical_fail; + last_canonical_tx_error = canonical_result; + ++consecutive_canonical_tx_failures; + } #if CONFIG_AR_SMV_BROADCAST_MIRROR patch_packet(mirror, mirror_sample_count, row, signal_state); @@ -560,6 +575,18 @@ void publisher_task(void* argument) { update_local_timing( local, lateness_us, missed_slots, canonical_sample_count, signal_state.generation); g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); + if (consecutive_canonical_tx_failures >= kTransportFailureStopThreshold) { + ESP_LOGE(kTag, + "SV transport fault: %lu consecutive canonical TX failures; last=%s; forcing STOP", + static_cast(consecutive_canonical_tx_failures), + esp_err_to_name(last_canonical_tx_error)); + merge_stats(local); + live_control_force_stop(); + stop_sample_clock(sample_clock); + schedule_anchored = false; + consecutive_canonical_tx_failures = 0U; + continue; + } canonical_sample_count = static_cast( (canonical_sample_count + 1U) % active_profile.sample_counter_modulus); mirror_sample_count = static_cast(mirror_sample_count + 1U); diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index 0f07483e9..b7217280b 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -5,6 +5,8 @@ #include "esp_err.h" #include "esp_eth_mac_esp.h" #include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "sdkconfig.h" #if CONFIG_AR_PTP_LAB_TX @@ -30,6 +32,28 @@ enum { AR_RMII_RXD1_GPIO = 30, }; +#if CONFIG_AR_PTP_LAB_TX +static void ar_esp32p4_restore_normal_emac(void) +{ + if (s_eth_handle == NULL) { + return; + } + + bool enable = false; + const esp_err_t result = esp_eth_ioctl( + s_eth_handle, + (esp_eth_io_cmd_t)ETH_MAC_ESP_CMD_PTP_ENABLE, + &enable); + if (result != ESP_OK) { + ESP_LOGW(TAG, + "Unable to restore normal EMAC mode after failed PTP start: %s", + esp_err_to_name(result)); + } else { + ESP_LOGI(TAG, "Normal EMAC mode restored after failed PTP start"); + } +} +#endif + esp_eth_handle_t ar_esp32p4_eth_init(void) { eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); @@ -76,15 +100,6 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) } s_eth_handle = handle; -#if CONFIG_AR_PTP_LAB_TX - // Boot auto-start uses the same checked admission/readiness contract as the - // live serial control path. A rejected or failed start is never reported as - // running merely because an older runtime is still cleaning up. - if (!ar_ptp_lab_try_start(handle)) { - ESP_LOGW(TAG, "PTP auto-start was rejected or could not start"); - } -#endif - ESP_LOGI(TAG, "Ethernet configured: PHY addr=%d MDC=%d MDIO=%d REF_CLK=%d", AR_PHY_ADDRESS, AR_MDC_GPIO, AR_MDIO_GPIO, AR_RMII_REF_CLK_GPIO); @@ -95,9 +110,25 @@ bool ar_esp32p4_ptp_start(void) { #if CONFIG_AR_PTP_LAB_TX if (s_eth_handle == NULL) { + ESP_LOGE(TAG, "PTP start rejected: Ethernet driver is not initialized"); return false; } - return ar_ptp_lab_try_start(s_eth_handle); + + // SOURCE start is accepted only after verified Announce + Sync + Follow_Up + // transmission. A failed start must not leave the shared EMAC in IEEE1588 + // timestamp mode because SMV uses the same MAC immediately afterwards. + const bool started = ar_ptp_lab_try_start(s_eth_handle); + if (started) { + return true; + } + + ar_ptp_lab_stop(); + // The PTP task owns RX callback/runtime teardown. Give that bounded cleanup + // time to retire before restoring the MAC timestamp mode. This path executes + // only after the 1.6 s verified-start timeout/failure, never in the SMV hot path. + vTaskDelay(pdMS_TO_TICKS(50)); + ar_esp32p4_restore_normal_emac(); + return false; #else return false; #endif diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 553de97fd..5e962448b 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -236,15 +236,22 @@ void print_identity() noexcept { } const esp_app_desc_t* app = esp_app_get_description(); const char* version = app != nullptr ? app->version : "unknown"; +#if CONFIG_AR_PTP_LAB_TX + const char* capabilities = g_control_lease_timer != nullptr + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"; +#else const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE" - : "SMV-4I4V,LIVE-SETPOINTS"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS"; +#endif ESP_LOGI(kTag, - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s boot_id=%016llX capabilities=%s", + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s build=%s boot_id=%016llX capabilities=%s", static_cast(device_id[0]), static_cast(device_id[1]), static_cast(device_id[2]), static_cast(device_id[3]), static_cast(device_id[4]), static_cast(device_id[5]), version, + ARSTACK_FIRMWARE_BUILD_ID, static_cast(g_boot_id), capabilities); } @@ -308,8 +315,11 @@ void handle_ptp_command(char* save) noexcept { ESP_LOGE(kTag, "Usage: PTP START"); return; } - if (!ar_esp32p4_ptp_start()) ESP_LOGE(kTag, "PTP start rejected: Ethernet is unavailable"); - else ESP_LOGI(kTag, "PTP start accepted"); + if (!ar_esp32p4_ptp_start()) { + ESP_LOGE(kTag, "PTP start rejected: Ethernet link or role runtime readiness failed"); + } else { + ESP_LOGI(kTag, "PTP start accepted"); + } print_ptp_state(); return; } @@ -637,6 +647,7 @@ void live_control_force_stop() noexcept { g_start_request.store(false, std::memory_order_release); clear_control_session(); wake_publisher(); + ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime"); } SvLiveSignalState live_signal_snapshot() noexcept { diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index bdbce7dcc..347760d8c 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -77,6 +77,7 @@ struct PtpLabContext final { PtpLabContext g_ptp_context{}; std::atomic_bool g_ptp_started{false}; +std::atomic_bool g_ptp_ready{false}; std::atomic_bool g_stop_requested{false}; std::atomic_bool g_ptp_accept_rx{false}; std::atomic g_announce_sent{0U}; @@ -102,11 +103,13 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { config.domain_number = static_cast(CONFIG_AR_PTP_DOMAIN); #if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN config.vlan_enabled = true; + config.vlan_id = static_cast(CONFIG_AR_PTP_VLAN_ID); + config.vlan_priority = static_cast(CONFIG_AR_PTP_VLAN_PRIORITY); #else config.vlan_enabled = false; + config.vlan_id = 0U; + config.vlan_priority = 0U; #endif - config.vlan_id = static_cast(CONFIG_AR_PTP_VLAN_ID); - config.vlan_priority = static_cast(CONFIG_AR_PTP_VLAN_PRIORITY); config.port_number = static_cast(CONFIG_AR_PTP_PORT_NUMBER); config.announce_interval_ms = static_cast(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); config.sync_interval_ms = static_cast(CONFIG_AR_PTP_SYNC_INTERVAL_MS); @@ -272,6 +275,19 @@ void record_live_sent(const PtpMessageType message_type) noexcept { return true; } +void disable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { + if (eth_handle == nullptr) return; + bool enable = false; + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_PTP_ENABLE), + &enable); + if (result != ESP_OK) { + ESP_LOGW(kTag, "Unable to restore normal EMAC mode after PTP SOURCE: %s", + esp_err_to_name(result)); + } +} + void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { eth_mac_time_t initial_time{}; const std::time_t system_time = std::time(nullptr); @@ -304,7 +320,12 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { const esp_eth_handle_t eth_handle, std::vector& frame) noexcept { if (frame.empty()) return false; - return esp_eth_transmit(eth_handle, frame.data(), frame.size()) == ESP_OK; + const auto result = esp_eth_transmit(eth_handle, frame.data(), frame.size()); + if (result != ESP_OK) { + ESP_LOGE(kTag, "PTP general TX failed: %s", esp_err_to_name(result)); + return false; + } + return true; } [[nodiscard]] bool transmit_with_hw_timestamp( @@ -313,15 +334,24 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { PtpTimestamp& timestamp) noexcept { if (frame.empty()) return false; eth_mac_time_t tx_timestamp{}; - // esp_eth_transmit_ctrl_vargs argc counts buffer/length pairs. One PTP - // Ethernet frame is exactly one pair. + // ESP-IDF 5.5 forwards argc as the number of variadic arguments. The + // ESP32-P4 EMAC implementation computes buf_num = argc / 2, therefore one + // Ethernet frame requires two arguments: buffer pointer + buffer length. const auto result = esp_eth_transmit_ctrl_vargs( eth_handle, &tx_timestamp, - 1U, + 2U, frame.data(), frame.size()); - if (result != ESP_OK || !valid_hw_timestamp(tx_timestamp)) { + if (result != ESP_OK) { + ESP_LOGE(kTag, "PTP hardware-timestamp TX failed: %s", esp_err_to_name(result)); + return false; + } + if (!valid_hw_timestamp(tx_timestamp)) { + ESP_LOGE(kTag, + "PTP TX completed without a valid hardware timestamp: sec=%lu ns=%lu", + static_cast(tx_timestamp.seconds), + static_cast(tx_timestamp.nanoseconds)); return false; } timestamp = to_ptp_timestamp(tx_timestamp); @@ -465,7 +495,9 @@ void finish_runtime(PtpLabContext& context) { status.last_error.empty() ? "" : " lastError=", status.last_error.empty() ? "" : status.last_error.c_str()); } + disable_hardware_ptp(context.eth_handle); context.task_handle = nullptr; + g_ptp_ready.store(false, std::memory_order_release); g_ptp_started.store(false, std::memory_order_release); } @@ -571,6 +603,8 @@ void ptp_lab_task(void* argument) { if (success) { consecutive_failures = 0U; context.runtime->clear_error(); + const bool emitted_core_timing = g_announce_sent.load(std::memory_order_relaxed) > 0U && g_sync_sent.load(std::memory_order_relaxed) > 0U && g_follow_up_sent.load(std::memory_order_relaxed) > 0U; + if (emitted_core_timing) g_ptp_ready.store(true, std::memory_order_release); } else { context.runtime->record_error("Ethernet transmit or hardware timestamp failure"); g_tx_failure_count.fetch_add(1U, std::memory_order_relaxed); @@ -603,6 +637,8 @@ void ptp_lab_task(void*) { #endif +void stop_ptp_lab() noexcept; + void start_ptp_lab(const esp_eth_handle_t eth_handle) { if (eth_handle == nullptr) { ESP_LOGE(kTag, "PTP lab broadcaster not started: Ethernet handle is null"); @@ -618,6 +654,7 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { portEXIT_CRITICAL(&g_control_mux); reset_live_status(); + g_ptp_ready.store(false, std::memory_order_release); g_ptp_accept_rx.store(false, std::memory_order_release); g_stop_requested.store(false, std::memory_order_release); g_ptp_context.eth_handle = eth_handle; @@ -647,7 +684,16 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { g_ptp_context.task_handle = nullptr; g_ptp_started.store(false, std::memory_order_release); ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0"); + return; } + + constexpr unsigned kStartReadyPolls = 160U; + for (unsigned attempt = 0U; attempt < kStartReadyPolls; ++attempt) { + if (g_ptp_ready.load(std::memory_order_acquire) || !g_ptp_started.load(std::memory_order_acquire)) return; + vTaskDelay(pdMS_TO_TICKS(10)); + } + ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX"); + stop_ptp_lab(); } void stop_ptp_lab() noexcept { @@ -656,10 +702,16 @@ void stop_ptp_lab() noexcept { if (g_ptp_context.task_handle != nullptr) { xTaskNotifyGive(g_ptp_context.task_handle); } + constexpr unsigned kCleanupPolls = 100U; + for (unsigned attempt = 0U; attempt < kCleanupPolls; ++attempt) { + if (!g_ptp_started.load(std::memory_order_acquire)) return; + vTaskDelay(pdMS_TO_TICKS(5)); + } + ESP_LOGE(kTag, "PTP SOURCE cleanup timeout; EMAC rollback incomplete after 500 ms"); } [[nodiscard]] bool ptp_lab_is_running() noexcept { - return g_ptp_started.load(std::memory_order_acquire); + return g_ptp_ready.load(std::memory_order_acquire); } [[nodiscard]] bool configure_ptp_lab(const ar_ptp_lab_config_t& config) { @@ -715,7 +767,7 @@ extern "C" bool ar_ptp_lab_is_running(void) { extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { if (status == nullptr) return false; - status->is_running = ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire); + status->is_running = ar::esp32p4::smv::g_ptp_ready.load(std::memory_order_acquire); status->announce_sent = ar::esp32p4::smv::g_announce_sent.load(std::memory_order_relaxed); status->sync_sent = ar::esp32p4::smv::g_sync_sent.load(std::memory_order_relaxed); status->follow_up_sent = ar::esp32p4::smv::g_follow_up_sent.load(std::memory_order_relaxed); diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp index 7a248d57e..74d5efda0 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp @@ -147,6 +147,19 @@ ar_ptp_lab_status_t g_receiver_status{}; return true; } +void disable_hardware_ptp(const esp_eth_handle_t handle) noexcept { + if (handle == nullptr) return; + bool enable = false; + const auto result = esp_eth_ioctl( + handle, + static_cast(ETH_MAC_ESP_CMD_PTP_ENABLE), + &enable); + if (result != ESP_OK) { + ESP_LOGW(kTag, "Unable to restore normal EMAC mode after PTP receiver/monitor: %s", + esp_err_to_name(result)); + } +} + void seed_hardware_clock(const esp_eth_handle_t handle) noexcept { eth_mac_time_t current{}; if (esp_eth_ioctl( @@ -176,15 +189,26 @@ void seed_hardware_clock(const esp_eth_handle_t handle) noexcept { PtpTimestamp& timestamp) noexcept { if (frame.empty()) return false; eth_mac_time_t tx_timestamp{}; - // esp_eth_transmit_ctrl_vargs argc counts buffer/length pairs. One PTP - // Ethernet frame is exactly one pair. + // ESP-IDF 5.5 forwards argc as the number of variadic arguments. The + // ESP32-P4 EMAC implementation computes buf_num = argc / 2, therefore one + // Ethernet frame requires two arguments: buffer pointer + buffer length. const auto result = esp_eth_transmit_ctrl_vargs( handle, &tx_timestamp, - 1U, + 2U, frame.data(), frame.size()); - if (result != ESP_OK || !valid_hw_timestamp(tx_timestamp)) return false; + if (result != ESP_OK) { + ESP_LOGE(kTag, "PTP receiver hardware-timestamp TX failed: %s", esp_err_to_name(result)); + return false; + } + if (!valid_hw_timestamp(tx_timestamp)) { + ESP_LOGE(kTag, + "PTP receiver TX completed without a valid hardware timestamp: sec=%lu ns=%lu", + static_cast(tx_timestamp.seconds), + static_cast(tx_timestamp.nanoseconds)); + return false; + } timestamp = to_ptp_timestamp(tx_timestamp); return true; } @@ -669,6 +693,7 @@ void finish_receiver(ReceiverContext& context) noexcept { context.eth_handle, 0, context.applied_frequency_ppb)); } smp_synch_lab_set_measured(std::nullopt); + disable_hardware_ptp(context.eth_handle); context.receiver.reset(); context.discipline.reset(); context.task_handle = nullptr; @@ -923,6 +948,12 @@ void ptp_receiver_stop() noexcept { if (g_receiver_context.task_handle != nullptr) { xTaskNotifyGive(g_receiver_context.task_handle); } + constexpr unsigned kCleanupPolls = 100U; + for (unsigned attempt = 0U; attempt < kCleanupPolls; ++attempt) { + if (!g_receiver_running.load(std::memory_order_acquire)) return; + vTaskDelay(pdMS_TO_TICKS(5)); + } + ESP_LOGE(kTag, "PTP receiver cleanup timeout; EMAC rollback incomplete after 500 ms"); } bool ptp_receiver_is_running() noexcept { diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index 8e5129f5f..628e4f454 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -1,9 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "smp_synch_lab.hpp" +#include "live_control.hpp" #include "ariec61850/sampled_values/wire_field_offsets.hpp" +#include "esp_log.h" + #include #include #include @@ -16,10 +19,18 @@ namespace { using ar::iec61850::sampled_values::find_smp_synch_value_offset; using ar::iec61850::time_sync::resolve_sv_sync_policy; +constexpr char kTag[] = "ar_smv_txguard"; +constexpr std::uint16_t kDiagnosticMirrorAppId = 0x4F01U; +// At the production 4000 fps profile this trips after 100 ms of uninterrupted +// canonical SV transport failure. Mirror failures never drive this guard. +constexpr std::uint32_t kCanonicalFailureStopThreshold = 400U; + std::atomic g_mode{ static_cast(SvSyncPolicyMode::external_ptp_auto)}; // -1 means PTP-P2 has not supplied measured lock evidence yet. std::atomic g_measured_value{-1}; +std::atomic g_consecutive_canonical_tx_failures{0U}; +std::atomic_bool g_transport_fault_reported{false}; [[nodiscard]] std::optional measured_value() noexcept { const int raw = g_measured_value.load(std::memory_order_acquire); @@ -41,6 +52,47 @@ std::atomic g_measured_value{-1}; return resolve_sv_sync_policy(mode, measured_value()); } +[[nodiscard]] bool diagnostic_mirror_frame(const std::span bytes) noexcept { + // Untagged IEC 61850-9-2: EtherType 0x88BA, APPID immediately follows. + if (bytes.size() >= 16U && bytes[12] == 0x88U && bytes[13] == 0xBAU) { + const auto app_id = static_cast( + (static_cast(bytes[14]) << 8U) | bytes[15]); + return app_id == kDiagnosticMirrorAppId; + } + + // 802.1Q tagged IEC 61850-9-2: inner EtherType 0x88BA, then APPID. + if (bytes.size() >= 20U && bytes[12] == 0x81U && bytes[13] == 0x00U && + bytes[16] == 0x88U && bytes[17] == 0xBAU) { + const auto app_id = static_cast( + (static_cast(bytes[18]) << 8U) | bytes[19]); + return app_id == kDiagnosticMirrorAppId; + } + return false; +} + +void observe_canonical_tx_result(const esp_err_t result) noexcept { + if (result == ESP_OK) { + g_consecutive_canonical_tx_failures.store(0U, std::memory_order_release); + g_transport_fault_reported.store(false, std::memory_order_release); + return; + } + + const auto failures = + g_consecutive_canonical_tx_failures.fetch_add(1U, std::memory_order_acq_rel) + 1U; + if (failures < kCanonicalFailureStopThreshold) return; + + // The sample clock can be perfectly healthy while the Ethernet data plane is + // completely dead. Fail closed instead of leaving Studio green at + // "Injection running" with txFail=4000. + if (!g_transport_fault_reported.exchange(true, std::memory_order_acq_rel)) { + ESP_LOGE(kTag, + "Canonical SV transport fault: %lu consecutive TX failures (%s); forcing STOP", + static_cast(failures), + esp_err_to_name(result)); + } + live_control_force_stop(); +} + } // namespace void smp_synch_lab_set_mode(const SvSyncPolicyMode mode) noexcept { @@ -69,15 +121,21 @@ esp_err_t smp_synch_lab_transmit( const esp_eth_handle_t handle, void* buffer, const std::size_t length) noexcept { + auto bytes = std::span{}; if (buffer != nullptr) { - auto bytes = std::span{static_cast(buffer), length}; + bytes = std::span{static_cast(buffer), length}; const auto value_offset = find_smp_synch_value_offset(bytes); if (value_offset.has_value()) { const auto decision = current_decision(); bytes[*value_offset] = static_cast(decision.value); } } - return ::esp_eth_transmit(handle, buffer, length); + + const auto result = ::esp_eth_transmit(handle, buffer, length); + if (!diagnostic_mirror_frame(bytes)) { + observe_canonical_tx_result(result); + } + return result; } } // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index 3f9d52ca4..3ca61f730 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -9,6 +9,10 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_FREERTOS_HZ=1000 CONFIG_ETH_ENABLED=y CONFIG_ETH_USE_ESP32_EMAC=y +# P0 staging guard: CONFIG_ETH_USE_ESP32_EMAC=y +# v0.1.1 has concurrent SMV and PTP L2 producers sharing one ESP32-P4 EMAC. +# Serialize all driver TX paths so normal SV TX and timestamped PTP TX cannot race. +CONFIG_ETH_TRANSMIT_MUTEX=y CONFIG_AR_SMV_LIVE_TX=y CONFIG_AR_SMV_P2_REALTIME_50HZ=y CONFIG_AR_SMV_CANONICAL_VLAN=y @@ -16,3 +20,32 @@ CONFIG_AR_SMV_BROADCAST_MIRROR=y # CONFIG_AR_SMV_DIAGNOSTIC_PROBES is not set CONFIG_AR_SMV_CURRENT_RMS_MA=1000 CONFIG_AR_SMV_VOLTAGE_RMS_MV=57740 + +# v0.1.1 production timing companion. LAB_SOURCE preserves the existing +# ARIEC61850/ARSVIN lab behavior while using ESP32-P4 IEEE1588 HW timestamps. +CONFIG_AR_PTP_LAB_TX=y +CONFIG_AR_PTP_OPERATING_ROLE=0 +CONFIG_AR_PTP_DOMAIN=0 +CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0 +CONFIG_AR_PTP_PORT_NUMBER=1 +# CONFIG_AR_PTP_VLAN is not set +CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000 +CONFIG_AR_PTP_SYNC_INTERVAL_MS=250 +CONFIG_AR_PTP_RESPOND_PDELAY=y +CONFIG_AR_PTP_PDELAY_REQUEST_INTERVAL_MS=1000 +CONFIG_AR_PTP_PRIORITY1=128 +CONFIG_AR_PTP_PRIORITY2=128 +CONFIG_AR_PTP_CLOCK_CLASS=248 +CONFIG_AR_PTP_CLOCK_ACCURACY=254 +CONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE=65535 +CONFIG_AR_PTP_TIME_SOURCE=160 +CONFIG_AR_PTP_CURRENT_UTC_OFFSET=37 +CONFIG_AR_PTP_MAX_PATH_DELAY_NS=5000000 +CONFIG_AR_PTP_MAX_PATH_JITTER_NS=100000 +CONFIG_AR_PTP_LOCK_OFFSET_NS=2000 +CONFIG_AR_PTP_UNLOCK_OFFSET_NS=20000 +CONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS=1000000 +CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8 +CONFIG_AR_PTP_SYNC_TIMEOUT_MS=1000 +CONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS=5000 +CONFIG_AR_PTP_MAX_FREQ_ADJ_PPB=100000