From cd256bc22ec69139bd683c1e49120bc850db2775 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:12:25 +0700 Subject: [PATCH 001/170] chore: stage PTP production integration automation --- .../ptp-production-v011-integration.yml | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 .github/workflows/ptp-production-v011-integration.yml diff --git a/.github/workflows/ptp-production-v011-integration.yml b/.github/workflows/ptp-production-v011-integration.yml new file mode 100644 index 000000000..55bcb6ea8 --- /dev/null +++ b/.github/workflows/ptp-production-v011-integration.yml @@ -0,0 +1,136 @@ +name: Stage PTP production v0.1.1 integration + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + +permissions: + contents: write + +jobs: + integrate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Apply production PTP profile + shell: python + run: | + from pathlib import Path + + def replace_once(path, old, new, label): + p = Path(path) + text = p.read_text(encoding="utf-8") + if text.count(old) != 1: + raise SystemExit(f"{label}: expected exactly one match, got {text.count(old)}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + # Product patch version: first public firmware with PTP compiled into the production image. + replace_once( + "apps/arstack_studio/CMakeLists.txt", + "project(ARStackStudio VERSION 0.1.0 LANGUAGES CXX)", + "project(ARStackStudio VERSION 0.1.1 LANGUAGES CXX)", + "Studio version") + replace_once( + "embedded/esp32p4_smv_injector/CMakeLists.txt", + 'set(PROJECT_VER "0.1.0")', + 'set(PROJECT_VER "0.1.1")', + "firmware version") + replace_once( + "apps/arstack_studio/src/main.cpp", + '#define ARSTACK_STUDIO_VERSION "0.1.0"', + '#define ARSTACK_STUDIO_VERSION "0.1.1"', + "Studio fallback version") + + # Production firmware profile: preserve conservative ARSVIN-compatible source defaults, + # but compile and auto-start the hardware-timestamped PTP-P2 subsystem. + p = Path("embedded/esp32p4_smv_injector/sdkconfig.defaults") + text = p.read_text(encoding="utf-8").rstrip() + "\n" + if "CONFIG_AR_PTP_LAB_TX=" in text or "# CONFIG_AR_PTP_LAB_TX is not set" in text: + raise SystemExit("sdkconfig.defaults already contains an explicit PTP profile; manual reconciliation required") + text += '''\n# v0.1.1 production timing companion. LAB_SOURCE preserves the existing\n# ARIEC61850/ARSVIN lab behavior while using ESP32-P4 IEEE1588 HW timestamps.\nCONFIG_AR_PTP_LAB_TX=y\nCONFIG_AR_PTP_OPERATING_ROLE=0\nCONFIG_AR_PTP_DOMAIN=0\nCONFIG_AR_PTP_TRANSPORT_SPECIFIC=0\nCONFIG_AR_PTP_PORT_NUMBER=1\n# CONFIG_AR_PTP_VLAN is not set\nCONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000\nCONFIG_AR_PTP_SYNC_INTERVAL_MS=250\nCONFIG_AR_PTP_RESPOND_PDELAY=y\nCONFIG_AR_PTP_PDELAY_REQUEST_INTERVAL_MS=1000\nCONFIG_AR_PTP_PRIORITY1=128\nCONFIG_AR_PTP_PRIORITY2=128\nCONFIG_AR_PTP_CLOCK_CLASS=248\nCONFIG_AR_PTP_CLOCK_ACCURACY=254\nCONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE=65535\nCONFIG_AR_PTP_TIME_SOURCE=160\nCONFIG_AR_PTP_CURRENT_UTC_OFFSET=37\nCONFIG_AR_PTP_MAX_PATH_DELAY_NS=5000000\nCONFIG_AR_PTP_MAX_PATH_JITTER_NS=100000\nCONFIG_AR_PTP_LOCK_OFFSET_NS=2000\nCONFIG_AR_PTP_UNLOCK_OFFSET_NS=20000\nCONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS=1000000\nCONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8\nCONFIG_AR_PTP_SYNC_TIMEOUT_MS=1000\nCONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS=5000\nCONFIG_AR_PTP_MAX_FREQ_ADJ_PPB=100000\n''' + p.write_text(text, encoding="utf-8") + + # Firmware semantic identity advertises the product timing contract. + replace_once( + "embedded/esp32p4_smv_injector/main/live_control.cpp", + ''' const char* capabilities = g_control_lease_timer != nullptr\n ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"\n : "SMV-4I4V,LIVE-SETPOINTS";''', + '''#if CONFIG_AR_PTP_LAB_TX\n const char* capabilities = g_control_lease_timer != nullptr\n ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"\n : "SMV-4I4V,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO";\n#else\n const char* capabilities = g_control_lease_timer != nullptr\n ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"\n : "SMV-4I4V,LIVE-SETPOINTS";\n#endif''', + "semantic identity capabilities") + + # Studio v0.1.1 fails closed against a firmware bundle that does not carry PTP-P2. + replace_once( + "apps/arstack_studio/src/DeviceController.cpp", + ''' static const QStringList requiredCapabilities{\n QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n QStringLiteral("SESSION-LEASE")};''', + ''' static const QStringList requiredCapabilities{\n QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n QStringLiteral("SESSION-LEASE"),\n QStringLiteral("PTP-P2"),\n QStringLiteral("SMPSYNCH-AUTO")};''', + "current identity capability contract") + + p = Path("apps/arstack_studio/src/main.cpp") + text = p.read_text(encoding="utf-8") + text = text.replace( + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE", + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") + # Keep the capability-negative regression version-current and omit only PTP-P2. + text = text.replace( + "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF \"\n \"capabilities=SMV-4I4V,LIVE-SETPOINTS\"", + "device_id=A1B2C3D4E5F6 firmware=0.1.1 boot_id=0123456789ABCDEF \"\n \"capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,SMPSYNCH-AUTO\"") + p.write_text(text, encoding="utf-8") + + # Release firmware now depends on the portable time-sync core and must prove PTP is ON. + p = Path(".github/workflows/esp32p4-smv-injector.yml") + text = p.read_text(encoding="utf-8") + text = text.replace("ARSTACK_FIRMWARE_VERSION: '0.1.0'", "ARSTACK_FIRMWARE_VERSION: '0.1.1'", 1) + trigger_anchor = " - 'include/ariec61850/mms/utc_time.hpp'\n" + if text.count(trigger_anchor) != 2: + raise SystemExit(f"firmware workflow trigger anchor count={text.count(trigger_anchor)}") + text = text.replace( + trigger_anchor, + trigger_anchor + " - 'include/ariec61850/time_sync/**'\n - 'src/time_sync/**'\n") + build_anchor = ''' - name: Verify P0 pre-v3 silicon policy\n run: |\n set -euo pipefail\n grep -qx 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' sdkconfig\n echo 'ESP32-P4 pre-v3 build policy: PASS'\n''' + production_gate = build_anchor + '''\n - name: Verify production PTP-P2 profile\n run: |\n set -euo pipefail\n grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig\n grep -qx 'CONFIG_AR_PTP_DOMAIN=0' sdkconfig\n grep -qx 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0' sdkconfig\n grep -qx 'CONFIG_AR_PTP_RESPOND_PDELAY=y' sdkconfig\n grep -qx 'CONFIG_AR_PTP_SYNC_INTERVAL_MS=250' sdkconfig\n grep -qx 'CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000' sdkconfig\n echo 'Production timing profile: PASS · PTP-P2 LAB_SOURCE · HW timestamp adapter compiled'\n''' + if text.count(build_anchor) != 1: + raise SystemExit("firmware silicon gate anchor mismatch") + text = text.replace(build_anchor, production_gate, 1) + text = text.replace( + '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS"],', + '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],', + 1) + p.write_text(text, encoding="utf-8") + + p = Path(".github/workflows/arstack-studio-release.yml") + text = p.read_text(encoding="utf-8") + if text.count("ARSTACK_VERSION: '0.1.0'") != 1: + raise SystemExit("release version anchor mismatch") + p.write_text(text.replace("ARSTACK_VERSION: '0.1.0'", "ARSTACK_VERSION: '0.1.1'", 1), encoding="utf-8") + + # Correct documentation: PTP engine existed already; v0.1.1 promotes it into the product image. + replace_once( + "docs/PTP_LAB_TIMING.md", + "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.", + "PTP product default documentation") + replace_once( + "docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md", + " capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE", + " capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO", + "identity architecture capability example") + + Path("docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md").write_text('''# ARStack Studio v0.1.1 — PTP production integration\n\nStatus: **release candidate integration**.\n\n## Purpose\n\nv0.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.\n\n## Production default\n\nThe normal firmware image compiles `CONFIG_AR_PTP_LAB_TX=y` and starts in `LAB_SOURCE` mode with conservative ARSVIN-compatible defaults:\n\n- Layer-2 PTPv2 (`0x88F7`)\n- domain 0\n- `transportSpecific=0`\n- untagged PTP by default\n- Announce every 1000 ms\n- two-step Sync every 250 ms\n- Pdelay response enabled\n- clockClass 248\n- clockAccuracy Unknown\n- internal-oscillator time source\n\nThe ESP32-P4 adapter uses its IEEE1588 hardware clock and hardware TX/RX descriptor timestamps for the existing two-step Sync and peer-delay paths.\n\n## Roles retained\n\n- `LAB_SOURCE` — broadcasts Announce / Sync / Follow_Up and answers peer delay.\n- `TIME_RECEIVER` — external-source selection, hardware timestamp correlation, path/offset measurement and bounded clock discipline.\n- `MONITOR` — passive timing observation.\n\nRole changes remain controlled by the existing stopped-only profile path.\n\n## smpSynch truth boundary\n\n`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.\n\n## Product capability contract\n\nThe 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.\n\n## Release gate\n\nSoftware CI must prove:\n\n1. the normal production firmware build has PTP-P2 enabled;\n2. the dedicated PTP matrix still builds SOURCE and TIME_RECEIVER;\n3. the Windows Studio/package contract accepts the v0.1.1 firmware identity and manifest;\n4. no IED Simulator source is changed by this integration.\n\nPhysical 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.\n''', encoding="utf-8") + + Path(".github/workflows/ptp-production-v011-integration.yml").unlink() + + - name: Commit integration + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git diff --cached --name-only + git commit -m "PTP: promote hardware timing engine into v0.1.1 production firmware" + git push origin HEAD:feature/ptp-production-v0.1.1 From 9cbe678a12c2b6ec5b8da4d556a360a3f2a97a16 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:14:35 +0700 Subject: [PATCH 002/170] chore: split PTP source integration from workflow updates --- .../ptp-production-v011-integration.yml | 42 ++----------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ptp-production-v011-integration.yml b/.github/workflows/ptp-production-v011-integration.yml index 55bcb6ea8..4401a6038 100644 --- a/.github/workflows/ptp-production-v011-integration.yml +++ b/.github/workflows/ptp-production-v011-integration.yml @@ -18,7 +18,7 @@ jobs: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - name: Apply production PTP profile + - name: Apply production PTP source/config/docs shell: python run: | from pathlib import Path @@ -30,7 +30,6 @@ jobs: raise SystemExit(f"{label}: expected exactly one match, got {text.count(old)}") p.write_text(text.replace(old, new, 1), encoding="utf-8") - # Product patch version: first public firmware with PTP compiled into the production image. replace_once( "apps/arstack_studio/CMakeLists.txt", "project(ARStackStudio VERSION 0.1.0 LANGUAGES CXX)", @@ -47,8 +46,6 @@ jobs: '#define ARSTACK_STUDIO_VERSION "0.1.1"', "Studio fallback version") - # Production firmware profile: preserve conservative ARSVIN-compatible source defaults, - # but compile and auto-start the hardware-timestamped PTP-P2 subsystem. p = Path("embedded/esp32p4_smv_injector/sdkconfig.defaults") text = p.read_text(encoding="utf-8").rstrip() + "\n" if "CONFIG_AR_PTP_LAB_TX=" in text or "# CONFIG_AR_PTP_LAB_TX is not set" in text: @@ -56,14 +53,12 @@ jobs: text += '''\n# v0.1.1 production timing companion. LAB_SOURCE preserves the existing\n# ARIEC61850/ARSVIN lab behavior while using ESP32-P4 IEEE1588 HW timestamps.\nCONFIG_AR_PTP_LAB_TX=y\nCONFIG_AR_PTP_OPERATING_ROLE=0\nCONFIG_AR_PTP_DOMAIN=0\nCONFIG_AR_PTP_TRANSPORT_SPECIFIC=0\nCONFIG_AR_PTP_PORT_NUMBER=1\n# CONFIG_AR_PTP_VLAN is not set\nCONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000\nCONFIG_AR_PTP_SYNC_INTERVAL_MS=250\nCONFIG_AR_PTP_RESPOND_PDELAY=y\nCONFIG_AR_PTP_PDELAY_REQUEST_INTERVAL_MS=1000\nCONFIG_AR_PTP_PRIORITY1=128\nCONFIG_AR_PTP_PRIORITY2=128\nCONFIG_AR_PTP_CLOCK_CLASS=248\nCONFIG_AR_PTP_CLOCK_ACCURACY=254\nCONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE=65535\nCONFIG_AR_PTP_TIME_SOURCE=160\nCONFIG_AR_PTP_CURRENT_UTC_OFFSET=37\nCONFIG_AR_PTP_MAX_PATH_DELAY_NS=5000000\nCONFIG_AR_PTP_MAX_PATH_JITTER_NS=100000\nCONFIG_AR_PTP_LOCK_OFFSET_NS=2000\nCONFIG_AR_PTP_UNLOCK_OFFSET_NS=20000\nCONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS=1000000\nCONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8\nCONFIG_AR_PTP_SYNC_TIMEOUT_MS=1000\nCONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS=5000\nCONFIG_AR_PTP_MAX_FREQ_ADJ_PPB=100000\n''' p.write_text(text, encoding="utf-8") - # Firmware semantic identity advertises the product timing contract. replace_once( "embedded/esp32p4_smv_injector/main/live_control.cpp", ''' const char* capabilities = g_control_lease_timer != nullptr\n ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"\n : "SMV-4I4V,LIVE-SETPOINTS";''', '''#if CONFIG_AR_PTP_LAB_TX\n const char* capabilities = g_control_lease_timer != nullptr\n ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"\n : "SMV-4I4V,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO";\n#else\n const char* capabilities = g_control_lease_timer != nullptr\n ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"\n : "SMV-4I4V,LIVE-SETPOINTS";\n#endif''', "semantic identity capabilities") - # Studio v0.1.1 fails closed against a firmware bundle that does not carry PTP-P2. replace_once( "apps/arstack_studio/src/DeviceController.cpp", ''' static const QStringList requiredCapabilities{\n QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n QStringLiteral("SESSION-LEASE")};''', @@ -75,40 +70,11 @@ jobs: text = text.replace( "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE", "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") - # Keep the capability-negative regression version-current and omit only PTP-P2. text = text.replace( "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF \"\n \"capabilities=SMV-4I4V,LIVE-SETPOINTS\"", "device_id=A1B2C3D4E5F6 firmware=0.1.1 boot_id=0123456789ABCDEF \"\n \"capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,SMPSYNCH-AUTO\"") p.write_text(text, encoding="utf-8") - # Release firmware now depends on the portable time-sync core and must prove PTP is ON. - p = Path(".github/workflows/esp32p4-smv-injector.yml") - text = p.read_text(encoding="utf-8") - text = text.replace("ARSTACK_FIRMWARE_VERSION: '0.1.0'", "ARSTACK_FIRMWARE_VERSION: '0.1.1'", 1) - trigger_anchor = " - 'include/ariec61850/mms/utc_time.hpp'\n" - if text.count(trigger_anchor) != 2: - raise SystemExit(f"firmware workflow trigger anchor count={text.count(trigger_anchor)}") - text = text.replace( - trigger_anchor, - trigger_anchor + " - 'include/ariec61850/time_sync/**'\n - 'src/time_sync/**'\n") - build_anchor = ''' - name: Verify P0 pre-v3 silicon policy\n run: |\n set -euo pipefail\n grep -qx 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' sdkconfig\n echo 'ESP32-P4 pre-v3 build policy: PASS'\n''' - production_gate = build_anchor + '''\n - name: Verify production PTP-P2 profile\n run: |\n set -euo pipefail\n grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig\n grep -qx 'CONFIG_AR_PTP_DOMAIN=0' sdkconfig\n grep -qx 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0' sdkconfig\n grep -qx 'CONFIG_AR_PTP_RESPOND_PDELAY=y' sdkconfig\n grep -qx 'CONFIG_AR_PTP_SYNC_INTERVAL_MS=250' sdkconfig\n grep -qx 'CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000' sdkconfig\n echo 'Production timing profile: PASS · PTP-P2 LAB_SOURCE · HW timestamp adapter compiled'\n''' - if text.count(build_anchor) != 1: - raise SystemExit("firmware silicon gate anchor mismatch") - text = text.replace(build_anchor, production_gate, 1) - text = text.replace( - '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS"],', - '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],', - 1) - p.write_text(text, encoding="utf-8") - - p = Path(".github/workflows/arstack-studio-release.yml") - text = p.read_text(encoding="utf-8") - if text.count("ARSTACK_VERSION: '0.1.0'") != 1: - raise SystemExit("release version anchor mismatch") - p.write_text(text.replace("ARSTACK_VERSION: '0.1.0'", "ARSTACK_VERSION: '0.1.1'", 1), encoding="utf-8") - - # Correct documentation: PTP engine existed already; v0.1.1 promotes it into the product image. replace_once( "docs/PTP_LAB_TIMING.md", "PTP transmission is **off by default**.", @@ -122,14 +88,12 @@ jobs: Path("docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md").write_text('''# ARStack Studio v0.1.1 — PTP production integration\n\nStatus: **release candidate integration**.\n\n## Purpose\n\nv0.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.\n\n## Production default\n\nThe normal firmware image compiles `CONFIG_AR_PTP_LAB_TX=y` and starts in `LAB_SOURCE` mode with conservative ARSVIN-compatible defaults:\n\n- Layer-2 PTPv2 (`0x88F7`)\n- domain 0\n- `transportSpecific=0`\n- untagged PTP by default\n- Announce every 1000 ms\n- two-step Sync every 250 ms\n- Pdelay response enabled\n- clockClass 248\n- clockAccuracy Unknown\n- internal-oscillator time source\n\nThe ESP32-P4 adapter uses its IEEE1588 hardware clock and hardware TX/RX descriptor timestamps for the existing two-step Sync and peer-delay paths.\n\n## Roles retained\n\n- `LAB_SOURCE` — broadcasts Announce / Sync / Follow_Up and answers peer delay.\n- `TIME_RECEIVER` — external-source selection, hardware timestamp correlation, path/offset measurement and bounded clock discipline.\n- `MONITOR` — passive timing observation.\n\nRole changes remain controlled by the existing stopped-only profile path.\n\n## smpSynch truth boundary\n\n`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.\n\n## Product capability contract\n\nThe 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.\n\n## Release gate\n\nSoftware CI must prove:\n\n1. the normal production firmware build has PTP-P2 enabled;\n2. the dedicated PTP matrix still builds SOURCE and TIME_RECEIVER;\n3. the Windows Studio/package contract accepts the v0.1.1 firmware identity and manifest;\n4. no IED Simulator source is changed by this integration.\n\nPhysical 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.\n''', encoding="utf-8") - Path(".github/workflows/ptp-production-v011-integration.yml").unlink() - - - name: Commit integration + - name: Commit source integration shell: bash run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A + git add apps/arstack_studio/CMakeLists.txt apps/arstack_studio/src/DeviceController.cpp apps/arstack_studio/src/main.cpp docs/PTP_LAB_TIMING.md docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md embedded/esp32p4_smv_injector/CMakeLists.txt embedded/esp32p4_smv_injector/main/live_control.cpp embedded/esp32p4_smv_injector/sdkconfig.defaults git diff --cached --check git diff --cached --name-only git commit -m "PTP: promote hardware timing engine into v0.1.1 production firmware" From 63d050f5f19284fa054d9dc577970a9bf4412a29 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:14:43 +0000 Subject: [PATCH 003/170] PTP: promote hardware timing engine into v0.1.1 production firmware --- apps/arstack_studio/CMakeLists.txt | 2 +- apps/arstack_studio/src/DeviceController.cpp | 4 +- apps/arstack_studio/src/main.cpp | 16 +++--- docs/PTP_LAB_TIMING.md | 2 +- ...V_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md | 2 +- docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md | 51 +++++++++++++++++++ embedded/esp32p4_smv_injector/CMakeLists.txt | 2 +- .../main/live_control.cpp | 6 +++ .../esp32p4_smv_injector/sdkconfig.defaults | 29 +++++++++++ 9 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 05f47d343..33e2013a8 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) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index ae72cf0e9..3ed045c04 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -317,7 +317,9 @@ bool DeviceController::identitySupportsCurrentContract( static const QStringList requiredCapabilities{ QStringLiteral("SMV-4I4V"), QStringLiteral("LIVE-SETPOINTS"), - QStringLiteral("SESSION-LEASE")}; + QStringLiteral("SESSION-LEASE"), + QStringLiteral("PTP-P2"), + QStringLiteral("SMPSYNCH-AUTO")}; if (identity.product != QStringLiteral("SMV-INJECTOR") || identity.target != QStringLiteral("ESP32-P4") || identity.protocolVersion != QStringLiteral("1") || diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 31c8e981d..e0fd276f7 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 { @@ -190,7 +190,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { 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") + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); const bool currentParsed = DeviceController::parseIdentityLine(currentLine, currentIdentity); const bool currentAccepted = currentParsed && @@ -200,7 +200,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { 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 capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); const bool legacyParsed = DeviceController::parseIdentityLine(legacyLine, legacyIdentity); const bool legacyRejectedAsCurrent = legacyParsed && @@ -220,8 +220,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 +231,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 = 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..17b0eb373 100644 --- a/embedded/esp32p4_smv_injector/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/CMakeLists.txt @@ -2,7 +2,7 @@ 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") include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(arstack_esp32p4_smv_injector) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 553de97fd..4bb3acd9c 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -236,9 +236,15 @@ 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,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO" + : "SMV-4I4V,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"; +#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", static_cast(device_id[0]), static_cast(device_id[1]), diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index 3f9d52ca4..c3976c4b9 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -16,3 +16,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 From b39931fc0b6598ec2c45fee06c151635127c03bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:17:15 +0700 Subject: [PATCH 004/170] ci: make PTP-P2 part of production SMV firmware contract --- .github/workflows/esp32p4-smv-injector.yml | 26 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index b43450b0c..fc22b6e13 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,10 +22,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' @@ -31,7 +35,7 @@ permissions: contents: read env: - ARSTACK_FIRMWARE_VERSION: '0.1.0' + ARSTACK_FIRMWARE_VERSION: '0.1.1' ARSTACK_PROTOCOL_VERSION: '1' jobs: @@ -55,6 +59,18 @@ jobs: grep -qx 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' sdkconfig echo 'ESP32-P4 pre-v3 build policy: PASS' + - name: Verify production PTP-P2 profile + run: | + set -euo pipefail + 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 · PTP-P2 LAB_SOURCE · HW timestamp adapter compiled' + - name: Verify realtime START and telemetry boundary policy run: | set -euo pipefail @@ -107,7 +123,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 +145,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 +165,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 From b68795da9f8cfe3ba9e97487969f6be15a9a0ed6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:17:58 +0700 Subject: [PATCH 005/170] release: advance ARStack Studio package contract to v0.1.1 --- .github/workflows/arstack-studio-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 9fbe5dd92..028bb807d 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -22,7 +22,7 @@ concurrency: cancel-in-progress: true env: - ARSTACK_VERSION: '0.1.0' + ARSTACK_VERSION: '0.1.1' QT_VERSION: '6.10.3' ESPFLASH_VERSION: '4.5.0' ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' @@ -324,4 +324,4 @@ jobs: files: | release/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-portable.zip release/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-setup.exe - release/SHA256SUMS.txt + release/SHA256SUMS.txt \ No newline at end of file From 10a0ed9ae959c830c1c63c51da35f0fa7bb3afab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:18:17 +0700 Subject: [PATCH 006/170] chore: remove PTP production staging helper --- .../ptp-production-v011-integration.yml | 100 ------------------ 1 file changed, 100 deletions(-) delete mode 100644 .github/workflows/ptp-production-v011-integration.yml diff --git a/.github/workflows/ptp-production-v011-integration.yml b/.github/workflows/ptp-production-v011-integration.yml deleted file mode 100644 index 4401a6038..000000000 --- a/.github/workflows/ptp-production-v011-integration.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Stage PTP production v0.1.1 integration - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - -permissions: - contents: write - -jobs: - integrate: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - - name: Apply production PTP source/config/docs - shell: python - run: | - from pathlib import Path - - def replace_once(path, old, new, label): - p = Path(path) - text = p.read_text(encoding="utf-8") - if text.count(old) != 1: - raise SystemExit(f"{label}: expected exactly one match, got {text.count(old)}") - p.write_text(text.replace(old, new, 1), encoding="utf-8") - - replace_once( - "apps/arstack_studio/CMakeLists.txt", - "project(ARStackStudio VERSION 0.1.0 LANGUAGES CXX)", - "project(ARStackStudio VERSION 0.1.1 LANGUAGES CXX)", - "Studio version") - replace_once( - "embedded/esp32p4_smv_injector/CMakeLists.txt", - 'set(PROJECT_VER "0.1.0")', - 'set(PROJECT_VER "0.1.1")', - "firmware version") - replace_once( - "apps/arstack_studio/src/main.cpp", - '#define ARSTACK_STUDIO_VERSION "0.1.0"', - '#define ARSTACK_STUDIO_VERSION "0.1.1"', - "Studio fallback version") - - p = Path("embedded/esp32p4_smv_injector/sdkconfig.defaults") - text = p.read_text(encoding="utf-8").rstrip() + "\n" - if "CONFIG_AR_PTP_LAB_TX=" in text or "# CONFIG_AR_PTP_LAB_TX is not set" in text: - raise SystemExit("sdkconfig.defaults already contains an explicit PTP profile; manual reconciliation required") - text += '''\n# v0.1.1 production timing companion. LAB_SOURCE preserves the existing\n# ARIEC61850/ARSVIN lab behavior while using ESP32-P4 IEEE1588 HW timestamps.\nCONFIG_AR_PTP_LAB_TX=y\nCONFIG_AR_PTP_OPERATING_ROLE=0\nCONFIG_AR_PTP_DOMAIN=0\nCONFIG_AR_PTP_TRANSPORT_SPECIFIC=0\nCONFIG_AR_PTP_PORT_NUMBER=1\n# CONFIG_AR_PTP_VLAN is not set\nCONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000\nCONFIG_AR_PTP_SYNC_INTERVAL_MS=250\nCONFIG_AR_PTP_RESPOND_PDELAY=y\nCONFIG_AR_PTP_PDELAY_REQUEST_INTERVAL_MS=1000\nCONFIG_AR_PTP_PRIORITY1=128\nCONFIG_AR_PTP_PRIORITY2=128\nCONFIG_AR_PTP_CLOCK_CLASS=248\nCONFIG_AR_PTP_CLOCK_ACCURACY=254\nCONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE=65535\nCONFIG_AR_PTP_TIME_SOURCE=160\nCONFIG_AR_PTP_CURRENT_UTC_OFFSET=37\nCONFIG_AR_PTP_MAX_PATH_DELAY_NS=5000000\nCONFIG_AR_PTP_MAX_PATH_JITTER_NS=100000\nCONFIG_AR_PTP_LOCK_OFFSET_NS=2000\nCONFIG_AR_PTP_UNLOCK_OFFSET_NS=20000\nCONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS=1000000\nCONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8\nCONFIG_AR_PTP_SYNC_TIMEOUT_MS=1000\nCONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS=5000\nCONFIG_AR_PTP_MAX_FREQ_ADJ_PPB=100000\n''' - p.write_text(text, encoding="utf-8") - - replace_once( - "embedded/esp32p4_smv_injector/main/live_control.cpp", - ''' const char* capabilities = g_control_lease_timer != nullptr\n ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"\n : "SMV-4I4V,LIVE-SETPOINTS";''', - '''#if CONFIG_AR_PTP_LAB_TX\n const char* capabilities = g_control_lease_timer != nullptr\n ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"\n : "SMV-4I4V,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO";\n#else\n const char* capabilities = g_control_lease_timer != nullptr\n ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"\n : "SMV-4I4V,LIVE-SETPOINTS";\n#endif''', - "semantic identity capabilities") - - replace_once( - "apps/arstack_studio/src/DeviceController.cpp", - ''' static const QStringList requiredCapabilities{\n QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n QStringLiteral("SESSION-LEASE")};''', - ''' static const QStringList requiredCapabilities{\n QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n QStringLiteral("SESSION-LEASE"),\n QStringLiteral("PTP-P2"),\n QStringLiteral("SMPSYNCH-AUTO")};''', - "current identity capability contract") - - p = Path("apps/arstack_studio/src/main.cpp") - text = p.read_text(encoding="utf-8") - text = text.replace( - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE", - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") - text = text.replace( - "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF \"\n \"capabilities=SMV-4I4V,LIVE-SETPOINTS\"", - "device_id=A1B2C3D4E5F6 firmware=0.1.1 boot_id=0123456789ABCDEF \"\n \"capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,SMPSYNCH-AUTO\"") - p.write_text(text, encoding="utf-8") - - replace_once( - "docs/PTP_LAB_TIMING.md", - "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.", - "PTP product default documentation") - replace_once( - "docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md", - " capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE", - " capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO", - "identity architecture capability example") - - Path("docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md").write_text('''# ARStack Studio v0.1.1 — PTP production integration\n\nStatus: **release candidate integration**.\n\n## Purpose\n\nv0.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.\n\n## Production default\n\nThe normal firmware image compiles `CONFIG_AR_PTP_LAB_TX=y` and starts in `LAB_SOURCE` mode with conservative ARSVIN-compatible defaults:\n\n- Layer-2 PTPv2 (`0x88F7`)\n- domain 0\n- `transportSpecific=0`\n- untagged PTP by default\n- Announce every 1000 ms\n- two-step Sync every 250 ms\n- Pdelay response enabled\n- clockClass 248\n- clockAccuracy Unknown\n- internal-oscillator time source\n\nThe ESP32-P4 adapter uses its IEEE1588 hardware clock and hardware TX/RX descriptor timestamps for the existing two-step Sync and peer-delay paths.\n\n## Roles retained\n\n- `LAB_SOURCE` — broadcasts Announce / Sync / Follow_Up and answers peer delay.\n- `TIME_RECEIVER` — external-source selection, hardware timestamp correlation, path/offset measurement and bounded clock discipline.\n- `MONITOR` — passive timing observation.\n\nRole changes remain controlled by the existing stopped-only profile path.\n\n## smpSynch truth boundary\n\n`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.\n\n## Product capability contract\n\nThe 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.\n\n## Release gate\n\nSoftware CI must prove:\n\n1. the normal production firmware build has PTP-P2 enabled;\n2. the dedicated PTP matrix still builds SOURCE and TIME_RECEIVER;\n3. the Windows Studio/package contract accepts the v0.1.1 firmware identity and manifest;\n4. no IED Simulator source is changed by this integration.\n\nPhysical 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.\n''', encoding="utf-8") - - - name: Commit source integration - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add apps/arstack_studio/CMakeLists.txt apps/arstack_studio/src/DeviceController.cpp apps/arstack_studio/src/main.cpp docs/PTP_LAB_TIMING.md docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md docs/SMV_STUDIO_V0.1.1_PTP_PRODUCTION.md embedded/esp32p4_smv_injector/CMakeLists.txt embedded/esp32p4_smv_injector/main/live_control.cpp embedded/esp32p4_smv_injector/sdkconfig.defaults - git diff --cached --check - git diff --cached --name-only - git commit -m "PTP: promote hardware timing engine into v0.1.1 production firmware" - git push origin HEAD:feature/ptp-production-v0.1.1 From 3b3a5b0768cc056e0a82f8f2a91d1bc3dee7cf05 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:22:18 +0700 Subject: [PATCH 007/170] chore: stage v0.1.1 manifest capability hardening --- .../workflows/ptp-v011-manifest-hardening.yml | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/ptp-v011-manifest-hardening.yml diff --git a/.github/workflows/ptp-v011-manifest-hardening.yml b/.github/workflows/ptp-v011-manifest-hardening.yml new file mode 100644 index 000000000..ec801e629 --- /dev/null +++ b/.github/workflows/ptp-v011-manifest-hardening.yml @@ -0,0 +1,47 @@ +name: Stage v0.1.1 manifest capability hardening + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + +permissions: + contents: write + +jobs: + harden: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Require production firmware capabilities in manifest + shell: python + run: | + from pathlib import Path + p = Path('apps/arstack_studio/src/FirmwareManager.cpp') + text = p.read_text(encoding='utf-8') + text = text.replace('#include \n#include ', '#include \n#include \n#include ', 1) + old = ''' const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n const qint64 flashOffset = object.value(QStringLiteral("flashOffset")).toVariant().toLongLong();\n\n if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName ||\n expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy)) {\n bundleStatus_ = QStringLiteral("Firmware manifest fields are incomplete or unsafe.");\n return false;\n }''' + new = ''' const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n const qint64 flashOffset = object.value(QStringLiteral("flashOffset")).toVariant().toLongLong();\n const QJsonArray capabilities = object.value(QStringLiteral("capabilities")).toArray();\n const auto hasCapability = [&capabilities](const QString& wanted) {\n for (const auto& value : capabilities) {\n if (value.toString().compare(wanted, Qt::CaseInsensitive) == 0) return true;\n }\n return false;\n };\n const bool productionCapabilities =\n hasCapability(QStringLiteral("SMV-4I4V")) &&\n hasCapability(QStringLiteral("PROFILE")) &&\n hasCapability(QStringLiteral("LIVE-SETPOINTS")) &&\n hasCapability(QStringLiteral("SESSION-LEASE")) &&\n hasCapability(QStringLiteral("PTP-P2")) &&\n hasCapability(QStringLiteral("SMPSYNCH-AUTO"));\n\n if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName ||\n expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n bundleStatus_ = QStringLiteral("Firmware manifest fields/capabilities are incomplete or unsafe.");\n return false;\n }''' + if text.count(old) != 1: + raise SystemExit(f'manifest validation anchor count={text.count(old)}') + text = text.replace(old, new, 1) + text = text.replace( + 'bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · SHA-256 verified")', + 'bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")', + 1) + p.write_text(text, encoding='utf-8') + + - name: Commit hardening + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add apps/arstack_studio/src/FirmwareManager.cpp + git diff --cached --check + git commit -m "PTP: fail closed on incomplete v0.1.1 firmware capabilities" + git push origin HEAD:feature/ptp-production-v0.1.1 From 4b739ff63076baa338e440ee555f399ec1d0a9c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:22:26 +0000 Subject: [PATCH 008/170] PTP: fail closed on incomplete v0.1.1 firmware capabilities --- apps/arstack_studio/src/FirmwareManager.cpp | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 3814c25c2..4ee20e623 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -177,7 +178,7 @@ void FirmwareManager::refreshBundle() { return; } bundleReady_ = true; - bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · SHA-256 verified") + bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified") .arg(firmwareVersion_); emit stateChanged(); } @@ -209,11 +210,25 @@ bool FirmwareManager::loadManifest() { const QString version = object.value(QStringLiteral("version")).toString().trimmed(); 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."); + revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) { + bundleStatus_ = QStringLiteral("Firmware manifest fields/capabilities are incomplete or unsafe."); return false; } From 082e2b1097445f1fe8497933cc2f48d877dc2385 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:22:59 +0700 Subject: [PATCH 009/170] chore: remove v0.1.1 manifest staging helper --- .../workflows/ptp-v011-manifest-hardening.yml | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/ptp-v011-manifest-hardening.yml diff --git a/.github/workflows/ptp-v011-manifest-hardening.yml b/.github/workflows/ptp-v011-manifest-hardening.yml deleted file mode 100644 index ec801e629..000000000 --- a/.github/workflows/ptp-v011-manifest-hardening.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Stage v0.1.1 manifest capability hardening - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - -permissions: - contents: write - -jobs: - harden: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - - name: Require production firmware capabilities in manifest - shell: python - run: | - from pathlib import Path - p = Path('apps/arstack_studio/src/FirmwareManager.cpp') - text = p.read_text(encoding='utf-8') - text = text.replace('#include \n#include ', '#include \n#include \n#include ', 1) - old = ''' const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n const qint64 flashOffset = object.value(QStringLiteral("flashOffset")).toVariant().toLongLong();\n\n if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName ||\n expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy)) {\n bundleStatus_ = QStringLiteral("Firmware manifest fields are incomplete or unsafe.");\n return false;\n }''' - new = ''' const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n const qint64 flashOffset = object.value(QStringLiteral("flashOffset")).toVariant().toLongLong();\n const QJsonArray capabilities = object.value(QStringLiteral("capabilities")).toArray();\n const auto hasCapability = [&capabilities](const QString& wanted) {\n for (const auto& value : capabilities) {\n if (value.toString().compare(wanted, Qt::CaseInsensitive) == 0) return true;\n }\n return false;\n };\n const bool productionCapabilities =\n hasCapability(QStringLiteral("SMV-4I4V")) &&\n hasCapability(QStringLiteral("PROFILE")) &&\n hasCapability(QStringLiteral("LIVE-SETPOINTS")) &&\n hasCapability(QStringLiteral("SESSION-LEASE")) &&\n hasCapability(QStringLiteral("PTP-P2")) &&\n hasCapability(QStringLiteral("SMPSYNCH-AUTO"));\n\n if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName ||\n expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n bundleStatus_ = QStringLiteral("Firmware manifest fields/capabilities are incomplete or unsafe.");\n return false;\n }''' - if text.count(old) != 1: - raise SystemExit(f'manifest validation anchor count={text.count(old)}') - text = text.replace(old, new, 1) - text = text.replace( - 'bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · SHA-256 verified")', - 'bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")', - 1) - p.write_text(text, encoding='utf-8') - - - name: Commit hardening - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add apps/arstack_studio/src/FirmwareManager.cpp - git diff --cached --check - git commit -m "PTP: fail closed on incomplete v0.1.1 firmware capabilities" - git push origin HEAD:feature/ptp-production-v0.1.1 From 5535b1497b5250ebd92b5546b7da9cb75f6f343e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:29:07 +0700 Subject: [PATCH 010/170] chore: stage PTP VLAN-off production compile fix --- .github/workflows/ptp-vlan-off-build-fix.yml | 41 ++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/ptp-vlan-off-build-fix.yml diff --git a/.github/workflows/ptp-vlan-off-build-fix.yml b/.github/workflows/ptp-vlan-off-build-fix.yml new file mode 100644 index 000000000..43fe80fde --- /dev/null +++ b/.github/workflows/ptp-vlan-off-build-fix.yml @@ -0,0 +1,41 @@ +name: Stage PTP VLAN-off production compile fix + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Guard VLAN-only Kconfig macros + shell: python + run: | + from pathlib import Path + p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') + text = p.read_text(encoding='utf-8') + old = '''#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN\n config.vlan_enabled = true;\n#else\n config.vlan_enabled = false;\n#endif\n config.vlan_id = static_cast(CONFIG_AR_PTP_VLAN_ID);\n config.vlan_priority = static_cast(CONFIG_AR_PTP_VLAN_PRIORITY);''' + new = '''#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN\n config.vlan_enabled = true;\n config.vlan_id = static_cast(CONFIG_AR_PTP_VLAN_ID);\n config.vlan_priority = static_cast(CONFIG_AR_PTP_VLAN_PRIORITY);\n#else\n config.vlan_enabled = false;\n config.vlan_id = 0U;\n config.vlan_priority = 0U;\n#endif''' + if text.count(old) != 1: + raise SystemExit(f'PTP VLAN defaults anchor count={text.count(old)}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + - name: Commit source fix + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp + git diff --cached --check + git commit -m "PTP: support untagged production profile without VLAN Kconfig macros" + git push origin HEAD:feature/ptp-production-v0.1.1 From 0c581cb8fdc55e72371d0c2266fbd9421b9f79a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:29:25 +0000 Subject: [PATCH 011/170] PTP: support untagged production profile without VLAN Kconfig macros --- embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index bdbce7dcc..e61051413 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -102,11 +102,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); From 53607af975eaa73b42654639be70b337feb52a44 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:29:49 +0700 Subject: [PATCH 012/170] chore: remove PTP VLAN-off staging helper --- .github/workflows/ptp-vlan-off-build-fix.yml | 41 -------------------- 1 file changed, 41 deletions(-) delete mode 100644 .github/workflows/ptp-vlan-off-build-fix.yml diff --git a/.github/workflows/ptp-vlan-off-build-fix.yml b/.github/workflows/ptp-vlan-off-build-fix.yml deleted file mode 100644 index 43fe80fde..000000000 --- a/.github/workflows/ptp-vlan-off-build-fix.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Stage PTP VLAN-off production compile fix - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - - name: Guard VLAN-only Kconfig macros - shell: python - run: | - from pathlib import Path - p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') - text = p.read_text(encoding='utf-8') - old = '''#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN\n config.vlan_enabled = true;\n#else\n config.vlan_enabled = false;\n#endif\n config.vlan_id = static_cast(CONFIG_AR_PTP_VLAN_ID);\n config.vlan_priority = static_cast(CONFIG_AR_PTP_VLAN_PRIORITY);''' - new = '''#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN\n config.vlan_enabled = true;\n config.vlan_id = static_cast(CONFIG_AR_PTP_VLAN_ID);\n config.vlan_priority = static_cast(CONFIG_AR_PTP_VLAN_PRIORITY);\n#else\n config.vlan_enabled = false;\n config.vlan_id = 0U;\n config.vlan_priority = 0U;\n#endif''' - if text.count(old) != 1: - raise SystemExit(f'PTP VLAN defaults anchor count={text.count(old)}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - - name: Commit source fix - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp - git diff --cached --check - git commit -m "PTP: support untagged production profile without VLAN Kconfig macros" - git push origin HEAD:feature/ptp-production-v0.1.1 From efbba0b0be98566f97c5dab6dee65dee0c80e740 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:45:43 +0700 Subject: [PATCH 013/170] chore: stage v0.1.1 lifecycle and PTP stabilization --- .github/workflows/stabilize-v011-runtime.yml | 488 +++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 .github/workflows/stabilize-v011-runtime.yml diff --git a/.github/workflows/stabilize-v011-runtime.yml b/.github/workflows/stabilize-v011-runtime.yml new file mode 100644 index 000000000..e8f037b43 --- /dev/null +++ b/.github/workflows/stabilize-v011-runtime.yml @@ -0,0 +1,488 @@ +name: Stage v0.1.1 lifecycle and PTP stabilization + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + +permissions: + contents: write + +jobs: + stabilize: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Integrate bounded lifecycle ownership and stabilize PTP source UX + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Carry the already-reviewed lifecycle ownership patch into the one + # v0.1.1 candidate. Keep the safety branch/PR intact for provenance. + if ! git log --format=%s | grep -Fxq 'fix(studio): retire workers before GUI event loop exits'; then + git cherry-pick 75e5f2dfd1fcf382bb2698a55ce9fcff57413390 + fi + + python - <<'PY' + from pathlib import Path + + def replace_once(path, old, new, label): + p = Path(path) + text = p.read_text(encoding='utf-8') + if old not in text: + raise SystemExit(f'missing anchor {label} in {path}') + if text.count(old) != 1: + raise SystemExit(f'expected one {label} in {path}, got {text.count(old)}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + # Normal operator close must request application shutdown directly. + replace_once( + 'apps/arstack_studio/qml/Main.qml', + ''' title: "ARStack Studio · SMV Injector"\n''', + ''' title: "ARStack Studio · SMV + PTP · v" + Qt.application.version\n''', + 'window title') + replace_once( + 'apps/arstack_studio/qml/Main.qml', + ''' property bool telemetryExpanded: false\n\n font.family: root.uiFont\n\n // Graceful window close is still a safety boundary. QML submits only the\n // Stop intent; the supervisor owns whether the current session may act.\n onClosing: function(close) {\n if (workflowBar.session && workflowBar.session.state === "RUNNING")\n workflowBar.session.requestStop()\n }\n''', + ''' property bool telemetryExpanded: false\n property bool applicationShutdownRequested: false\n\n font.family: root.uiFont\n\n // The main window is the application lifetime boundary. Do not queue a\n // second asynchronous STOP from QML while C++ teardown owns STOP/COM close.\n // Explicit Qt.quit() also prevents hidden Advanced/detached windows from\n // keeping the GUI process alive after the operator closes the main window.\n onClosing: function(close) {\n close.accepted = true\n if (root.applicationShutdownRequested)\n return\n root.applicationShutdownRequested = true\n configurationWindow.hide()\n detachedPhasorWindow.hide()\n detachedWaveformWindow.hide()\n Qt.quit()\n }\n''', + 'normal close boundary') + + # PTP must never auto-start before esp_eth_start/link readiness. + p = Path('embedded/esp32p4_smv_injector/main/ethernet_port.c') + text = p.read_text(encoding='utf-8') + auto = '''\n#if CONFIG_AR_PTP_LAB_TX\n // Boot auto-start uses the same checked admission/readiness contract as the\n // live serial control path. A rejected or failed start is never reported as\n // running merely because an older runtime is still cleaning up.\n if (!ar_ptp_lab_try_start(handle)) {\n ESP_LOGW(TAG, "PTP auto-start was rejected or could not start");\n }\n#endif\n''' + if auto not in text: + raise SystemExit('missing PTP auto-start block') + text = text.replace(auto, '\n', 1) + old_start = '''bool ar_esp32p4_ptp_start(void)\n{\n#if CONFIG_AR_PTP_LAB_TX\n if (s_eth_handle == NULL) {\n return false;\n }\n return ar_ptp_lab_try_start(s_eth_handle);\n#else\n return false;\n#endif\n}\n''' + new_start = '''bool ar_esp32p4_ptp_start(void)\n{\n#if CONFIG_AR_PTP_LAB_TX\n if (s_eth_handle == NULL) {\n ESP_LOGE(TAG, "PTP start rejected: Ethernet driver is not initialized");\n return false;\n }\n bool link_up = false;\n const esp_err_t link_result = esp_eth_ioctl(s_eth_handle, ETH_CMD_G_LINK, &link_up);\n if (link_result != ESP_OK || !link_up) {\n ESP_LOGE(TAG, "PTP start rejected: Ethernet link is not up");\n return false;\n }\n return ar_ptp_lab_try_start(s_eth_handle);\n#else\n return false;\n#endif\n}\n''' + if old_start not in text: + raise SystemExit('missing ar_esp32p4_ptp_start anchor') + p.write_text(text.replace(old_start, new_start, 1), encoding='utf-8') + + # Source RUNNING means frames have actually left the MAC, not merely\n # that a FreeRTOS task was created. + p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') + text = p.read_text(encoding='utf-8') + text = text.replace( + 'std::atomic_bool g_ptp_started{false};\nstd::atomic_bool g_stop_requested{false};', + 'std::atomic_bool g_ptp_started{false};\nstd::atomic_bool g_ptp_ready{false};\nstd::atomic_bool g_stop_requested{false};', 1) + text = text.replace( + ' context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);', + ' context.task_handle = nullptr;\n g_ptp_ready.store(false, std::memory_order_release);\n g_ptp_started.store(false, std::memory_order_release);', 1) + old_success = ''' if (success) {\n consecutive_failures = 0U;\n context.runtime->clear_error();\n } else {\n''' + new_success = ''' if (success) {\n consecutive_failures = 0U;\n context.runtime->clear_error();\n const bool emitted_core_timing =\n g_announce_sent.load(std::memory_order_relaxed) > 0U &&\n g_sync_sent.load(std::memory_order_relaxed) > 0U &&\n g_follow_up_sent.load(std::memory_order_relaxed) > 0U;\n if (emitted_core_timing) {\n g_ptp_ready.store(true, std::memory_order_release);\n }\n } else {\n''' + if old_success not in text: + raise SystemExit('missing PTP success block') + text = text.replace(old_success, new_success, 1) + text = text.replace( + ' reset_live_status();\n g_ptp_accept_rx.store(false, std::memory_order_release);', + ' reset_live_status();\n g_ptp_ready.store(false, std::memory_order_release);\n g_ptp_accept_rx.store(false, std::memory_order_release);', 1) + old_create = ''' if (xTaskCreatePinnedToCore(\n &ptp_lab_task,\n "ar_ptp_lab",\n 7168,\n &g_ptp_context,\n 4,\n &g_ptp_context.task_handle,\n 0) != pdPASS) {\n g_ptp_context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);\n ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0");\n }\n}\n''' + new_create = ''' if (xTaskCreatePinnedToCore(\n &ptp_lab_task,\n "ar_ptp_lab",\n 7168,\n &g_ptp_context,\n 4,\n &g_ptp_context.task_handle,\n 0) != pdPASS) {\n g_ptp_context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);\n ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0");\n return;\n }\n\n // The serial command path is not realtime. Spend a bounded interval proving\n // that SOURCE emitted Announce + Sync + Follow_Up before reporting RUNNING.\n constexpr unsigned kStartReadyPolls = 160U;\n for (unsigned attempt = 0U; attempt < kStartReadyPolls; ++attempt) {\n if (g_ptp_ready.load(std::memory_order_acquire) ||\n !g_ptp_started.load(std::memory_order_acquire)) {\n return;\n }\n vTaskDelay(pdMS_TO_TICKS(10));\n }\n ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX");\n stop_ptp_lab();\n}\n''' + if old_create not in text: + raise SystemExit('missing PTP task create block') + text = text.replace(old_create, new_create, 1) + text = text.replace( + '''[[nodiscard]] bool ptp_lab_is_running() noexcept {\n return g_ptp_started.load(std::memory_order_acquire);\n}\n''', + '''[[nodiscard]] bool ptp_lab_is_running() noexcept {\n return g_ptp_ready.load(std::memory_order_acquire);\n}\n''', 1) + text = text.replace( + ' 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);', 1) + p.write_text(text, encoding='utf-8') + + replace_once( + 'embedded/esp32p4_smv_injector/main/live_control.cpp', + ''' if (!ar_esp32p4_ptp_start()) ESP_LOGE(kTag, "PTP start rejected: Ethernet is unavailable");\n else ESP_LOGI(kTag, "PTP start accepted");\n''', + ''' if (!ar_esp32p4_ptp_start()) {\n ESP_LOGE(kTag, "PTP start rejected: Ethernet link/source readiness failed; no verified timing frames were emitted");\n } else {\n ESP_LOGI(kTag, "PTP start accepted: verified Announce/Sync/Follow_Up transmission");\n }\n''', + 'PTP start truth message') + + # Surface semantic PTP failures in Studio immediately. + p = Path('apps/arstack_studio/src/DeviceController.cpp') + text = p.read_text(encoding='utf-8') + anchor = ''' if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) {\n emit deviceMessage(QStringLiteral("PTP expert profile accepted."));\n static_cast(sendPtpShow());\n }\n''' + addition = anchor + ''' if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) {\n ptpStatus_ = QStringLiteral("Timing frames verified");\n emit deviceMessage(QStringLiteral("PTP source is transmitting verified timing frames."));\n static_cast(sendPtpShow());\n }\n if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) ||\n line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) {\n ptpRunning_ = false;\n ptpStatus_ = QStringLiteral("PTP start failed");\n emit ptpStateChanged();\n setError(QStringLiteral("PTP source did not emit timing frames. Check the Ethernet link and retry Start PTP Source."));\n }\n''' + if anchor not in text: + raise SystemExit('missing DeviceController PTP accepted anchor') + p.write_text(text.replace(anchor, addition, 1), encoding='utf-8') + + # Pass the controller so the timing panel can use the common toast/status path. + replace_once( + 'apps/arstack_studio/qml/ConfigurationHub.qml', + ''' PtpExpertPanel {\n theme: hub.theme\n device: hub.device\n session: hub.session\n''', + ''' PtpExpertPanel {\n theme: hub.theme\n controller: hub.controller\n device: hub.device\n session: hub.session\n''', + 'PTP controller binding') + + # Replace expert-first timing page with a product-first control surface. + Path('apps/arstack_studio/qml/PtpExpertPanel.qml').write_text(r'''// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +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 roleValues: ["SOURCE", "RECEIVER", "MONITOR"] + property bool advancedVisible: false + property bool startPending: false + + function roleIndex(role) { + if (role === "RECEIVER") return 1 + if (role === "MONITOR") return 2 + return 0 + } + + 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() { + if (!panel.session) return false + return panel.session.requestConfigurePtp({ + domain: Number(domainField.text), + transportSpecific: Number(transportField.text), + vlanEnabled: vlanCheck.checked, + vlanId: Number(vlanField.text), + vlanPriority: Number(pcpField.text), + announceIntervalMs: Number(announceField.text), + syncIntervalMs: Number(syncField.text), + respondToPeerDelay: pdelayCheck.checked + }) + } + + 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) + } + } + + readonly property string role: panel.device.ptpRole + readonly property bool sourceRole: role === "SOURCE" + readonly property bool receiverRole: role === "RECEIVER" + readonly property bool monitorRole: role === "MONITOR" + 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.role + " ACTIVE") + : panel.device.ptpStatus === "PTP start failed" ? "START FAILED" : "OFF" + + Timer { + id: statusPoll + interval: 700 + repeat: true + running: panel.startPending || panel.device.ptpRunning + onTriggered: { + if (panel.session) panel.session.requestPtpRefresh() + if (panel.device.ptpRunning) panel.startPending = false + } + } + + Timer { + id: startDeadline + 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) + } + } + + 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) + } + } + } + + ScrollView { + anchors.fill: parent + contentWidth: availableWidth + clip: true + + ColumnLayout { + width: parent.width + spacing: 12 + + 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 + implicitHeight: 74 + radius: 8 + color: panel.theme.panelAlt + border.width: 1 + border.color: panel.theme.lineSoft + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 14 + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + Label { text: "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.role) + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending && !panel.session.updatingFirmware + font.family: panel.uiFont + font.pixelSize: 10 + } + } + ColumnLayout { + Layout.preferredWidth: 285 + spacing: 2 + Label { + text: modeCombo.currentIndex === 0 ? "Board sends Announce + two-step Sync/Follow_Up." + : modeCombo.currentIndex === 1 ? "Board follows external PTP and reports measured lock." + : "Listen only; no timing frames are transmitted." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + } + Label { + text: modeCombo.currentIndex === 0 ? "SV smpSynch stays 0 unless receiver discipline is measured." + : modeCombo.currentIndex === 1 ? "AUTO smpSynch follows measured receiver evidence." + : "No clock discipline is applied." + wrapMode: Text.WordWrap + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + } + } + } + } + + RowLayout { + Layout.fillWidth: true + 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 + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 96 + 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 { + anchors.fill: parent + anchors.margins: 11 + columns: 5 + columnSpacing: 14 + rowSpacing: 6 + 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 } + 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: 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 on wire path: source 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 + } + } + } + + Rectangle { + visible: panel.receiverRole + Layout.fillWidth: true + implicitHeight: 86 + 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 } + } + } + + 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"; 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 } } + 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 remains 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 } + } + } +} +''', encoding='utf-8') + PY + + git add apps/arstack_studio/qml/Main.qml \ + apps/arstack_studio/qml/ConfigurationHub.qml \ + apps/arstack_studio/qml/PtpExpertPanel.qml \ + apps/arstack_studio/src/DeviceController.cpp \ + embedded/esp32p4_smv_injector/main/ethernet_port.c \ + embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp \ + embedded/esp32p4_smv_injector/main/live_control.cpp + git diff --cached --check + if ! git diff --cached --quiet; then + git commit -m "fix(v0.1.1): verify PTP source TX and normal GUI shutdown" + fi + git push origin HEAD:feature/ptp-production-v0.1.1 From 0199df5967cbf65f2958639ee46373df45a67a0d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:47:14 +0700 Subject: [PATCH 014/170] chore: simplify v0.1.1 stabilization staging --- .github/workflows/stabilize-v011-runtime.yml | 474 +------------------ 1 file changed, 9 insertions(+), 465 deletions(-) diff --git a/.github/workflows/stabilize-v011-runtime.yml b/.github/workflows/stabilize-v011-runtime.yml index e8f037b43..81b250893 100644 --- a/.github/workflows/stabilize-v011-runtime.yml +++ b/.github/workflows/stabilize-v011-runtime.yml @@ -1,4 +1,4 @@ -name: Stage v0.1.1 lifecycle and PTP stabilization +name: Stage v0.1.1 lifecycle ownership on: push: @@ -9,480 +9,24 @@ permissions: contents: write jobs: - stabilize: - if: github.actor != 'github-actions[bot]' + lifecycle: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - - name: Integrate bounded lifecycle ownership and stabilize PTP source UX + - name: Carry bounded lifecycle patch shell: bash run: | set -euo pipefail + if [ "$GITHUB_ACTOR" = "github-actions[bot]" ]; then + exit 0 + fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - # Carry the already-reviewed lifecycle ownership patch into the one - # v0.1.1 candidate. Keep the safety branch/PR intact for provenance. - if ! git log --format=%s | grep -Fxq 'fix(studio): retire workers before GUI event loop exits'; then - git cherry-pick 75e5f2dfd1fcf382bb2698a55ce9fcff57413390 - fi - - python - <<'PY' - from pathlib import Path - - def replace_once(path, old, new, label): - p = Path(path) - text = p.read_text(encoding='utf-8') - if old not in text: - raise SystemExit(f'missing anchor {label} in {path}') - if text.count(old) != 1: - raise SystemExit(f'expected one {label} in {path}, got {text.count(old)}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - # Normal operator close must request application shutdown directly. - replace_once( - 'apps/arstack_studio/qml/Main.qml', - ''' title: "ARStack Studio · SMV Injector"\n''', - ''' title: "ARStack Studio · SMV + PTP · v" + Qt.application.version\n''', - 'window title') - replace_once( - 'apps/arstack_studio/qml/Main.qml', - ''' property bool telemetryExpanded: false\n\n font.family: root.uiFont\n\n // Graceful window close is still a safety boundary. QML submits only the\n // Stop intent; the supervisor owns whether the current session may act.\n onClosing: function(close) {\n if (workflowBar.session && workflowBar.session.state === "RUNNING")\n workflowBar.session.requestStop()\n }\n''', - ''' property bool telemetryExpanded: false\n property bool applicationShutdownRequested: false\n\n font.family: root.uiFont\n\n // The main window is the application lifetime boundary. Do not queue a\n // second asynchronous STOP from QML while C++ teardown owns STOP/COM close.\n // Explicit Qt.quit() also prevents hidden Advanced/detached windows from\n // keeping the GUI process alive after the operator closes the main window.\n onClosing: function(close) {\n close.accepted = true\n if (root.applicationShutdownRequested)\n return\n root.applicationShutdownRequested = true\n configurationWindow.hide()\n detachedPhasorWindow.hide()\n detachedWaveformWindow.hide()\n Qt.quit()\n }\n''', - 'normal close boundary') - - # PTP must never auto-start before esp_eth_start/link readiness. - p = Path('embedded/esp32p4_smv_injector/main/ethernet_port.c') - text = p.read_text(encoding='utf-8') - auto = '''\n#if CONFIG_AR_PTP_LAB_TX\n // Boot auto-start uses the same checked admission/readiness contract as the\n // live serial control path. A rejected or failed start is never reported as\n // running merely because an older runtime is still cleaning up.\n if (!ar_ptp_lab_try_start(handle)) {\n ESP_LOGW(TAG, "PTP auto-start was rejected or could not start");\n }\n#endif\n''' - if auto not in text: - raise SystemExit('missing PTP auto-start block') - text = text.replace(auto, '\n', 1) - old_start = '''bool ar_esp32p4_ptp_start(void)\n{\n#if CONFIG_AR_PTP_LAB_TX\n if (s_eth_handle == NULL) {\n return false;\n }\n return ar_ptp_lab_try_start(s_eth_handle);\n#else\n return false;\n#endif\n}\n''' - new_start = '''bool ar_esp32p4_ptp_start(void)\n{\n#if CONFIG_AR_PTP_LAB_TX\n if (s_eth_handle == NULL) {\n ESP_LOGE(TAG, "PTP start rejected: Ethernet driver is not initialized");\n return false;\n }\n bool link_up = false;\n const esp_err_t link_result = esp_eth_ioctl(s_eth_handle, ETH_CMD_G_LINK, &link_up);\n if (link_result != ESP_OK || !link_up) {\n ESP_LOGE(TAG, "PTP start rejected: Ethernet link is not up");\n return false;\n }\n return ar_ptp_lab_try_start(s_eth_handle);\n#else\n return false;\n#endif\n}\n''' - if old_start not in text: - raise SystemExit('missing ar_esp32p4_ptp_start anchor') - p.write_text(text.replace(old_start, new_start, 1), encoding='utf-8') - - # Source RUNNING means frames have actually left the MAC, not merely\n # that a FreeRTOS task was created. - p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') - text = p.read_text(encoding='utf-8') - text = text.replace( - 'std::atomic_bool g_ptp_started{false};\nstd::atomic_bool g_stop_requested{false};', - 'std::atomic_bool g_ptp_started{false};\nstd::atomic_bool g_ptp_ready{false};\nstd::atomic_bool g_stop_requested{false};', 1) - text = text.replace( - ' context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);', - ' context.task_handle = nullptr;\n g_ptp_ready.store(false, std::memory_order_release);\n g_ptp_started.store(false, std::memory_order_release);', 1) - old_success = ''' if (success) {\n consecutive_failures = 0U;\n context.runtime->clear_error();\n } else {\n''' - new_success = ''' if (success) {\n consecutive_failures = 0U;\n context.runtime->clear_error();\n const bool emitted_core_timing =\n g_announce_sent.load(std::memory_order_relaxed) > 0U &&\n g_sync_sent.load(std::memory_order_relaxed) > 0U &&\n g_follow_up_sent.load(std::memory_order_relaxed) > 0U;\n if (emitted_core_timing) {\n g_ptp_ready.store(true, std::memory_order_release);\n }\n } else {\n''' - if old_success not in text: - raise SystemExit('missing PTP success block') - text = text.replace(old_success, new_success, 1) - text = text.replace( - ' reset_live_status();\n g_ptp_accept_rx.store(false, std::memory_order_release);', - ' reset_live_status();\n g_ptp_ready.store(false, std::memory_order_release);\n g_ptp_accept_rx.store(false, std::memory_order_release);', 1) - old_create = ''' if (xTaskCreatePinnedToCore(\n &ptp_lab_task,\n "ar_ptp_lab",\n 7168,\n &g_ptp_context,\n 4,\n &g_ptp_context.task_handle,\n 0) != pdPASS) {\n g_ptp_context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);\n ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0");\n }\n}\n''' - new_create = ''' if (xTaskCreatePinnedToCore(\n &ptp_lab_task,\n "ar_ptp_lab",\n 7168,\n &g_ptp_context,\n 4,\n &g_ptp_context.task_handle,\n 0) != pdPASS) {\n g_ptp_context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);\n ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0");\n return;\n }\n\n // The serial command path is not realtime. Spend a bounded interval proving\n // that SOURCE emitted Announce + Sync + Follow_Up before reporting RUNNING.\n constexpr unsigned kStartReadyPolls = 160U;\n for (unsigned attempt = 0U; attempt < kStartReadyPolls; ++attempt) {\n if (g_ptp_ready.load(std::memory_order_acquire) ||\n !g_ptp_started.load(std::memory_order_acquire)) {\n return;\n }\n vTaskDelay(pdMS_TO_TICKS(10));\n }\n ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX");\n stop_ptp_lab();\n}\n''' - if old_create not in text: - raise SystemExit('missing PTP task create block') - text = text.replace(old_create, new_create, 1) - text = text.replace( - '''[[nodiscard]] bool ptp_lab_is_running() noexcept {\n return g_ptp_started.load(std::memory_order_acquire);\n}\n''', - '''[[nodiscard]] bool ptp_lab_is_running() noexcept {\n return g_ptp_ready.load(std::memory_order_acquire);\n}\n''', 1) - text = text.replace( - ' 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);', 1) - p.write_text(text, encoding='utf-8') - - replace_once( - 'embedded/esp32p4_smv_injector/main/live_control.cpp', - ''' if (!ar_esp32p4_ptp_start()) ESP_LOGE(kTag, "PTP start rejected: Ethernet is unavailable");\n else ESP_LOGI(kTag, "PTP start accepted");\n''', - ''' if (!ar_esp32p4_ptp_start()) {\n ESP_LOGE(kTag, "PTP start rejected: Ethernet link/source readiness failed; no verified timing frames were emitted");\n } else {\n ESP_LOGI(kTag, "PTP start accepted: verified Announce/Sync/Follow_Up transmission");\n }\n''', - 'PTP start truth message') - - # Surface semantic PTP failures in Studio immediately. - p = Path('apps/arstack_studio/src/DeviceController.cpp') - text = p.read_text(encoding='utf-8') - anchor = ''' if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) {\n emit deviceMessage(QStringLiteral("PTP expert profile accepted."));\n static_cast(sendPtpShow());\n }\n''' - addition = anchor + ''' if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) {\n ptpStatus_ = QStringLiteral("Timing frames verified");\n emit deviceMessage(QStringLiteral("PTP source is transmitting verified timing frames."));\n static_cast(sendPtpShow());\n }\n if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) ||\n line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) {\n ptpRunning_ = false;\n ptpStatus_ = QStringLiteral("PTP start failed");\n emit ptpStateChanged();\n setError(QStringLiteral("PTP source did not emit timing frames. Check the Ethernet link and retry Start PTP Source."));\n }\n''' - if anchor not in text: - raise SystemExit('missing DeviceController PTP accepted anchor') - p.write_text(text.replace(anchor, addition, 1), encoding='utf-8') - - # Pass the controller so the timing panel can use the common toast/status path. - replace_once( - 'apps/arstack_studio/qml/ConfigurationHub.qml', - ''' PtpExpertPanel {\n theme: hub.theme\n device: hub.device\n session: hub.session\n''', - ''' PtpExpertPanel {\n theme: hub.theme\n controller: hub.controller\n device: hub.device\n session: hub.session\n''', - 'PTP controller binding') - - # Replace expert-first timing page with a product-first control surface. - Path('apps/arstack_studio/qml/PtpExpertPanel.qml').write_text(r'''// SPDX-License-Identifier: GPL-3.0-or-later -import QtQuick -import QtQuick.Controls -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 roleValues: ["SOURCE", "RECEIVER", "MONITOR"] - property bool advancedVisible: false - property bool startPending: false - - function roleIndex(role) { - if (role === "RECEIVER") return 1 - if (role === "MONITOR") return 2 - return 0 - } - - 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() { - if (!panel.session) return false - return panel.session.requestConfigurePtp({ - domain: Number(domainField.text), - transportSpecific: Number(transportField.text), - vlanEnabled: vlanCheck.checked, - vlanId: Number(vlanField.text), - vlanPriority: Number(pcpField.text), - announceIntervalMs: Number(announceField.text), - syncIntervalMs: Number(syncField.text), - respondToPeerDelay: pdelayCheck.checked - }) - } - - 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) - } - } - - readonly property string role: panel.device.ptpRole - readonly property bool sourceRole: role === "SOURCE" - readonly property bool receiverRole: role === "RECEIVER" - readonly property bool monitorRole: role === "MONITOR" - 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.role + " ACTIVE") - : panel.device.ptpStatus === "PTP start failed" ? "START FAILED" : "OFF" - - Timer { - id: statusPoll - interval: 700 - repeat: true - running: panel.startPending || panel.device.ptpRunning - onTriggered: { - if (panel.session) panel.session.requestPtpRefresh() - if (panel.device.ptpRunning) panel.startPending = false - } - } - - Timer { - id: startDeadline - 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) - } - } - - 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) - } - } - } - - ScrollView { - anchors.fill: parent - contentWidth: availableWidth - clip: true - - ColumnLayout { - width: parent.width - spacing: 12 - - 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 - implicitHeight: 74 - radius: 8 - color: panel.theme.panelAlt - border.width: 1 - border.color: panel.theme.lineSoft - RowLayout { - anchors.fill: parent - anchors.margins: 12 - spacing: 14 - ColumnLayout { - Layout.fillWidth: true - spacing: 3 - Label { text: "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.role) - enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending && !panel.session.updatingFirmware - font.family: panel.uiFont - font.pixelSize: 10 - } - } - ColumnLayout { - Layout.preferredWidth: 285 - spacing: 2 - Label { - text: modeCombo.currentIndex === 0 ? "Board sends Announce + two-step Sync/Follow_Up." - : modeCombo.currentIndex === 1 ? "Board follows external PTP and reports measured lock." - : "Listen only; no timing frames are transmitted." - wrapMode: Text.WordWrap - color: panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 9 - } - Label { - text: modeCombo.currentIndex === 0 ? "SV smpSynch stays 0 unless receiver discipline is measured." - : modeCombo.currentIndex === 1 ? "AUTO smpSynch follows measured receiver evidence." - : "No clock discipline is applied." - wrapMode: Text.WordWrap - color: panel.theme.muted - font.family: panel.uiFont - font.pixelSize: 8 - } - } - } - } - - RowLayout { - Layout.fillWidth: true - 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 - } - } - - Rectangle { - Layout.fillWidth: true - implicitHeight: 96 - 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 { - anchors.fill: parent - anchors.margins: 11 - columns: 5 - columnSpacing: 14 - rowSpacing: 6 - 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 } - 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: 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 on wire path: source 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 - } - } - } - - Rectangle { - visible: panel.receiverRole - Layout.fillWidth: true - implicitHeight: 86 - 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 } - } - } - - 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"; 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 } } - 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 remains 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 } - } - } -} -''', encoding='utf-8') - PY - - git add apps/arstack_studio/qml/Main.qml \ - apps/arstack_studio/qml/ConfigurationHub.qml \ - apps/arstack_studio/qml/PtpExpertPanel.qml \ - apps/arstack_studio/src/DeviceController.cpp \ - embedded/esp32p4_smv_injector/main/ethernet_port.c \ - embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp \ - embedded/esp32p4_smv_injector/main/live_control.cpp - git diff --cached --check - if ! git diff --cached --quiet; then - git commit -m "fix(v0.1.1): verify PTP source TX and normal GUI shutdown" + if git log --format=%s | grep -Fxq 'fix(studio): retire workers before GUI event loop exits'; then + exit 0 fi + git cherry-pick 75e5f2dfd1fcf382bb2698a55ce9fcff57413390 git push origin HEAD:feature/ptp-production-v0.1.1 From 232474b7b8b3247f94319b07b361c36e5ce96a78 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:50:24 +0000 Subject: [PATCH 015/170] fix(studio): retire workers before GUI event loop exits --- apps/arstack_studio/src/DeviceController.cpp | 75 +++++++++++++++++-- apps/arstack_studio/src/DeviceController.hpp | 10 +++ apps/arstack_studio/src/FirmwareManager.cpp | 64 +++++++++++++--- apps/arstack_studio/src/FirmwareManager.hpp | 6 +- .../src/SmartSessionController.cpp | 28 ++++++- .../src/SmartSessionController.hpp | 11 +++ .../src/StudioDeviceController.hpp | 12 --- apps/arstack_studio/src/main.cpp | 45 +++++++++-- 8 files changed, 213 insertions(+), 38 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 3ed045c04..a7707f256 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 @@ -73,19 +76,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() { diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 661f64df0..7854f4507 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -100,6 +100,14 @@ 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 discoveryStatus() const; @@ -330,6 +338,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/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 4ee20e623..a0174e3cc 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,8 @@ #include #include #include +#include +#include #include @@ -46,7 +49,7 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { } FirmwareManager::~FirmwareManager() { - shutdown(); + static_cast(shutdown()); } void FirmwareManager::connectWorkerSignals() { @@ -333,23 +336,64 @@ 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; - 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() { diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 6261ffe55..259490eb2 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -71,7 +71,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(); diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index e35551617..efaeb559c 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -98,6 +98,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_; } @@ -237,7 +262,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(); @@ -925,6 +950,7 @@ bool SmartSessionController::deviceControlAvailable() const noexcept { } void SmartSessionController::reconcile() { + if (shuttingDown_) return; if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) { setPresentation( QStringLiteral("INITIALIZING"), diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index e6b6268a1..e07b23068 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -48,6 +48,9 @@ class SmartSessionController : public QObject { Q_ENUM(PortOwner) explicit SmartSessionController(QObject* parent = nullptr); + ~SmartSessionController() override; + + void shutdown(); [[nodiscard]] QObject* device() const noexcept; [[nodiscard]] QObject* profiles() const noexcept; @@ -188,6 +191,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 +233,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() { @@ -350,6 +360,7 @@ class SmartSessionController : public QObject { 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/main.cpp b/apps/arstack_studio/src/main.cpp index e0fd276f7..db09219e6 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -445,12 +445,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 +470,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."; @@ -507,8 +532,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 From 4d9a4779a0219bf62c2d34122100bd93f0eb3b47 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:48:34 +0700 Subject: [PATCH 016/170] chore: stage verified PTP source and normal-close fixes --- .github/workflows/stabilize-v011-runtime.yml | 80 +++++++++++++++++--- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/.github/workflows/stabilize-v011-runtime.yml b/.github/workflows/stabilize-v011-runtime.yml index 81b250893..6c5b20a58 100644 --- a/.github/workflows/stabilize-v011-runtime.yml +++ b/.github/workflows/stabilize-v011-runtime.yml @@ -1,4 +1,4 @@ -name: Stage v0.1.1 lifecycle ownership +name: Stage v0.1.1 stabilization fixes on: push: @@ -9,24 +9,84 @@ permissions: contents: write jobs: - lifecycle: + stabilize: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - name: Carry bounded lifecycle patch + - name: Apply runtime and normal-close fixes shell: bash run: | set -euo pipefail - if [ "$GITHUB_ACTOR" = "github-actions[bot]" ]; then - exit 0 - fi + if [ "$GITHUB_ACTOR" = "github-actions[bot]" ]; then exit 0; fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - if git log --format=%s | grep -Fxq 'fix(studio): retire workers before GUI event loop exits'; then - exit 0 - fi - git cherry-pick 75e5f2dfd1fcf382bb2698a55ce9fcff57413390 + python - <<'PY' + from pathlib import Path + + def replace(path, old, new, label): + p = Path(path) + s = p.read_text(encoding='utf-8') + if old not in s: + raise SystemExit(f'missing {label}: {path}') + p.write_text(s.replace(old, new, 1), encoding='utf-8') + + replace('apps/arstack_studio/qml/Main.qml', ' title: "ARStack Studio · SMV Injector"\n', ' title: "ARStack Studio · SMV + PTP · v" + Qt.application.version\n', 'title') + old_close = ' property bool telemetryExpanded: false\n\n font.family: root.uiFont\n\n // Graceful window close is still a safety boundary. QML submits only the\n // Stop intent; the supervisor owns whether the current session may act.\n onClosing: function(close) {\n if (workflowBar.session && workflowBar.session.state === "RUNNING")\n workflowBar.session.requestStop()\n }\n' + new_close = ' property bool telemetryExpanded: false\n property bool applicationShutdownRequested: false\n\n font.family: root.uiFont\n\n // Primary-window close owns application exit. C++ teardown owns STOP, COM\n // close and worker retirement; QML must not race it with another async STOP.\n onClosing: function(close) {\n close.accepted = true\n if (root.applicationShutdownRequested) return\n root.applicationShutdownRequested = true\n configurationWindow.hide()\n detachedPhasorWindow.hide()\n detachedWaveformWindow.hide()\n Qt.quit()\n }\n' + replace('apps/arstack_studio/qml/Main.qml', old_close, new_close, 'normal close') + + p = Path('embedded/esp32p4_smv_injector/main/ethernet_port.c') + s = p.read_text(encoding='utf-8') + auto = '\n#if CONFIG_AR_PTP_LAB_TX\n // Boot auto-start uses the same checked admission/readiness contract as the\n // live serial control path. A rejected or failed start is never reported as\n // running merely because an older runtime is still cleaning up.\n if (!ar_ptp_lab_try_start(handle)) {\n ESP_LOGW(TAG, "PTP auto-start was rejected or could not start");\n }\n#endif\n' + if auto not in s: raise SystemExit('missing premature PTP auto-start') + s = s.replace(auto, '\n', 1) + old = 'bool ar_esp32p4_ptp_start(void)\n{\n#if CONFIG_AR_PTP_LAB_TX\n if (s_eth_handle == NULL) {\n return false;\n }\n return ar_ptp_lab_try_start(s_eth_handle);\n#else\n return false;\n#endif\n}\n' + new = 'bool ar_esp32p4_ptp_start(void)\n{\n#if CONFIG_AR_PTP_LAB_TX\n if (s_eth_handle == NULL) {\n ESP_LOGE(TAG, "PTP start rejected: Ethernet driver is not initialized");\n return false;\n }\n bool link_up = false;\n const esp_err_t link_result = esp_eth_ioctl(s_eth_handle, ETH_CMD_G_LINK, &link_up);\n if (link_result != ESP_OK || !link_up) {\n ESP_LOGE(TAG, "PTP start rejected: Ethernet link is not up");\n return false;\n }\n return ar_ptp_lab_try_start(s_eth_handle);\n#else\n return false;\n#endif\n}\n' + if old not in s: raise SystemExit('missing PTP start function') + p.write_text(s.replace(old, new, 1), encoding='utf-8') + + p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') + s = p.read_text(encoding='utf-8') + replace_atomic = 'std::atomic_bool g_ptp_started{false};\nstd::atomic_bool g_stop_requested{false};' + if replace_atomic not in s: raise SystemExit('missing PTP atomics') + s = s.replace(replace_atomic, 'std::atomic_bool g_ptp_started{false};\nstd::atomic_bool g_ptp_ready{false};\nstd::atomic_bool g_stop_requested{false};', 1) + old_finish = ' context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);' + if old_finish not in s: raise SystemExit('missing PTP finish') + s = s.replace(old_finish, ' context.task_handle = nullptr;\n g_ptp_ready.store(false, std::memory_order_release);\n g_ptp_started.store(false, std::memory_order_release);', 1) + old_success = ' if (success) {\n consecutive_failures = 0U;\n context.runtime->clear_error();\n } else {' + new_success = ' if (success) {\n consecutive_failures = 0U;\n context.runtime->clear_error();\n 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;\n if (emitted_core_timing) g_ptp_ready.store(true, std::memory_order_release);\n } else {' + if old_success not in s: raise SystemExit('missing PTP send-success block') + s = s.replace(old_success, new_success, 1) + reset = ' reset_live_status();\n g_ptp_accept_rx.store(false, std::memory_order_release);' + if reset not in s: raise SystemExit('missing PTP reset') + s = s.replace(reset, ' reset_live_status();\n g_ptp_ready.store(false, std::memory_order_release);\n g_ptp_accept_rx.store(false, std::memory_order_release);', 1) + old_create = ' g_ptp_context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);\n ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0");\n }\n}\n\nvoid stop_ptp_lab() noexcept {' + new_create = ' g_ptp_context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);\n ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0");\n return;\n }\n\n constexpr unsigned kStartReadyPolls = 160U;\n for (unsigned attempt = 0U; attempt < kStartReadyPolls; ++attempt) {\n if (g_ptp_ready.load(std::memory_order_acquire) || !g_ptp_started.load(std::memory_order_acquire)) return;\n vTaskDelay(pdMS_TO_TICKS(10));\n }\n ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX");\n stop_ptp_lab();\n}\n\nvoid stop_ptp_lab() noexcept {' + if old_create not in s: raise SystemExit('missing PTP task creation tail') + s = s.replace(old_create, new_create, 1) + old_running = '[[nodiscard]] bool ptp_lab_is_running() noexcept {\n return g_ptp_started.load(std::memory_order_acquire);\n}' + if old_running not in s: raise SystemExit('missing PTP running state') + s = s.replace(old_running, '[[nodiscard]] bool ptp_lab_is_running() noexcept {\n return g_ptp_ready.load(std::memory_order_acquire);\n}', 1) + status = ' status->is_running = ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire);' + if status not in s: raise SystemExit('missing PTP status running') + s = s.replace(status, ' status->is_running = ar::esp32p4::smv::g_ptp_ready.load(std::memory_order_acquire);', 1) + p.write_text(s, encoding='utf-8') + + replace('embedded/esp32p4_smv_injector/main/live_control.cpp', ' if (!ar_esp32p4_ptp_start()) ESP_LOGE(kTag, "PTP start rejected: Ethernet is unavailable");\n else ESP_LOGI(kTag, "PTP start accepted");\n', ' if (!ar_esp32p4_ptp_start()) {\n ESP_LOGE(kTag, "PTP start rejected: Ethernet link/source readiness failed; no verified timing frames were emitted");\n } else {\n ESP_LOGI(kTag, "PTP start accepted: verified Announce/Sync/Follow_Up transmission");\n }\n', 'PTP truth message') + + p = Path('apps/arstack_studio/src/DeviceController.cpp') + s = p.read_text(encoding='utf-8') + anchor = ' if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) {\n emit deviceMessage(QStringLiteral("PTP expert profile accepted."));\n static_cast(sendPtpShow());\n }\n' + extra = anchor + ' if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) {\n ptpStatus_ = QStringLiteral("Timing frames verified");\n emit deviceMessage(QStringLiteral("PTP source is transmitting verified timing frames."));\n static_cast(sendPtpShow());\n }\n if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) {\n ptpRunning_ = false;\n ptpStatus_ = QStringLiteral("PTP start failed");\n emit ptpStateChanged();\n setError(QStringLiteral("PTP source did not emit timing frames. Check the Ethernet link and retry Start PTP Source."));\n }\n' + if anchor not in s: raise SystemExit('missing DeviceController PTP config anchor') + p.write_text(s.replace(anchor, extra, 1), encoding='utf-8') + + replace('apps/arstack_studio/qml/ConfigurationHub.qml', ' PtpExpertPanel {\n theme: hub.theme\n device: hub.device\n', ' PtpExpertPanel {\n theme: hub.theme\n controller: hub.controller\n device: hub.device\n', 'PTP controller binding') + PY + git add apps/arstack_studio/qml/Main.qml apps/arstack_studio/qml/ConfigurationHub.qml apps/arstack_studio/src/DeviceController.cpp embedded/esp32p4_smv_injector/main/ethernet_port.c embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/live_control.cpp + git diff --cached --check + git commit -m "fix(v0.1.1): verify PTP source TX and normal GUI shutdown" git push origin HEAD:feature/ptp-production-v0.1.1 From 8e2f3e05992fcffb2c3095dd7b336af8ad81bef6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:49:00 +0000 Subject: [PATCH 017/170] fix(v0.1.1): verify PTP source TX and normal GUI shutdown --- apps/arstack_studio/qml/ConfigurationHub.qml | 1 + apps/arstack_studio/qml/Main.qml | 16 +++++++++++----- apps/arstack_studio/src/DeviceController.cpp | 11 +++++++++++ .../esp32p4_smv_injector/main/ethernet_port.c | 15 +++++++-------- .../esp32p4_smv_injector/main/live_control.cpp | 7 +++++-- .../esp32p4_smv_injector/main/ptp_lab_task.cpp | 18 ++++++++++++++++-- 6 files changed, 51 insertions(+), 17 deletions(-) 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/Main.qml b/apps/arstack_studio/qml/Main.qml index af7fd321e..7ae7bd9e3 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -11,7 +11,7 @@ ApplicationWindow { minimumWidth: 1080 minimumHeight: 720 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,20 @@ 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() + Qt.quit() } FontLoader { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index a7707f256..fced96af7 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -987,6 +987,17 @@ 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_ = QStringLiteral("Timing frames verified"); + emit deviceMessage(QStringLiteral("PTP source is transmitting verified timing frames.")); + 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(QStringLiteral("PTP source did not emit timing frames. Check the Ethernet link and retry Start PTP Source.")); + } if (line.contains(QStringLiteral("PROFILE commit rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PROFILE rejected"), Qt::CaseInsensitive)) { diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index 0f07483e9..e19101143 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -76,14 +76,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", @@ -95,6 +87,13 @@ 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; + } + bool link_up = false; + const esp_err_t link_result = esp_eth_ioctl(s_eth_handle, ETH_CMD_G_LINK, &link_up); + if (link_result != ESP_OK || !link_up) { + ESP_LOGE(TAG, "PTP start rejected: Ethernet link is not up"); return false; } return ar_ptp_lab_try_start(s_eth_handle); diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 4bb3acd9c..0726a411c 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -314,8 +314,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/source readiness failed; no verified timing frames were emitted"); + } else { + ESP_LOGI(kTag, "PTP start accepted: verified Announce/Sync/Follow_Up transmission"); + } print_ptp_state(); return; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index e61051413..31c10622f 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}; @@ -468,6 +469,7 @@ void finish_runtime(PtpLabContext& context) { status.last_error.empty() ? "" : status.last_error.c_str()); } context.task_handle = nullptr; + g_ptp_ready.store(false, std::memory_order_release); g_ptp_started.store(false, std::memory_order_release); } @@ -573,6 +575,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); @@ -620,6 +624,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; @@ -649,7 +654,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 { @@ -661,7 +675,7 @@ void stop_ptp_lab() noexcept { } [[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) { @@ -717,7 +731,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); From 822c47eb801b6a4256e80e6c6420e9dd2fa935f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:50:40 +0700 Subject: [PATCH 018/170] ux(ptp): make source start obvious and evidence-driven --- apps/arstack_studio/qml/PtpExpertPanel.qml | 528 +++++++++++++-------- 1 file changed, 321 insertions(+), 207 deletions(-) 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 } } } } From a2e81a8fef9d5005b42b2b806d98d785b25f41c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:51:37 +0700 Subject: [PATCH 019/170] ci(studio): gate normal Windows close and immediate reopen --- .../arstack-studio-windows-lifecycle.yml | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/arstack-studio-windows-lifecycle.yml diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml new file mode 100644 index 000000000..04d93998e --- /dev/null +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -0,0 +1,84 @@ +name: ARStack Studio Windows Lifecycle + +on: + push: + paths: + - 'apps/arstack_studio/**' + - '.github/workflows/arstack-studio-windows-lifecycle.yml' + pull_request: + paths: + - 'apps/arstack_studio/**' + - '.github/workflows/arstack-studio-windows-lifecycle.yml' + +permissions: + contents: read + +concurrency: + group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + close-exit-reopen: + name: Close -> 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' From 25f0d97e799ccbabaf8d90c683ad3953e7e0158f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:52:06 +0700 Subject: [PATCH 020/170] chore: stage real main-window close regression --- .github/workflows/stabilize-v011-runtime.yml | 75 ++++---------------- 1 file changed, 12 insertions(+), 63 deletions(-) diff --git a/.github/workflows/stabilize-v011-runtime.yml b/.github/workflows/stabilize-v011-runtime.yml index 6c5b20a58..7566671ec 100644 --- a/.github/workflows/stabilize-v011-runtime.yml +++ b/.github/workflows/stabilize-v011-runtime.yml @@ -1,4 +1,4 @@ -name: Stage v0.1.1 stabilization fixes +name: Stage normal-close regression path on: push: @@ -9,14 +9,14 @@ permissions: contents: write jobs: - stabilize: + patch: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - name: Apply runtime and normal-close fixes + - name: Exercise real QQuickWindow close path shell: bash run: | set -euo pipefail @@ -25,68 +25,17 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" python - <<'PY' from pathlib import Path - - def replace(path, old, new, label): - p = Path(path) - s = p.read_text(encoding='utf-8') - if old not in s: - raise SystemExit(f'missing {label}: {path}') - p.write_text(s.replace(old, new, 1), encoding='utf-8') - - replace('apps/arstack_studio/qml/Main.qml', ' title: "ARStack Studio · SMV Injector"\n', ' title: "ARStack Studio · SMV + PTP · v" + Qt.application.version\n', 'title') - old_close = ' property bool telemetryExpanded: false\n\n font.family: root.uiFont\n\n // Graceful window close is still a safety boundary. QML submits only the\n // Stop intent; the supervisor owns whether the current session may act.\n onClosing: function(close) {\n if (workflowBar.session && workflowBar.session.state === "RUNNING")\n workflowBar.session.requestStop()\n }\n' - new_close = ' property bool telemetryExpanded: false\n property bool applicationShutdownRequested: false\n\n font.family: root.uiFont\n\n // Primary-window close owns application exit. C++ teardown owns STOP, COM\n // close and worker retirement; QML must not race it with another async STOP.\n onClosing: function(close) {\n close.accepted = true\n if (root.applicationShutdownRequested) return\n root.applicationShutdownRequested = true\n configurationWindow.hide()\n detachedPhasorWindow.hide()\n detachedWaveformWindow.hide()\n Qt.quit()\n }\n' - replace('apps/arstack_studio/qml/Main.qml', old_close, new_close, 'normal close') - - p = Path('embedded/esp32p4_smv_injector/main/ethernet_port.c') - s = p.read_text(encoding='utf-8') - auto = '\n#if CONFIG_AR_PTP_LAB_TX\n // Boot auto-start uses the same checked admission/readiness contract as the\n // live serial control path. A rejected or failed start is never reported as\n // running merely because an older runtime is still cleaning up.\n if (!ar_ptp_lab_try_start(handle)) {\n ESP_LOGW(TAG, "PTP auto-start was rejected or could not start");\n }\n#endif\n' - if auto not in s: raise SystemExit('missing premature PTP auto-start') - s = s.replace(auto, '\n', 1) - old = 'bool ar_esp32p4_ptp_start(void)\n{\n#if CONFIG_AR_PTP_LAB_TX\n if (s_eth_handle == NULL) {\n return false;\n }\n return ar_ptp_lab_try_start(s_eth_handle);\n#else\n return false;\n#endif\n}\n' - new = 'bool ar_esp32p4_ptp_start(void)\n{\n#if CONFIG_AR_PTP_LAB_TX\n if (s_eth_handle == NULL) {\n ESP_LOGE(TAG, "PTP start rejected: Ethernet driver is not initialized");\n return false;\n }\n bool link_up = false;\n const esp_err_t link_result = esp_eth_ioctl(s_eth_handle, ETH_CMD_G_LINK, &link_up);\n if (link_result != ESP_OK || !link_up) {\n ESP_LOGE(TAG, "PTP start rejected: Ethernet link is not up");\n return false;\n }\n return ar_ptp_lab_try_start(s_eth_handle);\n#else\n return false;\n#endif\n}\n' - if old not in s: raise SystemExit('missing PTP start function') - p.write_text(s.replace(old, new, 1), encoding='utf-8') - - p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') + p = Path('apps/arstack_studio/src/main.cpp') s = p.read_text(encoding='utf-8') - replace_atomic = 'std::atomic_bool g_ptp_started{false};\nstd::atomic_bool g_stop_requested{false};' - if replace_atomic not in s: raise SystemExit('missing PTP atomics') - s = s.replace(replace_atomic, 'std::atomic_bool g_ptp_started{false};\nstd::atomic_bool g_ptp_ready{false};\nstd::atomic_bool g_stop_requested{false};', 1) - old_finish = ' context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);' - if old_finish not in s: raise SystemExit('missing PTP finish') - s = s.replace(old_finish, ' context.task_handle = nullptr;\n g_ptp_ready.store(false, std::memory_order_release);\n g_ptp_started.store(false, std::memory_order_release);', 1) - old_success = ' if (success) {\n consecutive_failures = 0U;\n context.runtime->clear_error();\n } else {' - new_success = ' if (success) {\n consecutive_failures = 0U;\n context.runtime->clear_error();\n 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;\n if (emitted_core_timing) g_ptp_ready.store(true, std::memory_order_release);\n } else {' - if old_success not in s: raise SystemExit('missing PTP send-success block') - s = s.replace(old_success, new_success, 1) - reset = ' reset_live_status();\n g_ptp_accept_rx.store(false, std::memory_order_release);' - if reset not in s: raise SystemExit('missing PTP reset') - s = s.replace(reset, ' reset_live_status();\n g_ptp_ready.store(false, std::memory_order_release);\n g_ptp_accept_rx.store(false, std::memory_order_release);', 1) - old_create = ' g_ptp_context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);\n ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0");\n }\n}\n\nvoid stop_ptp_lab() noexcept {' - new_create = ' g_ptp_context.task_handle = nullptr;\n g_ptp_started.store(false, std::memory_order_release);\n ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0");\n return;\n }\n\n constexpr unsigned kStartReadyPolls = 160U;\n for (unsigned attempt = 0U; attempt < kStartReadyPolls; ++attempt) {\n if (g_ptp_ready.load(std::memory_order_acquire) || !g_ptp_started.load(std::memory_order_acquire)) return;\n vTaskDelay(pdMS_TO_TICKS(10));\n }\n ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX");\n stop_ptp_lab();\n}\n\nvoid stop_ptp_lab() noexcept {' - if old_create not in s: raise SystemExit('missing PTP task creation tail') - s = s.replace(old_create, new_create, 1) - old_running = '[[nodiscard]] bool ptp_lab_is_running() noexcept {\n return g_ptp_started.load(std::memory_order_acquire);\n}' - if old_running not in s: raise SystemExit('missing PTP running state') - s = s.replace(old_running, '[[nodiscard]] bool ptp_lab_is_running() noexcept {\n return g_ptp_ready.load(std::memory_order_acquire);\n}', 1) - status = ' status->is_running = ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire);' - if status not in s: raise SystemExit('missing PTP status running') - s = s.replace(status, ' status->is_running = ar::esp32p4::smv::g_ptp_ready.load(std::memory_order_acquire);', 1) + old = ' QCloseEvent closeEvent;\n QCoreApplication::sendEvent(lifecycleTarget.data(), &closeEvent);\n if (!primaryCloseFilter.closeObserved()) {' + new = ' lifecycleTarget->close();\n if (!primaryCloseFilter.closeObserved()) {' + if old not in s: + raise SystemExit('normal-close regression anchor not found') + s = s.replace(old, new, 1) + s = s.replace('Application lifecycle regression: primary QEvent::Close bypassed the lifetime filter.', 'Application lifecycle regression: normal primary-window close bypassed the lifetime filter.', 1) p.write_text(s, encoding='utf-8') - - replace('embedded/esp32p4_smv_injector/main/live_control.cpp', ' if (!ar_esp32p4_ptp_start()) ESP_LOGE(kTag, "PTP start rejected: Ethernet is unavailable");\n else ESP_LOGI(kTag, "PTP start accepted");\n', ' if (!ar_esp32p4_ptp_start()) {\n ESP_LOGE(kTag, "PTP start rejected: Ethernet link/source readiness failed; no verified timing frames were emitted");\n } else {\n ESP_LOGI(kTag, "PTP start accepted: verified Announce/Sync/Follow_Up transmission");\n }\n', 'PTP truth message') - - p = Path('apps/arstack_studio/src/DeviceController.cpp') - s = p.read_text(encoding='utf-8') - anchor = ' if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) {\n emit deviceMessage(QStringLiteral("PTP expert profile accepted."));\n static_cast(sendPtpShow());\n }\n' - extra = anchor + ' if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) {\n ptpStatus_ = QStringLiteral("Timing frames verified");\n emit deviceMessage(QStringLiteral("PTP source is transmitting verified timing frames."));\n static_cast(sendPtpShow());\n }\n if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) {\n ptpRunning_ = false;\n ptpStatus_ = QStringLiteral("PTP start failed");\n emit ptpStateChanged();\n setError(QStringLiteral("PTP source did not emit timing frames. Check the Ethernet link and retry Start PTP Source."));\n }\n' - if anchor not in s: raise SystemExit('missing DeviceController PTP config anchor') - p.write_text(s.replace(anchor, extra, 1), encoding='utf-8') - - replace('apps/arstack_studio/qml/ConfigurationHub.qml', ' PtpExpertPanel {\n theme: hub.theme\n device: hub.device\n', ' PtpExpertPanel {\n theme: hub.theme\n controller: hub.controller\n device: hub.device\n', 'PTP controller binding') PY - git add apps/arstack_studio/qml/Main.qml apps/arstack_studio/qml/ConfigurationHub.qml apps/arstack_studio/src/DeviceController.cpp embedded/esp32p4_smv_injector/main/ethernet_port.c embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/live_control.cpp + git add apps/arstack_studio/src/main.cpp git diff --cached --check - git commit -m "fix(v0.1.1): verify PTP source TX and normal GUI shutdown" + git commit -m "test(studio): exercise real primary-window close path" git push origin HEAD:feature/ptp-production-v0.1.1 From a6dc074b7cedde5b42f71a1eb3fcd4ed9b7a4ccc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:55:21 +0700 Subject: [PATCH 021/170] ci(ptp): lock verified source-start contract --- .github/workflows/esp32p4-ptp-lab.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index b7c7c3583..226e92a00 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -45,6 +45,31 @@ jobs: - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + - name: Verify PTP source readiness contract + shell: bash + run: | + python - <<'PY' + from pathlib import Path + eth = Path('main/ethernet_port.c').read_text(encoding='utf-8') + ptp = Path('main/ptp_lab_task.cpp').read_text(encoding='utf-8') + if 'ar_ptp_lab_try_start(handle)' in eth: + raise SystemExit('PTP must not auto-start inside Ethernet initialization before esp_eth_start/link readiness') + required = [ + 'ETH_CMD_G_LINK', + '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 source contract: explicit link-ready start + verified Announce/Sync/Follow_Up TX') + PY + - name: Enable guarded PTP-P2 profile shell: bash run: | From ba9899dec6a49c487fa6b2a48964e58606edf3e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:56:30 +0000 Subject: [PATCH 022/170] test(studio): exercise real primary-window close path --- apps/arstack_studio/src/main.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index db09219e6..885de2160 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -515,10 +515,9 @@ 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; } From fccf0171d0037ad80f9d8241d38dedf467feb66a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:57:29 +0700 Subject: [PATCH 023/170] chore: stage role-correct PTP status text --- .github/workflows/stabilize-v011-runtime.yml | 28 +++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/stabilize-v011-runtime.yml b/.github/workflows/stabilize-v011-runtime.yml index 7566671ec..5afe11de4 100644 --- a/.github/workflows/stabilize-v011-runtime.yml +++ b/.github/workflows/stabilize-v011-runtime.yml @@ -1,4 +1,4 @@ -name: Stage normal-close regression path +name: Stage role-correct PTP status on: push: @@ -16,7 +16,7 @@ jobs: with: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - name: Exercise real QQuickWindow close path + - name: Keep SOURCE evidence separate from receiver/monitor semantics shell: bash run: | set -euo pipefail @@ -25,17 +25,21 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" python - <<'PY' from pathlib import Path - p = Path('apps/arstack_studio/src/main.cpp') + p = Path('embedded/esp32p4_smv_injector/main/live_control.cpp') s = p.read_text(encoding='utf-8') - old = ' QCloseEvent closeEvent;\n QCoreApplication::sendEvent(lifecycleTarget.data(), &closeEvent);\n if (!primaryCloseFilter.closeObserved()) {' - new = ' lifecycleTarget->close();\n if (!primaryCloseFilter.closeObserved()) {' - if old not in s: - raise SystemExit('normal-close regression anchor not found') - s = s.replace(old, new, 1) - s = s.replace('Application lifecycle regression: primary QEvent::Close bypassed the lifetime filter.', 'Application lifecycle regression: normal primary-window close bypassed the lifetime filter.', 1) - p.write_text(s, encoding='utf-8') + old = ' if (!ar_esp32p4_ptp_start()) {\n ESP_LOGE(kTag, "PTP start rejected: Ethernet link/source readiness failed; no verified timing frames were emitted");\n } else {\n ESP_LOGI(kTag, "PTP start accepted: verified Announce/Sync/Follow_Up transmission");\n }\n' + new = ' if (!ar_esp32p4_ptp_start()) {\n ESP_LOGE(kTag, "PTP start rejected: Ethernet link or role runtime readiness failed");\n } else {\n ESP_LOGI(kTag, "PTP start accepted");\n }\n' + if old not in s: raise SystemExit('live-control PTP status anchor missing') + p.write_text(s.replace(old, new, 1), encoding='utf-8') + + p = Path('apps/arstack_studio/src/DeviceController.cpp') + s = p.read_text(encoding='utf-8') + old = ' if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) {\n ptpStatus_ = QStringLiteral("Timing frames verified");\n emit deviceMessage(QStringLiteral("PTP source is transmitting verified timing frames."));\n static_cast(sendPtpShow());\n }\n if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) {\n ptpRunning_ = false;\n ptpStatus_ = QStringLiteral("PTP start failed");\n emit ptpStateChanged();\n setError(QStringLiteral("PTP source did not emit timing frames. Check the Ethernet link and retry Start PTP Source."));\n }\n' + new = ' if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) {\n ptpStatus_ = ptpRole_ == QStringLiteral("SOURCE") ? QStringLiteral("Source TX verified") : QStringLiteral("Timing runtime started");\n emit deviceMessage(ptpRole_ == QStringLiteral("SOURCE")\n ? QStringLiteral("PTP source start verified; reading live TX counters.")\n : QStringLiteral("PTP %1 runtime started.").arg(ptpRole_.toLower()));\n static_cast(sendPtpShow());\n }\n if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) {\n ptpRunning_ = false;\n ptpStatus_ = QStringLiteral("PTP start failed");\n emit ptpStateChanged();\n setError(ptpRole_ == QStringLiteral("SOURCE")\n ? QStringLiteral("PTP source did not emit Announce/Sync/Follow_Up. Check the Ethernet link and retry Start PTP Source.")\n : QStringLiteral("PTP %1 could not start. Check the Ethernet link and retry.").arg(ptpRole_.toLower()));\n }\n' + if old not in s: raise SystemExit('DeviceController PTP status anchor missing') + p.write_text(s.replace(old, new, 1), encoding='utf-8') PY - git add apps/arstack_studio/src/main.cpp + git add embedded/esp32p4_smv_injector/main/live_control.cpp apps/arstack_studio/src/DeviceController.cpp git diff --cached --check - git commit -m "test(studio): exercise real primary-window close path" + git commit -m "fix(ptp): keep role-specific start semantics truthful" git push origin HEAD:feature/ptp-production-v0.1.1 From 4d98df266501a7f56509e3e1a158e850257bbdc0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:06:10 +0000 Subject: [PATCH 024/170] fix(ptp): keep role-specific start semantics truthful --- apps/arstack_studio/src/DeviceController.cpp | 10 +++++++--- embedded/esp32p4_smv_injector/main/live_control.cpp | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index fced96af7..5acd70ae9 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -988,15 +988,19 @@ void DeviceController::processLine(const QString& rawLine) { static_cast(sendPtpShow()); } if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) { - ptpStatus_ = QStringLiteral("Timing frames verified"); - emit deviceMessage(QStringLiteral("PTP source is transmitting verified timing frames.")); + 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(QStringLiteral("PTP source did not emit timing frames. Check the Ethernet link and retry Start PTP Source.")); + 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) || diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 0726a411c..4cd6b5fee 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -315,9 +315,9 @@ void handle_ptp_command(char* save) noexcept { return; } if (!ar_esp32p4_ptp_start()) { - ESP_LOGE(kTag, "PTP start rejected: Ethernet link/source readiness failed; no verified timing frames were emitted"); + ESP_LOGE(kTag, "PTP start rejected: Ethernet link or role runtime readiness failed"); } else { - ESP_LOGI(kTag, "PTP start accepted: verified Announce/Sync/Follow_Up transmission"); + ESP_LOGI(kTag, "PTP start accepted"); } print_ptp_state(); return; From 41bd10f1c2bde27c5e64f65de367c83d210e7b90 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 01:56:28 +0700 Subject: [PATCH 025/170] chore(ci): remove temporary v0.1.1 staging workflow --- .github/workflows/stabilize-v011-runtime.yml | 45 -------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/stabilize-v011-runtime.yml diff --git a/.github/workflows/stabilize-v011-runtime.yml b/.github/workflows/stabilize-v011-runtime.yml deleted file mode 100644 index 5afe11de4..000000000 --- a/.github/workflows/stabilize-v011-runtime.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Stage role-correct PTP status - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - name: Keep SOURCE evidence separate from receiver/monitor semantics - shell: bash - run: | - set -euo pipefail - if [ "$GITHUB_ACTOR" = "github-actions[bot]" ]; then exit 0; fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - python - <<'PY' - from pathlib import Path - p = Path('embedded/esp32p4_smv_injector/main/live_control.cpp') - s = p.read_text(encoding='utf-8') - old = ' if (!ar_esp32p4_ptp_start()) {\n ESP_LOGE(kTag, "PTP start rejected: Ethernet link/source readiness failed; no verified timing frames were emitted");\n } else {\n ESP_LOGI(kTag, "PTP start accepted: verified Announce/Sync/Follow_Up transmission");\n }\n' - new = ' if (!ar_esp32p4_ptp_start()) {\n ESP_LOGE(kTag, "PTP start rejected: Ethernet link or role runtime readiness failed");\n } else {\n ESP_LOGI(kTag, "PTP start accepted");\n }\n' - if old not in s: raise SystemExit('live-control PTP status anchor missing') - p.write_text(s.replace(old, new, 1), encoding='utf-8') - - p = Path('apps/arstack_studio/src/DeviceController.cpp') - s = p.read_text(encoding='utf-8') - old = ' if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) {\n ptpStatus_ = QStringLiteral("Timing frames verified");\n emit deviceMessage(QStringLiteral("PTP source is transmitting verified timing frames."));\n static_cast(sendPtpShow());\n }\n if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) {\n ptpRunning_ = false;\n ptpStatus_ = QStringLiteral("PTP start failed");\n emit ptpStateChanged();\n setError(QStringLiteral("PTP source did not emit timing frames. Check the Ethernet link and retry Start PTP Source."));\n }\n' - new = ' if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) {\n ptpStatus_ = ptpRole_ == QStringLiteral("SOURCE") ? QStringLiteral("Source TX verified") : QStringLiteral("Timing runtime started");\n emit deviceMessage(ptpRole_ == QStringLiteral("SOURCE")\n ? QStringLiteral("PTP source start verified; reading live TX counters.")\n : QStringLiteral("PTP %1 runtime started.").arg(ptpRole_.toLower()));\n static_cast(sendPtpShow());\n }\n if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) {\n ptpRunning_ = false;\n ptpStatus_ = QStringLiteral("PTP start failed");\n emit ptpStateChanged();\n setError(ptpRole_ == QStringLiteral("SOURCE")\n ? QStringLiteral("PTP source did not emit Announce/Sync/Follow_Up. Check the Ethernet link and retry Start PTP Source.")\n : QStringLiteral("PTP %1 could not start. Check the Ethernet link and retry.").arg(ptpRole_.toLower()));\n }\n' - if old not in s: raise SystemExit('DeviceController PTP status anchor missing') - p.write_text(s.replace(old, new, 1), encoding='utf-8') - PY - git add embedded/esp32p4_smv_injector/main/live_control.cpp apps/arstack_studio/src/DeviceController.cpp - git diff --cached --check - git commit -m "fix(ptp): keep role-specific start semantics truthful" - git push origin HEAD:feature/ptp-production-v0.1.1 From b1d500349fff4c7d446dd7906d0ddabb0846f814 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 02:03:34 +0700 Subject: [PATCH 026/170] fix(ptp): use verified TX readiness on ESP-IDF 5.5 --- .../esp32p4_smv_injector/main/ethernet_port.c | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index e19101143..bb7414a1a 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -76,7 +76,6 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) } s_eth_handle = handle; - 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); @@ -90,12 +89,15 @@ bool ar_esp32p4_ptp_start(void) ESP_LOGE(TAG, "PTP start rejected: Ethernet driver is not initialized"); return false; } - bool link_up = false; - const esp_err_t link_result = esp_eth_ioctl(s_eth_handle, ETH_CMD_G_LINK, &link_up); - if (link_result != ESP_OK || !link_up) { - ESP_LOGE(TAG, "PTP start rejected: Ethernet link is not up"); - return false; - } + + // ESP-IDF 5.5 does not expose a portable ETH_CMD_G_LINK ioctl. More + // importantly, the source runtime already has the stronger readiness + // contract we need: ar_ptp_lab_try_start() only succeeds when the source + // has emitted verified Announce + Sync + Follow_Up frames. Boot auto-start + // is intentionally absent, so this operator path runs only after app_main + // has called esp_eth_start(). A disconnected/not-ready link therefore + // fails closed through the bounded PTP TX-readiness check instead of being + // inferred from a driver-specific link query. return ar_ptp_lab_try_start(s_eth_handle); #else return false; From 685e9c7d1ed4c614b0b1d15d833e7a2cec7c7152 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 02:06:39 +0700 Subject: [PATCH 027/170] chore(ci): stage v0.1.1 harness fixture fix --- .github/workflows/patch-v011-harness.yml | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/patch-v011-harness.yml diff --git a/.github/workflows/patch-v011-harness.yml b/.github/workflows/patch-v011-harness.yml new file mode 100644 index 000000000..d562566e1 --- /dev/null +++ b/.github/workflows/patch-v011-harness.yml @@ -0,0 +1,38 @@ +name: Patch v0.1.1 deterministic harness + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + - name: Align S8 fixture with v0.1.1 semantic identity + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + python - <<'PY' + from pathlib import Path + p = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') + text = p.read_text(encoding='utf-8') + old = ''' result.capabilities = {\n QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n QStringLiteral("SESSION-LEASE")};\n''' + new = ''' result.capabilities = {\n QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n QStringLiteral("SESSION-LEASE"),\n QStringLiteral("PTP-P2"),\n QStringLiteral("SMPSYNCH-AUTO")};\n''' + if text.count(old) != 1: + raise SystemExit(f'expected exactly one stale identity fixture, found {text.count(old)}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + PY + git add apps/arstack_studio/src/DeterministicSessionHarness.cpp + git commit -m "test(studio): align S8 fixture with v0.1.1 capabilities" + git push origin HEAD:feature/ptp-production-v0.1.1 From fe35964e038efb9a14f6c23bf1049e2ed017153e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:07:39 +0000 Subject: [PATCH 028/170] test(studio): align S8 fixture with v0.1.1 capabilities --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index fd0140d73..478852752 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -104,7 +104,9 @@ class DeterministicSessionHarness final { result.capabilities = { QStringLiteral("SMV-4I4V"), QStringLiteral("LIVE-SETPOINTS"), - QStringLiteral("SESSION-LEASE")}; + QStringLiteral("SESSION-LEASE"), + QStringLiteral("PTP-P2"), + QStringLiteral("SMPSYNCH-AUTO")}; return result; } From 10cfc12783d39d79db047f47160e007731166d66 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 02:09:06 +0700 Subject: [PATCH 029/170] ci(ptp): gate verified TX readiness on ESP-IDF 5.5 --- .github/workflows/esp32p4-ptp-lab.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 226e92a00..2d19fd46e 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -51,11 +51,20 @@ jobs: 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') + + # 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 esp_eth_start/link readiness') + 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 has no portable ETH_CMD_G_LINK query. Admission truth is + # therefore the stronger bounded runtime contract: explicit operator + # start is accepted only after real Announce + Sync + Follow_Up TX. required = [ - 'ETH_CMD_G_LINK', + 'return ar_ptp_lab_try_start(s_eth_handle);', 'g_ptp_ready', 'PTP source readiness timeout', 'g_announce_sent.load', @@ -67,7 +76,7 @@ jobs: missing = [token for token in required if token not in combined] if missing: raise SystemExit('PTP verified-start contract missing: ' + ', '.join(missing)) - print('PTP source contract: explicit link-ready start + verified Announce/Sync/Follow_Up TX') + print('PTP source contract: explicit post-Ethernet-start operator path + verified Announce/Sync/Follow_Up TX') PY - name: Enable guarded PTP-P2 profile From 9f286ed7f7711c90d81c1ce2548b023caa632620 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 02:09:16 +0700 Subject: [PATCH 030/170] chore(ci): remove temporary v0.1.1 harness patch workflow --- .github/workflows/patch-v011-harness.yml | 38 ------------------------ 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/patch-v011-harness.yml diff --git a/.github/workflows/patch-v011-harness.yml b/.github/workflows/patch-v011-harness.yml deleted file mode 100644 index d562566e1..000000000 --- a/.github/workflows/patch-v011-harness.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Patch v0.1.1 deterministic harness - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - name: Align S8 fixture with v0.1.1 semantic identity - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - python - <<'PY' - from pathlib import Path - p = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') - text = p.read_text(encoding='utf-8') - old = ''' result.capabilities = {\n QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n QStringLiteral("SESSION-LEASE")};\n''' - new = ''' result.capabilities = {\n QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n QStringLiteral("SESSION-LEASE"),\n QStringLiteral("PTP-P2"),\n QStringLiteral("SMPSYNCH-AUTO")};\n''' - if text.count(old) != 1: - raise SystemExit(f'expected exactly one stale identity fixture, found {text.count(old)}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - PY - git add apps/arstack_studio/src/DeterministicSessionHarness.cpp - git commit -m "test(studio): align S8 fixture with v0.1.1 capabilities" - git push origin HEAD:feature/ptp-production-v0.1.1 From c6f1c5906df48a62a0cad3bec24478c74d477e80 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 02:13:56 +0700 Subject: [PATCH 031/170] chore(ci): stage PTP stop declaration fix --- .../workflows/patch-ptp-stop-declaration.yml | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/patch-ptp-stop-declaration.yml diff --git a/.github/workflows/patch-ptp-stop-declaration.yml b/.github/workflows/patch-ptp-stop-declaration.yml new file mode 100644 index 000000000..68d8b4f55 --- /dev/null +++ b/.github/workflows/patch-ptp-stop-declaration.yml @@ -0,0 +1,38 @@ +name: Patch PTP stop declaration + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + - name: Declare bounded stop helper before source start + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + python - <<'PY' + from pathlib import Path + p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') + text = p.read_text(encoding='utf-8') + anchor = 'void start_ptp_lab(const esp_eth_handle_t eth_handle) {\n' + if text.count(anchor) != 1: + raise SystemExit(f'expected one start_ptp_lab anchor, found {text.count(anchor)}') + text = text.replace(anchor, 'void stop_ptp_lab() noexcept;\n\n' + anchor, 1) + p.write_text(text, encoding='utf-8') + PY + git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp + git commit -m "fix(ptp): declare stop helper before bounded start" + git push origin HEAD:feature/ptp-production-v0.1.1 From 8530d665cac202cb9207debb06490250a84affe3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 02:16:26 +0700 Subject: [PATCH 032/170] chore(ci): remove temporary PTP declaration helper --- .../workflows/patch-ptp-stop-declaration.yml | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/patch-ptp-stop-declaration.yml diff --git a/.github/workflows/patch-ptp-stop-declaration.yml b/.github/workflows/patch-ptp-stop-declaration.yml deleted file mode 100644 index 68d8b4f55..000000000 --- a/.github/workflows/patch-ptp-stop-declaration.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Patch PTP stop declaration - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - name: Declare bounded stop helper before source start - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - python - <<'PY' - from pathlib import Path - p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') - text = p.read_text(encoding='utf-8') - anchor = 'void start_ptp_lab(const esp_eth_handle_t eth_handle) {\n' - if text.count(anchor) != 1: - raise SystemExit(f'expected one start_ptp_lab anchor, found {text.count(anchor)}') - text = text.replace(anchor, 'void stop_ptp_lab() noexcept;\n\n' + anchor, 1) - p.write_text(text, encoding='utf-8') - PY - git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp - git commit -m "fix(ptp): declare stop helper before bounded start" - git push origin HEAD:feature/ptp-production-v0.1.1 From 17036bb5acaeec31cd93cbc8f48bf3f037f0b984 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 02:16:35 +0700 Subject: [PATCH 033/170] fix(ptp): declare bounded source stop helper --- .../esp32p4_smv_injector/main/ptp_lab_forward.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/ptp_lab_forward.hpp diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_forward.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_forward.hpp new file mode 100644 index 000000000..361295e41 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_forward.hpp @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// Internal forward declaration injected only into ptp_lab_task.cpp. +// The bounded SOURCE start path may request teardown before the later helper +// definition is encountered by the compiler. +namespace ar::esp32p4::smv { +namespace { +void stop_ptp_lab() noexcept; +} // namespace +} // namespace ar::esp32p4::smv From b6da439d330c5067d7fe8a86638499d834a60597 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 02:16:48 +0700 Subject: [PATCH 034/170] build(ptp): inject internal stop declaration for source runtime --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index 48e478b6b..96e1704a6 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -25,8 +25,11 @@ if(CONFIG_AR_PTP_LAB_TX) # P1.5's proven lab-source implementation remains intact. Rename only its # public C facade so P2 can route SOURCE/RECEIVER/MONITOR without duplicating # the source timing path or turning one Ethernet port into an invalid - # simultaneous master+slave role. + # simultaneous master+slave role. The private forward header is injected + # only into this translation unit so the bounded start path can invoke its + # later-defined stop helper without exposing internal lifecycle authority. set_property(SOURCE "ptp_lab_task.cpp" APPEND PROPERTY COMPILE_OPTIONS + "-include" "${CMAKE_CURRENT_LIST_DIR}/ptp_lab_forward.hpp" "-Dar_ptp_lab_get_default_config=ar_ptp_source_get_default_config" "-Dar_ptp_lab_configure=ar_ptp_source_configure" "-Dar_ptp_lab_get_config=ar_ptp_source_get_config" From 7e7768c114d585579fbcba327ac37a3e874c453d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:19:17 +0000 Subject: [PATCH 035/170] fix(ptp): declare stop helper before bounded start --- embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 31c10622f..bf7361320 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -609,6 +609,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"); From 0b3a85af59a2100853ecd1baebe58e1bdeced901 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 10:28:00 +0700 Subject: [PATCH 036/170] build(ptp): keep lifecycle declaration local to source --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index 96e1704a6..48e478b6b 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -25,11 +25,8 @@ if(CONFIG_AR_PTP_LAB_TX) # P1.5's proven lab-source implementation remains intact. Rename only its # public C facade so P2 can route SOURCE/RECEIVER/MONITOR without duplicating # the source timing path or turning one Ethernet port into an invalid - # simultaneous master+slave role. The private forward header is injected - # only into this translation unit so the bounded start path can invoke its - # later-defined stop helper without exposing internal lifecycle authority. + # simultaneous master+slave role. set_property(SOURCE "ptp_lab_task.cpp" APPEND PROPERTY COMPILE_OPTIONS - "-include" "${CMAKE_CURRENT_LIST_DIR}/ptp_lab_forward.hpp" "-Dar_ptp_lab_get_default_config=ar_ptp_source_get_default_config" "-Dar_ptp_lab_configure=ar_ptp_source_configure" "-Dar_ptp_lab_get_config=ar_ptp_source_get_config" From 566afaf9953ae37f7a33005fc9c2f0590f19bc71 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 10:28:06 +0700 Subject: [PATCH 037/170] chore(ptp): remove temporary forward-declaration shim --- .../esp32p4_smv_injector/main/ptp_lab_forward.hpp | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 embedded/esp32p4_smv_injector/main/ptp_lab_forward.hpp diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_forward.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_forward.hpp deleted file mode 100644 index 361295e41..000000000 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_forward.hpp +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -#pragma once - -// Internal forward declaration injected only into ptp_lab_task.cpp. -// The bounded SOURCE start path may request teardown before the later helper -// definition is encountered by the compiler. -namespace ar::esp32p4::smv { -namespace { -void stop_ptp_lab() noexcept; -} // namespace -} // namespace ar::esp32p4::smv From cc15cc9a93b40f8543a270bfc97aa7ea7ca14eee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:18:19 +0700 Subject: [PATCH 038/170] ci: stage P0 shared Ethernet TX recovery --- .../p0-shared-ethernet-tx-recovery.yml | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 .github/workflows/p0-shared-ethernet-tx-recovery.yml diff --git a/.github/workflows/p0-shared-ethernet-tx-recovery.yml b/.github/workflows/p0-shared-ethernet-tx-recovery.yml new file mode 100644 index 000000000..536668122 --- /dev/null +++ b/.github/workflows/p0-shared-ethernet-tx-recovery.yml @@ -0,0 +1,215 @@ +name: Stage P0 shared Ethernet TX recovery + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Apply P0 shared Ethernet TX recovery + shell: python + run: | + from pathlib import Path + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected exactly one anchor, found {count}') + return text.replace(old, new, 1) + + # 1) ESP-IDF Ethernet driver serialization for concurrent SMV + PTP producers. + p = Path('embedded/esp32p4_smv_injector/sdkconfig.defaults') + text = p.read_text(encoding='utf-8') + anchor = 'CONFIG_ETH_USE_ESP32_EMAC=y\n' + text = replace_once( + text, + anchor, + anchor + '# SMV and PTP are concurrent L2 TX producers in v0.1.1; serialize the shared EMAC.\nCONFIG_ETH_TRANSMIT_MUTEX=y\n', + 'sdkconfig Ethernet mutex') + p.write_text(text, encoding='utf-8') + + # 2) Hardware PTP mode must be rolled back on every STOP/start-failure. + p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') + text = p.read_text(encoding='utf-8') + enable_block = '''[[nodiscard]] bool enable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { + bool enable = true; + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_PTP_ENABLE), + &enable); + if (result != ESP_OK) { + ESP_LOGE(kTag, "Failed to enable ESP32-P4 IEEE1588 hardware clock: %s", + esp_err_to_name(result)); + return false; + } + return true; + } + '''.replace(' ', '') + disable_block = enable_block + ''' + 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, "Failed to restore normal EMAC mode after PTP: %s", + esp_err_to_name(result)); + } + } + '''.replace(' ', '') + text = replace_once(text, enable_block, disable_block, 'PTP disable helper') + + finish_anchor = ''' } + context.task_handle = nullptr; + g_ptp_ready.store(false, std::memory_order_release); + g_ptp_started.store(false, std::memory_order_release); + } + '''.replace(' ', '') + finish_replacement = ''' } + // Hardware timestamp mode changes the shared EMAC data path. Restore normal + // Ethernet mode before publishing STOPPED so SMV can continue independently. + 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); + } + '''.replace(' ', '') + text = replace_once(text, finish_anchor, finish_replacement, 'PTP teardown rollback') + + stop_anchor = '''void stop_ptp_lab() noexcept { + if (!g_ptp_started.load(std::memory_order_acquire)) return; + g_stop_requested.store(true, std::memory_order_release); + if (g_ptp_context.task_handle != nullptr) { + xTaskNotifyGive(g_ptp_context.task_handle); + } + } + '''.replace(' ', '') + stop_replacement = '''void stop_ptp_lab() noexcept { + if (!g_ptp_started.load(std::memory_order_acquire)) return; + g_stop_requested.store(true, std::memory_order_release); + if (g_ptp_context.task_handle != nullptr) { + xTaskNotifyGive(g_ptp_context.task_handle); + } + + // A failed SOURCE start must not return to the operator while the EMAC is + // still in PTP timestamp mode. Wait only for bounded worker-owned teardown. + constexpr unsigned kStopCleanupPolls = 100U; + for (unsigned attempt = 0U; attempt < kStopCleanupPolls; ++attempt) { + if (!g_ptp_started.load(std::memory_order_acquire)) return; + vTaskDelay(pdMS_TO_TICKS(5)); + } + ESP_LOGE(kTag, "PTP stop cleanup timeout; EMAC rollback did not complete within 500 ms"); + } + '''.replace(' ', '') + text = replace_once(text, stop_anchor, stop_replacement, 'bounded PTP stop cleanup') + p.write_text(text, encoding='utf-8') + + # 3) Fail closed when canonical SMV transport is dead even though the sample clock is healthy. + p = Path('embedded/esp32p4_smv_injector/main/app_main.cpp') + text = p.read_text(encoding='utf-8') + text = replace_once( + text, + 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n', + 'using ar::esp32p4::smv::live_control_bind_publisher_task;\nusing ar::esp32p4::smv::live_control_force_stop;\n', + 'force-stop import') + text = replace_once( + text, + 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n', + 'constexpr std::uint32_t kStatsMergeEvery = 400U;\nconstexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at the P0 4000 fps profile\n', + 'transport threshold') + text = replace_once( + text, + ' std::int64_t expected_wake_us = 0;\n', + ' std::int64_t expected_wake_us = 0;\n std::uint32_t consecutive_canonical_tx_failures = 0U;\n esp_err_t last_canonical_tx_error = ESP_OK;\n', + 'transport state') + text = replace_once( + text, + ' schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n', + ' schedule_anchored = false;\n consecutive_canonical_tx_failures = 0U;\n last_canonical_tx_error = ESP_OK;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n', + 'transport reset at START') + + old_tx = ''' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { + ++local.canonical_fail; + #if CONFIG_AR_SMV_BROADCAST_MIRROR + ++local.mirror_fail; + #endif + } else { + 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; + '''.replace(' ', '') + new_tx = ''' 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 + } else { + 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; + 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; + } + '''.replace(' ', '') + text = replace_once(text, old_tx, new_tx, 'canonical TX health tracking') + + timing_anchor = ''' 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); + '''.replace(' ', '') + timing_replacement = timing_anchor + ''' 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; + } + '''.replace(' ', '') + text = replace_once(text, timing_anchor, timing_replacement, 'fail-closed transport STOP') + p.write_text(text, encoding='utf-8') + + - name: Verify and commit P0 patch + shell: bash + run: | + set -euo pipefail + grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' embedded/esp32p4_smv_injector/sdkconfig.defaults + grep -q 'disable_hardware_ptp(context.eth_handle);' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp + grep -q 'kTransportFailureStopThreshold = 400U' embedded/esp32p4_smv_injector/main/app_main.cpp + grep -q 'live_control_force_stop();' embedded/esp32p4_smv_injector/main/app_main.cpp + git diff --check + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add embedded/esp32p4_smv_injector/sdkconfig.defaults \ + embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp \ + embedded/esp32p4_smv_injector/main/app_main.cpp + git commit -m 'fix(p0): recover shared Ethernet TX for SMV and PTP' + git push origin HEAD:feature/ptp-production-v0.1.1 From 20c3e68b5cf1f4e9478620ea9a00213187fd08d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:23:13 +0700 Subject: [PATCH 039/170] ci: stage authoritative P0 SMV/PTP recovery --- .../workflows/p0-authoritative-recovery.yml | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 .github/workflows/p0-authoritative-recovery.yml diff --git a/.github/workflows/p0-authoritative-recovery.yml b/.github/workflows/p0-authoritative-recovery.yml new file mode 100644 index 000000000..117e8c0a0 --- /dev/null +++ b/.github/workflows/p0-authoritative-recovery.yml @@ -0,0 +1,354 @@ +name: P0 authoritative SMV/PTP recovery + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + +permissions: + contents: write + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Apply authoritative P0 recovery + shell: python + run: | + from pathlib import Path + import re + + changed = [] + + def write_if_changed(path: str, before: str, after: str) -> None: + if before != after: + Path(path).write_text(after, encoding='utf-8') + changed.append(path) + + def replace_required(text: str, old: str, new: str, label: str) -> str: + if new in text: + return text + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected one anchor, found {count}') + return text.replace(old, new, 1) + + def patch_function(text: str, signature: str, old: str, new: str, label: str) -> str: + start = text.find(signature) + if start < 0: + raise SystemExit(f'{label}: function not found') + end = text.find('\n}\n', start) + if end < 0: + raise SystemExit(f'{label}: function end not found') + end += 3 + body = text[start:end] + if new in body: + return text + count = body.count(old) + if count != 1: + raise SystemExit(f'{label}: expected one in-function anchor, found {count}') + body = body.replace(old, new, 1) + return text[:start] + body + text[end:] + + # ------------------------------------------------------------------ + # 1) Shared Ethernet serialization: v0.1.1 runs SMV and PTP as + # concurrent L2 producers on the same ESP-IDF Ethernet handle. + # ------------------------------------------------------------------ + path = 'embedded/esp32p4_smv_injector/sdkconfig.defaults' + before = Path(path).read_text(encoding='utf-8') + after = before + if 'CONFIG_ETH_TRANSMIT_MUTEX=y' not in after: + after = replace_required( + after, + 'CONFIG_ETH_USE_ESP32_EMAC=y\n', + 'CONFIG_ETH_USE_ESP32_EMAC=y\n' + '# SMV and PTP share one EMAC TX path; serialize concurrent producers.\n' + 'CONFIG_ETH_TRANSMIT_MUTEX=y\n', + 'Ethernet TX mutex') + write_if_changed(path, before, after) + + # ------------------------------------------------------------------ + # 2) ESP-IDF direct ctrl_vargs contract: argc is the number of raw + # variadic arguments. One buffer/length pair therefore requires 2. + # Fix both active hardware-timestamp TX paths. + # ------------------------------------------------------------------ + for path in ( + 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp', + 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp'): + before = Path(path).read_text(encoding='utf-8') + after = before + pattern = re.compile( + r'(const auto result = esp_eth_transmit_ctrl_vargs\(\n' + r'\s*[a-zA-Z_][a-zA-Z0-9_]*,\n' + r'\s*&tx_timestamp,\n\s*)1U(,\n\s*frame\.data\(\),\n\s*frame\.size\(\)\);)') + if 'esp_eth_transmit_ctrl_vargs argc is the raw variadic-argument count' not in after: + after, count = pattern.subn(r'\g<1>2U\2', after, count=1) + if count != 1: + raise SystemExit(f'{path}: hardware TX argc anchor not found') + after = after.replace( + '// esp_eth_transmit_ctrl_vargs argc counts buffer/length pairs. One PTP\n' + ' // Ethernet frame is exactly one pair.\n', + '// esp_eth_transmit_ctrl_vargs argc is the raw variadic-argument count.\n' + ' // One Ethernet frame supplies buffer + length, therefore argc=2.\n', + 1) + write_if_changed(path, before, after) + + # ------------------------------------------------------------------ + # 3) Hardware PTP mode is shared EMAC state. Roll it back on every + # role teardown and make STOP wait for bounded worker cleanup. + # ------------------------------------------------------------------ + helper = '''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: %s", + esp_err_to_name(result)); + } + } + + '''.replace(' ', '') + + for path in ( + 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp', + 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp'): + before = Path(path).read_text(encoding='utf-8') + after = before + if 'void disable_hardware_ptp(' not in after: + marker = 'void seed_hardware_clock(' + pos = after.find(marker) + if pos < 0: + raise SystemExit(f'{path}: seed_hardware_clock anchor missing') + after = after[:pos] + helper + after[pos:] + write_if_changed(path, before, after) + + path = 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp' + before = Path(path).read_text(encoding='utf-8') + after = patch_function( + before, + 'void finish_runtime(PtpLabContext& context)', + ' context.task_handle = nullptr;\n', + ' disable_hardware_ptp(context.eth_handle);\n' + ' context.task_handle = nullptr;\n', + 'SOURCE EMAC rollback') + after = patch_function( + after, + 'void stop_ptp_lab() noexcept', + ' if (g_ptp_context.task_handle != nullptr) {\n' + ' xTaskNotifyGive(g_ptp_context.task_handle);\n' + ' }\n', + ' if (g_ptp_context.task_handle != nullptr) {\n' + ' xTaskNotifyGive(g_ptp_context.task_handle);\n' + ' }\n' + ' constexpr unsigned kCleanupPolls = 100U;\n' + ' for (unsigned attempt = 0U; attempt < kCleanupPolls; ++attempt) {\n' + ' if (!g_ptp_started.load(std::memory_order_acquire)) return;\n' + ' vTaskDelay(pdMS_TO_TICKS(5));\n' + ' }\n' + ' ESP_LOGE(kTag, "PTP SOURCE cleanup timeout; EMAC rollback incomplete after 500 ms");\n', + 'SOURCE bounded STOP') + write_if_changed(path, before, after) + + path = 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp' + before = Path(path).read_text(encoding='utf-8') + after = patch_function( + before, + 'void finish_receiver(ReceiverContext& context) noexcept', + ' smp_synch_lab_set_measured(std::nullopt);\n' + ' context.receiver.reset();\n', + ' smp_synch_lab_set_measured(std::nullopt);\n' + ' disable_hardware_ptp(context.eth_handle);\n' + ' context.receiver.reset();\n', + 'RECEIVER/MONITOR EMAC rollback') + after = patch_function( + after, + 'void ptp_receiver_stop() noexcept', + ' if (g_receiver_context.task_handle != nullptr) {\n' + ' xTaskNotifyGive(g_receiver_context.task_handle);\n' + ' }\n', + ' if (g_receiver_context.task_handle != nullptr) {\n' + ' xTaskNotifyGive(g_receiver_context.task_handle);\n' + ' }\n' + ' constexpr unsigned kCleanupPolls = 100U;\n' + ' for (unsigned attempt = 0U; attempt < kCleanupPolls; ++attempt) {\n' + ' if (!g_receiver_running.load(std::memory_order_acquire)) return;\n' + ' vTaskDelay(pdMS_TO_TICKS(5));\n' + ' }\n' + ' ESP_LOGE(kTag, "PTP receiver cleanup timeout; EMAC rollback incomplete after 500 ms");\n', + 'RECEIVER/MONITOR bounded STOP') + write_if_changed(path, before, after) + + # ------------------------------------------------------------------ + # 4) SMV fail-closed transport state. A healthy 4 kHz scheduler with + # 0 successful Ethernet frames must not remain RUNNING. + # ------------------------------------------------------------------ + path = 'embedded/esp32p4_smv_injector/main/app_main.cpp' + before = Path(path).read_text(encoding='utf-8') + after = before + if 'kTransportFailureStopThreshold' not in after: + after = replace_required( + after, + 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n', + 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n' + 'using ar::esp32p4::smv::live_control_force_stop;\n', + 'force-stop import') + after = replace_required( + after, + 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n', + 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n' + 'constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4000 fps\n', + 'transport threshold') + after = replace_required( + after, + ' std::int64_t expected_wake_us = 0;\n', + ' std::int64_t expected_wake_us = 0;\n' + ' std::uint32_t consecutive_canonical_tx_failures = 0U;\n' + ' esp_err_t last_canonical_tx_error = ESP_OK;\n', + 'transport state') + after = replace_required( + after, + ' schedule_anchored = false;\n' + ' expected_schedule.reset(active_profile.publisher_rate_hz);\n', + ' schedule_anchored = false;\n' + ' consecutive_canonical_tx_failures = 0U;\n' + ' last_canonical_tx_error = ESP_OK;\n' + ' expected_schedule.reset(active_profile.publisher_rate_hz);\n', + 'START transport reset') + old = ''' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { + ++local.canonical_fail; + #if CONFIG_AR_SMV_BROADCAST_MIRROR + ++local.mirror_fail; + #endif + } else { + 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; + '''.replace(' ', '') + new = ''' 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 + } else { + 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; + 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; + } + '''.replace(' ', '') + after = replace_required(after, old, new, 'canonical TX health') + anchor = ''' 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); + '''.replace(' ', '') + replacement = anchor + ''' 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; + } + '''.replace(' ', '') + after = replace_required(after, anchor, replacement, 'transport fail-close') + write_if_changed(path, before, after) + + # Make forced transport STOP visible to Studio's existing parser. + path = 'embedded/esp32p4_smv_injector/main/live_control.cpp' + before = Path(path).read_text(encoding='utf-8') + after = patch_function( + before, + 'void live_control_force_stop() noexcept', + ' wake_publisher();\n', + ' wake_publisher();\n' + ' ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime");\n', + 'fail-closed STOP observability') + write_if_changed(path, before, after) + + # ------------------------------------------------------------------ + # 5) Repair the Studio CI contract fixture: it was still manufacturing + # a 0.1.0 bundle without the production capabilities required by 0.1.1. + # ------------------------------------------------------------------ + path = '.github/workflows/arstack-studio-qt.yml' + before = Path(path).read_text(encoding='utf-8') + after = before + after = after.replace('arstack-esp32p4-smv-0.1.0.bin', 'arstack-esp32p4-smv-0.1.1.bin') + after = after.replace('"version": "0.1.0",', '"version": "0.1.1",') + manifest_anchor = ' "protocol": 1,\n "flashOffset": 0,\n' + manifest_new = (' "protocol": 1,\n' + ' "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],\n' + ' "flashOffset": 0,\n') + if '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"]' not in after: + after = replace_required(after, manifest_anchor, manifest_new, 'Studio v0.1.1 manifest fixture') + write_if_changed(path, before, after) + + # Lock the Ethernet safety contract in firmware CI. + path = '.github/workflows/esp32p4-smv-injector.yml' + before = Path(path).read_text(encoding='utf-8') + after = before + if "grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig" not in after: + after = replace_required( + after, + " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", + " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n" + " grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig\n", + 'firmware mutex CI gate') + write_if_changed(path, before, after) + + print('Patched files:') + for path in changed: + print(f' {path}') + if not changed: + print(' none (already converged)') + + - name: Verify, remove staging workflows, and commit + shell: bash + run: | + set -euo pipefail + grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' embedded/esp32p4_smv_injector/sdkconfig.defaults + test "$(grep -R -n -A5 'esp_eth_transmit_ctrl_vargs(' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp | grep -c '2U,')" -eq 2 + grep -q 'disable_hardware_ptp(context.eth_handle);' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp + grep -q 'disable_hardware_ptp(context.eth_handle);' embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp + grep -q 'kTransportFailureStopThreshold = 400U' embedded/esp32p4_smv_injector/main/app_main.cpp + grep -q 'STOP accepted: SV transmission suppressed by fail-closed runtime' embedded/esp32p4_smv_injector/main/live_control.cpp + grep -q 'arstack-esp32p4-smv-0.1.1.bin' .github/workflows/arstack-studio-qt.yml + grep -q 'SMPSYNCH-AUTO' .github/workflows/arstack-studio-qt.yml + git diff --check + + rm -f .github/workflows/p0-authoritative-recovery.yml + rm -f .github/workflows/p0-shared-ethernet-tx-recovery.yml + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + if git diff --cached --quiet; then + echo 'No changes to commit.' + exit 0 + fi + git commit -m 'fix(p0): recover SMV/PTP Ethernet TX and Studio contract' + git pull --rebase origin feature/ptp-production-v0.1.1 + git push origin HEAD:feature/ptp-production-v0.1.1 From f022b34b301e62fc4f92b0edc8e7ebfca283cc2c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:24:21 +0700 Subject: [PATCH 040/170] fix(p0): serialize concurrent SMV and PTP Ethernet TX --- embedded/esp32p4_smv_injector/sdkconfig.defaults | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index c3976c4b9..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 From 5d6990fc8508c2bcd550d8462f75d49ecac2bfe0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:26:31 +0700 Subject: [PATCH 041/170] fix(p0): fail closed when canonical SV transport is dead --- .../main/smp_synch_lab.cpp | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) 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 From 01005634f50b23c7b104742153d11bfc4db820d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:27:08 +0700 Subject: [PATCH 042/170] fix(p0): restore EMAC after failed PTP source start --- .../esp32p4_smv_injector/main/ethernet_port.c | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index bb7414a1a..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(); @@ -90,15 +114,21 @@ bool ar_esp32p4_ptp_start(void) return false; } - // ESP-IDF 5.5 does not expose a portable ETH_CMD_G_LINK ioctl. More - // importantly, the source runtime already has the stronger readiness - // contract we need: ar_ptp_lab_try_start() only succeeds when the source - // has emitted verified Announce + Sync + Follow_Up frames. Boot auto-start - // is intentionally absent, so this operator path runs only after app_main - // has called esp_eth_start(). A disconnected/not-ready link therefore - // fails closed through the bounded PTP TX-readiness check instead of being - // inferred from a driver-specific link query. - 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 From 094fb8659f102144d3adfe61e6ff3717ec63426b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:27:18 +0700 Subject: [PATCH 043/170] chore: remove P0 Ethernet recovery staging helper --- .../p0-shared-ethernet-tx-recovery.yml | 215 ------------------ 1 file changed, 215 deletions(-) delete mode 100644 .github/workflows/p0-shared-ethernet-tx-recovery.yml diff --git a/.github/workflows/p0-shared-ethernet-tx-recovery.yml b/.github/workflows/p0-shared-ethernet-tx-recovery.yml deleted file mode 100644 index 536668122..000000000 --- a/.github/workflows/p0-shared-ethernet-tx-recovery.yml +++ /dev/null @@ -1,215 +0,0 @@ -name: Stage P0 shared Ethernet TX recovery - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - - name: Apply P0 shared Ethernet TX recovery - shell: python - run: | - from pathlib import Path - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected exactly one anchor, found {count}') - return text.replace(old, new, 1) - - # 1) ESP-IDF Ethernet driver serialization for concurrent SMV + PTP producers. - p = Path('embedded/esp32p4_smv_injector/sdkconfig.defaults') - text = p.read_text(encoding='utf-8') - anchor = 'CONFIG_ETH_USE_ESP32_EMAC=y\n' - text = replace_once( - text, - anchor, - anchor + '# SMV and PTP are concurrent L2 TX producers in v0.1.1; serialize the shared EMAC.\nCONFIG_ETH_TRANSMIT_MUTEX=y\n', - 'sdkconfig Ethernet mutex') - p.write_text(text, encoding='utf-8') - - # 2) Hardware PTP mode must be rolled back on every STOP/start-failure. - p = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') - text = p.read_text(encoding='utf-8') - enable_block = '''[[nodiscard]] bool enable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { - bool enable = true; - const auto result = esp_eth_ioctl( - eth_handle, - static_cast(ETH_MAC_ESP_CMD_PTP_ENABLE), - &enable); - if (result != ESP_OK) { - ESP_LOGE(kTag, "Failed to enable ESP32-P4 IEEE1588 hardware clock: %s", - esp_err_to_name(result)); - return false; - } - return true; - } - '''.replace(' ', '') - disable_block = enable_block + ''' - 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, "Failed to restore normal EMAC mode after PTP: %s", - esp_err_to_name(result)); - } - } - '''.replace(' ', '') - text = replace_once(text, enable_block, disable_block, 'PTP disable helper') - - finish_anchor = ''' } - context.task_handle = nullptr; - g_ptp_ready.store(false, std::memory_order_release); - g_ptp_started.store(false, std::memory_order_release); - } - '''.replace(' ', '') - finish_replacement = ''' } - // Hardware timestamp mode changes the shared EMAC data path. Restore normal - // Ethernet mode before publishing STOPPED so SMV can continue independently. - 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); - } - '''.replace(' ', '') - text = replace_once(text, finish_anchor, finish_replacement, 'PTP teardown rollback') - - stop_anchor = '''void stop_ptp_lab() noexcept { - if (!g_ptp_started.load(std::memory_order_acquire)) return; - g_stop_requested.store(true, std::memory_order_release); - if (g_ptp_context.task_handle != nullptr) { - xTaskNotifyGive(g_ptp_context.task_handle); - } - } - '''.replace(' ', '') - stop_replacement = '''void stop_ptp_lab() noexcept { - if (!g_ptp_started.load(std::memory_order_acquire)) return; - g_stop_requested.store(true, std::memory_order_release); - if (g_ptp_context.task_handle != nullptr) { - xTaskNotifyGive(g_ptp_context.task_handle); - } - - // A failed SOURCE start must not return to the operator while the EMAC is - // still in PTP timestamp mode. Wait only for bounded worker-owned teardown. - constexpr unsigned kStopCleanupPolls = 100U; - for (unsigned attempt = 0U; attempt < kStopCleanupPolls; ++attempt) { - if (!g_ptp_started.load(std::memory_order_acquire)) return; - vTaskDelay(pdMS_TO_TICKS(5)); - } - ESP_LOGE(kTag, "PTP stop cleanup timeout; EMAC rollback did not complete within 500 ms"); - } - '''.replace(' ', '') - text = replace_once(text, stop_anchor, stop_replacement, 'bounded PTP stop cleanup') - p.write_text(text, encoding='utf-8') - - # 3) Fail closed when canonical SMV transport is dead even though the sample clock is healthy. - p = Path('embedded/esp32p4_smv_injector/main/app_main.cpp') - text = p.read_text(encoding='utf-8') - text = replace_once( - text, - 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n', - 'using ar::esp32p4::smv::live_control_bind_publisher_task;\nusing ar::esp32p4::smv::live_control_force_stop;\n', - 'force-stop import') - text = replace_once( - text, - 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n', - 'constexpr std::uint32_t kStatsMergeEvery = 400U;\nconstexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at the P0 4000 fps profile\n', - 'transport threshold') - text = replace_once( - text, - ' std::int64_t expected_wake_us = 0;\n', - ' std::int64_t expected_wake_us = 0;\n std::uint32_t consecutive_canonical_tx_failures = 0U;\n esp_err_t last_canonical_tx_error = ESP_OK;\n', - 'transport state') - text = replace_once( - text, - ' schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n', - ' schedule_anchored = false;\n consecutive_canonical_tx_failures = 0U;\n last_canonical_tx_error = ESP_OK;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n', - 'transport reset at START') - - old_tx = ''' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { - ++local.canonical_fail; - #if CONFIG_AR_SMV_BROADCAST_MIRROR - ++local.mirror_fail; - #endif - } else { - 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; - '''.replace(' ', '') - new_tx = ''' 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 - } else { - 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; - 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; - } - '''.replace(' ', '') - text = replace_once(text, old_tx, new_tx, 'canonical TX health tracking') - - timing_anchor = ''' 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); - '''.replace(' ', '') - timing_replacement = timing_anchor + ''' 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; - } - '''.replace(' ', '') - text = replace_once(text, timing_anchor, timing_replacement, 'fail-closed transport STOP') - p.write_text(text, encoding='utf-8') - - - name: Verify and commit P0 patch - shell: bash - run: | - set -euo pipefail - grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' embedded/esp32p4_smv_injector/sdkconfig.defaults - grep -q 'disable_hardware_ptp(context.eth_handle);' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp - grep -q 'kTransportFailureStopThreshold = 400U' embedded/esp32p4_smv_injector/main/app_main.cpp - grep -q 'live_control_force_stop();' embedded/esp32p4_smv_injector/main/app_main.cpp - git diff --check - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add embedded/esp32p4_smv_injector/sdkconfig.defaults \ - embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp \ - embedded/esp32p4_smv_injector/main/app_main.cpp - git commit -m 'fix(p0): recover shared Ethernet TX for SMV and PTP' - git push origin HEAD:feature/ptp-production-v0.1.1 From b3f66982332528dea9a251bbc6a1bc1c79d23a63 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:29:24 +0700 Subject: [PATCH 044/170] ci: rerun authoritative P0 recovery on current head --- .../workflows/p0-authoritative-recovery.yml | 401 ++++++++---------- 1 file changed, 170 insertions(+), 231 deletions(-) diff --git a/.github/workflows/p0-authoritative-recovery.yml b/.github/workflows/p0-authoritative-recovery.yml index 117e8c0a0..a3b0f6952 100644 --- a/.github/workflows/p0-authoritative-recovery.yml +++ b/.github/workflows/p0-authoritative-recovery.yml @@ -10,7 +10,6 @@ permissions: jobs: repair: - if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -18,7 +17,7 @@ jobs: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - name: Apply authoritative P0 recovery + - name: Patch current source atomically shell: python run: | from pathlib import Path @@ -26,83 +25,24 @@ jobs: changed = [] - def write_if_changed(path: str, before: str, after: str) -> None: + def load(path): + return Path(path).read_text(encoding='utf-8') + + def save(path, before, after): if before != after: Path(path).write_text(after, encoding='utf-8') changed.append(path) - def replace_required(text: str, old: str, new: str, label: str) -> str: + def one(text, old, new, label): if new in text: return text - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected one anchor, found {count}') + n = text.count(old) + if n != 1: + raise SystemExit(f'{label}: expected one anchor, found {n}') return text.replace(old, new, 1) - def patch_function(text: str, signature: str, old: str, new: str, label: str) -> str: - start = text.find(signature) - if start < 0: - raise SystemExit(f'{label}: function not found') - end = text.find('\n}\n', start) - if end < 0: - raise SystemExit(f'{label}: function end not found') - end += 3 - body = text[start:end] - if new in body: - return text - count = body.count(old) - if count != 1: - raise SystemExit(f'{label}: expected one in-function anchor, found {count}') - body = body.replace(old, new, 1) - return text[:start] + body + text[end:] - - # ------------------------------------------------------------------ - # 1) Shared Ethernet serialization: v0.1.1 runs SMV and PTP as - # concurrent L2 producers on the same ESP-IDF Ethernet handle. - # ------------------------------------------------------------------ - path = 'embedded/esp32p4_smv_injector/sdkconfig.defaults' - before = Path(path).read_text(encoding='utf-8') - after = before - if 'CONFIG_ETH_TRANSMIT_MUTEX=y' not in after: - after = replace_required( - after, - 'CONFIG_ETH_USE_ESP32_EMAC=y\n', - 'CONFIG_ETH_USE_ESP32_EMAC=y\n' - '# SMV and PTP share one EMAC TX path; serialize concurrent producers.\n' - 'CONFIG_ETH_TRANSMIT_MUTEX=y\n', - 'Ethernet TX mutex') - write_if_changed(path, before, after) - - # ------------------------------------------------------------------ - # 2) ESP-IDF direct ctrl_vargs contract: argc is the number of raw - # variadic arguments. One buffer/length pair therefore requires 2. - # Fix both active hardware-timestamp TX paths. - # ------------------------------------------------------------------ - for path in ( - 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp', - 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp'): - before = Path(path).read_text(encoding='utf-8') - after = before - pattern = re.compile( - r'(const auto result = esp_eth_transmit_ctrl_vargs\(\n' - r'\s*[a-zA-Z_][a-zA-Z0-9_]*,\n' - r'\s*&tx_timestamp,\n\s*)1U(,\n\s*frame\.data\(\),\n\s*frame\.size\(\)\);)') - if 'esp_eth_transmit_ctrl_vargs argc is the raw variadic-argument count' not in after: - after, count = pattern.subn(r'\g<1>2U\2', after, count=1) - if count != 1: - raise SystemExit(f'{path}: hardware TX argc anchor not found') - after = after.replace( - '// esp_eth_transmit_ctrl_vargs argc counts buffer/length pairs. One PTP\n' - ' // Ethernet frame is exactly one pair.\n', - '// esp_eth_transmit_ctrl_vargs argc is the raw variadic-argument count.\n' - ' // One Ethernet frame supplies buffer + length, therefore argc=2.\n', - 1) - write_if_changed(path, before, after) - - # ------------------------------------------------------------------ - # 3) Hardware PTP mode is shared EMAC state. Roll it back on every - # role teardown and make STOP wait for bounded worker cleanup. - # ------------------------------------------------------------------ + # Correct ESP-IDF transmit_ctrl_vargs contract and restore normal + # EMAC mode whenever any PTP role tears down. helper = '''void disable_hardware_ptp(const esp_eth_handle_t handle) noexcept { if (handle == nullptr) return; bool enable = false; @@ -116,36 +56,52 @@ jobs: } } - '''.replace(' ', '') + ''' - for path in ( + for path in [ 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp', - 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp'): - before = Path(path).read_text(encoding='utf-8') + 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp']: + before = load(path) after = before + after = after.replace( + '// esp_eth_transmit_ctrl_vargs argc counts buffer/length pairs. One PTP\n' + ' // Ethernet frame is exactly one pair.\n' + ' const auto result = esp_eth_transmit_ctrl_vargs(\n' + ' ', + '// esp_eth_transmit_ctrl_vargs argc is the raw variadic-argument count.\n' + ' // One Ethernet frame supplies buffer + length, therefore argc=2.\n' + ' const auto result = esp_eth_transmit_ctrl_vargs(\n' + ' ', + 1) + pattern = re.compile( + r'(const auto result = esp_eth_transmit_ctrl_vargs\(\n' + r'\s*[A-Za-z_][A-Za-z0-9_]*,\n' + r'\s*&tx_timestamp,\n\s*)1U(,\n\s*frame\.data\(\),\n\s*frame\.size\(\)\);)') + after, n = pattern.subn(r'\g<1>2U\2', after, count=1) + if n == 0 and not re.search(r'&tx_timestamp,\n\s*2U,', after): + raise SystemExit(f'{path}: ctrl_vargs argc patch did not converge') if 'void disable_hardware_ptp(' not in after: - marker = 'void seed_hardware_clock(' - pos = after.find(marker) + pos = after.find('void seed_hardware_clock(') if pos < 0: raise SystemExit(f'{path}: seed_hardware_clock anchor missing') after = after[:pos] + helper + after[pos:] - write_if_changed(path, before, after) + save(path, before, after) path = 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp' - before = Path(path).read_text(encoding='utf-8') - after = patch_function( - before, - 'void finish_runtime(PtpLabContext& context)', - ' context.task_handle = nullptr;\n', + before = load(path) + after = before + after = one( + after, + ' context.task_handle = nullptr;\n g_ptp_ready.store(false, std::memory_order_release);', ' disable_hardware_ptp(context.eth_handle);\n' - ' context.task_handle = nullptr;\n', + ' context.task_handle = nullptr;\n g_ptp_ready.store(false, std::memory_order_release);', 'SOURCE EMAC rollback') - after = patch_function( + after = one( after, - 'void stop_ptp_lab() noexcept', ' if (g_ptp_context.task_handle != nullptr) {\n' ' xTaskNotifyGive(g_ptp_context.task_handle);\n' - ' }\n', + ' }\n' + '}\n\n[[nodiscard]] bool ptp_lab_is_running()', ' if (g_ptp_context.task_handle != nullptr) {\n' ' xTaskNotifyGive(g_ptp_context.task_handle);\n' ' }\n' @@ -154,27 +110,27 @@ jobs: ' if (!g_ptp_started.load(std::memory_order_acquire)) return;\n' ' vTaskDelay(pdMS_TO_TICKS(5));\n' ' }\n' - ' ESP_LOGE(kTag, "PTP SOURCE cleanup timeout; EMAC rollback incomplete after 500 ms");\n', + ' ESP_LOGE(kTag, "PTP SOURCE cleanup timeout; EMAC rollback incomplete after 500 ms");\n' + '}\n\n[[nodiscard]] bool ptp_lab_is_running()', 'SOURCE bounded STOP') - write_if_changed(path, before, after) + save(path, before, after) path = 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp' - before = Path(path).read_text(encoding='utf-8') - after = patch_function( - before, - 'void finish_receiver(ReceiverContext& context) noexcept', - ' smp_synch_lab_set_measured(std::nullopt);\n' - ' context.receiver.reset();\n', + before = load(path) + after = before + after = one( + after, + ' smp_synch_lab_set_measured(std::nullopt);\n context.receiver.reset();', ' smp_synch_lab_set_measured(std::nullopt);\n' ' disable_hardware_ptp(context.eth_handle);\n' - ' context.receiver.reset();\n', + ' context.receiver.reset();', 'RECEIVER/MONITOR EMAC rollback') - after = patch_function( + after = one( after, - 'void ptp_receiver_stop() noexcept', ' if (g_receiver_context.task_handle != nullptr) {\n' ' xTaskNotifyGive(g_receiver_context.task_handle);\n' - ' }\n', + ' }\n' + '}\n\nbool ptp_receiver_is_running()', ' if (g_receiver_context.task_handle != nullptr) {\n' ' xTaskNotifyGive(g_receiver_context.task_handle);\n' ' }\n' @@ -183,156 +139,145 @@ jobs: ' if (!g_receiver_running.load(std::memory_order_acquire)) return;\n' ' vTaskDelay(pdMS_TO_TICKS(5));\n' ' }\n' - ' ESP_LOGE(kTag, "PTP receiver cleanup timeout; EMAC rollback incomplete after 500 ms");\n', + ' ESP_LOGE(kTag, "PTP receiver cleanup timeout; EMAC rollback incomplete after 500 ms");\n' + '}\n\nbool ptp_receiver_is_running()', 'RECEIVER/MONITOR bounded STOP') - write_if_changed(path, before, after) + save(path, before, after) - # ------------------------------------------------------------------ - # 4) SMV fail-closed transport state. A healthy 4 kHz scheduler with - # 0 successful Ethernet frames must not remain RUNNING. - # ------------------------------------------------------------------ + # Fail closed when the deterministic scheduler is alive but canonical + # Ethernet TX is continuously failing. This prevents false RUNNING. path = 'embedded/esp32p4_smv_injector/main/app_main.cpp' - before = Path(path).read_text(encoding='utf-8') + before = load(path) after = before - if 'kTransportFailureStopThreshold' not in after: - after = replace_required( - after, - 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n', - 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n' - 'using ar::esp32p4::smv::live_control_force_stop;\n', - 'force-stop import') - after = replace_required( - after, - 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n', - 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n' - 'constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4000 fps\n', - 'transport threshold') - after = replace_required( - after, - ' std::int64_t expected_wake_us = 0;\n', - ' std::int64_t expected_wake_us = 0;\n' - ' std::uint32_t consecutive_canonical_tx_failures = 0U;\n' - ' esp_err_t last_canonical_tx_error = ESP_OK;\n', - 'transport state') - after = replace_required( - after, - ' schedule_anchored = false;\n' - ' expected_schedule.reset(active_profile.publisher_rate_hz);\n', - ' schedule_anchored = false;\n' - ' consecutive_canonical_tx_failures = 0U;\n' - ' last_canonical_tx_error = ESP_OK;\n' - ' expected_schedule.reset(active_profile.publisher_rate_hz);\n', - 'START transport reset') - old = ''' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { - ++local.canonical_fail; - #if CONFIG_AR_SMV_BROADCAST_MIRROR - ++local.mirror_fail; - #endif - } else { - 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; - '''.replace(' ', '') - new = ''' 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 - } else { - 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; - 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; - } - '''.replace(' ', '') - after = replace_required(after, old, new, 'canonical TX health') - anchor = ''' 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); - '''.replace(' ', '') - replacement = anchor + ''' 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; - } - '''.replace(' ', '') - after = replace_required(after, anchor, replacement, 'transport fail-close') - write_if_changed(path, before, after) + after = one( + after, + 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n', + 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n' + 'using ar::esp32p4::smv::live_control_force_stop;\n', + 'force-stop import') + after = one( + after, + 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n', + 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n' + 'constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz\n', + 'transport threshold') + after = one( + after, + ' bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n', + ' bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n' + ' std::uint32_t consecutive_canonical_tx_failures = 0U;\n' + ' esp_err_t last_canonical_tx_error = ESP_OK;\n', + 'transport state') + after = one( + after, + ' schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n', + ' schedule_anchored = false;\n' + ' consecutive_canonical_tx_failures = 0U;\n' + ' last_canonical_tx_error = ESP_OK;\n' + ' expected_schedule.reset(active_profile.publisher_rate_hz);\n', + 'START transport reset') + after = one( + after, + ' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) {\n' + ' ++local.canonical_fail;\n' + '#if CONFIG_AR_SMV_BROADCAST_MIRROR\n' + ' ++local.mirror_fail;\n' + '#endif\n' + ' } else {\n' + ' patch_packet(canonical, canonical_sample_count, row, signal_state);\n' + ' const esp_err_t canonical_result =\n' + ' esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size());\n' + ' if (canonical_result == ESP_OK) ++local.canonical_ok;\n' + ' else ++local.canonical_fail;\n', + ' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) {\n' + ' ++local.canonical_fail;\n' + ' last_canonical_tx_error = ESP_ERR_INVALID_STATE;\n' + ' ++consecutive_canonical_tx_failures;\n' + '#if CONFIG_AR_SMV_BROADCAST_MIRROR\n' + ' ++local.mirror_fail;\n' + '#endif\n' + ' } else {\n' + ' patch_packet(canonical, canonical_sample_count, row, signal_state);\n' + ' const esp_err_t canonical_result =\n' + ' esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size());\n' + ' if (canonical_result == ESP_OK) {\n' + ' ++local.canonical_ok;\n' + ' consecutive_canonical_tx_failures = 0U;\n' + ' last_canonical_tx_error = ESP_OK;\n' + ' } else {\n' + ' ++local.canonical_fail;\n' + ' last_canonical_tx_error = canonical_result;\n' + ' ++consecutive_canonical_tx_failures;\n' + ' }\n', + 'canonical TX health') + anchor = (' g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed);\n' + ' canonical_sample_count = static_cast(\n') + replacement = (' g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed);\n' + ' if (consecutive_canonical_tx_failures >= kTransportFailureStopThreshold) {\n' + ' ESP_LOGE(kTag,\n' + ' "SV transport fault: %lu consecutive canonical TX failures; last=%s; forcing STOP",\n' + ' static_cast(consecutive_canonical_tx_failures),\n' + ' esp_err_to_name(last_canonical_tx_error));\n' + ' merge_stats(local);\n' + ' live_control_force_stop();\n' + ' stop_sample_clock(sample_clock);\n' + ' schedule_anchored = false;\n' + ' consecutive_canonical_tx_failures = 0U;\n' + ' continue;\n' + ' }\n' + ' canonical_sample_count = static_cast(\n') + after = one(after, anchor, replacement, 'transport fail-close') + save(path, before, after) - # Make forced transport STOP visible to Studio's existing parser. path = 'embedded/esp32p4_smv_injector/main/live_control.cpp' - before = Path(path).read_text(encoding='utf-8') - after = patch_function( + before = load(path) + after = one( before, - 'void live_control_force_stop() noexcept', - ' wake_publisher();\n', - ' wake_publisher();\n' - ' ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime");\n', - 'fail-closed STOP observability') - write_if_changed(path, before, after) + ' clear_control_session();\n wake_publisher();\n}\n\nSvLiveSignalState live_signal_snapshot()', + ' clear_control_session();\n wake_publisher();\n' + ' ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime");\n' + '}\n\nSvLiveSignalState live_signal_snapshot()', + 'forced STOP observability') + save(path, before, after) - # ------------------------------------------------------------------ - # 5) Repair the Studio CI contract fixture: it was still manufacturing - # a 0.1.0 bundle without the production capabilities required by 0.1.1. - # ------------------------------------------------------------------ + # Repair Studio's own v0.1.1 bundle-contract fixture. path = '.github/workflows/arstack-studio-qt.yml' - before = Path(path).read_text(encoding='utf-8') - after = before - after = after.replace('arstack-esp32p4-smv-0.1.0.bin', 'arstack-esp32p4-smv-0.1.1.bin') + before = load(path) + after = before.replace('arstack-esp32p4-smv-0.1.0.bin', 'arstack-esp32p4-smv-0.1.1.bin') after = after.replace('"version": "0.1.0",', '"version": "0.1.1",') - manifest_anchor = ' "protocol": 1,\n "flashOffset": 0,\n' - manifest_new = (' "protocol": 1,\n' - ' "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],\n' - ' "flashOffset": 0,\n') if '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"]' not in after: - after = replace_required(after, manifest_anchor, manifest_new, 'Studio v0.1.1 manifest fixture') - write_if_changed(path, before, after) + after = one( + after, + ' "protocol": 1,\n "flashOffset": 0,\n', + ' "protocol": 1,\n' + ' "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],\n' + ' "flashOffset": 0,\n', + 'Studio manifest capabilities') + save(path, before, after) - # Lock the Ethernet safety contract in firmware CI. + # Keep production CI from silently dropping TX serialization later. path = '.github/workflows/esp32p4-smv-injector.yml' - before = Path(path).read_text(encoding='utf-8') + before = load(path) after = before if "grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig" not in after: - after = replace_required( + after = one( after, " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n" " grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig\n", - 'firmware mutex CI gate') - write_if_changed(path, before, after) + 'firmware mutex gate') + save(path, before, after) - print('Patched files:') - for path in changed: - print(f' {path}') - if not changed: - print(' none (already converged)') + print('changed:') + print('\n'.join(changed) if changed else 'none') - - name: Verify, remove staging workflows, and commit + - name: Verify source contract and commit shell: bash run: | set -euo pipefail grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' embedded/esp32p4_smv_injector/sdkconfig.defaults - test "$(grep -R -n -A5 'esp_eth_transmit_ctrl_vargs(' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp | grep -c '2U,')" -eq 2 - grep -q 'disable_hardware_ptp(context.eth_handle);' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp - grep -q 'disable_hardware_ptp(context.eth_handle);' embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp + test "$(grep -R -A5 'esp_eth_transmit_ctrl_vargs(' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp | grep -c '2U,')" -eq 2 + test "$(grep -R -c 'disable_hardware_ptp(context.eth_handle);' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp | awk -F: '{s+=$2} END {print s}')" -eq 2 grep -q 'kTransportFailureStopThreshold = 400U' embedded/esp32p4_smv_injector/main/app_main.cpp grep -q 'STOP accepted: SV transmission suppressed by fail-closed runtime' embedded/esp32p4_smv_injector/main/live_control.cpp grep -q 'arstack-esp32p4-smv-0.1.1.bin' .github/workflows/arstack-studio-qt.yml @@ -340,15 +285,9 @@ jobs: git diff --check rm -f .github/workflows/p0-authoritative-recovery.yml - rm -f .github/workflows/p0-shared-ethernet-tx-recovery.yml - git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add -A - if git diff --cached --quiet; then - echo 'No changes to commit.' - exit 0 - fi - git commit -m 'fix(p0): recover SMV/PTP Ethernet TX and Studio contract' + git commit -m 'fix(p0): recover SMV/PTP Ethernet TX and fail closed' git pull --rebase origin feature/ptp-production-v0.1.1 git push origin HEAD:feature/ptp-production-v0.1.1 From 7026ad5c1d1e4ea870304b7b26621258e7218c23 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:34:51 +0700 Subject: [PATCH 045/170] chore: remove temporary P0 authoritative recovery workflow --- .../workflows/p0-authoritative-recovery.yml | 293 ------------------ 1 file changed, 293 deletions(-) delete mode 100644 .github/workflows/p0-authoritative-recovery.yml diff --git a/.github/workflows/p0-authoritative-recovery.yml b/.github/workflows/p0-authoritative-recovery.yml deleted file mode 100644 index a3b0f6952..000000000 --- a/.github/workflows/p0-authoritative-recovery.yml +++ /dev/null @@ -1,293 +0,0 @@ -name: P0 authoritative SMV/PTP recovery - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - - name: Patch current source atomically - shell: python - run: | - from pathlib import Path - import re - - changed = [] - - def load(path): - return Path(path).read_text(encoding='utf-8') - - def save(path, before, after): - if before != after: - Path(path).write_text(after, encoding='utf-8') - changed.append(path) - - def one(text, old, new, label): - if new in text: - return text - n = text.count(old) - if n != 1: - raise SystemExit(f'{label}: expected one anchor, found {n}') - return text.replace(old, new, 1) - - # Correct ESP-IDF transmit_ctrl_vargs contract and restore normal - # EMAC mode whenever any PTP role tears down. - helper = '''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: %s", - esp_err_to_name(result)); - } - } - - ''' - - for path in [ - 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp', - 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp']: - before = load(path) - after = before - after = after.replace( - '// esp_eth_transmit_ctrl_vargs argc counts buffer/length pairs. One PTP\n' - ' // Ethernet frame is exactly one pair.\n' - ' const auto result = esp_eth_transmit_ctrl_vargs(\n' - ' ', - '// esp_eth_transmit_ctrl_vargs argc is the raw variadic-argument count.\n' - ' // One Ethernet frame supplies buffer + length, therefore argc=2.\n' - ' const auto result = esp_eth_transmit_ctrl_vargs(\n' - ' ', - 1) - pattern = re.compile( - r'(const auto result = esp_eth_transmit_ctrl_vargs\(\n' - r'\s*[A-Za-z_][A-Za-z0-9_]*,\n' - r'\s*&tx_timestamp,\n\s*)1U(,\n\s*frame\.data\(\),\n\s*frame\.size\(\)\);)') - after, n = pattern.subn(r'\g<1>2U\2', after, count=1) - if n == 0 and not re.search(r'&tx_timestamp,\n\s*2U,', after): - raise SystemExit(f'{path}: ctrl_vargs argc patch did not converge') - if 'void disable_hardware_ptp(' not in after: - pos = after.find('void seed_hardware_clock(') - if pos < 0: - raise SystemExit(f'{path}: seed_hardware_clock anchor missing') - after = after[:pos] + helper + after[pos:] - save(path, before, after) - - path = 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp' - before = load(path) - after = before - after = one( - after, - ' context.task_handle = nullptr;\n g_ptp_ready.store(false, std::memory_order_release);', - ' disable_hardware_ptp(context.eth_handle);\n' - ' context.task_handle = nullptr;\n g_ptp_ready.store(false, std::memory_order_release);', - 'SOURCE EMAC rollback') - after = one( - after, - ' if (g_ptp_context.task_handle != nullptr) {\n' - ' xTaskNotifyGive(g_ptp_context.task_handle);\n' - ' }\n' - '}\n\n[[nodiscard]] bool ptp_lab_is_running()', - ' if (g_ptp_context.task_handle != nullptr) {\n' - ' xTaskNotifyGive(g_ptp_context.task_handle);\n' - ' }\n' - ' constexpr unsigned kCleanupPolls = 100U;\n' - ' for (unsigned attempt = 0U; attempt < kCleanupPolls; ++attempt) {\n' - ' if (!g_ptp_started.load(std::memory_order_acquire)) return;\n' - ' vTaskDelay(pdMS_TO_TICKS(5));\n' - ' }\n' - ' ESP_LOGE(kTag, "PTP SOURCE cleanup timeout; EMAC rollback incomplete after 500 ms");\n' - '}\n\n[[nodiscard]] bool ptp_lab_is_running()', - 'SOURCE bounded STOP') - save(path, before, after) - - path = 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp' - before = load(path) - after = before - after = one( - after, - ' smp_synch_lab_set_measured(std::nullopt);\n context.receiver.reset();', - ' smp_synch_lab_set_measured(std::nullopt);\n' - ' disable_hardware_ptp(context.eth_handle);\n' - ' context.receiver.reset();', - 'RECEIVER/MONITOR EMAC rollback') - after = one( - after, - ' if (g_receiver_context.task_handle != nullptr) {\n' - ' xTaskNotifyGive(g_receiver_context.task_handle);\n' - ' }\n' - '}\n\nbool ptp_receiver_is_running()', - ' if (g_receiver_context.task_handle != nullptr) {\n' - ' xTaskNotifyGive(g_receiver_context.task_handle);\n' - ' }\n' - ' constexpr unsigned kCleanupPolls = 100U;\n' - ' for (unsigned attempt = 0U; attempt < kCleanupPolls; ++attempt) {\n' - ' if (!g_receiver_running.load(std::memory_order_acquire)) return;\n' - ' vTaskDelay(pdMS_TO_TICKS(5));\n' - ' }\n' - ' ESP_LOGE(kTag, "PTP receiver cleanup timeout; EMAC rollback incomplete after 500 ms");\n' - '}\n\nbool ptp_receiver_is_running()', - 'RECEIVER/MONITOR bounded STOP') - save(path, before, after) - - # Fail closed when the deterministic scheduler is alive but canonical - # Ethernet TX is continuously failing. This prevents false RUNNING. - path = 'embedded/esp32p4_smv_injector/main/app_main.cpp' - before = load(path) - after = before - after = one( - after, - 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n', - 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n' - 'using ar::esp32p4::smv::live_control_force_stop;\n', - 'force-stop import') - after = one( - after, - 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n', - 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n' - 'constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz\n', - 'transport threshold') - after = one( - after, - ' bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n', - ' bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n' - ' std::uint32_t consecutive_canonical_tx_failures = 0U;\n' - ' esp_err_t last_canonical_tx_error = ESP_OK;\n', - 'transport state') - after = one( - after, - ' schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n', - ' schedule_anchored = false;\n' - ' consecutive_canonical_tx_failures = 0U;\n' - ' last_canonical_tx_error = ESP_OK;\n' - ' expected_schedule.reset(active_profile.publisher_rate_hz);\n', - 'START transport reset') - after = one( - after, - ' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) {\n' - ' ++local.canonical_fail;\n' - '#if CONFIG_AR_SMV_BROADCAST_MIRROR\n' - ' ++local.mirror_fail;\n' - '#endif\n' - ' } else {\n' - ' patch_packet(canonical, canonical_sample_count, row, signal_state);\n' - ' const esp_err_t canonical_result =\n' - ' esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size());\n' - ' if (canonical_result == ESP_OK) ++local.canonical_ok;\n' - ' else ++local.canonical_fail;\n', - ' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) {\n' - ' ++local.canonical_fail;\n' - ' last_canonical_tx_error = ESP_ERR_INVALID_STATE;\n' - ' ++consecutive_canonical_tx_failures;\n' - '#if CONFIG_AR_SMV_BROADCAST_MIRROR\n' - ' ++local.mirror_fail;\n' - '#endif\n' - ' } else {\n' - ' patch_packet(canonical, canonical_sample_count, row, signal_state);\n' - ' const esp_err_t canonical_result =\n' - ' esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size());\n' - ' if (canonical_result == ESP_OK) {\n' - ' ++local.canonical_ok;\n' - ' consecutive_canonical_tx_failures = 0U;\n' - ' last_canonical_tx_error = ESP_OK;\n' - ' } else {\n' - ' ++local.canonical_fail;\n' - ' last_canonical_tx_error = canonical_result;\n' - ' ++consecutive_canonical_tx_failures;\n' - ' }\n', - 'canonical TX health') - anchor = (' g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed);\n' - ' canonical_sample_count = static_cast(\n') - replacement = (' g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed);\n' - ' if (consecutive_canonical_tx_failures >= kTransportFailureStopThreshold) {\n' - ' ESP_LOGE(kTag,\n' - ' "SV transport fault: %lu consecutive canonical TX failures; last=%s; forcing STOP",\n' - ' static_cast(consecutive_canonical_tx_failures),\n' - ' esp_err_to_name(last_canonical_tx_error));\n' - ' merge_stats(local);\n' - ' live_control_force_stop();\n' - ' stop_sample_clock(sample_clock);\n' - ' schedule_anchored = false;\n' - ' consecutive_canonical_tx_failures = 0U;\n' - ' continue;\n' - ' }\n' - ' canonical_sample_count = static_cast(\n') - after = one(after, anchor, replacement, 'transport fail-close') - save(path, before, after) - - path = 'embedded/esp32p4_smv_injector/main/live_control.cpp' - before = load(path) - after = one( - before, - ' clear_control_session();\n wake_publisher();\n}\n\nSvLiveSignalState live_signal_snapshot()', - ' clear_control_session();\n wake_publisher();\n' - ' ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime");\n' - '}\n\nSvLiveSignalState live_signal_snapshot()', - 'forced STOP observability') - save(path, before, after) - - # Repair Studio's own v0.1.1 bundle-contract fixture. - path = '.github/workflows/arstack-studio-qt.yml' - before = load(path) - after = before.replace('arstack-esp32p4-smv-0.1.0.bin', 'arstack-esp32p4-smv-0.1.1.bin') - after = after.replace('"version": "0.1.0",', '"version": "0.1.1",') - if '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"]' not in after: - after = one( - after, - ' "protocol": 1,\n "flashOffset": 0,\n', - ' "protocol": 1,\n' - ' "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],\n' - ' "flashOffset": 0,\n', - 'Studio manifest capabilities') - save(path, before, after) - - # Keep production CI from silently dropping TX serialization later. - path = '.github/workflows/esp32p4-smv-injector.yml' - before = load(path) - after = before - if "grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig" not in after: - after = one( - after, - " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", - " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n" - " grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig\n", - 'firmware mutex gate') - save(path, before, after) - - print('changed:') - print('\n'.join(changed) if changed else 'none') - - - name: Verify source contract and commit - shell: bash - run: | - set -euo pipefail - grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' embedded/esp32p4_smv_injector/sdkconfig.defaults - test "$(grep -R -A5 'esp_eth_transmit_ctrl_vargs(' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp | grep -c '2U,')" -eq 2 - test "$(grep -R -c 'disable_hardware_ptp(context.eth_handle);' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp | awk -F: '{s+=$2} END {print s}')" -eq 2 - grep -q 'kTransportFailureStopThreshold = 400U' embedded/esp32p4_smv_injector/main/app_main.cpp - grep -q 'STOP accepted: SV transmission suppressed by fail-closed runtime' embedded/esp32p4_smv_injector/main/live_control.cpp - grep -q 'arstack-esp32p4-smv-0.1.1.bin' .github/workflows/arstack-studio-qt.yml - grep -q 'SMPSYNCH-AUTO' .github/workflows/arstack-studio-qt.yml - git diff --check - - rm -f .github/workflows/p0-authoritative-recovery.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'fix(p0): recover SMV/PTP Ethernet TX and fail closed' - git pull --rebase origin feature/ptp-production-v0.1.1 - git push origin HEAD:feature/ptp-production-v0.1.1 From 9e22a6a0614a8b978967ecd52d48b20a80a23a56 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:40:24 +0700 Subject: [PATCH 046/170] ci: stage authoritative SMV/PTP P0 repair --- .github/workflows/p0-smv-final-repair.yml | 385 ++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 .github/workflows/p0-smv-final-repair.yml diff --git a/.github/workflows/p0-smv-final-repair.yml b/.github/workflows/p0-smv-final-repair.yml new file mode 100644 index 000000000..f1ad8a295 --- /dev/null +++ b/.github/workflows/p0-smv-final-repair.yml @@ -0,0 +1,385 @@ +name: P0 SMV final repair + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + paths: + - '.github/workflows/p0-smv-final-repair.yml' + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Apply source and CI repair + shell: python + run: | + from pathlib import Path + + changed = [] + + def read(path): + return Path(path).read_text(encoding='utf-8') + + def write(path, before, after): + if before != after: + Path(path).write_text(after, encoding='utf-8') + changed.append(path) + + def one(text, old, new, label): + if new in text: + return text + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected exactly one anchor, found {count}') + return text.replace(old, new, 1) + + # --- SMV transport: never report RUNNING indefinitely when the EMAC + # accepts zero canonical frames. Keep the threshold short and bounded: + # 400 consecutive failures = 100 ms at the production 4 kHz rate. + path = 'embedded/esp32p4_smv_injector/main/app_main.cpp' + before = read(path) + after = before + after = one( + after, + 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n', + 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n' + 'using ar::esp32p4::smv::live_control_force_stop;\n', + 'SMV force-stop import') + after = one( + after, + 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n', + 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n' + 'constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz\n', + 'SMV transport failure threshold') + after = one( + after, + ' bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n', + ' bool schedule_anchored = false;\n' + ' std::int64_t expected_wake_us = 0;\n' + ' std::uint32_t consecutive_canonical_tx_failures = 0U;\n' + ' esp_err_t last_canonical_tx_error = ESP_OK;\n', + 'SMV transport state') + after = one( + after, + ' schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n', + ' schedule_anchored = false;\n' + ' consecutive_canonical_tx_failures = 0U;\n' + ' last_canonical_tx_error = ESP_OK;\n' + ' expected_schedule.reset(active_profile.publisher_rate_hz);\n', + 'SMV START health reset') + after = one( + after, + ''' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { + ++local.canonical_fail; +#if CONFIG_AR_SMV_BROADCAST_MIRROR + ++local.mirror_fail; +#endif + } else { + 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 ((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 + } else { + 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; + 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; + } +''', + 'SMV canonical TX health tracking') + after = one( + after, + ''' g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); + canonical_sample_count = static_cast( +''', + ''' 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( +''', + 'SMV fail-closed runtime') + write(path, before, after) + + path = 'embedded/esp32p4_smv_injector/main/live_control.cpp' + before = read(path) + after = one( + before, + ''' clear_control_session(); + wake_publisher(); +} + +SvLiveSignalState live_signal_snapshot() noexcept { +''', + ''' clear_control_session(); + wake_publisher(); + ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime"); +} + +SvLiveSignalState live_signal_snapshot() noexcept { +''', + 'forced STOP observability') + write(path, before, after) + + # --- PTP SOURCE: hardware IEEE1588 mode is shared EMAC state. Always + # restore normal mode before advertising teardown complete, and make + # STOP bounded so a caller cannot race a subsequent mode transition. + path = 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp' + before = read(path) + after = before + helper = '''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)); + } +} + +''' + if 'void disable_hardware_ptp(const esp_eth_handle_t eth_handle)' not in after: + anchor = 'void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept {' + if anchor not in after: + raise SystemExit('PTP SOURCE hardware-clock anchor missing') + after = after.replace(anchor, helper + anchor, 1) + after = one( + after, + ''' context.task_handle = nullptr; + g_ptp_ready.store(false, std::memory_order_release); +''', + ''' disable_hardware_ptp(context.eth_handle); + context.task_handle = nullptr; + g_ptp_ready.store(false, std::memory_order_release); +''', + 'PTP SOURCE EMAC rollback') + after = one( + after, + ''' if (g_ptp_context.task_handle != nullptr) { + xTaskNotifyGive(g_ptp_context.task_handle); + } +} + +[[nodiscard]] bool ptp_lab_is_running() 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 { +''', + 'PTP SOURCE bounded STOP') + write(path, before, after) + + # --- PTP RECEIVER/MONITOR: same shared-hardware cleanup contract. + path = 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp' + before = read(path) + after = before + helper = '''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)); + } +} + +''' + if 'void disable_hardware_ptp(const esp_eth_handle_t handle)' not in after: + anchor = 'void seed_hardware_clock(const esp_eth_handle_t handle) noexcept {' + if anchor not in after: + raise SystemExit('PTP receiver hardware-clock anchor missing') + after = after.replace(anchor, helper + anchor, 1) + after = one( + after, + ''' smp_synch_lab_set_measured(std::nullopt); + context.receiver.reset(); +''', + ''' smp_synch_lab_set_measured(std::nullopt); + disable_hardware_ptp(context.eth_handle); + context.receiver.reset(); +''', + 'PTP receiver EMAC rollback') + after = one( + after, + ''' if (g_receiver_context.task_handle != nullptr) { + xTaskNotifyGive(g_receiver_context.task_handle); + } +} + +bool ptp_receiver_is_running() 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 { +''', + 'PTP receiver bounded STOP') + write(path, before, after) + + # --- Studio contract fixture must exercise the production 0.1.1 + # manifest rather than intentionally feeding the new parser 0.1.0. + path = '.github/workflows/arstack-studio-qt.yml' + before = read(path) + after = before.replace('arstack-esp32p4-smv-0.1.0.bin', 'arstack-esp32p4-smv-0.1.1.bin') + after = after.replace('"version": "0.1.0",', '"version": "0.1.1",') + if '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"]' not in after: + after = one( + after, + ' "protocol": 1,\n "flashOffset": 0,\n', + ' "protocol": 1,\n' + ' "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],\n' + ' "flashOffset": 0,\n', + 'Studio production manifest capabilities') + write(path, before, after) + + # --- PTP workflow was asserting an old textual implementation shape. + # Assert the current checked-start + rollback contract instead. + path = '.github/workflows/esp32p4-ptp-lab.yml' + before = read(path) + after = before.replace( + " 'return ar_ptp_lab_try_start(s_eth_handle);',", + " 'const bool started = ar_ptp_lab_try_start(s_eth_handle);',") + if after == before: + raise SystemExit('PTP workflow current-start assertion anchor missing') + write(path, before, after) + + # --- Normal production build must permanently retain serialized TX. + path = '.github/workflows/esp32p4-smv-injector.yml' + before = read(path) + after = before + if "grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig" not in after: + after = one( + after, + " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", + " grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig\n" + " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", + 'production TX serialization CI gate') + # Lock fail-closed source policy in the same existing source audit. + if "kTransportFailureStopThreshold = 400U" not in after: + after = one( + after, + " assert 'rate_baseline_valid = true;' in source\n", + " assert 'rate_baseline_valid = true;' in source\n" + " assert 'kTransportFailureStopThreshold = 400U' in source\n" + " assert 'live_control_force_stop();' in source\n" + " assert 'SV transport fault:' in source\n", + 'SMV fail-closed CI assertions') + write(path, before, after) + + required = { + 'embedded/esp32p4_smv_injector/main/app_main.cpp': [ + 'kTransportFailureStopThreshold = 400U', + 'live_control_force_stop();', + 'SV transport fault:' + ], + 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp': [ + 'disable_hardware_ptp(context.eth_handle);', + 'PTP SOURCE cleanup timeout' + ], + 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp': [ + 'disable_hardware_ptp(context.eth_handle);', + 'PTP receiver cleanup timeout' + ], + '.github/workflows/arstack-studio-qt.yml': [ + 'arstack-esp32p4-smv-0.1.1.bin', + '"PTP-P2", "SMPSYNCH-AUTO"' + ], + '.github/workflows/esp32p4-smv-injector.yml': [ + "CONFIG_ETH_TRANSMIT_MUTEX=y" + ], + } + for file, tokens in required.items(): + data = read(file) + missing = [token for token in tokens if token not in data] + if missing: + raise SystemExit(f'{file}: repair verification failed: {missing}') + + print('Patched files:') + for item in changed: + print(' -', item) + + - name: Validate diff + shell: bash + run: | + set -euo pipefail + git diff --check + grep -n "kTransportFailureStopThreshold\|live_control_force_stop\|SV transport fault" embedded/esp32p4_smv_injector/main/app_main.cpp + grep -n "disable_hardware_ptp\|cleanup timeout" embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp + grep -n "0.1.1\|PTP-P2" .github/workflows/arstack-studio-qt.yml | tail -20 + grep -n "CONFIG_ETH_TRANSMIT_MUTEX" .github/workflows/esp32p4-smv-injector.yml + + - name: Commit authoritative repair and remove staging workflow + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git rm .github/workflows/p0-smv-final-repair.yml + git add \ + embedded/esp32p4_smv_injector/main/app_main.cpp \ + embedded/esp32p4_smv_injector/main/live_control.cpp \ + embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp \ + embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp \ + .github/workflows/arstack-studio-qt.yml \ + .github/workflows/esp32p4-ptp-lab.yml \ + .github/workflows/esp32p4-smv-injector.yml + git diff --cached --check + git commit -m 'fix(p0): fail closed SMV transport and restore shared EMAC state' + git push origin HEAD:feature/ptp-production-v0.1.1 From 26d57a23fd536683cf80b27cd80381517854be72 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:41:19 +0700 Subject: [PATCH 047/170] ci: move P0 repair logic into standalone script --- .github/scripts/p0_smv_final_repair.py | 344 +++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 .github/scripts/p0_smv_final_repair.py diff --git a/.github/scripts/p0_smv_final_repair.py b/.github/scripts/p0_smv_final_repair.py new file mode 100644 index 000000000..9a10fb8f7 --- /dev/null +++ b/.github/scripts/p0_smv_final_repair.py @@ -0,0 +1,344 @@ +from pathlib import Path + +changed = [] + + +def read(path): + return Path(path).read_text(encoding="utf-8") + + +def write(path, before, after): + if before != after: + Path(path).write_text(after, encoding="utf-8") + changed.append(path) + + +def one(text, old, new, label): + if new in text: + return text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one anchor, found {count}") + return text.replace(old, new, 1) + + +# SMV transport: force STOP instead of reporting RUNNING indefinitely when +# canonical Ethernet transmission continuously fails. 400 frames = 100 ms at 4 kHz. +path = "embedded/esp32p4_smv_injector/main/app_main.cpp" +before = read(path) +after = before +after = one( + after, + "using ar::esp32p4::smv::live_control_bind_publisher_task;\n", + "using ar::esp32p4::smv::live_control_bind_publisher_task;\n" + "using ar::esp32p4::smv::live_control_force_stop;\n", + "SMV force-stop import", +) +after = one( + after, + "constexpr std::uint32_t kStatsMergeEvery = 400U;\n", + "constexpr std::uint32_t kStatsMergeEvery = 400U;\n" + "constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz\n", + "SMV transport failure threshold", +) +after = one( + after, + " bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n", + " bool schedule_anchored = false;\n" + " std::int64_t expected_wake_us = 0;\n" + " std::uint32_t consecutive_canonical_tx_failures = 0U;\n" + " esp_err_t last_canonical_tx_error = ESP_OK;\n", + "SMV transport state", +) +after = one( + after, + " schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n", + " schedule_anchored = false;\n" + " consecutive_canonical_tx_failures = 0U;\n" + " last_canonical_tx_error = ESP_OK;\n" + " expected_schedule.reset(active_profile.publisher_rate_hz);\n", + "SMV START health reset", +) +after = one( + after, + """ if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { + ++local.canonical_fail; +#if CONFIG_AR_SMV_BROADCAST_MIRROR + ++local.mirror_fail; +#endif + } else { + 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 ((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 + } else { + 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; + 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; + } +""", + "SMV canonical TX health tracking", +) +after = one( + after, + """ g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); + canonical_sample_count = static_cast( +""", + """ 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( +""", + "SMV fail-closed runtime", +) +write(path, before, after) + +path = "embedded/esp32p4_smv_injector/main/live_control.cpp" +before = read(path) +after = one( + before, + """ clear_control_session(); + wake_publisher(); +} + +SvLiveSignalState live_signal_snapshot() noexcept { +""", + """ clear_control_session(); + wake_publisher(); + ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime"); +} + +SvLiveSignalState live_signal_snapshot() noexcept { +""", + "forced STOP observability", +) +write(path, before, after) + +# PTP SOURCE: IEEE1588 hardware mode is shared EMAC state. Restore normal mode +# before teardown is published and make STOP bounded. +path = "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp" +before = read(path) +after = before +helper = """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)); + } +} + +""" +if "void disable_hardware_ptp(const esp_eth_handle_t eth_handle)" not in after: + anchor = "void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept {" + if anchor not in after: + raise SystemExit("PTP SOURCE hardware-clock anchor missing") + after = after.replace(anchor, helper + anchor, 1) +after = one( + after, + """ context.task_handle = nullptr; + g_ptp_ready.store(false, std::memory_order_release); +""", + """ disable_hardware_ptp(context.eth_handle); + context.task_handle = nullptr; + g_ptp_ready.store(false, std::memory_order_release); +""", + "PTP SOURCE EMAC rollback", +) +after = one( + after, + """ if (g_ptp_context.task_handle != nullptr) { + xTaskNotifyGive(g_ptp_context.task_handle); + } +} + +[[nodiscard]] bool ptp_lab_is_running() 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 { +""", + "PTP SOURCE bounded STOP", +) +write(path, before, after) + +# PTP RECEIVER/MONITOR: same shared-hardware cleanup contract. +path = "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp" +before = read(path) +after = before +helper = """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)); + } +} + +""" +if "void disable_hardware_ptp(const esp_eth_handle_t handle)" not in after: + anchor = "void seed_hardware_clock(const esp_eth_handle_t handle) noexcept {" + if anchor not in after: + raise SystemExit("PTP receiver hardware-clock anchor missing") + after = after.replace(anchor, helper + anchor, 1) +after = one( + after, + """ smp_synch_lab_set_measured(std::nullopt); + context.receiver.reset(); +""", + """ smp_synch_lab_set_measured(std::nullopt); + disable_hardware_ptp(context.eth_handle); + context.receiver.reset(); +""", + "PTP receiver EMAC rollback", +) +after = one( + after, + """ if (g_receiver_context.task_handle != nullptr) { + xTaskNotifyGive(g_receiver_context.task_handle); + } +} + +bool ptp_receiver_is_running() 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 { +""", + "PTP receiver bounded STOP", +) +write(path, before, after) + +# Studio CI fixture must use the production 0.1.1 contract. +path = ".github/workflows/arstack-studio-qt.yml" +before = read(path) +after = before.replace("arstack-esp32p4-smv-0.1.0.bin", "arstack-esp32p4-smv-0.1.1.bin") +after = after.replace('"version": "0.1.0",', '"version": "0.1.1",') +if '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"]' not in after: + after = one( + after, + ' "protocol": 1,\n "flashOffset": 0,\n', + ' "protocol": 1,\n' + ' "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],\n' + ' "flashOffset": 0,\n', + "Studio production manifest capabilities", + ) +write(path, before, after) + +# PTP CI was asserting an obsolete source spelling instead of the contract. +path = ".github/workflows/esp32p4-ptp-lab.yml" +before = read(path) +after = before.replace( + " 'return ar_ptp_lab_try_start(s_eth_handle);',", + " 'const bool started = ar_ptp_lab_try_start(s_eth_handle);',", +) +if after == before: + raise SystemExit("PTP workflow current-start assertion anchor missing") +write(path, before, after) + +# Production CI must retain shared-TX serialization and fail-closed transport. +path = ".github/workflows/esp32p4-smv-injector.yml" +before = read(path) +after = before +if "grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig" not in after: + after = one( + after, + " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", + " grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig\n" + " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", + "production TX serialization CI gate", + ) +if "assert 'kTransportFailureStopThreshold = 400U' in source" not in after: + after = one( + after, + " assert 'rate_baseline_valid = true;' in source\n", + " assert 'rate_baseline_valid = true;' in source\n" + " assert 'kTransportFailureStopThreshold = 400U' in source\n" + " assert 'live_control_force_stop();' in source\n" + " assert 'SV transport fault:' in source\n", + "SMV fail-closed CI assertions", + ) +write(path, before, after) + +required = { + "embedded/esp32p4_smv_injector/main/app_main.cpp": [ + "kTransportFailureStopThreshold = 400U", + "live_control_force_stop();", + "SV transport fault:", + ], + "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp": [ + "disable_hardware_ptp(context.eth_handle);", + "PTP SOURCE cleanup timeout", + ], + "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp": [ + "disable_hardware_ptp(context.eth_handle);", + "PTP receiver cleanup timeout", + ], + ".github/workflows/arstack-studio-qt.yml": [ + "arstack-esp32p4-smv-0.1.1.bin", + '"PTP-P2", "SMPSYNCH-AUTO"', + ], + ".github/workflows/esp32p4-smv-injector.yml": ["CONFIG_ETH_TRANSMIT_MUTEX=y"], +} +for file, tokens in required.items(): + data = read(file) + missing = [token for token in tokens if token not in data] + if missing: + raise SystemExit(f"{file}: repair verification failed: {missing}") + +print("Patched files:") +for item in changed: + print(" -", item) From cada9aa82d9b47896ee55562f8beabec8a196870 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:41:53 +0700 Subject: [PATCH 048/170] ci: run P0 repair script with minimal workflow --- .github/workflows/p0-smv-final-repair.yml | 369 +--------------------- 1 file changed, 14 insertions(+), 355 deletions(-) diff --git a/.github/workflows/p0-smv-final-repair.yml b/.github/workflows/p0-smv-final-repair.yml index f1ad8a295..d200d6e83 100644 --- a/.github/workflows/p0-smv-final-repair.yml +++ b/.github/workflows/p0-smv-final-repair.yml @@ -18,368 +18,27 @@ jobs: with: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - - name: Apply source and CI repair - shell: python - run: | - from pathlib import Path - - changed = [] - - def read(path): - return Path(path).read_text(encoding='utf-8') - - def write(path, before, after): - if before != after: - Path(path).write_text(after, encoding='utf-8') - changed.append(path) - - def one(text, old, new, label): - if new in text: - return text - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected exactly one anchor, found {count}') - return text.replace(old, new, 1) - - # --- SMV transport: never report RUNNING indefinitely when the EMAC - # accepts zero canonical frames. Keep the threshold short and bounded: - # 400 consecutive failures = 100 ms at the production 4 kHz rate. - path = 'embedded/esp32p4_smv_injector/main/app_main.cpp' - before = read(path) - after = before - after = one( - after, - 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n', - 'using ar::esp32p4::smv::live_control_bind_publisher_task;\n' - 'using ar::esp32p4::smv::live_control_force_stop;\n', - 'SMV force-stop import') - after = one( - after, - 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n', - 'constexpr std::uint32_t kStatsMergeEvery = 400U;\n' - 'constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz\n', - 'SMV transport failure threshold') - after = one( - after, - ' bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n', - ' bool schedule_anchored = false;\n' - ' std::int64_t expected_wake_us = 0;\n' - ' std::uint32_t consecutive_canonical_tx_failures = 0U;\n' - ' esp_err_t last_canonical_tx_error = ESP_OK;\n', - 'SMV transport state') - after = one( - after, - ' schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n', - ' schedule_anchored = false;\n' - ' consecutive_canonical_tx_failures = 0U;\n' - ' last_canonical_tx_error = ESP_OK;\n' - ' expected_schedule.reset(active_profile.publisher_rate_hz);\n', - 'SMV START health reset') - after = one( - after, - ''' if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { - ++local.canonical_fail; -#if CONFIG_AR_SMV_BROADCAST_MIRROR - ++local.mirror_fail; -#endif - } else { - 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 ((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 - } else { - 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; - 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; - } -''', - 'SMV canonical TX health tracking') - after = one( - after, - ''' g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); - canonical_sample_count = static_cast( -''', - ''' 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( -''', - 'SMV fail-closed runtime') - write(path, before, after) - - path = 'embedded/esp32p4_smv_injector/main/live_control.cpp' - before = read(path) - after = one( - before, - ''' clear_control_session(); - wake_publisher(); -} - -SvLiveSignalState live_signal_snapshot() noexcept { -''', - ''' clear_control_session(); - wake_publisher(); - ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime"); -} - -SvLiveSignalState live_signal_snapshot() noexcept { -''', - 'forced STOP observability') - write(path, before, after) - - # --- PTP SOURCE: hardware IEEE1588 mode is shared EMAC state. Always - # restore normal mode before advertising teardown complete, and make - # STOP bounded so a caller cannot race a subsequent mode transition. - path = 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp' - before = read(path) - after = before - helper = '''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)); - } -} - -''' - if 'void disable_hardware_ptp(const esp_eth_handle_t eth_handle)' not in after: - anchor = 'void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept {' - if anchor not in after: - raise SystemExit('PTP SOURCE hardware-clock anchor missing') - after = after.replace(anchor, helper + anchor, 1) - after = one( - after, - ''' context.task_handle = nullptr; - g_ptp_ready.store(false, std::memory_order_release); -''', - ''' disable_hardware_ptp(context.eth_handle); - context.task_handle = nullptr; - g_ptp_ready.store(false, std::memory_order_release); -''', - 'PTP SOURCE EMAC rollback') - after = one( - after, - ''' if (g_ptp_context.task_handle != nullptr) { - xTaskNotifyGive(g_ptp_context.task_handle); - } -} - -[[nodiscard]] bool ptp_lab_is_running() 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 { -''', - 'PTP SOURCE bounded STOP') - write(path, before, after) - - # --- PTP RECEIVER/MONITOR: same shared-hardware cleanup contract. - path = 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp' - before = read(path) - after = before - helper = '''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)); - } -} - -''' - if 'void disable_hardware_ptp(const esp_eth_handle_t handle)' not in after: - anchor = 'void seed_hardware_clock(const esp_eth_handle_t handle) noexcept {' - if anchor not in after: - raise SystemExit('PTP receiver hardware-clock anchor missing') - after = after.replace(anchor, helper + anchor, 1) - after = one( - after, - ''' smp_synch_lab_set_measured(std::nullopt); - context.receiver.reset(); -''', - ''' smp_synch_lab_set_measured(std::nullopt); - disable_hardware_ptp(context.eth_handle); - context.receiver.reset(); -''', - 'PTP receiver EMAC rollback') - after = one( - after, - ''' if (g_receiver_context.task_handle != nullptr) { - xTaskNotifyGive(g_receiver_context.task_handle); - } -} - -bool ptp_receiver_is_running() 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 { -''', - 'PTP receiver bounded STOP') - write(path, before, after) - - # --- Studio contract fixture must exercise the production 0.1.1 - # manifest rather than intentionally feeding the new parser 0.1.0. - path = '.github/workflows/arstack-studio-qt.yml' - before = read(path) - after = before.replace('arstack-esp32p4-smv-0.1.0.bin', 'arstack-esp32p4-smv-0.1.1.bin') - after = after.replace('"version": "0.1.0",', '"version": "0.1.1",') - if '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"]' not in after: - after = one( - after, - ' "protocol": 1,\n "flashOffset": 0,\n', - ' "protocol": 1,\n' - ' "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],\n' - ' "flashOffset": 0,\n', - 'Studio production manifest capabilities') - write(path, before, after) - - # --- PTP workflow was asserting an old textual implementation shape. - # Assert the current checked-start + rollback contract instead. - path = '.github/workflows/esp32p4-ptp-lab.yml' - before = read(path) - after = before.replace( - " 'return ar_ptp_lab_try_start(s_eth_handle);',", - " 'const bool started = ar_ptp_lab_try_start(s_eth_handle);',") - if after == before: - raise SystemExit('PTP workflow current-start assertion anchor missing') - write(path, before, after) - - # --- Normal production build must permanently retain serialized TX. - path = '.github/workflows/esp32p4-smv-injector.yml' - before = read(path) - after = before - if "grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig" not in after: - after = one( - after, - " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", - " grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig\n" - " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", - 'production TX serialization CI gate') - # Lock fail-closed source policy in the same existing source audit. - if "kTransportFailureStopThreshold = 400U" not in after: - after = one( - after, - " assert 'rate_baseline_valid = true;' in source\n", - " assert 'rate_baseline_valid = true;' in source\n" - " assert 'kTransportFailureStopThreshold = 400U' in source\n" - " assert 'live_control_force_stop();' in source\n" - " assert 'SV transport fault:' in source\n", - 'SMV fail-closed CI assertions') - write(path, before, after) - - required = { - 'embedded/esp32p4_smv_injector/main/app_main.cpp': [ - 'kTransportFailureStopThreshold = 400U', - 'live_control_force_stop();', - 'SV transport fault:' - ], - 'embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp': [ - 'disable_hardware_ptp(context.eth_handle);', - 'PTP SOURCE cleanup timeout' - ], - 'embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp': [ - 'disable_hardware_ptp(context.eth_handle);', - 'PTP receiver cleanup timeout' - ], - '.github/workflows/arstack-studio-qt.yml': [ - 'arstack-esp32p4-smv-0.1.1.bin', - '"PTP-P2", "SMPSYNCH-AUTO"' - ], - '.github/workflows/esp32p4-smv-injector.yml': [ - "CONFIG_ETH_TRANSMIT_MUTEX=y" - ], - } - for file, tokens in required.items(): - data = read(file) - missing = [token for token in tokens if token not in data] - if missing: - raise SystemExit(f'{file}: repair verification failed: {missing}') - - print('Patched files:') - for item in changed: - print(' -', item) - - - name: Validate diff - shell: bash + - name: Apply repair + run: python3 .github/scripts/p0_smv_final_repair.py + - name: Validate repair run: | set -euo pipefail git diff --check - grep -n "kTransportFailureStopThreshold\|live_control_force_stop\|SV transport fault" embedded/esp32p4_smv_injector/main/app_main.cpp - grep -n "disable_hardware_ptp\|cleanup timeout" embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp - grep -n "0.1.1\|PTP-P2" .github/workflows/arstack-studio-qt.yml | tail -20 - grep -n "CONFIG_ETH_TRANSMIT_MUTEX" .github/workflows/esp32p4-smv-injector.yml - - - name: Commit authoritative repair and remove staging workflow - shell: bash + grep -n 'kTransportFailureStopThreshold\|live_control_force_stop\|SV transport fault' embedded/esp32p4_smv_injector/main/app_main.cpp + grep -n 'disable_hardware_ptp\|cleanup timeout' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp + - name: Commit repair run: | set -euo pipefail git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git rm .github/workflows/p0-smv-final-repair.yml - git add \ - embedded/esp32p4_smv_injector/main/app_main.cpp \ - embedded/esp32p4_smv_injector/main/live_control.cpp \ - embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp \ - embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp \ - .github/workflows/arstack-studio-qt.yml \ - .github/workflows/esp32p4-ptp-lab.yml \ - .github/workflows/esp32p4-smv-injector.yml + git rm .github/workflows/p0-smv-final-repair.yml .github/scripts/p0_smv_final_repair.py + git add embedded/esp32p4_smv_injector/main/app_main.cpp + git add embedded/esp32p4_smv_injector/main/live_control.cpp + git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp + git add embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp + git add .github/workflows/arstack-studio-qt.yml + git add .github/workflows/esp32p4-ptp-lab.yml + git add .github/workflows/esp32p4-smv-injector.yml git diff --cached --check git commit -m 'fix(p0): fail closed SMV transport and restore shared EMAC state' git push origin HEAD:feature/ptp-production-v0.1.1 From fb6a402e72d4282ec791bd21d030f385948a467d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:46:05 +0700 Subject: [PATCH 049/170] ci: push P0 source repair separately from workflow edits --- .github/workflows/p0-smv-final-repair.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/p0-smv-final-repair.yml b/.github/workflows/p0-smv-final-repair.yml index d200d6e83..63f223a14 100644 --- a/.github/workflows/p0-smv-final-repair.yml +++ b/.github/workflows/p0-smv-final-repair.yml @@ -26,19 +26,15 @@ jobs: git diff --check grep -n 'kTransportFailureStopThreshold\|live_control_force_stop\|SV transport fault' embedded/esp32p4_smv_injector/main/app_main.cpp grep -n 'disable_hardware_ptp\|cleanup timeout' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp - - name: Commit repair + - name: Commit source repair run: | set -euo pipefail git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git rm .github/workflows/p0-smv-final-repair.yml .github/scripts/p0_smv_final_repair.py git add embedded/esp32p4_smv_injector/main/app_main.cpp git add embedded/esp32p4_smv_injector/main/live_control.cpp git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp git add embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp - git add .github/workflows/arstack-studio-qt.yml - git add .github/workflows/esp32p4-ptp-lab.yml - git add .github/workflows/esp32p4-smv-injector.yml git diff --cached --check git commit -m 'fix(p0): fail closed SMV transport and restore shared EMAC state' git push origin HEAD:feature/ptp-production-v0.1.1 From 326d21534af4f95835dd74a0a416d440c9eb5dbd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:48:14 +0700 Subject: [PATCH 050/170] ci: align PTP readiness gate with rollback start path --- .github/workflows/esp32p4-ptp-lab.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 2d19fd46e..b2261b4b1 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -63,8 +63,11 @@ jobs: # ESP-IDF 5.5 has no portable ETH_CMD_G_LINK query. Admission truth is # therefore the stronger bounded runtime contract: explicit operator # start is accepted only after real Announce + Sync + Follow_Up TX. + # The Ethernet facade intentionally stores the checked-start result so + # a failed SOURCE start can restore normal shared-EMAC mode. required = [ - 'return ar_ptp_lab_try_start(s_eth_handle);', + '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', @@ -76,7 +79,7 @@ jobs: missing = [token for token in required if token not in combined] if missing: raise SystemExit('PTP verified-start contract missing: ' + ', '.join(missing)) - print('PTP source contract: explicit post-Ethernet-start operator path + verified Announce/Sync/Follow_Up TX') + print('PTP source contract: explicit post-Ethernet-start path + verified TX + failed-start EMAC rollback') PY - name: Enable guarded PTP-P2 profile @@ -137,4 +140,4 @@ jobs: grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8$' sdkconfig - name: Build PTP-P2 firmware - run: source /opt/esp/idf/export.sh && idf.py build + run: source /opt/esp/idf/export.sh && idf.py build \ No newline at end of file From 91d6486a3b88ef83a4f3312becb04aa95a4a11ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:48:50 +0700 Subject: [PATCH 051/170] ci: lock shared EMAC and fail-closed SMV contract --- .github/workflows/esp32p4-smv-injector.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index fc22b6e13..f571a80f3 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -62,6 +62,7 @@ jobs: - 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 @@ -69,9 +70,9 @@ jobs: 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 · PTP-P2 LAB_SOURCE · HW timestamp adapter compiled' + echo 'Production timing profile: PASS · serialized shared EMAC TX · PTP-P2 LAB_SOURCE · HW timestamp adapter compiled' - - name: Verify realtime START and telemetry boundary policy + - name: Verify realtime START, transport health and telemetry boundary policy run: | set -euo pipefail python3 - <<'PY' @@ -90,7 +91,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 From 2fee41d6d8d3acc3b3d0203c2e74191136f77f00 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:49:39 +0700 Subject: [PATCH 052/170] ci: exercise Studio against v0.1.1 production firmware contract --- .github/workflows/arstack-studio-qt.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index e13de6a97..eb83f37e6 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -209,18 +209,19 @@ 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" < Date: Wed, 16 Sep 2026 14:50:21 +0700 Subject: [PATCH 053/170] ci: isolate P0 firmware source repair --- .github/scripts/p0_smv_source_repair.py | 237 ++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 .github/scripts/p0_smv_source_repair.py diff --git a/.github/scripts/p0_smv_source_repair.py b/.github/scripts/p0_smv_source_repair.py new file mode 100644 index 000000000..f7e14d20a --- /dev/null +++ b/.github/scripts/p0_smv_source_repair.py @@ -0,0 +1,237 @@ +from pathlib import Path + + +def read(path): + return Path(path).read_text(encoding="utf-8") + + +def write(path, before, after): + if before != after: + Path(path).write_text(after, encoding="utf-8") + + +def one(text, old, new, label): + if new in text: + return text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one anchor, found {count}") + return text.replace(old, new, 1) + + +path = "embedded/esp32p4_smv_injector/main/app_main.cpp" +before = read(path) +after = before +after = one(after, + "using ar::esp32p4::smv::live_control_bind_publisher_task;\n", + "using ar::esp32p4::smv::live_control_bind_publisher_task;\nusing ar::esp32p4::smv::live_control_force_stop;\n", + "SMV force-stop import") +after = one(after, + "constexpr std::uint32_t kStatsMergeEvery = 400U;\n", + "constexpr std::uint32_t kStatsMergeEvery = 400U;\nconstexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz\n", + "SMV transport threshold") +after = one(after, + " bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n", + " bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n std::uint32_t consecutive_canonical_tx_failures = 0U;\n esp_err_t last_canonical_tx_error = ESP_OK;\n", + "SMV transport state") +after = one(after, + " schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n", + " schedule_anchored = false;\n consecutive_canonical_tx_failures = 0U;\n last_canonical_tx_error = ESP_OK;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n", + "SMV START health reset") +after = one(after, +""" if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { + ++local.canonical_fail; +#if CONFIG_AR_SMV_BROADCAST_MIRROR + ++local.mirror_fail; +#endif + } else { + 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 ((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 + } else { + 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; + 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; + } +""", + "SMV canonical TX health") +after = one(after, +""" g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); + canonical_sample_count = static_cast( +""", +""" 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( +""", + "SMV fail-closed runtime") +write(path, before, after) + +path = "embedded/esp32p4_smv_injector/main/live_control.cpp" +before = read(path) +after = one(before, +""" clear_control_session(); + wake_publisher(); +} + +SvLiveSignalState live_signal_snapshot() noexcept { +""", +""" clear_control_session(); + wake_publisher(); + ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime"); +} + +SvLiveSignalState live_signal_snapshot() noexcept { +""", + "forced STOP observability") +write(path, before, after) + +path = "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp" +before = read(path) +after = before +helper = """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)); + } +} + +""" +if "void disable_hardware_ptp(const esp_eth_handle_t eth_handle)" not in after: + anchor = "void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept {" + if anchor not in after: + raise SystemExit("PTP SOURCE hardware-clock anchor missing") + after = after.replace(anchor, helper + anchor, 1) +after = one(after, +""" context.task_handle = nullptr; + g_ptp_ready.store(false, std::memory_order_release); +""", +""" disable_hardware_ptp(context.eth_handle); + context.task_handle = nullptr; + g_ptp_ready.store(false, std::memory_order_release); +""", + "PTP SOURCE EMAC rollback") +after = one(after, +""" if (g_ptp_context.task_handle != nullptr) { + xTaskNotifyGive(g_ptp_context.task_handle); + } +} + +[[nodiscard]] bool ptp_lab_is_running() 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 { +""", + "PTP SOURCE bounded STOP") +write(path, before, after) + +path = "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp" +before = read(path) +after = before +helper = """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)); + } +} + +""" +if "void disable_hardware_ptp(const esp_eth_handle_t handle)" not in after: + anchor = "void seed_hardware_clock(const esp_eth_handle_t handle) noexcept {" + if anchor not in after: + raise SystemExit("PTP receiver hardware-clock anchor missing") + after = after.replace(anchor, helper + anchor, 1) +after = one(after, +""" smp_synch_lab_set_measured(std::nullopt); + context.receiver.reset(); +""", +""" smp_synch_lab_set_measured(std::nullopt); + disable_hardware_ptp(context.eth_handle); + context.receiver.reset(); +""", + "PTP receiver EMAC rollback") +after = one(after, +""" if (g_receiver_context.task_handle != nullptr) { + xTaskNotifyGive(g_receiver_context.task_handle); + } +} + +bool ptp_receiver_is_running() 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 { +""", + "PTP receiver bounded STOP") +write(path, before, after) + +for file, tokens in { + "embedded/esp32p4_smv_injector/main/app_main.cpp": ["kTransportFailureStopThreshold = 400U", "live_control_force_stop();", "SV transport fault:"], + "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp": ["disable_hardware_ptp(context.eth_handle);", "PTP SOURCE cleanup timeout"], + "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp": ["disable_hardware_ptp(context.eth_handle);", "PTP receiver cleanup timeout"], +}.items(): + data = read(file) + missing = [token for token in tokens if token not in data] + if missing: + raise SystemExit(f"{file}: source repair verification failed: {missing}") + +print("P0 SMV/PTP source repair applied and verified") \ No newline at end of file From 36b0e355cd86fe8dc9d7971fa20d005c1dd67098 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:50:46 +0700 Subject: [PATCH 054/170] ci: run source-only P0 firmware repair from latest head --- .github/workflows/p0-smv-final-repair.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/p0-smv-final-repair.yml b/.github/workflows/p0-smv-final-repair.yml index 63f223a14..6915b7733 100644 --- a/.github/workflows/p0-smv-final-repair.yml +++ b/.github/workflows/p0-smv-final-repair.yml @@ -18,9 +18,9 @@ jobs: with: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - name: Apply repair - run: python3 .github/scripts/p0_smv_final_repair.py - - name: Validate repair + - name: Apply source repair + run: python3 .github/scripts/p0_smv_source_repair.py + - name: Validate source repair run: | set -euo pipefail git diff --check From f5a97391ece71569e2fa8313ab8108007baefb3c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:53:03 +0700 Subject: [PATCH 055/170] ci: execute P0 source repair on Windows runner --- .../p0-smv-source-repair-windows.yml | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/p0-smv-source-repair-windows.yml diff --git a/.github/workflows/p0-smv-source-repair-windows.yml b/.github/workflows/p0-smv-source-repair-windows.yml new file mode 100644 index 000000000..7d08abc4d --- /dev/null +++ b/.github/workflows/p0-smv-source-repair-windows.yml @@ -0,0 +1,54 @@ +name: P0 SMV source repair Windows + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + paths: + - '.github/workflows/p0-smv-source-repair-windows.yml' + +permissions: + contents: write + +jobs: + repair: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + - name: Apply source repair + shell: powershell + run: | + python .github/scripts/p0_smv_source_repair.py + if ($LASTEXITCODE -ne 0) { throw "source repair failed: $LASTEXITCODE" } + - name: Validate source repair + shell: powershell + run: | + git diff --check + if ($LASTEXITCODE -ne 0) { throw "git diff check failed: $LASTEXITCODE" } + Select-String -Path embedded/esp32p4_smv_injector/main/app_main.cpp -Pattern 'kTransportFailureStopThreshold','live_control_force_stop','SV transport fault' + Select-String -Path embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp,embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp -Pattern 'disable_hardware_ptp','cleanup timeout' + - name: Commit source repair + shell: powershell + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add embedded/esp32p4_smv_injector/main/app_main.cpp + git add embedded/esp32p4_smv_injector/main/live_control.cpp + git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp + git add embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp + git diff --cached --check + if ($LASTEXITCODE -ne 0) { throw "cached diff check failed: $LASTEXITCODE" } + $staged = git diff --cached --name-only + if (-not $staged) { + Write-Host 'Source repair already present; nothing to commit.' + exit 0 + } + git commit -m 'fix(p0): fail closed SMV transport and restore shared EMAC state' + if ($LASTEXITCODE -ne 0) { throw "commit failed: $LASTEXITCODE" } + git pull --rebase origin feature/ptp-production-v0.1.1 + if ($LASTEXITCODE -ne 0) { throw "rebase failed: $LASTEXITCODE" } + git push origin HEAD:feature/ptp-production-v0.1.1 + if ($LASTEXITCODE -ne 0) { throw "push failed: $LASTEXITCODE" } From ec757f8146874a37383789b545012d85d83ab5f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:53:12 +0000 Subject: [PATCH 056/170] fix(p0): fail closed SMV transport and restore shared EMAC state --- .../esp32p4_smv_injector/main/app_main.cpp | 31 +++++++++++++++++-- .../main/live_control.cpp | 1 + .../main/ptp_lab_task.cpp | 20 ++++++++++++ .../main/ptp_receiver_task.cpp | 20 ++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) 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/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 4cd6b5fee..c5466754a 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -646,6 +646,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 bf7361320..ec3c36477 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -275,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); @@ -468,6 +481,7 @@ 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); @@ -674,6 +688,12 @@ 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 { diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp index 7a248d57e..84b9ed6be 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( @@ -669,6 +682,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 +937,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 { From 6b387dfe8d68b0755ae4f317717c3e5bfc014e38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:53:55 +0700 Subject: [PATCH 057/170] ci: execute P0 source repair on ARM64 runner --- .../workflows/p0-smv-source-repair-arm64.yml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/p0-smv-source-repair-arm64.yml diff --git a/.github/workflows/p0-smv-source-repair-arm64.yml b/.github/workflows/p0-smv-source-repair-arm64.yml new file mode 100644 index 000000000..927beb53e --- /dev/null +++ b/.github/workflows/p0-smv-source-repair-arm64.yml @@ -0,0 +1,46 @@ +name: P0 SMV source repair ARM64 + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + paths: + - '.github/workflows/p0-smv-source-repair-arm64.yml' + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + - name: Apply and validate source repair + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/p0_smv_source_repair.py + git diff --check + grep -n 'kTransportFailureStopThreshold\|live_control_force_stop\|SV transport fault' embedded/esp32p4_smv_injector/main/app_main.cpp + grep -n 'disable_hardware_ptp\|cleanup timeout' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp + - name: Commit source repair + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add embedded/esp32p4_smv_injector/main/app_main.cpp + git add embedded/esp32p4_smv_injector/main/live_control.cpp + git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp + git add embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp + git diff --cached --check + if git diff --cached --quiet; then + echo 'Source repair already present; nothing to commit.' + exit 0 + fi + git commit -m 'fix(p0): fail closed SMV transport and restore shared EMAC state' + git pull --rebase origin feature/ptp-production-v0.1.1 + git push origin HEAD:feature/ptp-production-v0.1.1 From 722122f84d97a54daeeae8315b6734d2955d4956 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:56:12 +0700 Subject: [PATCH 058/170] chore: remove temporary P0 repair workflow --- .github/workflows/p0-smv-final-repair.yml | 40 ----------------------- 1 file changed, 40 deletions(-) delete mode 100644 .github/workflows/p0-smv-final-repair.yml diff --git a/.github/workflows/p0-smv-final-repair.yml b/.github/workflows/p0-smv-final-repair.yml deleted file mode 100644 index 6915b7733..000000000 --- a/.github/workflows/p0-smv-final-repair.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: P0 SMV final repair - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - paths: - - '.github/workflows/p0-smv-final-repair.yml' - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - name: Apply source repair - run: python3 .github/scripts/p0_smv_source_repair.py - - name: Validate source repair - run: | - set -euo pipefail - git diff --check - grep -n 'kTransportFailureStopThreshold\|live_control_force_stop\|SV transport fault' embedded/esp32p4_smv_injector/main/app_main.cpp - grep -n 'disable_hardware_ptp\|cleanup timeout' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp - - name: Commit source repair - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add embedded/esp32p4_smv_injector/main/app_main.cpp - git add embedded/esp32p4_smv_injector/main/live_control.cpp - git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp - git add embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp - git diff --cached --check - git commit -m 'fix(p0): fail closed SMV transport and restore shared EMAC state' - git push origin HEAD:feature/ptp-production-v0.1.1 From 23d2864db3963f5590eb81f4b66b6d0ab50940d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:56:25 +0700 Subject: [PATCH 059/170] chore: remove temporary Windows source repair workflow --- .../p0-smv-source-repair-windows.yml | 54 ------------------- 1 file changed, 54 deletions(-) delete mode 100644 .github/workflows/p0-smv-source-repair-windows.yml diff --git a/.github/workflows/p0-smv-source-repair-windows.yml b/.github/workflows/p0-smv-source-repair-windows.yml deleted file mode 100644 index 7d08abc4d..000000000 --- a/.github/workflows/p0-smv-source-repair-windows.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: P0 SMV source repair Windows - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - paths: - - '.github/workflows/p0-smv-source-repair-windows.yml' - -permissions: - contents: write - -jobs: - repair: - runs-on: windows-2022 - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - name: Apply source repair - shell: powershell - run: | - python .github/scripts/p0_smv_source_repair.py - if ($LASTEXITCODE -ne 0) { throw "source repair failed: $LASTEXITCODE" } - - name: Validate source repair - shell: powershell - run: | - git diff --check - if ($LASTEXITCODE -ne 0) { throw "git diff check failed: $LASTEXITCODE" } - Select-String -Path embedded/esp32p4_smv_injector/main/app_main.cpp -Pattern 'kTransportFailureStopThreshold','live_control_force_stop','SV transport fault' - Select-String -Path embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp,embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp -Pattern 'disable_hardware_ptp','cleanup timeout' - - name: Commit source repair - shell: powershell - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add embedded/esp32p4_smv_injector/main/app_main.cpp - git add embedded/esp32p4_smv_injector/main/live_control.cpp - git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp - git add embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp - git diff --cached --check - if ($LASTEXITCODE -ne 0) { throw "cached diff check failed: $LASTEXITCODE" } - $staged = git diff --cached --name-only - if (-not $staged) { - Write-Host 'Source repair already present; nothing to commit.' - exit 0 - } - git commit -m 'fix(p0): fail closed SMV transport and restore shared EMAC state' - if ($LASTEXITCODE -ne 0) { throw "commit failed: $LASTEXITCODE" } - git pull --rebase origin feature/ptp-production-v0.1.1 - if ($LASTEXITCODE -ne 0) { throw "rebase failed: $LASTEXITCODE" } - git push origin HEAD:feature/ptp-production-v0.1.1 - if ($LASTEXITCODE -ne 0) { throw "push failed: $LASTEXITCODE" } From f6ad55c10a390a0c4dad198adbce286a003760b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:56:37 +0700 Subject: [PATCH 060/170] chore: remove temporary ARM64 source repair workflow --- .../workflows/p0-smv-source-repair-arm64.yml | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 .github/workflows/p0-smv-source-repair-arm64.yml diff --git a/.github/workflows/p0-smv-source-repair-arm64.yml b/.github/workflows/p0-smv-source-repair-arm64.yml deleted file mode 100644 index 927beb53e..000000000 --- a/.github/workflows/p0-smv-source-repair-arm64.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: P0 SMV source repair ARM64 - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - paths: - - '.github/workflows/p0-smv-source-repair-arm64.yml' - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-24.04-arm - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - name: Apply and validate source repair - shell: bash - run: | - set -euo pipefail - python3 .github/scripts/p0_smv_source_repair.py - git diff --check - grep -n 'kTransportFailureStopThreshold\|live_control_force_stop\|SV transport fault' embedded/esp32p4_smv_injector/main/app_main.cpp - grep -n 'disable_hardware_ptp\|cleanup timeout' embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp - - name: Commit source repair - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add embedded/esp32p4_smv_injector/main/app_main.cpp - git add embedded/esp32p4_smv_injector/main/live_control.cpp - git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp - git add embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp - git diff --cached --check - if git diff --cached --quiet; then - echo 'Source repair already present; nothing to commit.' - exit 0 - fi - git commit -m 'fix(p0): fail closed SMV transport and restore shared EMAC state' - git pull --rebase origin feature/ptp-production-v0.1.1 - git push origin HEAD:feature/ptp-production-v0.1.1 From 330e08f423270aff833daff04d33874930861ed9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:56:44 +0700 Subject: [PATCH 061/170] chore: remove obsolete P0 repair script --- .github/scripts/p0_smv_final_repair.py | 344 ------------------------- 1 file changed, 344 deletions(-) delete mode 100644 .github/scripts/p0_smv_final_repair.py diff --git a/.github/scripts/p0_smv_final_repair.py b/.github/scripts/p0_smv_final_repair.py deleted file mode 100644 index 9a10fb8f7..000000000 --- a/.github/scripts/p0_smv_final_repair.py +++ /dev/null @@ -1,344 +0,0 @@ -from pathlib import Path - -changed = [] - - -def read(path): - return Path(path).read_text(encoding="utf-8") - - -def write(path, before, after): - if before != after: - Path(path).write_text(after, encoding="utf-8") - changed.append(path) - - -def one(text, old, new, label): - if new in text: - return text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one anchor, found {count}") - return text.replace(old, new, 1) - - -# SMV transport: force STOP instead of reporting RUNNING indefinitely when -# canonical Ethernet transmission continuously fails. 400 frames = 100 ms at 4 kHz. -path = "embedded/esp32p4_smv_injector/main/app_main.cpp" -before = read(path) -after = before -after = one( - after, - "using ar::esp32p4::smv::live_control_bind_publisher_task;\n", - "using ar::esp32p4::smv::live_control_bind_publisher_task;\n" - "using ar::esp32p4::smv::live_control_force_stop;\n", - "SMV force-stop import", -) -after = one( - after, - "constexpr std::uint32_t kStatsMergeEvery = 400U;\n", - "constexpr std::uint32_t kStatsMergeEvery = 400U;\n" - "constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz\n", - "SMV transport failure threshold", -) -after = one( - after, - " bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n", - " bool schedule_anchored = false;\n" - " std::int64_t expected_wake_us = 0;\n" - " std::uint32_t consecutive_canonical_tx_failures = 0U;\n" - " esp_err_t last_canonical_tx_error = ESP_OK;\n", - "SMV transport state", -) -after = one( - after, - " schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n", - " schedule_anchored = false;\n" - " consecutive_canonical_tx_failures = 0U;\n" - " last_canonical_tx_error = ESP_OK;\n" - " expected_schedule.reset(active_profile.publisher_rate_hz);\n", - "SMV START health reset", -) -after = one( - after, - """ if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { - ++local.canonical_fail; -#if CONFIG_AR_SMV_BROADCAST_MIRROR - ++local.mirror_fail; -#endif - } else { - 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 ((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 - } else { - 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; - 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; - } -""", - "SMV canonical TX health tracking", -) -after = one( - after, - """ g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); - canonical_sample_count = static_cast( -""", - """ 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( -""", - "SMV fail-closed runtime", -) -write(path, before, after) - -path = "embedded/esp32p4_smv_injector/main/live_control.cpp" -before = read(path) -after = one( - before, - """ clear_control_session(); - wake_publisher(); -} - -SvLiveSignalState live_signal_snapshot() noexcept { -""", - """ clear_control_session(); - wake_publisher(); - ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime"); -} - -SvLiveSignalState live_signal_snapshot() noexcept { -""", - "forced STOP observability", -) -write(path, before, after) - -# PTP SOURCE: IEEE1588 hardware mode is shared EMAC state. Restore normal mode -# before teardown is published and make STOP bounded. -path = "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp" -before = read(path) -after = before -helper = """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)); - } -} - -""" -if "void disable_hardware_ptp(const esp_eth_handle_t eth_handle)" not in after: - anchor = "void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept {" - if anchor not in after: - raise SystemExit("PTP SOURCE hardware-clock anchor missing") - after = after.replace(anchor, helper + anchor, 1) -after = one( - after, - """ context.task_handle = nullptr; - g_ptp_ready.store(false, std::memory_order_release); -""", - """ disable_hardware_ptp(context.eth_handle); - context.task_handle = nullptr; - g_ptp_ready.store(false, std::memory_order_release); -""", - "PTP SOURCE EMAC rollback", -) -after = one( - after, - """ if (g_ptp_context.task_handle != nullptr) { - xTaskNotifyGive(g_ptp_context.task_handle); - } -} - -[[nodiscard]] bool ptp_lab_is_running() 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 { -""", - "PTP SOURCE bounded STOP", -) -write(path, before, after) - -# PTP RECEIVER/MONITOR: same shared-hardware cleanup contract. -path = "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp" -before = read(path) -after = before -helper = """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)); - } -} - -""" -if "void disable_hardware_ptp(const esp_eth_handle_t handle)" not in after: - anchor = "void seed_hardware_clock(const esp_eth_handle_t handle) noexcept {" - if anchor not in after: - raise SystemExit("PTP receiver hardware-clock anchor missing") - after = after.replace(anchor, helper + anchor, 1) -after = one( - after, - """ smp_synch_lab_set_measured(std::nullopt); - context.receiver.reset(); -""", - """ smp_synch_lab_set_measured(std::nullopt); - disable_hardware_ptp(context.eth_handle); - context.receiver.reset(); -""", - "PTP receiver EMAC rollback", -) -after = one( - after, - """ if (g_receiver_context.task_handle != nullptr) { - xTaskNotifyGive(g_receiver_context.task_handle); - } -} - -bool ptp_receiver_is_running() 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 { -""", - "PTP receiver bounded STOP", -) -write(path, before, after) - -# Studio CI fixture must use the production 0.1.1 contract. -path = ".github/workflows/arstack-studio-qt.yml" -before = read(path) -after = before.replace("arstack-esp32p4-smv-0.1.0.bin", "arstack-esp32p4-smv-0.1.1.bin") -after = after.replace('"version": "0.1.0",', '"version": "0.1.1",') -if '"capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"]' not in after: - after = one( - after, - ' "protocol": 1,\n "flashOffset": 0,\n', - ' "protocol": 1,\n' - ' "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],\n' - ' "flashOffset": 0,\n', - "Studio production manifest capabilities", - ) -write(path, before, after) - -# PTP CI was asserting an obsolete source spelling instead of the contract. -path = ".github/workflows/esp32p4-ptp-lab.yml" -before = read(path) -after = before.replace( - " 'return ar_ptp_lab_try_start(s_eth_handle);',", - " 'const bool started = ar_ptp_lab_try_start(s_eth_handle);',", -) -if after == before: - raise SystemExit("PTP workflow current-start assertion anchor missing") -write(path, before, after) - -# Production CI must retain shared-TX serialization and fail-closed transport. -path = ".github/workflows/esp32p4-smv-injector.yml" -before = read(path) -after = before -if "grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig" not in after: - after = one( - after, - " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", - " grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig\n" - " grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig\n", - "production TX serialization CI gate", - ) -if "assert 'kTransportFailureStopThreshold = 400U' in source" not in after: - after = one( - after, - " assert 'rate_baseline_valid = true;' in source\n", - " assert 'rate_baseline_valid = true;' in source\n" - " assert 'kTransportFailureStopThreshold = 400U' in source\n" - " assert 'live_control_force_stop();' in source\n" - " assert 'SV transport fault:' in source\n", - "SMV fail-closed CI assertions", - ) -write(path, before, after) - -required = { - "embedded/esp32p4_smv_injector/main/app_main.cpp": [ - "kTransportFailureStopThreshold = 400U", - "live_control_force_stop();", - "SV transport fault:", - ], - "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp": [ - "disable_hardware_ptp(context.eth_handle);", - "PTP SOURCE cleanup timeout", - ], - "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp": [ - "disable_hardware_ptp(context.eth_handle);", - "PTP receiver cleanup timeout", - ], - ".github/workflows/arstack-studio-qt.yml": [ - "arstack-esp32p4-smv-0.1.1.bin", - '"PTP-P2", "SMPSYNCH-AUTO"', - ], - ".github/workflows/esp32p4-smv-injector.yml": ["CONFIG_ETH_TRANSMIT_MUTEX=y"], -} -for file, tokens in required.items(): - data = read(file) - missing = [token for token in tokens if token not in data] - if missing: - raise SystemExit(f"{file}: repair verification failed: {missing}") - -print("Patched files:") -for item in changed: - print(" -", item) From 3d85fe6f12622e16e08ef3dc4db2956ddba70cfe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:56:51 +0700 Subject: [PATCH 062/170] chore: remove temporary P0 source repair script --- .github/scripts/p0_smv_source_repair.py | 237 ------------------------ 1 file changed, 237 deletions(-) delete mode 100644 .github/scripts/p0_smv_source_repair.py diff --git a/.github/scripts/p0_smv_source_repair.py b/.github/scripts/p0_smv_source_repair.py deleted file mode 100644 index f7e14d20a..000000000 --- a/.github/scripts/p0_smv_source_repair.py +++ /dev/null @@ -1,237 +0,0 @@ -from pathlib import Path - - -def read(path): - return Path(path).read_text(encoding="utf-8") - - -def write(path, before, after): - if before != after: - Path(path).write_text(after, encoding="utf-8") - - -def one(text, old, new, label): - if new in text: - return text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one anchor, found {count}") - return text.replace(old, new, 1) - - -path = "embedded/esp32p4_smv_injector/main/app_main.cpp" -before = read(path) -after = before -after = one(after, - "using ar::esp32p4::smv::live_control_bind_publisher_task;\n", - "using ar::esp32p4::smv::live_control_bind_publisher_task;\nusing ar::esp32p4::smv::live_control_force_stop;\n", - "SMV force-stop import") -after = one(after, - "constexpr std::uint32_t kStatsMergeEvery = 400U;\n", - "constexpr std::uint32_t kStatsMergeEvery = 400U;\nconstexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz\n", - "SMV transport threshold") -after = one(after, - " bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n", - " bool schedule_anchored = false;\n std::int64_t expected_wake_us = 0;\n std::uint32_t consecutive_canonical_tx_failures = 0U;\n esp_err_t last_canonical_tx_error = ESP_OK;\n", - "SMV transport state") -after = one(after, - " schedule_anchored = false;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n", - " schedule_anchored = false;\n consecutive_canonical_tx_failures = 0U;\n last_canonical_tx_error = ESP_OK;\n expected_schedule.reset(active_profile.publisher_rate_hz);\n", - "SMV START health reset") -after = one(after, -""" if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { - ++local.canonical_fail; -#if CONFIG_AR_SMV_BROADCAST_MIRROR - ++local.mirror_fail; -#endif - } else { - 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 ((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 - } else { - 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; - 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; - } -""", - "SMV canonical TX health") -after = one(after, -""" g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); - canonical_sample_count = static_cast( -""", -""" 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( -""", - "SMV fail-closed runtime") -write(path, before, after) - -path = "embedded/esp32p4_smv_injector/main/live_control.cpp" -before = read(path) -after = one(before, -""" clear_control_session(); - wake_publisher(); -} - -SvLiveSignalState live_signal_snapshot() noexcept { -""", -""" clear_control_session(); - wake_publisher(); - ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime"); -} - -SvLiveSignalState live_signal_snapshot() noexcept { -""", - "forced STOP observability") -write(path, before, after) - -path = "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp" -before = read(path) -after = before -helper = """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)); - } -} - -""" -if "void disable_hardware_ptp(const esp_eth_handle_t eth_handle)" not in after: - anchor = "void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept {" - if anchor not in after: - raise SystemExit("PTP SOURCE hardware-clock anchor missing") - after = after.replace(anchor, helper + anchor, 1) -after = one(after, -""" context.task_handle = nullptr; - g_ptp_ready.store(false, std::memory_order_release); -""", -""" disable_hardware_ptp(context.eth_handle); - context.task_handle = nullptr; - g_ptp_ready.store(false, std::memory_order_release); -""", - "PTP SOURCE EMAC rollback") -after = one(after, -""" if (g_ptp_context.task_handle != nullptr) { - xTaskNotifyGive(g_ptp_context.task_handle); - } -} - -[[nodiscard]] bool ptp_lab_is_running() 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 { -""", - "PTP SOURCE bounded STOP") -write(path, before, after) - -path = "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp" -before = read(path) -after = before -helper = """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)); - } -} - -""" -if "void disable_hardware_ptp(const esp_eth_handle_t handle)" not in after: - anchor = "void seed_hardware_clock(const esp_eth_handle_t handle) noexcept {" - if anchor not in after: - raise SystemExit("PTP receiver hardware-clock anchor missing") - after = after.replace(anchor, helper + anchor, 1) -after = one(after, -""" smp_synch_lab_set_measured(std::nullopt); - context.receiver.reset(); -""", -""" smp_synch_lab_set_measured(std::nullopt); - disable_hardware_ptp(context.eth_handle); - context.receiver.reset(); -""", - "PTP receiver EMAC rollback") -after = one(after, -""" if (g_receiver_context.task_handle != nullptr) { - xTaskNotifyGive(g_receiver_context.task_handle); - } -} - -bool ptp_receiver_is_running() 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 { -""", - "PTP receiver bounded STOP") -write(path, before, after) - -for file, tokens in { - "embedded/esp32p4_smv_injector/main/app_main.cpp": ["kTransportFailureStopThreshold = 400U", "live_control_force_stop();", "SV transport fault:"], - "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp": ["disable_hardware_ptp(context.eth_handle);", "PTP SOURCE cleanup timeout"], - "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp": ["disable_hardware_ptp(context.eth_handle);", "PTP receiver cleanup timeout"], -}.items(): - data = read(file) - missing = [token for token in tokens if token not in data] - if missing: - raise SystemExit(f"{file}: source repair verification failed: {missing}") - -print("P0 SMV/PTP source repair applied and verified") \ No newline at end of file From 74ff9ee81fb42d4930419effe555041bf0d58c22 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:30:42 +0700 Subject: [PATCH 063/170] ci: apply smart legacy firmware recovery fix --- .../workflows/p0-smart-firmware-recovery.yml | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .github/workflows/p0-smart-firmware-recovery.yml diff --git a/.github/workflows/p0-smart-firmware-recovery.yml b/.github/workflows/p0-smart-firmware-recovery.yml new file mode 100644 index 000000000..888d25704 --- /dev/null +++ b/.github/workflows/p0-smart-firmware-recovery.yml @@ -0,0 +1,164 @@ +name: P0 Smart Firmware Recovery Repair + +on: + push: + paths: + - '.github/workflows/p0-smart-firmware-recovery.yml' + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + - name: Patch trusted legacy-board recovery and regression harness + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + + controller = Path('apps/arstack_studio/src/SmartSessionController.cpp') + text = controller.read_text(encoding='utf-8') + old = ''' // 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. + if (device_->identificationState() != DeviceController::IdentificationState::Unidentified) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + if (!manualRecoveryArmed_ || recoveryPending_) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + const QString recommended = device_->recommendedPort().trimmed(); + if (recommended.isEmpty() || !device_->ports().contains(recommended)) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + blankBoardPort_ = recommended; + blankBoardDetected_ = true; +''' + new = ''' // 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 (recoveryPending_) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + const QString recommended = device_->recommendedPort().trimmed(); + if (recommended.isEmpty() || !device_->ports().contains(recommended)) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + blankBoardPort_ = recommended; + blankBoardDetected_ = true; +''' + if old not in text: + raise SystemExit('SmartSession recovery block did not match expected source') + text = text.replace(old, new, 1) + old_status = ''' QStringLiteral("Manual recovery selected for %1 after bounded semantic identification failed. Studio will verify chip and revision before any firmware write.") + .arg(blankBoardPort_),''' + new_status = ''' 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_),''' + if old_status not in text: + raise SystemExit('Firmware-required presentation did not match expected source') + text = text.replace(old_status, new_status, 1) + controller.write_text(text, encoding='utf-8') + + harness = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') + text = harness.read_text(encoding='utf-8') + old_case = '{"auto identity timeout -> UNIDENTIFIED", automaticIdentityTimeoutStaysUnidentified()},' + new_case = '{"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()},\n {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()},' + if old_case not in text: + raise SystemExit('Harness auto-timeout case did not match expected source') + text = text.replace(old_case, new_case, 1) + + old_fn = ''' static bool automaticIdentityTimeoutStaysUnidentified() { + Fixture fixture; + if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; + return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && + !fixture.session.firmwareInstallVisible() && + !fixture.session.blankBoardDetected_ && + !fixture.session.startReady(); + } +''' + new_fn = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() { + Fixture fixture; + if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; + return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && + fixture.session.firmwareInstallVisible() && + fixture.session.blankBoardDetected_ && + fixture.session.blankBoardPort_ == QStringLiteral("COM7") && + !fixture.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(); + } +''' + if old_fn not in text: + raise SystemExit('Harness auto-timeout function did not match expected source') + text = text.replace(old_fn, new_fn, 1) + harness.write_text(text, encoding='utf-8') + PY + + git diff --check + git diff -- apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp + - name: Commit repair + shell: bash + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp + git commit -m 'fix(studio): auto-recover trusted legacy ESP32-P4 firmware' + git push origin HEAD:feature/ptp-production-v0.1.1 From 206eb8a7f2a5017079289f7ce7231664d5b7a6f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:32:52 +0700 Subject: [PATCH 064/170] ci: add smart firmware recovery patch helper --- .github/scripts/p0_smart_firmware_recovery.py | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/scripts/p0_smart_firmware_recovery.py diff --git a/.github/scripts/p0_smart_firmware_recovery.py b/.github/scripts/p0_smart_firmware_recovery.py new file mode 100644 index 000000000..ce62decd3 --- /dev/null +++ b/.github/scripts/p0_smart_firmware_recovery.py @@ -0,0 +1,129 @@ +from pathlib import Path + +controller = Path('apps/arstack_studio/src/SmartSessionController.cpp') +text = controller.read_text(encoding='utf-8') +old = ''' // 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. + if (device_->identificationState() != DeviceController::IdentificationState::Unidentified) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + if (!manualRecoveryArmed_ || recoveryPending_) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + const QString recommended = device_->recommendedPort().trimmed(); + if (recommended.isEmpty() || !device_->ports().contains(recommended)) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + blankBoardPort_ = recommended; + blankBoardDetected_ = true; +''' +new = ''' // 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 (recoveryPending_) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + const QString recommended = device_->recommendedPort().trimmed(); + if (recommended.isEmpty() || !device_->ports().contains(recommended)) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + blankBoardPort_ = recommended; + blankBoardDetected_ = true; +''' +if old not in text: + raise SystemExit('SmartSession recovery block did not match expected source') +text = text.replace(old, new, 1) +old_status = ''' QStringLiteral("Manual recovery selected for %1 after bounded semantic identification failed. Studio will verify chip and revision before any firmware write.") + .arg(blankBoardPort_),''' +new_status = ''' 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_),''' +if old_status not in text: + raise SystemExit('Firmware-required presentation did not match expected source') +text = text.replace(old_status, new_status, 1) +controller.write_text(text, encoding='utf-8') + +harness = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') +text = harness.read_text(encoding='utf-8') +old_case = '{"auto identity timeout -> UNIDENTIFIED", automaticIdentityTimeoutStaysUnidentified()},' +new_case = '{"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()},\n {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()},' +if old_case not in text: + raise SystemExit('Harness auto-timeout case did not match expected source') +text = text.replace(old_case, new_case, 1) +old_fn = ''' static bool automaticIdentityTimeoutStaysUnidentified() { + Fixture fixture; + if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; + return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && + !fixture.session.firmwareInstallVisible() && + !fixture.session.blankBoardDetected_ && + !fixture.session.startReady(); + } +''' +new_fn = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() { + Fixture fixture; + if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; + return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && + fixture.session.firmwareInstallVisible() && + fixture.session.blankBoardDetected_ && + fixture.session.blankBoardPort_ == QStringLiteral("COM7") && + !fixture.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(); + } +''' +if old_fn not in text: + raise SystemExit('Harness auto-timeout function did not match expected source') +text = text.replace(old_fn, new_fn, 1) +harness.write_text(text, encoding='utf-8') From 0c4515d4f708b2dec97f940e428737e66c4d525e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:33:01 +0700 Subject: [PATCH 065/170] ci: simplify smart firmware recovery repair runner --- .../workflows/p0-smart-firmware-recovery.yml | 142 +----------------- 1 file changed, 5 insertions(+), 137 deletions(-) diff --git a/.github/workflows/p0-smart-firmware-recovery.yml b/.github/workflows/p0-smart-firmware-recovery.yml index 888d25704..39be5acab 100644 --- a/.github/workflows/p0-smart-firmware-recovery.yml +++ b/.github/workflows/p0-smart-firmware-recovery.yml @@ -12,150 +12,18 @@ jobs: repair: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 with: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - name: Patch trusted legacy-board recovery and regression harness - shell: bash + - name: Apply source repair + run: python3 .github/scripts/p0_smart_firmware_recovery.py + - name: Validate diff run: | - python3 - <<'PY' - from pathlib import Path - - controller = Path('apps/arstack_studio/src/SmartSessionController.cpp') - text = controller.read_text(encoding='utf-8') - old = ''' // 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. - if (device_->identificationState() != DeviceController::IdentificationState::Unidentified) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - if (!manualRecoveryArmed_ || recoveryPending_) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - const QString recommended = device_->recommendedPort().trimmed(); - if (recommended.isEmpty() || !device_->ports().contains(recommended)) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - blankBoardPort_ = recommended; - blankBoardDetected_ = true; -''' - new = ''' // 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 (recoveryPending_) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - const QString recommended = device_->recommendedPort().trimmed(); - if (recommended.isEmpty() || !device_->ports().contains(recommended)) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - blankBoardPort_ = recommended; - blankBoardDetected_ = true; -''' - if old not in text: - raise SystemExit('SmartSession recovery block did not match expected source') - text = text.replace(old, new, 1) - old_status = ''' QStringLiteral("Manual recovery selected for %1 after bounded semantic identification failed. Studio will verify chip and revision before any firmware write.") - .arg(blankBoardPort_),''' - new_status = ''' 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_),''' - if old_status not in text: - raise SystemExit('Firmware-required presentation did not match expected source') - text = text.replace(old_status, new_status, 1) - controller.write_text(text, encoding='utf-8') - - harness = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') - text = harness.read_text(encoding='utf-8') - old_case = '{"auto identity timeout -> UNIDENTIFIED", automaticIdentityTimeoutStaysUnidentified()},' - new_case = '{"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()},\n {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()},' - if old_case not in text: - raise SystemExit('Harness auto-timeout case did not match expected source') - text = text.replace(old_case, new_case, 1) - - old_fn = ''' static bool automaticIdentityTimeoutStaysUnidentified() { - Fixture fixture; - if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; - return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && - !fixture.session.firmwareInstallVisible() && - !fixture.session.blankBoardDetected_ && - !fixture.session.startReady(); - } -''' - new_fn = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() { - Fixture fixture; - if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; - return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && - fixture.session.firmwareInstallVisible() && - fixture.session.blankBoardDetected_ && - fixture.session.blankBoardPort_ == QStringLiteral("COM7") && - !fixture.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(); - } -''' - if old_fn not in text: - raise SystemExit('Harness auto-timeout function did not match expected source') - text = text.replace(old_fn, new_fn, 1) - harness.write_text(text, encoding='utf-8') - PY - git diff --check git diff -- apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp - name: Commit repair - shell: bash run: | git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' From 1a2c6de63c55e70c655898f617093f451e9074e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:36:01 +0700 Subject: [PATCH 066/170] ci: run smart firmware recovery repair on Windows --- .github/workflows/p0-smart-firmware-recovery.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/p0-smart-firmware-recovery.yml b/.github/workflows/p0-smart-firmware-recovery.yml index 39be5acab..fd97e668f 100644 --- a/.github/workflows/p0-smart-firmware-recovery.yml +++ b/.github/workflows/p0-smart-firmware-recovery.yml @@ -10,7 +10,7 @@ permissions: jobs: repair: - runs-on: ubuntu-latest + runs-on: windows-2022 steps: - name: Checkout uses: actions/checkout@v4 @@ -18,15 +18,23 @@ jobs: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - name: Apply source repair - run: python3 .github/scripts/p0_smart_firmware_recovery.py + shell: powershell + run: | + python .github\scripts\p0_smart_firmware_recovery.py + if ($LASTEXITCODE -ne 0) { throw "Source repair failed: $LASTEXITCODE" } - name: Validate diff + shell: powershell run: | git diff --check + if ($LASTEXITCODE -ne 0) { throw "git diff --check failed" } git diff -- apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp - name: Commit repair + shell: powershell run: | git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp git commit -m 'fix(studio): auto-recover trusted legacy ESP32-P4 firmware' + if ($LASTEXITCODE -ne 0) { throw "Commit failed: $LASTEXITCODE" } git push origin HEAD:feature/ptp-production-v0.1.1 + if ($LASTEXITCODE -ne 0) { throw "Push failed: $LASTEXITCODE" } From 40a33f7c67782a3ba7aaaa90fc982f69efedd679 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:36:28 +0000 Subject: [PATCH 067/170] fix(studio): auto-recover trusted legacy ESP32-P4 firmware --- .../src/DeterministicSessionHarness.cpp | 41 ++++++++++++++++--- .../src/SmartSessionController.cpp | 14 +++---- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 478852752..ae800d635 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -28,7 +28,8 @@ class DeterministicSessionHarness final { const CaseResult cases[] = { {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, - {"auto identity timeout -> UNIDENTIFIED", automaticIdentityTimeoutStaysUnidentified()}, + {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, + {"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()}, @@ -264,15 +265,45 @@ class DeterministicSessionHarness final { !fixture.session.startReady(); } - static bool automaticIdentityTimeoutStaysUnidentified() { + 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 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/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index efaeb559c..33d38c14a 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -757,18 +757,18 @@ 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; @@ -1013,7 +1013,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); From 5d930fe14e545ff15b8ad7a8089b6663f7f642ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:38:29 +0700 Subject: [PATCH 068/170] chore: remove temporary smart recovery patch helper --- .github/scripts/p0_smart_firmware_recovery.py | 129 ------------------ 1 file changed, 129 deletions(-) delete mode 100644 .github/scripts/p0_smart_firmware_recovery.py diff --git a/.github/scripts/p0_smart_firmware_recovery.py b/.github/scripts/p0_smart_firmware_recovery.py deleted file mode 100644 index ce62decd3..000000000 --- a/.github/scripts/p0_smart_firmware_recovery.py +++ /dev/null @@ -1,129 +0,0 @@ -from pathlib import Path - -controller = Path('apps/arstack_studio/src/SmartSessionController.cpp') -text = controller.read_text(encoding='utf-8') -old = ''' // 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. - if (device_->identificationState() != DeviceController::IdentificationState::Unidentified) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - if (!manualRecoveryArmed_ || recoveryPending_) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - const QString recommended = device_->recommendedPort().trimmed(); - if (recommended.isEmpty() || !device_->ports().contains(recommended)) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - blankBoardPort_ = recommended; - blankBoardDetected_ = true; -''' -new = ''' // 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 (recoveryPending_) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - const QString recommended = device_->recommendedPort().trimmed(); - if (recommended.isEmpty() || !device_->ports().contains(recommended)) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - blankBoardPort_ = recommended; - blankBoardDetected_ = true; -''' -if old not in text: - raise SystemExit('SmartSession recovery block did not match expected source') -text = text.replace(old, new, 1) -old_status = ''' QStringLiteral("Manual recovery selected for %1 after bounded semantic identification failed. Studio will verify chip and revision before any firmware write.") - .arg(blankBoardPort_),''' -new_status = ''' 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_),''' -if old_status not in text: - raise SystemExit('Firmware-required presentation did not match expected source') -text = text.replace(old_status, new_status, 1) -controller.write_text(text, encoding='utf-8') - -harness = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') -text = harness.read_text(encoding='utf-8') -old_case = '{"auto identity timeout -> UNIDENTIFIED", automaticIdentityTimeoutStaysUnidentified()},' -new_case = '{"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()},\n {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()},' -if old_case not in text: - raise SystemExit('Harness auto-timeout case did not match expected source') -text = text.replace(old_case, new_case, 1) -old_fn = ''' static bool automaticIdentityTimeoutStaysUnidentified() { - Fixture fixture; - if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; - return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && - !fixture.session.firmwareInstallVisible() && - !fixture.session.blankBoardDetected_ && - !fixture.session.startReady(); - } -''' -new_fn = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() { - Fixture fixture; - if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; - return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && - fixture.session.firmwareInstallVisible() && - fixture.session.blankBoardDetected_ && - fixture.session.blankBoardPort_ == QStringLiteral("COM7") && - !fixture.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(); - } -''' -if old_fn not in text: - raise SystemExit('Harness auto-timeout function did not match expected source') -text = text.replace(old_fn, new_fn, 1) -harness.write_text(text, encoding='utf-8') From eea95d675f04d8ba35a21e69ec0a0ab49c3529f8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:38:38 +0700 Subject: [PATCH 069/170] chore: remove temporary smart recovery workflow --- .../workflows/p0-smart-firmware-recovery.yml | 40 ------------------- 1 file changed, 40 deletions(-) delete mode 100644 .github/workflows/p0-smart-firmware-recovery.yml diff --git a/.github/workflows/p0-smart-firmware-recovery.yml b/.github/workflows/p0-smart-firmware-recovery.yml deleted file mode 100644 index fd97e668f..000000000 --- a/.github/workflows/p0-smart-firmware-recovery.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: P0 Smart Firmware Recovery Repair - -on: - push: - paths: - - '.github/workflows/p0-smart-firmware-recovery.yml' - -permissions: - contents: write - -jobs: - repair: - runs-on: windows-2022 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - name: Apply source repair - shell: powershell - run: | - python .github\scripts\p0_smart_firmware_recovery.py - if ($LASTEXITCODE -ne 0) { throw "Source repair failed: $LASTEXITCODE" } - - name: Validate diff - shell: powershell - run: | - git diff --check - if ($LASTEXITCODE -ne 0) { throw "git diff --check failed" } - git diff -- apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp - - name: Commit repair - shell: powershell - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp - git commit -m 'fix(studio): auto-recover trusted legacy ESP32-P4 firmware' - if ($LASTEXITCODE -ne 0) { throw "Commit failed: $LASTEXITCODE" } - git push origin HEAD:feature/ptp-production-v0.1.1 - if ($LASTEXITCODE -ne 0) { throw "Push failed: $LASTEXITCODE" } From f05db9151bfbbfe08fd24ab482126147d2264a0b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:48:09 +0700 Subject: [PATCH 070/170] ci: build smart recovery Windows candidate --- .../workflows/p0-smart-recovery-candidate.yml | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 .github/workflows/p0-smart-recovery-candidate.yml diff --git a/.github/workflows/p0-smart-recovery-candidate.yml b/.github/workflows/p0-smart-recovery-candidate.yml new file mode 100644 index 000000000..00c30f505 --- /dev/null +++ b/.github/workflows/p0-smart-recovery-candidate.yml @@ -0,0 +1,156 @@ +name: P0 Smart Recovery Windows Candidate + +on: + push: + paths: + - '.github/workflows/p0-smart-recovery-candidate.yml' + +permissions: + contents: read + actions: read + +env: + ARSTACK_VERSION: '0.1.1' + QT_VERSION: '6.10.3' + ESPFLASH_VERSION: '4.5.0' + ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' + VERIFIED_FIRMWARE_ARTIFACT_ID: '10436323004' + +jobs: + windows-candidate: + runs-on: windows-2022 + timeout-minutes: 25 + steps: + - name: Checkout fixed Studio source + uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + + - name: Download previously verified unchanged v0.1.1 firmware artifact + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $zip = Join-Path $env:RUNNER_TEMP 'firmware.zip' + $headers = @{ + Authorization = "Bearer $env:GH_TOKEN" + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + Invoke-WebRequest -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/artifacts/$env:VERIFIED_FIRMWARE_ARTIFACT_ID/zip" -Headers $headers -OutFile $zip + New-Item -ItemType Directory -Force firmware-package | Out-Null + Expand-Archive -Path $zip -DestinationPath firmware-package -Force + $manifest = Get-Content 'firmware-package\firmware-manifest.json' -Raw | ConvertFrom-Json + if ($manifest.version -ne '0.1.1' -or $manifest.chip -ne 'esp32p4' -or $manifest.flashOffset -ne 0) { + throw 'Verified firmware artifact contract mismatch.' + } + $image = Join-Path 'firmware-package' $manifest.image + if (-not (Test-Path $image)) { throw 'Verified firmware image missing.' } + $actual = (Get-FileHash $image -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $manifest.sha256.ToLowerInvariant()) { throw 'Verified firmware SHA-256 mismatch.' } + Write-Host "Firmware reuse verified: $($manifest.image) SHA256=$actual" + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure and build fixed Studio plus deterministic session harness + 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" } + cmake --build build-arstack-studio --config Release --target arstack_studio arstack_session_harness --parallel + if ($LASTEXITCODE -ne 0) { throw "Studio build failed: $LASTEXITCODE" } + + - name: Prove smart legacy-board recovery policy + shell: powershell + run: | + $harness = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' + & $harness + if ($LASTEXITCODE -ne 0) { throw "Deterministic session harness failed: $LASTEXITCODE" } + + - name: Stage fixed Studio with verified firmware + shell: powershell + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio' + New-Item -ItemType Directory -Force $stage | Out-Null + $sourceExe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' + $stageExe = Join-Path $stage 'ARStackStudio.exe' + Copy-Item $sourceExe $stageExe + windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $stageExe + if ($LASTEXITCODE -ne 0) { throw "windeployqt failed: $LASTEXITCODE" } + Copy-Item LICENSE, NOTICE, THIRD_PARTY_NOTICES.md, COPYRIGHT.md -Destination $stage + New-Item -ItemType Directory -Force (Join-Path $stage 'firmware') | Out-Null + Copy-Item 'firmware-package\*' -Destination (Join-Path $stage 'firmware') -Recurse + + - name: Bundle pinned espflash + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $url = "https://github.com/esp-rs/espflash/releases/download/v$env:ESPFLASH_VERSION/espflash-x86_64-pc-windows-msvc.zip" + $zip = Join-Path $env:RUNNER_TEMP 'espflash.zip' + Invoke-WebRequest -Uri $url -OutFile $zip + $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:ESPFLASH_WINDOWS_ZIP_SHA256) { throw "espflash SHA-256 mismatch: $actual" } + $extract = Join-Path $env:RUNNER_TEMP 'espflash' + Expand-Archive -Path $zip -DestinationPath $extract -Force + $exe = Get-ChildItem $extract -Filter espflash.exe -Recurse | Select-Object -First 1 + if (-not $exe) { throw 'espflash.exe not found' } + $tools = Join-Path $PWD 'dist\ARStack-Studio\tools' + New-Item -ItemType Directory -Force $tools | Out-Null + Copy-Item $exe.FullName (Join-Path $tools 'espflash.exe') + + - name: Packaged smart-recovery contract and lifecycle checks + shell: powershell + run: | + function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { + $p = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "$Label failed with exit code $($p.ExitCode)" } + } + $stage = Join-Path $PWD 'dist\ARStack-Studio' + $studio = Join-Path $stage 'ARStackStudio.exe' + $espflash = Join-Path $stage 'tools\espflash.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = $espflash + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + Assert-ExitZero $studio @('--check-firmware-contract') 'Packaged firmware contract' + Assert-ExitZero $studio @('--check-p0-policy') 'Packaged P0 policy' + Assert-ExitZero $studio @('--check-app-lifecycle') 'Packaged lifecycle' + Assert-ExitZero $espflash @('--version') 'Bundled espflash' + + - name: Build portable ZIP and installer + shell: powershell + run: | + $portable = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-smart-recovery-win-x64-portable.zip" + Compress-Archive -Path 'dist\ARStack-Studio\*' -DestinationPath $portable -CompressionLevel Optimal -Force + choco install nsis -y --no-progress + if ($LASTEXITCODE -ne 0) { throw "NSIS install failed: $LASTEXITCODE" } + $stage = (Resolve-Path 'dist\ARStack-Studio').Path + $out = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-smart-recovery-win-x64-setup.exe" + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path + & makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$out" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $out)) { throw 'NSIS installer build failed' } + $files = @($portable, $out) + $lines = foreach ($file in $files) { + $hash = (Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $([IO.Path]::GetFileName($file))" + } + $lines | Set-Content 'dist\SMART-RECOVERY-SHA256SUMS.txt' -Encoding ascii + Get-Content 'dist\SMART-RECOVERY-SHA256SUMS.txt' + + - name: Upload fixed Windows candidate + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-smart-recovery-windows-x64 + path: | + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-smart-recovery-win-x64-portable.zip + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-smart-recovery-win-x64-setup.exe + dist/SMART-RECOVERY-SHA256SUMS.txt + if-no-files-found: error + retention-days: 7 From 3c9e852a3b444202e15661cccc52b292d34a8a2d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:50:21 +0700 Subject: [PATCH 071/170] ci: route smart recovery candidate to Windows 2025 --- .github/workflows/p0-smart-recovery-candidate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/p0-smart-recovery-candidate.yml b/.github/workflows/p0-smart-recovery-candidate.yml index 00c30f505..47f6492e2 100644 --- a/.github/workflows/p0-smart-recovery-candidate.yml +++ b/.github/workflows/p0-smart-recovery-candidate.yml @@ -18,7 +18,7 @@ env: jobs: windows-candidate: - runs-on: windows-2022 + runs-on: windows-2025 timeout-minutes: 25 steps: - name: Checkout fixed Studio source From d9334b77a6f1d761c18b31fbdeffc4ee4f8950b5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:53:15 +0700 Subject: [PATCH 072/170] ci: build smart recovery candidate with VS2026 --- .github/workflows/p0-smart-recovery-candidate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/p0-smart-recovery-candidate.yml b/.github/workflows/p0-smart-recovery-candidate.yml index 47f6492e2..b9234030b 100644 --- a/.github/workflows/p0-smart-recovery-candidate.yml +++ b/.github/workflows/p0-smart-recovery-candidate.yml @@ -62,7 +62,7 @@ jobs: - name: Configure and build fixed Studio plus deterministic session harness shell: powershell run: | - cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 ` + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 18 2026" -A x64 ` -DARIEC61850_WARNINGS_AS_ERRORS=ON if ($LASTEXITCODE -ne 0) { throw "Studio configure failed: $LASTEXITCODE" } cmake --build build-arstack-studio --config Release --target arstack_studio arstack_session_harness --parallel From cc5c47e8ed1edda088db922c2345e76f72ba278f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:05:32 +0700 Subject: [PATCH 073/170] ci: invoke NSIS from installed absolute path --- .github/workflows/p0-smart-recovery-candidate.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/p0-smart-recovery-candidate.yml b/.github/workflows/p0-smart-recovery-candidate.yml index b9234030b..d8105b30a 100644 --- a/.github/workflows/p0-smart-recovery-candidate.yml +++ b/.github/workflows/p0-smart-recovery-candidate.yml @@ -127,6 +127,7 @@ jobs: - name: Build portable ZIP and installer shell: powershell run: | + $ErrorActionPreference = 'Stop' $portable = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-smart-recovery-win-x64-portable.zip" Compress-Archive -Path 'dist\ARStack-Studio\*' -DestinationPath $portable -CompressionLevel Optimal -Force choco install nsis -y --no-progress @@ -134,7 +135,9 @@ jobs: $stage = (Resolve-Path 'dist\ARStack-Studio').Path $out = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-smart-recovery-win-x64-setup.exe" $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path - & makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$out" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script + $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' + if (-not (Test-Path $makensis)) { throw "makensis.exe not found at $makensis" } + & $makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$out" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script if ($LASTEXITCODE -ne 0 -or -not (Test-Path $out)) { throw 'NSIS installer build failed' } $files = @($portable, $out) $lines = foreach ($file in $files) { From fe4661b2daf5afd2b13f506fdeee3ee473649188 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:16:37 +0700 Subject: [PATCH 074/170] chore: remove focused smart recovery candidate workflow --- .../workflows/p0-smart-recovery-candidate.yml | 159 ------------------ 1 file changed, 159 deletions(-) delete mode 100644 .github/workflows/p0-smart-recovery-candidate.yml diff --git a/.github/workflows/p0-smart-recovery-candidate.yml b/.github/workflows/p0-smart-recovery-candidate.yml deleted file mode 100644 index d8105b30a..000000000 --- a/.github/workflows/p0-smart-recovery-candidate.yml +++ /dev/null @@ -1,159 +0,0 @@ -name: P0 Smart Recovery Windows Candidate - -on: - push: - paths: - - '.github/workflows/p0-smart-recovery-candidate.yml' - -permissions: - contents: read - actions: read - -env: - ARSTACK_VERSION: '0.1.1' - QT_VERSION: '6.10.3' - ESPFLASH_VERSION: '4.5.0' - ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' - VERIFIED_FIRMWARE_ARTIFACT_ID: '10436323004' - -jobs: - windows-candidate: - runs-on: windows-2025 - timeout-minutes: 25 - steps: - - name: Checkout fixed Studio source - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - - - name: Download previously verified unchanged v0.1.1 firmware artifact - shell: powershell - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference = 'Stop' - $zip = Join-Path $env:RUNNER_TEMP 'firmware.zip' - $headers = @{ - Authorization = "Bearer $env:GH_TOKEN" - Accept = 'application/vnd.github+json' - 'X-GitHub-Api-Version' = '2022-11-28' - } - Invoke-WebRequest -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/artifacts/$env:VERIFIED_FIRMWARE_ARTIFACT_ID/zip" -Headers $headers -OutFile $zip - New-Item -ItemType Directory -Force firmware-package | Out-Null - Expand-Archive -Path $zip -DestinationPath firmware-package -Force - $manifest = Get-Content 'firmware-package\firmware-manifest.json' -Raw | ConvertFrom-Json - if ($manifest.version -ne '0.1.1' -or $manifest.chip -ne 'esp32p4' -or $manifest.flashOffset -ne 0) { - throw 'Verified firmware artifact contract mismatch.' - } - $image = Join-Path 'firmware-package' $manifest.image - if (-not (Test-Path $image)) { throw 'Verified firmware image missing.' } - $actual = (Get-FileHash $image -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $manifest.sha256.ToLowerInvariant()) { throw 'Verified firmware SHA-256 mismatch.' } - Write-Host "Firmware reuse verified: $($manifest.image) SHA256=$actual" - - - name: Install Qt - uses: jurplel/install-qt-action@v4 - with: - version: ${{ env.QT_VERSION }} - arch: win64_msvc2022_64 - modules: 'qtserialport' - cache: true - - - name: Configure and build fixed Studio plus deterministic session harness - shell: powershell - run: | - cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 18 2026" -A x64 ` - -DARIEC61850_WARNINGS_AS_ERRORS=ON - if ($LASTEXITCODE -ne 0) { throw "Studio configure failed: $LASTEXITCODE" } - cmake --build build-arstack-studio --config Release --target arstack_studio arstack_session_harness --parallel - if ($LASTEXITCODE -ne 0) { throw "Studio build failed: $LASTEXITCODE" } - - - name: Prove smart legacy-board recovery policy - shell: powershell - run: | - $harness = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' - & $harness - if ($LASTEXITCODE -ne 0) { throw "Deterministic session harness failed: $LASTEXITCODE" } - - - name: Stage fixed Studio with verified firmware - shell: powershell - run: | - $stage = Join-Path $PWD 'dist\ARStack-Studio' - New-Item -ItemType Directory -Force $stage | Out-Null - $sourceExe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' - $stageExe = Join-Path $stage 'ARStackStudio.exe' - Copy-Item $sourceExe $stageExe - windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $stageExe - if ($LASTEXITCODE -ne 0) { throw "windeployqt failed: $LASTEXITCODE" } - Copy-Item LICENSE, NOTICE, THIRD_PARTY_NOTICES.md, COPYRIGHT.md -Destination $stage - New-Item -ItemType Directory -Force (Join-Path $stage 'firmware') | Out-Null - Copy-Item 'firmware-package\*' -Destination (Join-Path $stage 'firmware') -Recurse - - - name: Bundle pinned espflash - shell: powershell - run: | - $ErrorActionPreference = 'Stop' - $url = "https://github.com/esp-rs/espflash/releases/download/v$env:ESPFLASH_VERSION/espflash-x86_64-pc-windows-msvc.zip" - $zip = Join-Path $env:RUNNER_TEMP 'espflash.zip' - Invoke-WebRequest -Uri $url -OutFile $zip - $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $env:ESPFLASH_WINDOWS_ZIP_SHA256) { throw "espflash SHA-256 mismatch: $actual" } - $extract = Join-Path $env:RUNNER_TEMP 'espflash' - Expand-Archive -Path $zip -DestinationPath $extract -Force - $exe = Get-ChildItem $extract -Filter espflash.exe -Recurse | Select-Object -First 1 - if (-not $exe) { throw 'espflash.exe not found' } - $tools = Join-Path $PWD 'dist\ARStack-Studio\tools' - New-Item -ItemType Directory -Force $tools | Out-Null - Copy-Item $exe.FullName (Join-Path $tools 'espflash.exe') - - - name: Packaged smart-recovery contract and lifecycle checks - shell: powershell - run: | - function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { - $p = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "$Label failed with exit code $($p.ExitCode)" } - } - $stage = Join-Path $PWD 'dist\ARStack-Studio' - $studio = Join-Path $stage 'ARStackStudio.exe' - $espflash = Join-Path $stage 'tools\espflash.exe' - $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' - $env:ARSTACK_STUDIO_ESPFLASH = $espflash - $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' - Assert-ExitZero $studio @('--check-firmware-contract') 'Packaged firmware contract' - Assert-ExitZero $studio @('--check-p0-policy') 'Packaged P0 policy' - Assert-ExitZero $studio @('--check-app-lifecycle') 'Packaged lifecycle' - Assert-ExitZero $espflash @('--version') 'Bundled espflash' - - - name: Build portable ZIP and installer - shell: powershell - run: | - $ErrorActionPreference = 'Stop' - $portable = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-smart-recovery-win-x64-portable.zip" - Compress-Archive -Path 'dist\ARStack-Studio\*' -DestinationPath $portable -CompressionLevel Optimal -Force - choco install nsis -y --no-progress - if ($LASTEXITCODE -ne 0) { throw "NSIS install failed: $LASTEXITCODE" } - $stage = (Resolve-Path 'dist\ARStack-Studio').Path - $out = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-smart-recovery-win-x64-setup.exe" - $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path - $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' - if (-not (Test-Path $makensis)) { throw "makensis.exe not found at $makensis" } - & $makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$out" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script - if ($LASTEXITCODE -ne 0 -or -not (Test-Path $out)) { throw 'NSIS installer build failed' } - $files = @($portable, $out) - $lines = foreach ($file in $files) { - $hash = (Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash $([IO.Path]::GetFileName($file))" - } - $lines | Set-Content 'dist\SMART-RECOVERY-SHA256SUMS.txt' -Encoding ascii - Get-Content 'dist\SMART-RECOVERY-SHA256SUMS.txt' - - - name: Upload fixed Windows candidate - uses: actions/upload-artifact@v4 - with: - name: arstack-studio-smart-recovery-windows-x64 - path: | - dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-smart-recovery-win-x64-portable.zip - dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-smart-recovery-win-x64-setup.exe - dist/SMART-RECOVERY-SHA256SUMS.txt - if-no-files-found: error - retention-days: 7 From 4cbde2267bf6b4156b916f9c767ce5d186131571 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:32:30 +0700 Subject: [PATCH 075/170] ci: apply physical P0 runtime repair --- .../workflows/p0-runtime-physical-repair.yml | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 .github/workflows/p0-runtime-physical-repair.yml diff --git a/.github/workflows/p0-runtime-physical-repair.yml b/.github/workflows/p0-runtime-physical-repair.yml new file mode 100644 index 000000000..10c08b69e --- /dev/null +++ b/.github/workflows/p0-runtime-physical-repair.yml @@ -0,0 +1,314 @@ +name: P0 Runtime Physical Repair + +on: + push: + paths: + - '.github/workflows/p0-runtime-physical-repair.yml' + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Apply runtime shutdown and smart recovery repair + shell: python + run: | + from pathlib import Path + + def replace(path, old, new, count=1): + p = Path(path) + text = p.read_text(encoding='utf-8') + actual = text.count(old) + if actual != count: + raise SystemExit(f'{path}: expected {count} occurrence(s), found {actual}: {old[:120]!r}') + p.write_text(text.replace(old, new, count), encoding='utf-8') + + # P0-A: primary close must force the application event loop to exit. + # The prior lifecycle regression masked the bug by calling exit() itself + # after window.close(), so remove that test-side escape hatch. + replace( + 'apps/arstack_studio/src/main.cpp', + ''' if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { + closeObserved_ = true; + app_->quit(); + } +''', + ''' if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { + closeObserved_ = true; + // 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); + } +''') + + replace( + 'apps/arstack_studio/src/main.cpp', + ''' if (!primaryCloseFilter.closeObserved()) { + qCritical().noquote() << "Application lifecycle regression: normal primary-window close bypassed the lifetime filter."; + QCoreApplication::exit(10); + return; + } + + QCoreApplication::exit(0); +''', + ''' if (!primaryCloseFilter.closeObserved()) { + qCritical().noquote() << "Application lifecycle regression: normal primary-window close bypassed the lifetime filter."; + QCoreApplication::exit(10); + return; + } + // Do not call exit() here. The regression must prove that the real + // primary-window close path itself terminates the process. +''') + + replace( + 'apps/arstack_studio/qml/Main.qml', + ''' configurationWindow.hide() + detachedPhasorWindow.hide() + detachedWaveformWindow.hide() + Qt.quit() +''', + ''' configurationWindow.hide() + detachedPhasorWindow.hide() + detachedWaveformWindow.hide() + // C++ owns process termination and bounded worker retirement. Do not + // race it with a second QML quit path. +''') + + # P0-B: remember the exact port that actually timed out. A single + # visible serial port is a valid recovery candidate even when Windows + # supplies weak/no USB metadata; firmware write remains protected by + # espflash ROM chip + revision verification before write-bin. + replace( + 'apps/arstack_studio/src/DeviceController.hpp', + ''' [[nodiscard]] QString recommendedPort() const; + [[nodiscard]] QString discoveryStatus() const; +''', + ''' [[nodiscard]] QString recommendedPort() const; + [[nodiscard]] QString recoveryCandidatePort() const; + [[nodiscard]] QString discoveryStatus() const; +''') + + replace( + 'apps/arstack_studio/src/DeviceController.hpp', + ''' QString recommendedPort_; + QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; +''', + ''' QString recommendedPort_; + QString recoveryCandidatePort_; + QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; +''') + + replace( + 'apps/arstack_studio/src/DeviceController.cpp', + '''QStringList DeviceController::ports() const { return ports_; } +QString DeviceController::recommendedPort() const { return recommendedPort_; } +QString DeviceController::discoveryStatus() const { return discoveryStatus_; } +''', + '''QStringList DeviceController::ports() const { return ports_; } +QString DeviceController::recommendedPort() const { return recommendedPort_; } +QString DeviceController::recoveryCandidatePort() const { return recoveryCandidatePort_; } +QString DeviceController::discoveryStatus() const { return discoveryStatus_; } +''') + + replace( + 'apps/arstack_studio/src/DeviceController.cpp', + ''' lastIdentificationPort_ = port; + identifyAttempts_ = attempts; + if (continuing) { +''', + ''' 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) { +''') + + replace( + 'apps/arstack_studio/src/DeviceController.cpp', + ''' ports_ = ports; + recommendedPort_ = recommendedPort; + highConfidenceCount_ = highConfidenceCount; + + if (identificationState_ == IdentificationState::Unidentified && +''', + ''' ports_ = ports; + recommendedPort_ = recommendedPort; + highConfidenceCount_ = highConfidenceCount; + if (!recoveryCandidatePort_.isEmpty() && !ports_.contains(recoveryCandidatePort_)) { + recoveryCandidatePort_.clear(); + } + + if (identificationState_ == IdentificationState::Unidentified && +''') + + replace( + 'apps/arstack_studio/src/DeviceController.cpp', + ''' portName_ = portName; + lastIdentificationPort_ = portName; + identifyAttempts_ = 0; +''', + ''' portName_ = portName; + lastIdentificationPort_ = portName; + recoveryCandidatePort_.clear(); + identifyAttempts_ = 0; +''') + + replace( + 'apps/arstack_studio/src/DeviceController.cpp', + '''void DeviceController::markDeviceVerified() { + if (deviceVerified_) return; + deviceVerified_ = true; + lastIdentificationPort_ = portName_; +''', + '''void DeviceController::markDeviceVerified() { + if (deviceVerified_) return; + deviceVerified_ = true; + recoveryCandidatePort_.clear(); + lastIdentificationPort_ = portName_; +''') + + # The helper must never select a stale recommendation. If metadata is + # weak but only one port is visible, that port is a recovery *offer* + # candidate; ROM verification still gates the actual flash. + replace( + 'apps/arstack_studio/src/SmartSessionController.cpp', + ''' const QString recommended = recommendedPort.trimmed(); + if (!recommended.isEmpty()) return recommended; + if (visiblePorts.size() != 1) return {}; + return visiblePorts.front().trimmed(); +''', + ''' const QString recommended = recommendedPort.trimmed(); + if (!recommended.isEmpty() && visiblePorts.contains(recommended, Qt::CaseInsensitive)) { + return recommended; + } + if (visiblePorts.size() != 1) return {}; + return visiblePorts.front().trimmed(); +''') + + replace( + 'apps/arstack_studio/src/SmartSessionController.cpp', + ''' const QString recommended = device_->recommendedPort().trimmed(); + if (recommended.isEmpty() || !device_->ports().contains(recommended)) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + blankBoardPort_ = recommended; + blankBoardDetected_ = true; +''', + ''' 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_ = recoveryPort; + blankBoardDetected_ = true; +''') + + replace( + 'apps/arstack_studio/src/SmartSessionController.cpp', + ''' 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.") + .arg(device_->identifyAttempts()), +''', + ''' 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()), +''') + + # Regression: prove the exact physical symptom -- one visible COM port, + # no metadata recommendation, semantic timeout -- becomes firmware + # required instead of UNIDENTIFIED. + replace( + 'apps/arstack_studio/src/DeterministicSessionHarness.cpp', + ''' {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, + {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, +''', + ''' {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, + {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, + {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, +''') + + marker = ''' static bool ambiguousIdentityTimeoutStaysUnidentified() { +''' + insertion = ''' 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(); + } + +''' + p = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') + text = p.read_text(encoding='utf-8') + if text.count(marker) != 1: + raise SystemExit('DeterministicSessionHarness.cpp: ambiguous case marker mismatch') + p.write_text(text.replace(marker, insertion + marker, 1), encoding='utf-8') + + - name: Sanity-check repaired source + shell: bash + run: | + set -euo pipefail + grep -q 'QCoreApplication::exit(0);' apps/arstack_studio/src/main.cpp + ! grep -q 'app_->quit();' apps/arstack_studio/src/main.cpp + grep -q 'recoveryCandidatePort' apps/arstack_studio/src/DeviceController.hpp + grep -q 'single visible COM without metadata' apps/arstack_studio/src/DeterministicSessionHarness.cpp + grep -q 'Disconnect unrelated serial devices' apps/arstack_studio/src/SmartSessionController.cpp + git diff --check + git diff -- apps/arstack_studio/src/main.cpp apps/arstack_studio/qml/Main.qml apps/arstack_studio/src/DeviceController.cpp apps/arstack_studio/src/DeviceController.hpp apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp + + - name: Commit repaired runtime source + shell: bash + run: | + set -euo pipefail + git config user.name 'Ari Sulistiono' + git config user.email 'ari.sulistiono@gmail.com' + git add apps/arstack_studio/src/main.cpp apps/arstack_studio/qml/Main.qml \ + apps/arstack_studio/src/DeviceController.cpp apps/arstack_studio/src/DeviceController.hpp \ + apps/arstack_studio/src/SmartSessionController.cpp \ + apps/arstack_studio/src/DeterministicSessionHarness.cpp + git commit -m 'fix(p0): hard-exit GUI close and infer single-board firmware recovery' + git push origin HEAD:feature/ptp-production-v0.1.1 From f29bcb0570b4a9bd31c15a8215d3574d1050fc8b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:33:23 +0700 Subject: [PATCH 076/170] ci: stage P0 runtime repair script --- .github/scripts/p0_runtime_physical_repair.py | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 .github/scripts/p0_runtime_physical_repair.py diff --git a/.github/scripts/p0_runtime_physical_repair.py b/.github/scripts/p0_runtime_physical_repair.py new file mode 100644 index 000000000..88a4e4d61 --- /dev/null +++ b/.github/scripts/p0_runtime_physical_repair.py @@ -0,0 +1,276 @@ +from pathlib import Path + + +def replace(path, old, new, count=1): + p = Path(path) + text = p.read_text(encoding="utf-8") + actual = text.count(old) + if actual != count: + raise SystemExit(f"{path}: expected {count} occurrence(s), found {actual}: {old[:120]!r}") + p.write_text(text.replace(old, new, count), encoding="utf-8") + + +# P0-A: primary close must force the application event loop to exit. +# The prior lifecycle regression masked the bug by calling exit() itself +# after window.close(), so remove that test-side escape hatch. +replace( + "apps/arstack_studio/src/main.cpp", + """ if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { + closeObserved_ = true; + app_->quit(); + } +""", + """ if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { + closeObserved_ = true; + // 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); + } +""", +) + +replace( + "apps/arstack_studio/src/main.cpp", + """ if (!primaryCloseFilter.closeObserved()) { + qCritical().noquote() << "Application lifecycle regression: normal primary-window close bypassed the lifetime filter."; + QCoreApplication::exit(10); + return; + } + + QCoreApplication::exit(0); +""", + """ if (!primaryCloseFilter.closeObserved()) { + qCritical().noquote() << "Application lifecycle regression: normal primary-window close bypassed the lifetime filter."; + QCoreApplication::exit(10); + return; + } + // Do not call exit() here. The regression must prove that the real + // primary-window close path itself terminates the process. +""", +) + +replace( + "apps/arstack_studio/qml/Main.qml", + """ configurationWindow.hide() + detachedPhasorWindow.hide() + detachedWaveformWindow.hide() + Qt.quit() +""", + """ configurationWindow.hide() + detachedPhasorWindow.hide() + detachedWaveformWindow.hide() + // C++ owns process termination and bounded worker retirement. Do not + // race it with a second QML quit path. +""", +) + +# P0-B: remember the exact port that actually timed out. A single visible +# serial port is a valid recovery candidate even when Windows supplies weak/no +# USB metadata. Firmware write remains protected by espflash ROM verification. +replace( + "apps/arstack_studio/src/DeviceController.hpp", + """ [[nodiscard]] QString recommendedPort() const; + [[nodiscard]] QString discoveryStatus() const; +""", + """ [[nodiscard]] QString recommendedPort() const; + [[nodiscard]] QString recoveryCandidatePort() const; + [[nodiscard]] QString discoveryStatus() const; +""", +) + +replace( + "apps/arstack_studio/src/DeviceController.hpp", + """ QString recommendedPort_; + QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; +""", + """ QString recommendedPort_; + QString recoveryCandidatePort_; + QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; +""", +) + +replace( + "apps/arstack_studio/src/DeviceController.cpp", + """QStringList DeviceController::ports() const { return ports_; } +QString DeviceController::recommendedPort() const { return recommendedPort_; } +QString DeviceController::discoveryStatus() const { return discoveryStatus_; } +""", + """QStringList DeviceController::ports() const { return ports_; } +QString DeviceController::recommendedPort() const { return recommendedPort_; } +QString DeviceController::recoveryCandidatePort() const { return recoveryCandidatePort_; } +QString DeviceController::discoveryStatus() const { return discoveryStatus_; } +""", +) + +replace( + "apps/arstack_studio/src/DeviceController.cpp", + """ lastIdentificationPort_ = port; + identifyAttempts_ = attempts; + if (continuing) { +""", + """ 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) { +""", +) + +replace( + "apps/arstack_studio/src/DeviceController.cpp", + """ ports_ = ports; + recommendedPort_ = recommendedPort; + highConfidenceCount_ = highConfidenceCount; + + if (identificationState_ == IdentificationState::Unidentified && +""", + """ ports_ = ports; + recommendedPort_ = recommendedPort; + highConfidenceCount_ = highConfidenceCount; + if (!recoveryCandidatePort_.isEmpty() && !ports_.contains(recoveryCandidatePort_)) { + recoveryCandidatePort_.clear(); + } + + if (identificationState_ == IdentificationState::Unidentified && +""", +) + +replace( + "apps/arstack_studio/src/DeviceController.cpp", + """ portName_ = portName; + lastIdentificationPort_ = portName; + identifyAttempts_ = 0; +""", + """ portName_ = portName; + lastIdentificationPort_ = portName; + recoveryCandidatePort_.clear(); + identifyAttempts_ = 0; +""", +) + +replace( + "apps/arstack_studio/src/DeviceController.cpp", + """void DeviceController::markDeviceVerified() { + if (deviceVerified_) return; + deviceVerified_ = true; + lastIdentificationPort_ = portName_; +""", + """void DeviceController::markDeviceVerified() { + if (deviceVerified_) return; + deviceVerified_ = true; + recoveryCandidatePort_.clear(); + lastIdentificationPort_ = portName_; +""", +) + +replace( + "apps/arstack_studio/src/SmartSessionController.cpp", + """ const QString recommended = recommendedPort.trimmed(); + if (!recommended.isEmpty()) return recommended; + if (visiblePorts.size() != 1) return {}; + return visiblePorts.front().trimmed(); +""", + """ const QString recommended = recommendedPort.trimmed(); + if (!recommended.isEmpty() && visiblePorts.contains(recommended, Qt::CaseInsensitive)) { + return recommended; + } + if (visiblePorts.size() != 1) return {}; + return visiblePorts.front().trimmed(); +""", +) + +replace( + "apps/arstack_studio/src/SmartSessionController.cpp", + """ const QString recommended = device_->recommendedPort().trimmed(); + if (recommended.isEmpty() || !device_->ports().contains(recommended)) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + blankBoardPort_ = recommended; + blankBoardDetected_ = true; +""", + """ 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_ = recoveryPort; + blankBoardDetected_ = true; +""", +) + +replace( + "apps/arstack_studio/src/SmartSessionController.cpp", + """ 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.") + .arg(device_->identifyAttempts()), +""", + """ 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()), +""", +) + +replace( + "apps/arstack_studio/src/DeterministicSessionHarness.cpp", + """ {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, + {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, +""", + """ {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, + {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, + {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, +""", +) + +marker = """ static bool ambiguousIdentityTimeoutStaysUnidentified() { +""" +insertion = """ 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(); + } + +""" +p = Path("apps/arstack_studio/src/DeterministicSessionHarness.cpp") +text = p.read_text(encoding="utf-8") +if text.count(marker) != 1: + raise SystemExit("DeterministicSessionHarness.cpp: ambiguous case marker mismatch") +p.write_text(text.replace(marker, insertion + marker, 1), encoding="utf-8") From efc542658c27adeb750ccfd452856b1b4f0a224c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:33:36 +0700 Subject: [PATCH 077/170] ci: simplify P0 runtime repair runner --- .../workflows/p0-runtime-physical-repair.yml | 280 +----------------- 1 file changed, 6 insertions(+), 274 deletions(-) diff --git a/.github/workflows/p0-runtime-physical-repair.yml b/.github/workflows/p0-runtime-physical-repair.yml index 10c08b69e..a7398a275 100644 --- a/.github/workflows/p0-runtime-physical-repair.yml +++ b/.github/workflows/p0-runtime-physical-repair.yml @@ -12,293 +12,25 @@ jobs: repair: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout repair branch + uses: actions/checkout@v4 with: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - - name: Apply runtime shutdown and smart recovery repair - shell: python - run: | - from pathlib import Path - - def replace(path, old, new, count=1): - p = Path(path) - text = p.read_text(encoding='utf-8') - actual = text.count(old) - if actual != count: - raise SystemExit(f'{path}: expected {count} occurrence(s), found {actual}: {old[:120]!r}') - p.write_text(text.replace(old, new, count), encoding='utf-8') - - # P0-A: primary close must force the application event loop to exit. - # The prior lifecycle regression masked the bug by calling exit() itself - # after window.close(), so remove that test-side escape hatch. - replace( - 'apps/arstack_studio/src/main.cpp', - ''' if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { - closeObserved_ = true; - app_->quit(); - } -''', - ''' if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { - closeObserved_ = true; - // 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); - } -''') - - replace( - 'apps/arstack_studio/src/main.cpp', - ''' if (!primaryCloseFilter.closeObserved()) { - qCritical().noquote() << "Application lifecycle regression: normal primary-window close bypassed the lifetime filter."; - QCoreApplication::exit(10); - return; - } - - QCoreApplication::exit(0); -''', - ''' if (!primaryCloseFilter.closeObserved()) { - qCritical().noquote() << "Application lifecycle regression: normal primary-window close bypassed the lifetime filter."; - QCoreApplication::exit(10); - return; - } - // Do not call exit() here. The regression must prove that the real - // primary-window close path itself terminates the process. -''') - - replace( - 'apps/arstack_studio/qml/Main.qml', - ''' configurationWindow.hide() - detachedPhasorWindow.hide() - detachedWaveformWindow.hide() - Qt.quit() -''', - ''' configurationWindow.hide() - detachedPhasorWindow.hide() - detachedWaveformWindow.hide() - // C++ owns process termination and bounded worker retirement. Do not - // race it with a second QML quit path. -''') - - # P0-B: remember the exact port that actually timed out. A single - # visible serial port is a valid recovery candidate even when Windows - # supplies weak/no USB metadata; firmware write remains protected by - # espflash ROM chip + revision verification before write-bin. - replace( - 'apps/arstack_studio/src/DeviceController.hpp', - ''' [[nodiscard]] QString recommendedPort() const; - [[nodiscard]] QString discoveryStatus() const; -''', - ''' [[nodiscard]] QString recommendedPort() const; - [[nodiscard]] QString recoveryCandidatePort() const; - [[nodiscard]] QString discoveryStatus() const; -''') - - replace( - 'apps/arstack_studio/src/DeviceController.hpp', - ''' QString recommendedPort_; - QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; -''', - ''' QString recommendedPort_; - QString recoveryCandidatePort_; - QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; -''') - - replace( - 'apps/arstack_studio/src/DeviceController.cpp', - '''QStringList DeviceController::ports() const { return ports_; } -QString DeviceController::recommendedPort() const { return recommendedPort_; } -QString DeviceController::discoveryStatus() const { return discoveryStatus_; } -''', - '''QStringList DeviceController::ports() const { return ports_; } -QString DeviceController::recommendedPort() const { return recommendedPort_; } -QString DeviceController::recoveryCandidatePort() const { return recoveryCandidatePort_; } -QString DeviceController::discoveryStatus() const { return discoveryStatus_; } -''') - - replace( - 'apps/arstack_studio/src/DeviceController.cpp', - ''' lastIdentificationPort_ = port; - identifyAttempts_ = attempts; - if (continuing) { -''', - ''' 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) { -''') - - replace( - 'apps/arstack_studio/src/DeviceController.cpp', - ''' ports_ = ports; - recommendedPort_ = recommendedPort; - highConfidenceCount_ = highConfidenceCount; - - if (identificationState_ == IdentificationState::Unidentified && -''', - ''' ports_ = ports; - recommendedPort_ = recommendedPort; - highConfidenceCount_ = highConfidenceCount; - if (!recoveryCandidatePort_.isEmpty() && !ports_.contains(recoveryCandidatePort_)) { - recoveryCandidatePort_.clear(); - } - - if (identificationState_ == IdentificationState::Unidentified && -''') - - replace( - 'apps/arstack_studio/src/DeviceController.cpp', - ''' portName_ = portName; - lastIdentificationPort_ = portName; - identifyAttempts_ = 0; -''', - ''' portName_ = portName; - lastIdentificationPort_ = portName; - recoveryCandidatePort_.clear(); - identifyAttempts_ = 0; -''') - - replace( - 'apps/arstack_studio/src/DeviceController.cpp', - '''void DeviceController::markDeviceVerified() { - if (deviceVerified_) return; - deviceVerified_ = true; - lastIdentificationPort_ = portName_; -''', - '''void DeviceController::markDeviceVerified() { - if (deviceVerified_) return; - deviceVerified_ = true; - recoveryCandidatePort_.clear(); - lastIdentificationPort_ = portName_; -''') - - # The helper must never select a stale recommendation. If metadata is - # weak but only one port is visible, that port is a recovery *offer* - # candidate; ROM verification still gates the actual flash. - replace( - 'apps/arstack_studio/src/SmartSessionController.cpp', - ''' const QString recommended = recommendedPort.trimmed(); - if (!recommended.isEmpty()) return recommended; - if (visiblePorts.size() != 1) return {}; - return visiblePorts.front().trimmed(); -''', - ''' const QString recommended = recommendedPort.trimmed(); - if (!recommended.isEmpty() && visiblePorts.contains(recommended, Qt::CaseInsensitive)) { - return recommended; - } - if (visiblePorts.size() != 1) return {}; - return visiblePorts.front().trimmed(); -''') - - replace( - 'apps/arstack_studio/src/SmartSessionController.cpp', - ''' const QString recommended = device_->recommendedPort().trimmed(); - if (recommended.isEmpty() || !device_->ports().contains(recommended)) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - blankBoardPort_ = recommended; - blankBoardDetected_ = true; -''', - ''' 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_ = recoveryPort; - blankBoardDetected_ = true; -''') - - replace( - 'apps/arstack_studio/src/SmartSessionController.cpp', - ''' 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.") - .arg(device_->identifyAttempts()), -''', - ''' 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()), -''') - - # Regression: prove the exact physical symptom -- one visible COM port, - # no metadata recommendation, semantic timeout -- becomes firmware - # required instead of UNIDENTIFIED. - replace( - 'apps/arstack_studio/src/DeterministicSessionHarness.cpp', - ''' {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, - {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, -''', - ''' {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, - {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, - {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, -''') - - marker = ''' static bool ambiguousIdentityTimeoutStaysUnidentified() { -''' - insertion = ''' 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(); - } - -''' - p = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') - text = p.read_text(encoding='utf-8') - if text.count(marker) != 1: - raise SystemExit('DeterministicSessionHarness.cpp: ambiguous case marker mismatch') - p.write_text(text.replace(marker, insertion + marker, 1), encoding='utf-8') + - name: Apply P0 runtime repair + run: python .github/scripts/p0_runtime_physical_repair.py - - name: Sanity-check repaired source + - name: Validate repaired source shell: bash run: | set -euo pipefail grep -q 'QCoreApplication::exit(0);' apps/arstack_studio/src/main.cpp - ! grep -q 'app_->quit();' apps/arstack_studio/src/main.cpp + if grep -q 'app_->quit();' apps/arstack_studio/src/main.cpp; then exit 1; fi grep -q 'recoveryCandidatePort' apps/arstack_studio/src/DeviceController.hpp grep -q 'single visible COM without metadata' apps/arstack_studio/src/DeterministicSessionHarness.cpp grep -q 'Disconnect unrelated serial devices' apps/arstack_studio/src/SmartSessionController.cpp git diff --check - git diff -- apps/arstack_studio/src/main.cpp apps/arstack_studio/qml/Main.qml apps/arstack_studio/src/DeviceController.cpp apps/arstack_studio/src/DeviceController.hpp apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp - name: Commit repaired runtime source shell: bash From 2e6e5db53b639c0a43e8433bd77e297bf586ba0c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:35:06 +0700 Subject: [PATCH 078/170] ci: move P0 repair to Ubuntu 22 runner --- .github/workflows/p0-runtime-physical-repair.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/p0-runtime-physical-repair.yml b/.github/workflows/p0-runtime-physical-repair.yml index a7398a275..c5999ea9a 100644 --- a/.github/workflows/p0-runtime-physical-repair.yml +++ b/.github/workflows/p0-runtime-physical-repair.yml @@ -10,7 +10,7 @@ permissions: jobs: repair: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout repair branch uses: actions/checkout@v4 @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - name: Apply P0 runtime repair - run: python .github/scripts/p0_runtime_physical_repair.py + run: python3 .github/scripts/p0_runtime_physical_repair.py - name: Validate repaired source shell: bash From 15fe7feaea89b7999bcde1f8275195ca26ba996e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 09:36:36 +0000 Subject: [PATCH 079/170] fix(p0): hard-exit GUI close and infer single-board firmware recovery --- apps/arstack_studio/qml/Main.qml | 3 +- .../src/DeterministicSessionHarness.cpp | 32 +++++++++++++++++++ apps/arstack_studio/src/DeviceController.cpp | 14 ++++++++ apps/arstack_studio/src/DeviceController.hpp | 2 ++ .../src/SmartSessionController.cpp | 15 ++++++--- apps/arstack_studio/src/main.cpp | 10 ++++-- 6 files changed, 67 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 7ae7bd9e3..44c59f4fa 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -57,7 +57,8 @@ ApplicationWindow { configurationWindow.hide() detachedPhasorWindow.hide() detachedWaveformWindow.hide() - Qt.quit() + // C++ owns process termination and bounded worker retirement. Do not + // race it with a second QML quit path. } FontLoader { diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index ae800d635..ee44086a7 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -29,6 +29,7 @@ class DeterministicSessionHarness final { {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, {"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()}, @@ -275,6 +276,37 @@ class DeterministicSessionHarness final { !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; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 5acd70ae9..2b1f16c73 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -244,6 +244,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); @@ -304,6 +312,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_; } @@ -469,6 +478,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_)) { @@ -501,6 +513,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto connected_ = true; portName_ = portName; lastIdentificationPort_ = portName; + recoveryCandidatePort_.clear(); identifyAttempts_ = 0; deviceVerified_ = false; clearIdentity(); @@ -812,6 +825,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); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 7854f4507..e85be5332 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -110,6 +110,7 @@ class DeviceController : public QObject { [[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; @@ -312,6 +313,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_; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 33d38c14a..ec821cc9f 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(); } @@ -774,14 +776,17 @@ void SmartSessionController::refreshRecoveryOfferFromIdentity() { 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; } @@ -1023,7 +1028,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); diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 885de2160..ec95d3eb4 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -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); } @@ -521,8 +525,8 @@ int main(int argc, char* argv[]) { 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."; From 63561ea63f1c69c8999d7ffda77d985657a7027d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:37:49 +0700 Subject: [PATCH 080/170] ci: run P0 runtime repair on Windows 2025 --- .../workflows/p0-runtime-physical-repair.yml | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/workflows/p0-runtime-physical-repair.yml b/.github/workflows/p0-runtime-physical-repair.yml index c5999ea9a..7a500090a 100644 --- a/.github/workflows/p0-runtime-physical-repair.yml +++ b/.github/workflows/p0-runtime-physical-repair.yml @@ -10,7 +10,8 @@ permissions: jobs: repair: - runs-on: ubuntu-22.04 + runs-on: windows-2025 + timeout-minutes: 10 steps: - name: Checkout repair branch uses: actions/checkout@v4 @@ -19,28 +20,20 @@ jobs: fetch-depth: 0 - name: Apply P0 runtime repair - run: python3 .github/scripts/p0_runtime_physical_repair.py - - - name: Validate repaired source - shell: bash + shell: powershell run: | - set -euo pipefail - grep -q 'QCoreApplication::exit(0);' apps/arstack_studio/src/main.cpp - if grep -q 'app_->quit();' apps/arstack_studio/src/main.cpp; then exit 1; fi - grep -q 'recoveryCandidatePort' apps/arstack_studio/src/DeviceController.hpp - grep -q 'single visible COM without metadata' apps/arstack_studio/src/DeterministicSessionHarness.cpp - grep -q 'Disconnect unrelated serial devices' apps/arstack_studio/src/SmartSessionController.cpp + python .github/scripts/p0_runtime_physical_repair.py + if ($LASTEXITCODE -ne 0) { throw "Repair script failed: $LASTEXITCODE" } git diff --check + if ($LASTEXITCODE -ne 0) { throw "git diff check failed: $LASTEXITCODE" } - name: Commit repaired runtime source - shell: bash + shell: powershell run: | - set -euo pipefail git config user.name 'Ari Sulistiono' git config user.email 'ari.sulistiono@gmail.com' - git add apps/arstack_studio/src/main.cpp apps/arstack_studio/qml/Main.qml \ - apps/arstack_studio/src/DeviceController.cpp apps/arstack_studio/src/DeviceController.hpp \ - apps/arstack_studio/src/SmartSessionController.cpp \ - apps/arstack_studio/src/DeterministicSessionHarness.cpp + git add apps/arstack_studio/src/main.cpp apps/arstack_studio/qml/Main.qml apps/arstack_studio/src/DeviceController.cpp apps/arstack_studio/src/DeviceController.hpp apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp git commit -m 'fix(p0): hard-exit GUI close and infer single-board firmware recovery' + if ($LASTEXITCODE -ne 0) { throw "Commit failed: $LASTEXITCODE" } git push origin HEAD:feature/ptp-production-v0.1.1 + if ($LASTEXITCODE -ne 0) { throw "Push failed: $LASTEXITCODE" } From 0c75e46151d5966a7dabfebe38ec7553fce0a987 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:38:57 +0700 Subject: [PATCH 081/170] ci: run P0 repair on macOS runner --- .github/workflows/p0-runtime-physical-repair.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/p0-runtime-physical-repair.yml b/.github/workflows/p0-runtime-physical-repair.yml index 7a500090a..78603ba8e 100644 --- a/.github/workflows/p0-runtime-physical-repair.yml +++ b/.github/workflows/p0-runtime-physical-repair.yml @@ -8,9 +8,13 @@ on: permissions: contents: write +concurrency: + group: p0-runtime-physical-repair + cancel-in-progress: true + jobs: repair: - runs-on: windows-2025 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Checkout repair branch @@ -20,20 +24,15 @@ jobs: fetch-depth: 0 - name: Apply P0 runtime repair - shell: powershell run: | - python .github/scripts/p0_runtime_physical_repair.py - if ($LASTEXITCODE -ne 0) { throw "Repair script failed: $LASTEXITCODE" } + python3 .github/scripts/p0_runtime_physical_repair.py git diff --check - if ($LASTEXITCODE -ne 0) { throw "git diff check failed: $LASTEXITCODE" } - name: Commit repaired runtime source - shell: powershell run: | + set -euo pipefail git config user.name 'Ari Sulistiono' git config user.email 'ari.sulistiono@gmail.com' git add apps/arstack_studio/src/main.cpp apps/arstack_studio/qml/Main.qml apps/arstack_studio/src/DeviceController.cpp apps/arstack_studio/src/DeviceController.hpp apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp git commit -m 'fix(p0): hard-exit GUI close and infer single-board firmware recovery' - if ($LASTEXITCODE -ne 0) { throw "Commit failed: $LASTEXITCODE" } git push origin HEAD:feature/ptp-production-v0.1.1 - if ($LASTEXITCODE -ne 0) { throw "Push failed: $LASTEXITCODE" } From 7d270ac98a9275e774aae765276787df40f48b4e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:42:29 +0700 Subject: [PATCH 082/170] ci: build physical P0 Windows candidate --- .../workflows/p0-runtime-physical-repair.yml | 213 ++++++++++++++++-- 1 file changed, 195 insertions(+), 18 deletions(-) diff --git a/.github/workflows/p0-runtime-physical-repair.yml b/.github/workflows/p0-runtime-physical-repair.yml index 78603ba8e..da38192e2 100644 --- a/.github/workflows/p0-runtime-physical-repair.yml +++ b/.github/workflows/p0-runtime-physical-repair.yml @@ -1,4 +1,4 @@ -name: P0 Runtime Physical Repair +name: P0 Runtime Physical Candidate on: push: @@ -6,33 +6,210 @@ on: - '.github/workflows/p0-runtime-physical-repair.yml' permissions: - contents: write + contents: read + actions: read concurrency: - group: p0-runtime-physical-repair + group: p0-runtime-physical-candidate cancel-in-progress: true +env: + ARSTACK_VERSION: '0.1.1' + QT_VERSION: '6.10.3' + ESPFLASH_VERSION: '4.5.0' + ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' + VERIFIED_FIRMWARE_ARTIFACT_ID: '10436323004' + jobs: - repair: - runs-on: macos-15 - timeout-minutes: 10 + windows-candidate: + runs-on: windows-2025 + timeout-minutes: 30 steps: - - name: Checkout repair branch + - name: Checkout repaired Studio source uses: actions/checkout@v4 with: ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - name: Apply P0 runtime repair + - name: Download verified unchanged v0.1.1 firmware + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $zip = Join-Path $env:RUNNER_TEMP 'firmware.zip' + $headers = @{ + Authorization = "Bearer $env:GH_TOKEN" + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + Invoke-WebRequest -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/artifacts/$env:VERIFIED_FIRMWARE_ARTIFACT_ID/zip" -Headers $headers -OutFile $zip + New-Item -ItemType Directory -Force firmware-package | Out-Null + Expand-Archive -Path $zip -DestinationPath firmware-package -Force + $manifest = Get-Content 'firmware-package\firmware-manifest.json' -Raw | ConvertFrom-Json + if ($manifest.version -ne '0.1.1' -or $manifest.chip -ne 'esp32p4' -or $manifest.flashOffset -ne 0) { + throw 'Verified firmware artifact contract mismatch.' + } + $image = Join-Path 'firmware-package' $manifest.image + if (-not (Test-Path $image)) { throw 'Verified firmware image missing.' } + $actual = (Get-FileHash $image -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $manifest.sha256.ToLowerInvariant()) { throw 'Verified firmware SHA-256 mismatch.' } + Write-Host "Firmware reuse verified: $($manifest.image) SHA256=$actual" + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure and build repaired Studio plus session harness + shell: powershell run: | - python3 .github/scripts/p0_runtime_physical_repair.py - git diff --check + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 18 2026" -A x64 ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON + if ($LASTEXITCODE -ne 0) { throw "Studio configure failed: $LASTEXITCODE" } + cmake --build build-arstack-studio --config Release --target arstack_studio arstack_session_harness --parallel + if ($LASTEXITCODE -ne 0) { throw "Studio build failed: $LASTEXITCODE" } - - name: Commit repaired runtime source + - name: Prove single-board stale-firmware recovery policy + shell: powershell run: | - set -euo pipefail - git config user.name 'Ari Sulistiono' - git config user.email 'ari.sulistiono@gmail.com' - git add apps/arstack_studio/src/main.cpp apps/arstack_studio/qml/Main.qml apps/arstack_studio/src/DeviceController.cpp apps/arstack_studio/src/DeviceController.hpp apps/arstack_studio/src/SmartSessionController.cpp apps/arstack_studio/src/DeterministicSessionHarness.cpp - git commit -m 'fix(p0): hard-exit GUI close and infer single-board firmware recovery' - git push origin HEAD:feature/ptp-production-v0.1.1 + $harness = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' + & $harness + if ($LASTEXITCODE -ne 0) { throw "Deterministic session harness failed: $LASTEXITCODE" } + + - name: Prove primary-window close kills the process twice + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $env:QT_QPA_PLATFORM = 'offscreen' + $env:QT_QUICK_BACKEND = 'software' + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' + function Invoke-CloseCycle([int]$Cycle) { + $p = Start-Process -FilePath $exe -ArgumentList @('--check-app-lifecycle') -PassThru + $pidValue = $p.Id + if (-not $p.WaitForExit(12000)) { + Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue + $p.Dispose() + throw "Cycle ${Cycle}: GUI close did not terminate ARStackStudio process within 12 seconds." + } + $exitCode = $p.ExitCode + $p.Dispose() + if ($exitCode -ne 0) { throw "Cycle ${Cycle}: lifecycle exited with code $exitCode." } + if (Get-Process -Id $pidValue -ErrorAction SilentlyContinue) { + throw "Cycle ${Cycle}: PID $pidValue still exists after normal primary-window close." + } + Write-Host "Cycle $Cycle PASS: primary window close -> worker retirement -> PID gone." + } + Invoke-CloseCycle 1 + Start-Sleep -Milliseconds 300 + Invoke-CloseCycle 2 + + - name: Stage Studio with verified firmware + shell: powershell + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio' + New-Item -ItemType Directory -Force $stage | Out-Null + $sourceExe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' + $stageExe = Join-Path $stage 'ARStackStudio.exe' + Copy-Item $sourceExe $stageExe + windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $stageExe + if ($LASTEXITCODE -ne 0) { throw "windeployqt failed: $LASTEXITCODE" } + Copy-Item LICENSE, NOTICE, THIRD_PARTY_NOTICES.md, COPYRIGHT.md -Destination $stage + New-Item -ItemType Directory -Force (Join-Path $stage 'firmware') | Out-Null + Copy-Item 'firmware-package\*' -Destination (Join-Path $stage 'firmware') -Recurse + + - name: Bundle pinned espflash + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $url = "https://github.com/esp-rs/espflash/releases/download/v$env:ESPFLASH_VERSION/espflash-x86_64-pc-windows-msvc.zip" + $zip = Join-Path $env:RUNNER_TEMP 'espflash.zip' + Invoke-WebRequest -Uri $url -OutFile $zip + $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:ESPFLASH_WINDOWS_ZIP_SHA256) { throw "espflash SHA-256 mismatch: $actual" } + $extract = Join-Path $env:RUNNER_TEMP 'espflash' + Expand-Archive -Path $zip -DestinationPath $extract -Force + $exe = Get-ChildItem $extract -Filter espflash.exe -Recurse | Select-Object -First 1 + if (-not $exe) { throw 'espflash.exe not found' } + $tools = Join-Path $PWD 'dist\ARStack-Studio\tools' + New-Item -ItemType Directory -Force $tools | Out-Null + Copy-Item $exe.FullName (Join-Path $tools 'espflash.exe') + + - name: Packaged contract checks + shell: powershell + run: | + function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { + $p = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "$Label failed with exit code $($p.ExitCode)" } + } + $stage = Join-Path $PWD 'dist\ARStack-Studio' + $studio = Join-Path $stage 'ARStackStudio.exe' + $espflash = Join-Path $stage 'tools\espflash.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = $espflash + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + Assert-ExitZero $studio @('--check-firmware-contract') 'Packaged firmware contract' + Assert-ExitZero $studio @('--check-p0-policy') 'Packaged P0 policy' + Assert-ExitZero $studio @('--check-app-lifecycle') 'Packaged lifecycle' + Assert-ExitZero $espflash @('--version') 'Bundled espflash' + + - name: Build portable ZIP and NSIS installer + shell: powershell + run: | + $portable = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-p0-runtime-win-x64-portable.zip" + Compress-Archive -Path 'dist\ARStack-Studio\*' -DestinationPath $portable -CompressionLevel Optimal -Force + choco install nsis -y --no-progress + if ($LASTEXITCODE -ne 0) { throw "NSIS install failed: $LASTEXITCODE" } + $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' + if (-not (Test-Path $makensis)) { throw "makensis.exe not found at $makensis" } + $stage = (Resolve-Path 'dist\ARStack-Studio').Path + $out = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-p0-runtime-win-x64-setup.exe" + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path + & $makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$out" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $out)) { throw 'NSIS installer build failed' } + $files = @($portable, $out) + $lines = foreach ($file in $files) { + $hash = (Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $([IO.Path]::GetFileName($file))" + } + $lines | Set-Content 'dist\P0-RUNTIME-SHA256SUMS.txt' -Encoding ascii + Get-Content 'dist\P0-RUNTIME-SHA256SUMS.txt' + + - name: Smoke installed EXE and prove installed PID exits + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $installer = (Resolve-Path "dist\ARStack-Studio-$env:ARSTACK_VERSION-p0-runtime-win-x64-setup.exe").Path + $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioP0Runtime' + $install = Start-Process -FilePath $installer -ArgumentList @('/S', "/D=$installDir") -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "Silent installer failed: $($install.ExitCode)" } + $exe = Join-Path $installDir 'ARStackStudio.exe' + if (-not (Test-Path $exe)) { throw 'Installed ARStackStudio.exe missing.' } + $env:QT_QPA_PLATFORM = 'offscreen' + $env:QT_QUICK_BACKEND = 'software' + $p = Start-Process -FilePath $exe -ArgumentList @('--check-app-lifecycle') -PassThru + $pidValue = $p.Id + if (-not $p.WaitForExit(12000)) { + Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue + throw 'Installed GUI close left ARStackStudio.exe alive.' + } + if ($p.ExitCode -ne 0) { throw "Installed lifecycle failed: $($p.ExitCode)" } + if (Get-Process -Id $pidValue -ErrorAction SilentlyContinue) { throw 'Installed ARStackStudio PID remained alive.' } + $uninstaller = Join-Path $installDir 'Uninstall.exe' + if (Test-Path $uninstaller) { + $u = Start-Process -FilePath $uninstaller -ArgumentList @('/S') -Wait -PassThru + if ($u.ExitCode -ne 0) { throw "Silent uninstall failed: $($u.ExitCode)" } + } + + - name: Upload physical P0 candidate + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-p0-runtime-windows-x64 + path: | + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-p0-runtime-win-x64-portable.zip + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-p0-runtime-win-x64-setup.exe + dist/P0-RUNTIME-SHA256SUMS.txt + if-no-files-found: error + retention-days: 7 From 1d92114d767789950423756e4346384ddbb40374 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:51:49 +0700 Subject: [PATCH 083/170] chore: remove temporary P0 runtime repair script --- .github/scripts/p0_runtime_physical_repair.py | 276 ------------------ 1 file changed, 276 deletions(-) delete mode 100644 .github/scripts/p0_runtime_physical_repair.py diff --git a/.github/scripts/p0_runtime_physical_repair.py b/.github/scripts/p0_runtime_physical_repair.py deleted file mode 100644 index 88a4e4d61..000000000 --- a/.github/scripts/p0_runtime_physical_repair.py +++ /dev/null @@ -1,276 +0,0 @@ -from pathlib import Path - - -def replace(path, old, new, count=1): - p = Path(path) - text = p.read_text(encoding="utf-8") - actual = text.count(old) - if actual != count: - raise SystemExit(f"{path}: expected {count} occurrence(s), found {actual}: {old[:120]!r}") - p.write_text(text.replace(old, new, count), encoding="utf-8") - - -# P0-A: primary close must force the application event loop to exit. -# The prior lifecycle regression masked the bug by calling exit() itself -# after window.close(), so remove that test-side escape hatch. -replace( - "apps/arstack_studio/src/main.cpp", - """ if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { - closeObserved_ = true; - app_->quit(); - } -""", - """ if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { - closeObserved_ = true; - // 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); - } -""", -) - -replace( - "apps/arstack_studio/src/main.cpp", - """ if (!primaryCloseFilter.closeObserved()) { - qCritical().noquote() << "Application lifecycle regression: normal primary-window close bypassed the lifetime filter."; - QCoreApplication::exit(10); - return; - } - - QCoreApplication::exit(0); -""", - """ if (!primaryCloseFilter.closeObserved()) { - qCritical().noquote() << "Application lifecycle regression: normal primary-window close bypassed the lifetime filter."; - QCoreApplication::exit(10); - return; - } - // Do not call exit() here. The regression must prove that the real - // primary-window close path itself terminates the process. -""", -) - -replace( - "apps/arstack_studio/qml/Main.qml", - """ configurationWindow.hide() - detachedPhasorWindow.hide() - detachedWaveformWindow.hide() - Qt.quit() -""", - """ configurationWindow.hide() - detachedPhasorWindow.hide() - detachedWaveformWindow.hide() - // C++ owns process termination and bounded worker retirement. Do not - // race it with a second QML quit path. -""", -) - -# P0-B: remember the exact port that actually timed out. A single visible -# serial port is a valid recovery candidate even when Windows supplies weak/no -# USB metadata. Firmware write remains protected by espflash ROM verification. -replace( - "apps/arstack_studio/src/DeviceController.hpp", - """ [[nodiscard]] QString recommendedPort() const; - [[nodiscard]] QString discoveryStatus() const; -""", - """ [[nodiscard]] QString recommendedPort() const; - [[nodiscard]] QString recoveryCandidatePort() const; - [[nodiscard]] QString discoveryStatus() const; -""", -) - -replace( - "apps/arstack_studio/src/DeviceController.hpp", - """ QString recommendedPort_; - QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; -""", - """ QString recommendedPort_; - QString recoveryCandidatePort_; - QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; -""", -) - -replace( - "apps/arstack_studio/src/DeviceController.cpp", - """QStringList DeviceController::ports() const { return ports_; } -QString DeviceController::recommendedPort() const { return recommendedPort_; } -QString DeviceController::discoveryStatus() const { return discoveryStatus_; } -""", - """QStringList DeviceController::ports() const { return ports_; } -QString DeviceController::recommendedPort() const { return recommendedPort_; } -QString DeviceController::recoveryCandidatePort() const { return recoveryCandidatePort_; } -QString DeviceController::discoveryStatus() const { return discoveryStatus_; } -""", -) - -replace( - "apps/arstack_studio/src/DeviceController.cpp", - """ lastIdentificationPort_ = port; - identifyAttempts_ = attempts; - if (continuing) { -""", - """ 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) { -""", -) - -replace( - "apps/arstack_studio/src/DeviceController.cpp", - """ ports_ = ports; - recommendedPort_ = recommendedPort; - highConfidenceCount_ = highConfidenceCount; - - if (identificationState_ == IdentificationState::Unidentified && -""", - """ ports_ = ports; - recommendedPort_ = recommendedPort; - highConfidenceCount_ = highConfidenceCount; - if (!recoveryCandidatePort_.isEmpty() && !ports_.contains(recoveryCandidatePort_)) { - recoveryCandidatePort_.clear(); - } - - if (identificationState_ == IdentificationState::Unidentified && -""", -) - -replace( - "apps/arstack_studio/src/DeviceController.cpp", - """ portName_ = portName; - lastIdentificationPort_ = portName; - identifyAttempts_ = 0; -""", - """ portName_ = portName; - lastIdentificationPort_ = portName; - recoveryCandidatePort_.clear(); - identifyAttempts_ = 0; -""", -) - -replace( - "apps/arstack_studio/src/DeviceController.cpp", - """void DeviceController::markDeviceVerified() { - if (deviceVerified_) return; - deviceVerified_ = true; - lastIdentificationPort_ = portName_; -""", - """void DeviceController::markDeviceVerified() { - if (deviceVerified_) return; - deviceVerified_ = true; - recoveryCandidatePort_.clear(); - lastIdentificationPort_ = portName_; -""", -) - -replace( - "apps/arstack_studio/src/SmartSessionController.cpp", - """ const QString recommended = recommendedPort.trimmed(); - if (!recommended.isEmpty()) return recommended; - if (visiblePorts.size() != 1) return {}; - return visiblePorts.front().trimmed(); -""", - """ const QString recommended = recommendedPort.trimmed(); - if (!recommended.isEmpty() && visiblePorts.contains(recommended, Qt::CaseInsensitive)) { - return recommended; - } - if (visiblePorts.size() != 1) return {}; - return visiblePorts.front().trimmed(); -""", -) - -replace( - "apps/arstack_studio/src/SmartSessionController.cpp", - """ const QString recommended = device_->recommendedPort().trimmed(); - if (recommended.isEmpty() || !device_->ports().contains(recommended)) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - return; - } - - blankBoardPort_ = recommended; - blankBoardDetected_ = true; -""", - """ 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_ = recoveryPort; - blankBoardDetected_ = true; -""", -) - -replace( - "apps/arstack_studio/src/SmartSessionController.cpp", - """ 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.") - .arg(device_->identifyAttempts()), -""", - """ 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()), -""", -) - -replace( - "apps/arstack_studio/src/DeterministicSessionHarness.cpp", - """ {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, - {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, -""", - """ {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, - {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, - {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, -""", -) - -marker = """ static bool ambiguousIdentityTimeoutStaysUnidentified() { -""" -insertion = """ 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(); - } - -""" -p = Path("apps/arstack_studio/src/DeterministicSessionHarness.cpp") -text = p.read_text(encoding="utf-8") -if text.count(marker) != 1: - raise SystemExit("DeterministicSessionHarness.cpp: ambiguous case marker mismatch") -p.write_text(text.replace(marker, insertion + marker, 1), encoding="utf-8") From 00201cf48ba93d6c0fffb8ce80727d30042666c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:52:01 +0700 Subject: [PATCH 084/170] chore: remove temporary P0 runtime candidate workflow --- .../workflows/p0-runtime-physical-repair.yml | 215 ------------------ 1 file changed, 215 deletions(-) delete mode 100644 .github/workflows/p0-runtime-physical-repair.yml diff --git a/.github/workflows/p0-runtime-physical-repair.yml b/.github/workflows/p0-runtime-physical-repair.yml deleted file mode 100644 index da38192e2..000000000 --- a/.github/workflows/p0-runtime-physical-repair.yml +++ /dev/null @@ -1,215 +0,0 @@ -name: P0 Runtime Physical Candidate - -on: - push: - paths: - - '.github/workflows/p0-runtime-physical-repair.yml' - -permissions: - contents: read - actions: read - -concurrency: - group: p0-runtime-physical-candidate - cancel-in-progress: true - -env: - ARSTACK_VERSION: '0.1.1' - QT_VERSION: '6.10.3' - ESPFLASH_VERSION: '4.5.0' - ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' - VERIFIED_FIRMWARE_ARTIFACT_ID: '10436323004' - -jobs: - windows-candidate: - runs-on: windows-2025 - timeout-minutes: 30 - steps: - - name: Checkout repaired Studio source - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - - - name: Download verified unchanged v0.1.1 firmware - shell: powershell - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference = 'Stop' - $zip = Join-Path $env:RUNNER_TEMP 'firmware.zip' - $headers = @{ - Authorization = "Bearer $env:GH_TOKEN" - Accept = 'application/vnd.github+json' - 'X-GitHub-Api-Version' = '2022-11-28' - } - Invoke-WebRequest -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/artifacts/$env:VERIFIED_FIRMWARE_ARTIFACT_ID/zip" -Headers $headers -OutFile $zip - New-Item -ItemType Directory -Force firmware-package | Out-Null - Expand-Archive -Path $zip -DestinationPath firmware-package -Force - $manifest = Get-Content 'firmware-package\firmware-manifest.json' -Raw | ConvertFrom-Json - if ($manifest.version -ne '0.1.1' -or $manifest.chip -ne 'esp32p4' -or $manifest.flashOffset -ne 0) { - throw 'Verified firmware artifact contract mismatch.' - } - $image = Join-Path 'firmware-package' $manifest.image - if (-not (Test-Path $image)) { throw 'Verified firmware image missing.' } - $actual = (Get-FileHash $image -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $manifest.sha256.ToLowerInvariant()) { throw 'Verified firmware SHA-256 mismatch.' } - Write-Host "Firmware reuse verified: $($manifest.image) SHA256=$actual" - - - name: Install Qt - uses: jurplel/install-qt-action@v4 - with: - version: ${{ env.QT_VERSION }} - arch: win64_msvc2022_64 - modules: 'qtserialport' - cache: true - - - name: Configure and build repaired Studio plus session harness - shell: powershell - run: | - cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 18 2026" -A x64 ` - -DARIEC61850_WARNINGS_AS_ERRORS=ON - if ($LASTEXITCODE -ne 0) { throw "Studio configure failed: $LASTEXITCODE" } - cmake --build build-arstack-studio --config Release --target arstack_studio arstack_session_harness --parallel - if ($LASTEXITCODE -ne 0) { throw "Studio build failed: $LASTEXITCODE" } - - - name: Prove single-board stale-firmware recovery policy - shell: powershell - run: | - $harness = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' - & $harness - if ($LASTEXITCODE -ne 0) { throw "Deterministic session harness failed: $LASTEXITCODE" } - - - name: Prove primary-window close kills the process twice - shell: powershell - run: | - $ErrorActionPreference = 'Stop' - $env:QT_QPA_PLATFORM = 'offscreen' - $env:QT_QUICK_BACKEND = 'software' - $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' - function Invoke-CloseCycle([int]$Cycle) { - $p = Start-Process -FilePath $exe -ArgumentList @('--check-app-lifecycle') -PassThru - $pidValue = $p.Id - if (-not $p.WaitForExit(12000)) { - Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue - $p.Dispose() - throw "Cycle ${Cycle}: GUI close did not terminate ARStackStudio process within 12 seconds." - } - $exitCode = $p.ExitCode - $p.Dispose() - if ($exitCode -ne 0) { throw "Cycle ${Cycle}: lifecycle exited with code $exitCode." } - if (Get-Process -Id $pidValue -ErrorAction SilentlyContinue) { - throw "Cycle ${Cycle}: PID $pidValue still exists after normal primary-window close." - } - Write-Host "Cycle $Cycle PASS: primary window close -> worker retirement -> PID gone." - } - Invoke-CloseCycle 1 - Start-Sleep -Milliseconds 300 - Invoke-CloseCycle 2 - - - name: Stage Studio with verified firmware - shell: powershell - run: | - $stage = Join-Path $PWD 'dist\ARStack-Studio' - New-Item -ItemType Directory -Force $stage | Out-Null - $sourceExe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' - $stageExe = Join-Path $stage 'ARStackStudio.exe' - Copy-Item $sourceExe $stageExe - windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $stageExe - if ($LASTEXITCODE -ne 0) { throw "windeployqt failed: $LASTEXITCODE" } - Copy-Item LICENSE, NOTICE, THIRD_PARTY_NOTICES.md, COPYRIGHT.md -Destination $stage - New-Item -ItemType Directory -Force (Join-Path $stage 'firmware') | Out-Null - Copy-Item 'firmware-package\*' -Destination (Join-Path $stage 'firmware') -Recurse - - - name: Bundle pinned espflash - shell: powershell - run: | - $ErrorActionPreference = 'Stop' - $url = "https://github.com/esp-rs/espflash/releases/download/v$env:ESPFLASH_VERSION/espflash-x86_64-pc-windows-msvc.zip" - $zip = Join-Path $env:RUNNER_TEMP 'espflash.zip' - Invoke-WebRequest -Uri $url -OutFile $zip - $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $env:ESPFLASH_WINDOWS_ZIP_SHA256) { throw "espflash SHA-256 mismatch: $actual" } - $extract = Join-Path $env:RUNNER_TEMP 'espflash' - Expand-Archive -Path $zip -DestinationPath $extract -Force - $exe = Get-ChildItem $extract -Filter espflash.exe -Recurse | Select-Object -First 1 - if (-not $exe) { throw 'espflash.exe not found' } - $tools = Join-Path $PWD 'dist\ARStack-Studio\tools' - New-Item -ItemType Directory -Force $tools | Out-Null - Copy-Item $exe.FullName (Join-Path $tools 'espflash.exe') - - - name: Packaged contract checks - shell: powershell - run: | - function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { - $p = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "$Label failed with exit code $($p.ExitCode)" } - } - $stage = Join-Path $PWD 'dist\ARStack-Studio' - $studio = Join-Path $stage 'ARStackStudio.exe' - $espflash = Join-Path $stage 'tools\espflash.exe' - $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' - $env:ARSTACK_STUDIO_ESPFLASH = $espflash - $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' - Assert-ExitZero $studio @('--check-firmware-contract') 'Packaged firmware contract' - Assert-ExitZero $studio @('--check-p0-policy') 'Packaged P0 policy' - Assert-ExitZero $studio @('--check-app-lifecycle') 'Packaged lifecycle' - Assert-ExitZero $espflash @('--version') 'Bundled espflash' - - - name: Build portable ZIP and NSIS installer - shell: powershell - run: | - $portable = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-p0-runtime-win-x64-portable.zip" - Compress-Archive -Path 'dist\ARStack-Studio\*' -DestinationPath $portable -CompressionLevel Optimal -Force - choco install nsis -y --no-progress - if ($LASTEXITCODE -ne 0) { throw "NSIS install failed: $LASTEXITCODE" } - $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' - if (-not (Test-Path $makensis)) { throw "makensis.exe not found at $makensis" } - $stage = (Resolve-Path 'dist\ARStack-Studio').Path - $out = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-p0-runtime-win-x64-setup.exe" - $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path - & $makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$out" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script - if ($LASTEXITCODE -ne 0 -or -not (Test-Path $out)) { throw 'NSIS installer build failed' } - $files = @($portable, $out) - $lines = foreach ($file in $files) { - $hash = (Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash $([IO.Path]::GetFileName($file))" - } - $lines | Set-Content 'dist\P0-RUNTIME-SHA256SUMS.txt' -Encoding ascii - Get-Content 'dist\P0-RUNTIME-SHA256SUMS.txt' - - - name: Smoke installed EXE and prove installed PID exits - shell: powershell - run: | - $ErrorActionPreference = 'Stop' - $installer = (Resolve-Path "dist\ARStack-Studio-$env:ARSTACK_VERSION-p0-runtime-win-x64-setup.exe").Path - $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioP0Runtime' - $install = Start-Process -FilePath $installer -ArgumentList @('/S', "/D=$installDir") -Wait -PassThru - if ($install.ExitCode -ne 0) { throw "Silent installer failed: $($install.ExitCode)" } - $exe = Join-Path $installDir 'ARStackStudio.exe' - if (-not (Test-Path $exe)) { throw 'Installed ARStackStudio.exe missing.' } - $env:QT_QPA_PLATFORM = 'offscreen' - $env:QT_QUICK_BACKEND = 'software' - $p = Start-Process -FilePath $exe -ArgumentList @('--check-app-lifecycle') -PassThru - $pidValue = $p.Id - if (-not $p.WaitForExit(12000)) { - Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue - throw 'Installed GUI close left ARStackStudio.exe alive.' - } - if ($p.ExitCode -ne 0) { throw "Installed lifecycle failed: $($p.ExitCode)" } - if (Get-Process -Id $pidValue -ErrorAction SilentlyContinue) { throw 'Installed ARStackStudio PID remained alive.' } - $uninstaller = Join-Path $installDir 'Uninstall.exe' - if (Test-Path $uninstaller) { - $u = Start-Process -FilePath $uninstaller -ArgumentList @('/S') -Wait -PassThru - if ($u.ExitCode -ne 0) { throw "Silent uninstall failed: $($u.ExitCode)" } - } - - - name: Upload physical P0 candidate - uses: actions/upload-artifact@v4 - with: - name: arstack-studio-p0-runtime-windows-x64 - path: | - dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-p0-runtime-win-x64-portable.zip - dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-p0-runtime-win-x64-setup.exe - dist/P0-RUNTIME-SHA256SUMS.txt - if-no-files-found: error - retention-days: 7 From bee1a588de72b03305f7b426dee7ac8a1af67698 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 17:07:10 +0700 Subject: [PATCH 085/170] ci: stage CH343 fast discovery repair --- .github/scripts/p0_ch343_fast_discovery.py | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/scripts/p0_ch343_fast_discovery.py diff --git a/.github/scripts/p0_ch343_fast_discovery.py b/.github/scripts/p0_ch343_fast_discovery.py new file mode 100644 index 000000000..6470f96b4 --- /dev/null +++ b/.github/scripts/p0_ch343_fast_discovery.py @@ -0,0 +1,46 @@ +from pathlib import Path + + +def replace(path: str, old: str, new: str, count: int = 1) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + actual = text.count(old) + if actual != count: + raise SystemExit(f"{path}: expected {count} occurrence(s), found {actual}: {old[:120]!r}") + p.write_text(text.replace(old, new, count), encoding="utf-8") + + +# Expose a pure metadata scorer so CI can lock the discovery policy without +# needing physical USB hardware. CH343 is only a transport hint; IDENTIFY stays +# authoritative for ARStack identity. +replace( + "apps/arstack_studio/src/DeviceIoWorker.hpp", + """ [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; }\n\n // S8B Windows ownership contract:""", + """ [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; }\n\n // Fast discovery policy: USB bridge metadata may prioritize a port, but it\n // never verifies device identity. The opened port must still answer the\n // semantic IDENTIFY contract before Studio can become READY.\n [[nodiscard]] static int portConfidenceForMetadata(\n const bool hasVendorIdentifier,\n const quint16 vendorIdentifier,\n const bool hasProductIdentifier,\n const quint16 productIdentifier,\n const QString& description,\n const QString& manufacturer,\n const QString& serialNumber) {\n int score = 0;\n if (hasVendorIdentifier && vendorIdentifier == 0x303AU) score += 100;\n\n const QString identity = QStringLiteral(\"%1 %2 %3\")\n .arg(description, manufacturer, serialNumber).toLower();\n\n // The current ARStack ESP32-P4 board exposes its console through a WCH\n // CH343 bridge on Windows. Treat that bridge as a fast transport\n // candidate, not as proof that the device is ARStack.\n const bool saysCh343 = identity.contains(QStringLiteral(\"ch343\"));\n const bool wchCh343 = hasVendorIdentifier && vendorIdentifier == 0x1A86U &&\n hasProductIdentifier && productIdentifier == 0x55D3U;\n if (wchCh343) score += 95;\n else if (saysCh343) score += 85;\n\n if (identity.contains(QStringLiteral(\"esp32-p4\"))) score += 90;\n else if (identity.contains(QStringLiteral(\"esp32\"))) score += 65;\n if (identity.contains(QStringLiteral(\"espressif\"))) score += 60;\n if (identity.contains(QStringLiteral(\"usb jtag\")) ||\n identity.contains(QStringLiteral(\"usb serial\")) ||\n identity.contains(QStringLiteral(\"usb-enhanced-serial\"))) {\n score += 15;\n }\n\n // Windows can expose many virtual Bluetooth COM ports. They are poor\n // automatic injector candidates and must never outrank a physical USB\n // bridge. Manual selection remains available.\n if (identity.contains(QStringLiteral(\"bluetooth\"))) score -= 120;\n return score;\n }\n\n // S8B Windows ownership contract:""", +) + +replace( + "apps/arstack_studio/src/DeviceIoWorker.cpp", + """[[nodiscard]] int portConfidence(const QSerialPortInfo& info) {\n int score = 0;\n if (info.hasVendorIdentifier() && info.vendorIdentifier() == 0x303AU) score += 100;\n const QString identity = QStringLiteral(\"%1 %2 %3\")\n .arg(info.description(), info.manufacturer(), info.serialNumber()).toLower();\n if (identity.contains(QStringLiteral(\"esp32-p4\"))) score += 90;\n else if (identity.contains(QStringLiteral(\"esp32\"))) score += 65;\n if (identity.contains(QStringLiteral(\"espressif\"))) score += 60;\n if (identity.contains(QStringLiteral(\"usb jtag\")) ||\n identity.contains(QStringLiteral(\"usb serial\"))) score += 15;\n return score;\n}\n""", + """[[nodiscard]] int portConfidence(const QSerialPortInfo& info) {\n return DeviceIoWorker::portConfidenceForMetadata(\n info.hasVendorIdentifier(),\n info.hasVendorIdentifier() ? info.vendorIdentifier() : 0U,\n info.hasProductIdentifier(),\n info.hasProductIdentifier() ? info.productIdentifier() : 0U,\n info.description(),\n info.manufacturer(),\n info.serialNumber());\n}\n""", +) + +replace( + "apps/arstack_studio/src/WindowsOwnershipHarness.cpp", + """bool crossProcessOwnershipAndCrashRecovery() {\n""", + """bool fastUsbDiscoveryPolicy() {\n const int ch343Exact = DeviceIoWorker::portConfidenceForMetadata(\n true, 0x1A86U, true, 0x55D3U,\n QStringLiteral(\"USB-Enhanced-SERIAL CH343\"), {}, {});\n const int ch343ByName = DeviceIoWorker::portConfidenceForMetadata(\n false, 0U, false, 0U,\n QStringLiteral(\"USB-Enhanced-SERIAL CH343\"), {}, {});\n const int bluetooth = DeviceIoWorker::portConfidenceForMetadata(\n false, 0U, false, 0U,\n QStringLiteral(\"Standard Serial over Bluetooth link\"), {}, {});\n const int generic = DeviceIoWorker::portConfidenceForMetadata(\n false, 0U, false, 0U, QStringLiteral(\"USB Serial Port\"), {}, {});\n const int espressif = DeviceIoWorker::portConfidenceForMetadata(\n true, 0x303AU, false, 0U, QStringLiteral(\"USB JTAG/serial debug unit\"),\n QStringLiteral(\"Espressif\"), {});\n\n return ch343Exact >= 60 && ch343ByName >= 60 && espressif >= 60 &&\n ch343Exact > generic && ch343ByName > generic &&\n ch343Exact > bluetooth && bluetooth < generic;\n}\n\nbool crossProcessOwnershipAndCrashRecovery() {\n""", +) + +replace( + "apps/arstack_studio/src/WindowsOwnershipHarness.cpp", + """ const bool serialPolicy = serialTransportLossPolicy();\n const bool processOwnership = crossProcessOwnershipAndCrashRecovery();\n\n qInfo().noquote()\n << \"S8B Windows ownership:\"\n << (serialPolicy ? \"PASS\" : \"FAIL\")\n << \"· Permission/Resource/DeviceNotFound/Read/Write errors force transport loss\";\n""", + """ const bool serialPolicy = serialTransportLossPolicy();\n const bool fastDiscovery = fastUsbDiscoveryPolicy();\n const bool processOwnership = crossProcessOwnershipAndCrashRecovery();\n\n qInfo().noquote()\n << \"S8B Windows ownership:\"\n << (serialPolicy ? \"PASS\" : \"FAIL\")\n << \"· Permission/Resource/DeviceNotFound/Read/Write errors force transport loss\";\n qInfo().noquote()\n << \"S8B Windows discovery:\"\n << (fastDiscovery ? \"PASS\" : \"FAIL\")\n << \"· CH343/Espressif USB outrank generic and Bluetooth COM ports; IDENTIFY remains authoritative\";\n""", +) + +replace( + "apps/arstack_studio/src/WindowsOwnershipHarness.cpp", + """ if (!serialPolicy || !processOwnership) {\n qCritical().noquote() << \"S8B Windows ownership integration harness: FAIL\";\n return 22;\n }\n\n qInfo().noquote()\n << \"S8B Windows ownership integration harness: PASS · OS process lock + crash recovery + serial transport-loss policy locked\";\n""", + """ if (!serialPolicy || !fastDiscovery || !processOwnership) {\n qCritical().noquote() << \"S8B Windows ownership/discovery integration harness: FAIL\";\n return 22;\n }\n\n qInfo().noquote()\n << \"S8B Windows ownership/discovery integration harness: PASS · fast CH343 discovery + OS process lock + crash recovery + serial transport-loss policy locked\";\n""", +) + +print("P0 CH343 fast-discovery source patch applied") From 5744e7435bd1fbbdcabbed2ba4cbb12a652fade6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 17:07:19 +0700 Subject: [PATCH 086/170] ci: run CH343 fast discovery repair --- .github/workflows/p0-ch343-fast-discovery.yml | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/p0-ch343-fast-discovery.yml diff --git a/.github/workflows/p0-ch343-fast-discovery.yml b/.github/workflows/p0-ch343-fast-discovery.yml new file mode 100644 index 000000000..a631db7e6 --- /dev/null +++ b/.github/workflows/p0-ch343-fast-discovery.yml @@ -0,0 +1,43 @@ +name: P0 CH343 Fast Discovery Repair + +on: + push: + paths: + - '.github/workflows/p0-ch343-fast-discovery.yml' + +permissions: + contents: write + +concurrency: + group: p0-ch343-fast-discovery + cancel-in-progress: true + +jobs: + repair: + runs-on: macos-15 + timeout-minutes: 10 + steps: + - name: Checkout repair branch + uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Apply CH343 fast-discovery repair + run: | + set -euo pipefail + python3 .github/scripts/p0_ch343_fast_discovery.py + git diff --check + grep -q '0x1A86U' apps/arstack_studio/src/DeviceIoWorker.hpp + grep -q '0x55D3U' apps/arstack_studio/src/DeviceIoWorker.hpp + grep -q 'bluetooth' apps/arstack_studio/src/DeviceIoWorker.hpp + grep -q 'fastUsbDiscoveryPolicy' apps/arstack_studio/src/WindowsOwnershipHarness.cpp + + - name: Commit repaired discovery source + run: | + set -euo pipefail + git config user.name 'Ari Sulistiono' + git config user.email 'ari.sulistiono@gmail.com' + git add apps/arstack_studio/src/DeviceIoWorker.cpp apps/arstack_studio/src/DeviceIoWorker.hpp apps/arstack_studio/src/WindowsOwnershipHarness.cpp + git commit -m 'fix(studio): prioritize CH343 USB injector discovery' + git push origin HEAD:feature/ptp-production-v0.1.1 From df3842f8c95a74b4d296d6160dd817041ff09fb3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 10:07:37 +0000 Subject: [PATCH 087/170] fix(studio): prioritize CH343 USB injector discovery --- apps/arstack_studio/src/DeviceIoWorker.cpp | 18 ++++---- apps/arstack_studio/src/DeviceIoWorker.hpp | 42 +++++++++++++++++++ .../src/WindowsOwnershipHarness.cpp | 32 ++++++++++++-- 3 files changed, 79 insertions(+), 13 deletions(-) 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/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; } From 991eca505d756aaa982b80d0281d506678239b62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 17:08:50 +0700 Subject: [PATCH 088/170] ci: build CH343 fast discovery Windows candidate --- .../p0-ch343-fast-discovery-candidate.yml | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 .github/workflows/p0-ch343-fast-discovery-candidate.yml diff --git a/.github/workflows/p0-ch343-fast-discovery-candidate.yml b/.github/workflows/p0-ch343-fast-discovery-candidate.yml new file mode 100644 index 000000000..36a18fd15 --- /dev/null +++ b/.github/workflows/p0-ch343-fast-discovery-candidate.yml @@ -0,0 +1,180 @@ +name: P0 CH343 Fast Discovery Windows Candidate + +on: + push: + paths: + - '.github/workflows/p0-ch343-fast-discovery-candidate.yml' + +permissions: + contents: read + actions: read + +concurrency: + group: p0-ch343-fast-discovery-candidate + cancel-in-progress: true + +env: + ARSTACK_VERSION: '0.1.1' + QT_VERSION: '6.10.3' + ESPFLASH_VERSION: '4.5.0' + ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' + +jobs: + windows-candidate: + runs-on: windows-2022 + timeout-minutes: 25 + steps: + - name: Checkout fast-discovery source + uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + + - name: Download verified unchanged v0.1.1 firmware + uses: actions/download-artifact@v4 + with: + name: arstack-esp32p4-smv-firmware + path: firmware-package + run-id: 35071107192 + github-token: ${{ github.token }} + repository: masarray/arstack61850 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure and build Studio plus regression harnesses + 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 "Configure failed: $LASTEXITCODE" } + cmake --build build-arstack-studio --config Release ` + --target arstack_studio arstack_session_harness arstack_windows_ownership_harness --parallel + if ($LASTEXITCODE -ne 0) { throw "Build failed: $LASTEXITCODE" } + + - name: Prove CH343 fast discovery and ownership policy + shell: powershell + run: | + & "$PWD\build-arstack-studio\Release\arstack_windows_ownership_harness.exe" + if ($LASTEXITCODE -ne 0) { throw "Windows discovery/ownership harness failed: $LASTEXITCODE" } + + - name: Prove deterministic session recovery policy + shell: powershell + run: | + & "$PWD\build-arstack-studio\Release\arstack_session_harness.exe" + if ($LASTEXITCODE -ne 0) { throw "Session harness failed: $LASTEXITCODE" } + + - name: Prove close kills process twice + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $env:QT_QPA_PLATFORM = 'offscreen' + $env:QT_QUICK_BACKEND = 'software' + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' + for ($cycle = 1; $cycle -le 2; $cycle++) { + $p = Start-Process -FilePath $exe -ArgumentList '--check-app-lifecycle' -PassThru + if (-not $p.WaitForExit(12000)) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + throw "Cycle $cycle: Studio PID did not exit after primary-window close" + } + if ($p.ExitCode -ne 0) { throw "Cycle $cycle: lifecycle exit code $($p.ExitCode)" } + if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { throw "Cycle $cycle: PID remained alive" } + } + + - name: Stage Studio with verified firmware + shell: powershell + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio' + New-Item -ItemType Directory -Force $stage | Out-Null + Copy-Item "$PWD\build-arstack-studio\Release\arstack_studio.exe" (Join-Path $stage 'ARStackStudio.exe') + windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime (Join-Path $stage 'ARStackStudio.exe') + Copy-Item LICENSE, NOTICE, THIRD_PARTY_NOTICES.md, COPYRIGHT.md -Destination $stage + New-Item -ItemType Directory -Force (Join-Path $stage 'firmware') | Out-Null + Copy-Item 'firmware-package\*' -Destination (Join-Path $stage 'firmware') -Recurse + + - name: Bundle pinned espflash + shell: powershell + run: | + $url = "https://github.com/esp-rs/espflash/releases/download/v$env:ESPFLASH_VERSION/espflash-x86_64-pc-windows-msvc.zip" + $zip = Join-Path $env:RUNNER_TEMP 'espflash.zip' + Invoke-WebRequest -Uri $url -OutFile $zip + $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:ESPFLASH_WINDOWS_ZIP_SHA256) { throw "espflash SHA mismatch: $actual" } + $extract = Join-Path $env:RUNNER_TEMP 'espflash' + Expand-Archive -Path $zip -DestinationPath $extract -Force + $espflash = Get-ChildItem $extract -Filter espflash.exe -Recurse | Select-Object -First 1 + if (-not $espflash) { throw 'espflash.exe not found' } + $tools = Join-Path $PWD 'dist\ARStack-Studio\tools' + New-Item -ItemType Directory -Force $tools | Out-Null + Copy-Item $espflash.FullName (Join-Path $tools 'espflash.exe') + + - name: Packaged contract and lifecycle checks + shell: powershell + run: | + function Assert-Zero([string]$FilePath, [string[]]$Arguments, [string]$Label) { + $p = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "$Label failed: $($p.ExitCode)" } + } + $stage = Join-Path $PWD 'dist\ARStack-Studio' + $studio = Join-Path $stage 'ARStackStudio.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe' + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + Assert-Zero $studio @('--check-firmware-contract') 'Firmware contract' + Assert-Zero $studio @('--check-p0-policy') 'P0 policy' + Assert-Zero $studio @('--check-app-lifecycle') 'Packaged lifecycle' + + - name: Build portable ZIP and NSIS installer + shell: powershell + run: | + $portable = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-CH343-fast-win-x64-portable.zip" + Compress-Archive -Path 'dist\ARStack-Studio\*' -DestinationPath $portable -CompressionLevel Optimal -Force + choco install nsis -y --no-progress + $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' + if (-not (Test-Path $makensis)) { throw "makensis not found: $makensis" } + $stage = (Resolve-Path 'dist\ARStack-Studio').Path + $setup = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-CH343-fast-win-x64-setup.exe" + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path + & $makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$setup" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $setup)) { throw 'NSIS installer build failed' } + $lines = foreach ($file in @($portable, $setup)) { + $hash = (Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $([IO.Path]::GetFileName($file))" + } + $lines | Set-Content 'dist\SHA256SUMS-CH343.txt' -Encoding ascii + Get-Content 'dist\SHA256SUMS-CH343.txt' + + - name: Smoke installed EXE and prove PID exits + shell: powershell + run: | + $installer = (Resolve-Path "dist\ARStack-Studio-$env:ARSTACK_VERSION-CH343-fast-win-x64-setup.exe").Path + $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioCH343' + $install = Start-Process -FilePath $installer -ArgumentList @('/S', "/D=$installDir") -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "Silent install failed: $($install.ExitCode)" } + $exe = Join-Path $installDir 'ARStackStudio.exe' + if (-not (Test-Path $exe)) { throw 'Installed EXE missing' } + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + $p = Start-Process -FilePath $exe -ArgumentList '--check-app-lifecycle' -PassThru + if (-not $p.WaitForExit(12000)) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + throw 'Installed Studio did not exit after primary-window close' + } + if ($p.ExitCode -ne 0) { throw "Installed lifecycle failed: $($p.ExitCode)" } + if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { throw 'Installed Studio PID remained alive' } + + - name: Upload CH343 fast candidate + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-ch343-fast-windows-x64 + path: | + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-CH343-fast-win-x64-portable.zip + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-CH343-fast-win-x64-setup.exe + dist/SHA256SUMS-CH343.txt + if-no-files-found: error + retention-days: 7 From f70bc23dd0f65d1131f1b9eb952b384cfca049ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 17:15:50 +0700 Subject: [PATCH 089/170] ci: fix CH343 candidate lifecycle gate --- .github/workflows/p0-ch343-fast-discovery-candidate.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/p0-ch343-fast-discovery-candidate.yml b/.github/workflows/p0-ch343-fast-discovery-candidate.yml index 36a18fd15..6254bebcf 100644 --- a/.github/workflows/p0-ch343-fast-discovery-candidate.yml +++ b/.github/workflows/p0-ch343-fast-discovery-candidate.yml @@ -79,10 +79,10 @@ jobs: $p = Start-Process -FilePath $exe -ArgumentList '--check-app-lifecycle' -PassThru if (-not $p.WaitForExit(12000)) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue - throw "Cycle $cycle: Studio PID did not exit after primary-window close" + throw "Cycle ${cycle}: Studio PID did not exit after primary-window close" } - if ($p.ExitCode -ne 0) { throw "Cycle $cycle: lifecycle exit code $($p.ExitCode)" } - if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { throw "Cycle $cycle: PID remained alive" } + if ($p.ExitCode -ne 0) { throw "Cycle ${cycle}: lifecycle exit code $($p.ExitCode)" } + if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { throw "Cycle ${cycle}: PID remained alive" } } - name: Stage Studio with verified firmware From 2d4a0ba90edf799beedf18fe24a27a811a7498bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 17:26:13 +0700 Subject: [PATCH 090/170] ci: remove CH343 repair helper --- .github/scripts/p0_ch343_fast_discovery.py | 46 ---------------------- 1 file changed, 46 deletions(-) delete mode 100644 .github/scripts/p0_ch343_fast_discovery.py diff --git a/.github/scripts/p0_ch343_fast_discovery.py b/.github/scripts/p0_ch343_fast_discovery.py deleted file mode 100644 index 6470f96b4..000000000 --- a/.github/scripts/p0_ch343_fast_discovery.py +++ /dev/null @@ -1,46 +0,0 @@ -from pathlib import Path - - -def replace(path: str, old: str, new: str, count: int = 1) -> None: - p = Path(path) - text = p.read_text(encoding="utf-8") - actual = text.count(old) - if actual != count: - raise SystemExit(f"{path}: expected {count} occurrence(s), found {actual}: {old[:120]!r}") - p.write_text(text.replace(old, new, count), encoding="utf-8") - - -# Expose a pure metadata scorer so CI can lock the discovery policy without -# needing physical USB hardware. CH343 is only a transport hint; IDENTIFY stays -# authoritative for ARStack identity. -replace( - "apps/arstack_studio/src/DeviceIoWorker.hpp", - """ [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; }\n\n // S8B Windows ownership contract:""", - """ [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; }\n\n // Fast discovery policy: USB bridge metadata may prioritize a port, but it\n // never verifies device identity. The opened port must still answer the\n // semantic IDENTIFY contract before Studio can become READY.\n [[nodiscard]] static int portConfidenceForMetadata(\n const bool hasVendorIdentifier,\n const quint16 vendorIdentifier,\n const bool hasProductIdentifier,\n const quint16 productIdentifier,\n const QString& description,\n const QString& manufacturer,\n const QString& serialNumber) {\n int score = 0;\n if (hasVendorIdentifier && vendorIdentifier == 0x303AU) score += 100;\n\n const QString identity = QStringLiteral(\"%1 %2 %3\")\n .arg(description, manufacturer, serialNumber).toLower();\n\n // The current ARStack ESP32-P4 board exposes its console through a WCH\n // CH343 bridge on Windows. Treat that bridge as a fast transport\n // candidate, not as proof that the device is ARStack.\n const bool saysCh343 = identity.contains(QStringLiteral(\"ch343\"));\n const bool wchCh343 = hasVendorIdentifier && vendorIdentifier == 0x1A86U &&\n hasProductIdentifier && productIdentifier == 0x55D3U;\n if (wchCh343) score += 95;\n else if (saysCh343) score += 85;\n\n if (identity.contains(QStringLiteral(\"esp32-p4\"))) score += 90;\n else if (identity.contains(QStringLiteral(\"esp32\"))) score += 65;\n if (identity.contains(QStringLiteral(\"espressif\"))) score += 60;\n if (identity.contains(QStringLiteral(\"usb jtag\")) ||\n identity.contains(QStringLiteral(\"usb serial\")) ||\n identity.contains(QStringLiteral(\"usb-enhanced-serial\"))) {\n score += 15;\n }\n\n // Windows can expose many virtual Bluetooth COM ports. They are poor\n // automatic injector candidates and must never outrank a physical USB\n // bridge. Manual selection remains available.\n if (identity.contains(QStringLiteral(\"bluetooth\"))) score -= 120;\n return score;\n }\n\n // S8B Windows ownership contract:""", -) - -replace( - "apps/arstack_studio/src/DeviceIoWorker.cpp", - """[[nodiscard]] int portConfidence(const QSerialPortInfo& info) {\n int score = 0;\n if (info.hasVendorIdentifier() && info.vendorIdentifier() == 0x303AU) score += 100;\n const QString identity = QStringLiteral(\"%1 %2 %3\")\n .arg(info.description(), info.manufacturer(), info.serialNumber()).toLower();\n if (identity.contains(QStringLiteral(\"esp32-p4\"))) score += 90;\n else if (identity.contains(QStringLiteral(\"esp32\"))) score += 65;\n if (identity.contains(QStringLiteral(\"espressif\"))) score += 60;\n if (identity.contains(QStringLiteral(\"usb jtag\")) ||\n identity.contains(QStringLiteral(\"usb serial\"))) score += 15;\n return score;\n}\n""", - """[[nodiscard]] int portConfidence(const QSerialPortInfo& info) {\n return DeviceIoWorker::portConfidenceForMetadata(\n info.hasVendorIdentifier(),\n info.hasVendorIdentifier() ? info.vendorIdentifier() : 0U,\n info.hasProductIdentifier(),\n info.hasProductIdentifier() ? info.productIdentifier() : 0U,\n info.description(),\n info.manufacturer(),\n info.serialNumber());\n}\n""", -) - -replace( - "apps/arstack_studio/src/WindowsOwnershipHarness.cpp", - """bool crossProcessOwnershipAndCrashRecovery() {\n""", - """bool fastUsbDiscoveryPolicy() {\n const int ch343Exact = DeviceIoWorker::portConfidenceForMetadata(\n true, 0x1A86U, true, 0x55D3U,\n QStringLiteral(\"USB-Enhanced-SERIAL CH343\"), {}, {});\n const int ch343ByName = DeviceIoWorker::portConfidenceForMetadata(\n false, 0U, false, 0U,\n QStringLiteral(\"USB-Enhanced-SERIAL CH343\"), {}, {});\n const int bluetooth = DeviceIoWorker::portConfidenceForMetadata(\n false, 0U, false, 0U,\n QStringLiteral(\"Standard Serial over Bluetooth link\"), {}, {});\n const int generic = DeviceIoWorker::portConfidenceForMetadata(\n false, 0U, false, 0U, QStringLiteral(\"USB Serial Port\"), {}, {});\n const int espressif = DeviceIoWorker::portConfidenceForMetadata(\n true, 0x303AU, false, 0U, QStringLiteral(\"USB JTAG/serial debug unit\"),\n QStringLiteral(\"Espressif\"), {});\n\n return ch343Exact >= 60 && ch343ByName >= 60 && espressif >= 60 &&\n ch343Exact > generic && ch343ByName > generic &&\n ch343Exact > bluetooth && bluetooth < generic;\n}\n\nbool crossProcessOwnershipAndCrashRecovery() {\n""", -) - -replace( - "apps/arstack_studio/src/WindowsOwnershipHarness.cpp", - """ const bool serialPolicy = serialTransportLossPolicy();\n const bool processOwnership = crossProcessOwnershipAndCrashRecovery();\n\n qInfo().noquote()\n << \"S8B Windows ownership:\"\n << (serialPolicy ? \"PASS\" : \"FAIL\")\n << \"· Permission/Resource/DeviceNotFound/Read/Write errors force transport loss\";\n""", - """ const bool serialPolicy = serialTransportLossPolicy();\n const bool fastDiscovery = fastUsbDiscoveryPolicy();\n const bool processOwnership = crossProcessOwnershipAndCrashRecovery();\n\n qInfo().noquote()\n << \"S8B Windows ownership:\"\n << (serialPolicy ? \"PASS\" : \"FAIL\")\n << \"· Permission/Resource/DeviceNotFound/Read/Write errors force transport loss\";\n qInfo().noquote()\n << \"S8B Windows discovery:\"\n << (fastDiscovery ? \"PASS\" : \"FAIL\")\n << \"· CH343/Espressif USB outrank generic and Bluetooth COM ports; IDENTIFY remains authoritative\";\n""", -) - -replace( - "apps/arstack_studio/src/WindowsOwnershipHarness.cpp", - """ if (!serialPolicy || !processOwnership) {\n qCritical().noquote() << \"S8B Windows ownership integration harness: FAIL\";\n return 22;\n }\n\n qInfo().noquote()\n << \"S8B Windows ownership integration harness: PASS · OS process lock + crash recovery + serial transport-loss policy locked\";\n""", - """ if (!serialPolicy || !fastDiscovery || !processOwnership) {\n qCritical().noquote() << \"S8B Windows ownership/discovery integration harness: FAIL\";\n return 22;\n }\n\n qInfo().noquote()\n << \"S8B Windows ownership/discovery integration harness: PASS · fast CH343 discovery + OS process lock + crash recovery + serial transport-loss policy locked\";\n""", -) - -print("P0 CH343 fast-discovery source patch applied") From 44f600d778802669d7e8958e3aba759d836a7be6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 17:26:21 +0700 Subject: [PATCH 091/170] ci: remove CH343 repair workflow --- .github/workflows/p0-ch343-fast-discovery.yml | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 .github/workflows/p0-ch343-fast-discovery.yml diff --git a/.github/workflows/p0-ch343-fast-discovery.yml b/.github/workflows/p0-ch343-fast-discovery.yml deleted file mode 100644 index a631db7e6..000000000 --- a/.github/workflows/p0-ch343-fast-discovery.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: P0 CH343 Fast Discovery Repair - -on: - push: - paths: - - '.github/workflows/p0-ch343-fast-discovery.yml' - -permissions: - contents: write - -concurrency: - group: p0-ch343-fast-discovery - cancel-in-progress: true - -jobs: - repair: - runs-on: macos-15 - timeout-minutes: 10 - steps: - - name: Checkout repair branch - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - - name: Apply CH343 fast-discovery repair - run: | - set -euo pipefail - python3 .github/scripts/p0_ch343_fast_discovery.py - git diff --check - grep -q '0x1A86U' apps/arstack_studio/src/DeviceIoWorker.hpp - grep -q '0x55D3U' apps/arstack_studio/src/DeviceIoWorker.hpp - grep -q 'bluetooth' apps/arstack_studio/src/DeviceIoWorker.hpp - grep -q 'fastUsbDiscoveryPolicy' apps/arstack_studio/src/WindowsOwnershipHarness.cpp - - - name: Commit repaired discovery source - run: | - set -euo pipefail - git config user.name 'Ari Sulistiono' - git config user.email 'ari.sulistiono@gmail.com' - git add apps/arstack_studio/src/DeviceIoWorker.cpp apps/arstack_studio/src/DeviceIoWorker.hpp apps/arstack_studio/src/WindowsOwnershipHarness.cpp - git commit -m 'fix(studio): prioritize CH343 USB injector discovery' - git push origin HEAD:feature/ptp-production-v0.1.1 From 402451d836f4c381ffa8d7820554af7b473f1b98 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 17:26:28 +0700 Subject: [PATCH 092/170] ci: remove CH343 candidate workflow --- .../p0-ch343-fast-discovery-candidate.yml | 180 ------------------ 1 file changed, 180 deletions(-) delete mode 100644 .github/workflows/p0-ch343-fast-discovery-candidate.yml diff --git a/.github/workflows/p0-ch343-fast-discovery-candidate.yml b/.github/workflows/p0-ch343-fast-discovery-candidate.yml deleted file mode 100644 index 6254bebcf..000000000 --- a/.github/workflows/p0-ch343-fast-discovery-candidate.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: P0 CH343 Fast Discovery Windows Candidate - -on: - push: - paths: - - '.github/workflows/p0-ch343-fast-discovery-candidate.yml' - -permissions: - contents: read - actions: read - -concurrency: - group: p0-ch343-fast-discovery-candidate - cancel-in-progress: true - -env: - ARSTACK_VERSION: '0.1.1' - QT_VERSION: '6.10.3' - ESPFLASH_VERSION: '4.5.0' - ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' - -jobs: - windows-candidate: - runs-on: windows-2022 - timeout-minutes: 25 - steps: - - name: Checkout fast-discovery source - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - - - name: Download verified unchanged v0.1.1 firmware - uses: actions/download-artifact@v4 - with: - name: arstack-esp32p4-smv-firmware - path: firmware-package - run-id: 35071107192 - github-token: ${{ github.token }} - repository: masarray/arstack61850 - - - name: Install Qt - uses: jurplel/install-qt-action@v4 - with: - version: ${{ env.QT_VERSION }} - arch: win64_msvc2022_64 - modules: 'qtserialport' - cache: true - - - name: Configure and build Studio plus regression harnesses - 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 "Configure failed: $LASTEXITCODE" } - cmake --build build-arstack-studio --config Release ` - --target arstack_studio arstack_session_harness arstack_windows_ownership_harness --parallel - if ($LASTEXITCODE -ne 0) { throw "Build failed: $LASTEXITCODE" } - - - name: Prove CH343 fast discovery and ownership policy - shell: powershell - run: | - & "$PWD\build-arstack-studio\Release\arstack_windows_ownership_harness.exe" - if ($LASTEXITCODE -ne 0) { throw "Windows discovery/ownership harness failed: $LASTEXITCODE" } - - - name: Prove deterministic session recovery policy - shell: powershell - run: | - & "$PWD\build-arstack-studio\Release\arstack_session_harness.exe" - if ($LASTEXITCODE -ne 0) { throw "Session harness failed: $LASTEXITCODE" } - - - name: Prove close kills process twice - shell: powershell - run: | - $ErrorActionPreference = 'Stop' - $env:QT_QPA_PLATFORM = 'offscreen' - $env:QT_QUICK_BACKEND = 'software' - $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' - for ($cycle = 1; $cycle -le 2; $cycle++) { - $p = Start-Process -FilePath $exe -ArgumentList '--check-app-lifecycle' -PassThru - if (-not $p.WaitForExit(12000)) { - Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue - throw "Cycle ${cycle}: Studio PID did not exit after primary-window close" - } - if ($p.ExitCode -ne 0) { throw "Cycle ${cycle}: lifecycle exit code $($p.ExitCode)" } - if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { throw "Cycle ${cycle}: PID remained alive" } - } - - - name: Stage Studio with verified firmware - shell: powershell - run: | - $stage = Join-Path $PWD 'dist\ARStack-Studio' - New-Item -ItemType Directory -Force $stage | Out-Null - Copy-Item "$PWD\build-arstack-studio\Release\arstack_studio.exe" (Join-Path $stage 'ARStackStudio.exe') - windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime (Join-Path $stage 'ARStackStudio.exe') - Copy-Item LICENSE, NOTICE, THIRD_PARTY_NOTICES.md, COPYRIGHT.md -Destination $stage - New-Item -ItemType Directory -Force (Join-Path $stage 'firmware') | Out-Null - Copy-Item 'firmware-package\*' -Destination (Join-Path $stage 'firmware') -Recurse - - - name: Bundle pinned espflash - shell: powershell - run: | - $url = "https://github.com/esp-rs/espflash/releases/download/v$env:ESPFLASH_VERSION/espflash-x86_64-pc-windows-msvc.zip" - $zip = Join-Path $env:RUNNER_TEMP 'espflash.zip' - Invoke-WebRequest -Uri $url -OutFile $zip - $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $env:ESPFLASH_WINDOWS_ZIP_SHA256) { throw "espflash SHA mismatch: $actual" } - $extract = Join-Path $env:RUNNER_TEMP 'espflash' - Expand-Archive -Path $zip -DestinationPath $extract -Force - $espflash = Get-ChildItem $extract -Filter espflash.exe -Recurse | Select-Object -First 1 - if (-not $espflash) { throw 'espflash.exe not found' } - $tools = Join-Path $PWD 'dist\ARStack-Studio\tools' - New-Item -ItemType Directory -Force $tools | Out-Null - Copy-Item $espflash.FullName (Join-Path $tools 'espflash.exe') - - - name: Packaged contract and lifecycle checks - shell: powershell - run: | - function Assert-Zero([string]$FilePath, [string[]]$Arguments, [string]$Label) { - $p = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "$Label failed: $($p.ExitCode)" } - } - $stage = Join-Path $PWD 'dist\ARStack-Studio' - $studio = Join-Path $stage 'ARStackStudio.exe' - $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' - $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe' - $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' - Assert-Zero $studio @('--check-firmware-contract') 'Firmware contract' - Assert-Zero $studio @('--check-p0-policy') 'P0 policy' - Assert-Zero $studio @('--check-app-lifecycle') 'Packaged lifecycle' - - - name: Build portable ZIP and NSIS installer - shell: powershell - run: | - $portable = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-CH343-fast-win-x64-portable.zip" - Compress-Archive -Path 'dist\ARStack-Studio\*' -DestinationPath $portable -CompressionLevel Optimal -Force - choco install nsis -y --no-progress - $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' - if (-not (Test-Path $makensis)) { throw "makensis not found: $makensis" } - $stage = (Resolve-Path 'dist\ARStack-Studio').Path - $setup = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-CH343-fast-win-x64-setup.exe" - $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path - & $makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$setup" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script - if ($LASTEXITCODE -ne 0 -or -not (Test-Path $setup)) { throw 'NSIS installer build failed' } - $lines = foreach ($file in @($portable, $setup)) { - $hash = (Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash $([IO.Path]::GetFileName($file))" - } - $lines | Set-Content 'dist\SHA256SUMS-CH343.txt' -Encoding ascii - Get-Content 'dist\SHA256SUMS-CH343.txt' - - - name: Smoke installed EXE and prove PID exits - shell: powershell - run: | - $installer = (Resolve-Path "dist\ARStack-Studio-$env:ARSTACK_VERSION-CH343-fast-win-x64-setup.exe").Path - $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioCH343' - $install = Start-Process -FilePath $installer -ArgumentList @('/S', "/D=$installDir") -Wait -PassThru - if ($install.ExitCode -ne 0) { throw "Silent install failed: $($install.ExitCode)" } - $exe = Join-Path $installDir 'ARStackStudio.exe' - if (-not (Test-Path $exe)) { throw 'Installed EXE missing' } - $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' - $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' - $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' - $p = Start-Process -FilePath $exe -ArgumentList '--check-app-lifecycle' -PassThru - if (-not $p.WaitForExit(12000)) { - Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue - throw 'Installed Studio did not exit after primary-window close' - } - if ($p.ExitCode -ne 0) { throw "Installed lifecycle failed: $($p.ExitCode)" } - if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { throw 'Installed Studio PID remained alive' } - - - name: Upload CH343 fast candidate - uses: actions/upload-artifact@v4 - with: - name: arstack-studio-ch343-fast-windows-x64 - path: | - dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-CH343-fast-win-x64-portable.zip - dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-CH343-fast-win-x64-setup.exe - dist/SHA256SUMS-CH343.txt - if-no-files-found: error - retention-days: 7 From 4616f98649c43f7bc9f2de79f6177d86ac3fc9f8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:19:31 +0700 Subject: [PATCH 093/170] ui: add modern ribbon action component --- apps/arstack_studio/qml/RibbonAction.qml | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 apps/arstack_studio/qml/RibbonAction.qml diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml new file mode 100644 index 000000000..9a57ea812 --- /dev/null +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +Button { + id: control + + property var theme + property string uiFont: "Inter" + property string tone: "neutral" + property url iconSource + property bool large: false + property string toolTipText: "" + + implicitWidth: large ? 72 : Math.max(70, label.implicitWidth + 28) + implicitHeight: large ? 54 : 28 + hoverEnabled: true + activeFocusOnTab: true + font.family: uiFont + font.pixelSize: large ? 10 : 9 + font.weight: Font.DemiBold + + readonly property color fillColor: !enabled ? theme.surface2 + : tone === "success" ? (hovered ? "#1e5c43" : "#194d38") + : tone === "danger" ? (hovered ? "#523039" : theme.redSoft) + : tone === "accent" ? (hovered ? "#1d3c5e" : theme.accentSoft) + : hovered ? theme.raisedHover : "transparent" + readonly property color edgeColor: !enabled ? theme.lineSoft + : tone === "success" ? "#347a59" + : tone === "danger" ? "#86434b" + : tone === "accent" ? "#315f8d" + : activeFocus ? theme.accent : "transparent" + readonly property color textColor: !enabled ? theme.muted2 + : tone === "success" ? "#d6f4e6" + : tone === "danger" ? "#ffdce0" + : tone === "accent" ? "#dcebff" : theme.textSoft + + contentItem: Item { + implicitWidth: control.large + ? Math.max(label.implicitWidth, control.iconSource.toString().length ? 22 : 0) + : label.implicitWidth + (control.iconSource.toString().length ? 22 : 0) + implicitHeight: control.large ? 46 : Math.max(18, label.implicitHeight) + + Image { + id: icon + visible: control.iconSource.toString().length > 0 + source: control.iconSource + width: control.large ? 20 : 14 + height: width + sourceSize.width: width * 2 + sourceSize.height: height * 2 + opacity: control.enabled ? 0.94 : 0.30 + anchors.horizontalCenter: control.large ? parent.horizontalCenter : undefined + anchors.left: control.large ? undefined : parent.left + anchors.top: control.large ? parent.top : undefined + anchors.verticalCenter: control.large ? undefined : parent.verticalCenter + } + + Text { + id: label + text: control.text + color: control.textColor + font: control.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: control.large ? parent.bottom : undefined + anchors.verticalCenter: control.large ? undefined : parent.verticalCenter + } + } + + background: Rectangle { + radius: 6 + color: control.fillColor + border.width: control.edgeColor === "transparent" ? 0 : 1 + border.color: control.edgeColor + Behavior on color { ColorAnimation { duration: 90 } } + } + + ToolTip.visible: hovered && toolTipText.length > 0 + ToolTip.text: toolTipText + ToolTip.delay: 420 +} From fe39de16a2bbc206e2aa73af8488e36d199ed712 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:19:42 +0700 Subject: [PATCH 094/170] ui: add ribbon group component --- apps/arstack_studio/qml/RibbonGroup.qml | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/arstack_studio/qml/RibbonGroup.qml 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 + } +} From 5e69ffc28b17a515bd134afacb7f5506b747aecb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:20:12 +0700 Subject: [PATCH 095/170] ui: add QML-native tabbed ribbon shell --- apps/arstack_studio/qml/ModernRibbon.qml | 299 +++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 apps/arstack_studio/qml/ModernRibbon.qml diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml new file mode 100644 index 000000000..fc9bb6874 --- /dev/null +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -0,0 +1,299 @@ +// 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 + property int currentTab: 0 + + implicitHeight: compact ? 70 : 96 + color: theme.surface2 + border.width: 1 + border.color: theme.line + + function chooseTab(index) { currentTab = index } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 30 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + spacing: 2 + + Repeater { + model: ["Home", "Injection", "View", "Device"] + delegate: Button { + required property int index + required property string modelData + implicitHeight: 28 + implicitWidth: Math.max(70, tabText.implicitWidth + 24) + hoverEnabled: true + onClicked: bar.chooseTab(index) + contentItem: Text { + id: tabText + text: modelData + color: bar.currentTab === index ? bar.theme.text : bar.theme.muted + font.family: bar.uiFont + font.pixelSize: 10 + font.weight: bar.currentTab === index ? Font.DemiBold : Font.Medium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 5 + color: parent.hovered ? bar.theme.raisedHover : "transparent" + Rectangle { + visible: bar.currentTab === index + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 2 + radius: 1 + color: bar.theme.accent + } + } + } + } + + Item { Layout.fillWidth: true } + + Rectangle { + implicitWidth: Math.max(142, stateText.implicitWidth + 34) + implicitHeight: 24 + radius: 6 + color: "#0d141c" + border.width: 1 + border.color: bar.workflow.smartStateColor + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 9 + spacing: 7 + Rectangle { width: 7; height: 7; radius: 4; color: bar.workflow.smartStateColor } + Label { + id: stateText + Layout.fillWidth: true + text: bar.workflow.displayState + color: bar.workflow.smartStateColor + font.family: bar.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: bar.theme.lineSoft } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 0 + visible: bar.currentTab === 0 + + RibbonGroup { + theme: bar.theme; uiFont: bar.uiFont; title: "Session" + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; large: !bar.compact + 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; large: !bar.compact + 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() + } + } + + RibbonGroup { + theme: bar.theme; uiFont: bar.uiFont; title: "Engineering" + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Open file" + iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") + enabled: bar.session.engineeringEditable + toolTipText: "Open IEC 61850 engineering configuration" + onClicked: bar.workflow.openEngineeringDialog() + } + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "4I + 4V" + iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + enabled: bar.session.engineeringEditable + toolTipText: "Use built-in 4I + 4V / 4000 fps profile" + onClicked: bar.workflow.useBuiltInProfile() + } + } + + RibbonGroup { + visible: bar.session.firmwareUpdateRequired || bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable + theme: bar.theme; uiFont: bar.uiFont; title: "Attention" + RibbonAction { + visible: bar.session.firmwareUpdateRequired + theme: bar.theme; uiFont: bar.uiFont; text: "Update firmware"; tone: "accent" + onClicked: bar.workflow.openUpdatePrompt() + } + RibbonAction { + visible: bar.session.firmwareInstallRequired + theme: bar.theme; uiFont: bar.uiFont; text: "Install firmware"; tone: "accent" + onClicked: bar.workflow.openInstallPrompt() + } + RibbonAction { + visible: bar.session.profileSyncRetryAvailable + theme: bar.theme; uiFont: bar.uiFont; text: "Retry profile"; tone: "accent" + onClicked: bar.workflow.retryProfileSync() + } + } + + Item { Layout.fillWidth: true } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 0 + visible: bar.currentTab === 1 + + RibbonGroup { + theme: bar.theme; uiFont: bar.uiFont; title: "Frequency" + RibbonAction { theme: bar.theme; uiFont: bar.uiFont; text: "0 Hz · DC"; onClicked: bar.controller.setFrequencyValue(0) } + RibbonAction { theme: bar.theme; uiFont: bar.uiFont; text: "50 Hz"; onClicked: bar.controller.setFrequencyValue(50) } + RibbonAction { theme: bar.theme; uiFont: bar.uiFont; text: "60 Hz"; onClicked: bar.controller.setFrequencyValue(60) } + } + + RibbonGroup { + theme: bar.theme; uiFont: bar.uiFont; title: "Three phase" + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Balanced" + iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") + toolTipText: "Reset three-phase magnitudes and angles to the balanced reference" + onClicked: bar.controller.balanced() + } + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Zero all" + iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") + onClicked: bar.controller.zeroAll() + } + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Link phases" + checkable: true + checked: bar.controller.phaseLink + tone: checked ? "accent" : "neutral" + onClicked: bar.controller.phaseLink = checked + } + } + + Item { Layout.fillWidth: true } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 0 + visible: bar.currentTab === 2 + + RibbonGroup { + theme: bar.theme; uiFont: bar.uiFont; title: "Preview" + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Phasor" + checkable: true + checked: bar.controller.phasorDockVisible || bar.controller.phasorDetached + tone: checked ? "accent" : "neutral" + onClicked: { + if (bar.controller.phasorDetached) bar.controller.phasorDetached = false + bar.controller.phasorDockVisible = checked + } + } + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Waveform" + checkable: true + checked: bar.controller.waveformDockVisible || bar.controller.waveformDetached + tone: checked ? "accent" : "neutral" + onClicked: { + if (bar.controller.waveformDetached) bar.controller.waveformDetached = false + bar.controller.waveformDockVisible = checked + } + } + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Monitor" + checkable: true + checked: bar.controller.telemetryDockVisible + tone: checked ? "accent" : "neutral" + onClicked: bar.controller.telemetryDockVisible = checked + } + } + + RibbonGroup { + theme: bar.theme; uiFont: bar.uiFont; title: "Layout" + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Reset layout" + iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + onClicked: bar.workflow.resetDockLayout() + } + } + + Item { Layout.fillWidth: true } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 0 + visible: bar.currentTab === 3 + + RibbonGroup { + theme: bar.theme; uiFont: bar.uiFont; title: "Device" + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Advanced" + iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + onClicked: bar.controller.openConfiguration() + } + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont; text: "Diagnostics" + iconSource: Qt.resolvedUrl("../assets/lucide/scan-search.svg") + onClicked: bar.controller.openDiagnostics() + } + } + + RibbonGroup { + theme: bar.theme; uiFont: bar.uiFont; title: "Connection" + RibbonAction { + theme: bar.theme; uiFont: bar.uiFont + text: bar.device.deviceVerified ? bar.device.portName : "Reconnect" + tone: bar.device.deviceVerified ? "success" : "accent" + toolTipText: bar.device.deviceVerified ? "Verified ARStack ESP32-P4 device" : "Run smart device discovery" + onClicked: if (!bar.device.deviceVerified) bar.session.requestConnect() + } + } + + Item { Layout.fillWidth: true } + } + } + } +} From d31a65f5917e91d75b2c7110ed282ef2803101a1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:20:46 +0700 Subject: [PATCH 096/170] ci: add phase 1 ribbon migration patcher --- .github/scripts/p0_ribbon_phase1.py | 137 ++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 .github/scripts/p0_ribbon_phase1.py 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') From cc0a4de446c580915ae50e058dc507b6a0d87a85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:21:03 +0700 Subject: [PATCH 097/170] ci: apply phase 1 ribbon migration --- .github/workflows/p0-ribbon-phase1.yml | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/p0-ribbon-phase1.yml diff --git a/.github/workflows/p0-ribbon-phase1.yml b/.github/workflows/p0-ribbon-phase1.yml new file mode 100644 index 000000000..0494a91b4 --- /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: ubuntu-latest + 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: python .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 From 17ebf92cde1cd55e9825549c84cfb28fa649dcd1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:24:31 +0700 Subject: [PATCH 098/170] ci: add P0 PTP varargs repair patcher --- .github/scripts/p0_ptp_vargs_fix.py | 124 ++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .github/scripts/p0_ptp_vargs_fix.py diff --git a/.github/scripts/p0_ptp_vargs_fix.py b/.github/scripts/p0_ptp_vargs_fix.py new file mode 100644 index 000000000..a7856e0fb --- /dev/null +++ b/.github/scripts/p0_ptp_vargs_fix.py @@ -0,0 +1,124 @@ +from pathlib import 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) + +lab_path = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') +lab = lab_path.read_text(encoding='utf-8') +old = '''[[nodiscard]] bool send_frame( + 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; +} + +[[nodiscard]] bool transmit_with_hw_timestamp( + const esp_eth_handle_t eth_handle, + std::vector& frame, + 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. + const auto result = esp_eth_transmit_ctrl_vargs( + eth_handle, + &tx_timestamp, + 1U, + frame.data(), + frame.size()); + if (result != ESP_OK || !valid_hw_timestamp(tx_timestamp)) { + return false; + } + timestamp = to_ptp_timestamp(tx_timestamp); + return true; +} +''' +new = '''[[nodiscard]] bool send_frame( + const esp_eth_handle_t eth_handle, + std::vector& frame) noexcept { + if (frame.empty()) return false; + 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( + const esp_eth_handle_t eth_handle, + std::vector& frame, + PtpTimestamp& timestamp) noexcept { + if (frame.empty()) return false; + eth_mac_time_t tx_timestamp{}; + // 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, + 2U, + frame.data(), + frame.size()); + 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); + return true; +} +''' +lab = replace_once(lab, old, new, 'PTP source varargs contract') +lab_path.write_text(lab, encoding='utf-8') + +receiver_path = Path('embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp') +receiver = receiver_path.read_text(encoding='utf-8') +old = ''' eth_mac_time_t tx_timestamp{}; + // esp_eth_transmit_ctrl_vargs argc counts buffer/length pairs. One PTP + // Ethernet frame is exactly one pair. + const auto result = esp_eth_transmit_ctrl_vargs( + handle, + &tx_timestamp, + 1U, + frame.data(), + frame.size()); + if (result != ESP_OK || !valid_hw_timestamp(tx_timestamp)) return false; + timestamp = to_ptp_timestamp(tx_timestamp); +''' +new = ''' eth_mac_time_t tx_timestamp{}; + // 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, + 2U, + frame.data(), + frame.size()); + 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); +''' +receiver = replace_once(receiver, old, new, 'PTP receiver varargs contract') +receiver_path.write_text(receiver, encoding='utf-8') + +print('PTP ESP-IDF 5.5 varargs contract + diagnostics patch applied') From b268636518ae89703770c9a66c2f7784e7ea7fea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:24:43 +0700 Subject: [PATCH 099/170] ci: apply P0 PTP varargs correction --- .github/workflows/p0-ptp-vargs-fix.yml | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/p0-ptp-vargs-fix.yml diff --git a/.github/workflows/p0-ptp-vargs-fix.yml b/.github/workflows/p0-ptp-vargs-fix.yml new file mode 100644 index 000000000..474827d73 --- /dev/null +++ b/.github/workflows/p0-ptp-vargs-fix.yml @@ -0,0 +1,35 @@ +name: P0 PTP Vargs Fix Apply + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + paths: + - '.github/workflows/p0-ptp-vargs-fix.yml' + - '.github/scripts/p0_ptp_vargs_fix.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 ESP-IDF 5.5 varargs correction + run: python3 .github/scripts/p0_ptp_vargs_fix.py + - name: Commit source changes + run: | + if git diff --quiet; then + echo 'PTP varargs patch already present.' + exit 0 + fi + git config user.name 'ARStack CI Repair' + git config user.email 'actions@users.noreply.github.com' + git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp + git commit -m 'fix(ptp): honor ESP-IDF 5.5 varargs contract and expose TX failures' + git push origin HEAD:feature/ptp-production-v0.1.1 From d9503ef7a8ad7399358112a09de45c8d9f982926 Mon Sep 17 00:00:00 2001 From: ARStack CI Repair Date: Wed, 16 Sep 2026 11:25:00 +0000 Subject: [PATCH 100/170] fix(ptp): honor ESP-IDF 5.5 varargs contract and expose TX failures --- .../main/ptp_lab_task.cpp | 24 +++++++++++++++---- .../main/ptp_receiver_task.cpp | 19 +++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index ec3c36477..347760d8c 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -320,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( @@ -329,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); diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp index 84b9ed6be..74d5efda0 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp @@ -189,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; } From 54f3f32bd1ab53d753e0f97df1e022457d579556 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:26:00 +0700 Subject: [PATCH 101/170] ci: move ribbon phase 1 apply to macOS runner --- .github/workflows/p0-ribbon-phase1.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/p0-ribbon-phase1.yml b/.github/workflows/p0-ribbon-phase1.yml index 0494a91b4..d2c848a4f 100644 --- a/.github/workflows/p0-ribbon-phase1.yml +++ b/.github/workflows/p0-ribbon-phase1.yml @@ -13,7 +13,7 @@ permissions: jobs: apply: - runs-on: ubuntu-latest + runs-on: macos-15 timeout-minutes: 5 steps: - uses: actions/checkout@v4 @@ -21,7 +21,7 @@ jobs: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - name: Apply phase 1 ribbon patch - run: python .github/scripts/p0_ribbon_phase1.py + run: python3 .github/scripts/p0_ribbon_phase1.py - name: Commit source changes run: | if git diff --quiet; then From 53c2e195f79c693e32803adf0d0713b44f9d2744 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:28:43 +0700 Subject: [PATCH 102/170] ci: lock ESP-IDF 5.5 PTP timestamp varargs contract --- .github/workflows/esp32p4-ptp-lab.yml | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index b2261b4b1..fc7bc4923 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -45,7 +45,7 @@ jobs: - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 - - name: Verify PTP source readiness contract + - name: Verify PTP source readiness and ESP-IDF 5.5 TX contract shell: bash run: | python - <<'PY' @@ -53,6 +53,7 @@ jobs: 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: @@ -60,11 +61,21 @@ jobs: 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 has no portable ETH_CMD_G_LINK query. Admission truth is - # therefore the stronger bounded runtime contract: explicit operator - # start is accepted only after real Announce + Sync + Follow_Up TX. - # The Ethernet facade intentionally stores the checked-start result so - # a failed SOURCE start can restore normal shared-EMAC mode. + # 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();', @@ -79,7 +90,7 @@ jobs: missing = [token for token in required if token not in combined] if missing: raise SystemExit('PTP verified-start contract missing: ' + ', '.join(missing)) - print('PTP source contract: explicit post-Ethernet-start path + verified TX + failed-start EMAC rollback') + 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 @@ -140,4 +151,4 @@ jobs: grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8$' sdkconfig - name: Build PTP-P2 firmware - run: source /opt/esp/idf/export.sh && idf.py build \ No newline at end of file + run: source /opt/esp/idf/export.sh && idf.py build From 0b2f9c53b723575086d9f79d70cb9001223db691 Mon Sep 17 00:00:00 2001 From: ARStack CI Repair Date: Wed, 16 Sep 2026 11:29:04 +0000 Subject: [PATCH 103/170] ui: adopt modern tabbed ribbon and compact instrument shell --- apps/arstack_studio/CMakeLists.txt | 3 + apps/arstack_studio/qml/Main.qml | 33 ++-- apps/arstack_studio/qml/SignalMatrix.qml | 2 +- apps/arstack_studio/qml/StudioTheme.qml | 1 + apps/arstack_studio/qml/WorkflowBar.qml | 230 +++-------------------- 5 files changed, 47 insertions(+), 222 deletions(-) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 33e2013a8..be5234e8f 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -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/Main.qml b/apps/arstack_studio/qml/Main.qml index 44c59f4fa..b0d3e0ab8 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -6,10 +6,10 @@ 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 + PTP · v" + Qt.application.version color: studioTheme.bg @@ -66,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 } @@ -679,8 +679,8 @@ ApplicationWindow { WorkflowBar { id: workflowBar Layout.fillWidth: true - Layout.preferredHeight: 72 - Layout.minimumHeight: 72 + Layout.preferredHeight: workflowBar.implicitHeight + Layout.minimumHeight: workflowBar.implicitHeight theme: studioTheme controller: root device: device @@ -763,10 +763,6 @@ 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 } @@ -780,8 +776,8 @@ ApplicationWindow { implicitWidth: 84 text: "50.000" suffixText: "Hz" - enabled: root.signalFrequency > 0 - validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } + enabled: true + validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 } onTextEdited: { var value = root.parseOperatorNumber(text) if (root.validFrequency(value)) { @@ -798,15 +794,16 @@ ApplicationWindow { 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) + 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 diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index dae10bc78..afc735280 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -116,7 +116,7 @@ Rectangle { property real pendingPhase: angle Layout.fillWidth: true - Layout.preferredHeight: matrix.compact ? 54 : 60 + Layout.preferredHeight: matrix.compact ? 46 : 50 color: signalRow.selected ? "#111c27" : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#131f2b" : rowHover.hovered ? "#101820" : "transparent" diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml index 5318ca9c2..f4f36966b 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" diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index b99626fb6..b9b2cdf61 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -277,6 +277,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" @@ -500,211 +517,18 @@ SurfacePanel { } } - implicitHeight: 72 - color: theme.surface2 - border.color: theme.line + implicitHeight: ribbon.compact ? 70 : 96 + color: "transparent" + border.width: 0 - component MenuButton: CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - implicitHeight: 24 - implicitWidth: 66 - font.pixelSize: 10 - } - - 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 } } From d5b5dfbb87651447ad48cea7f2ab64e5000ee4ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:29:25 +0700 Subject: [PATCH 104/170] ci: add Windows fallback for ribbon phase 1 apply --- .../workflows/p0-ribbon-phase1-windows.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/p0-ribbon-phase1-windows.yml 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 From e1777ae021969a61010e683c132540eaa5025b30 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:49:03 +0700 Subject: [PATCH 105/170] ci(p0): add ESP-IDF 5.5.4 macOS fallback build gate --- .../workflows/p0-espidf55-macos-verify.yml | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .github/workflows/p0-espidf55-macos-verify.yml diff --git a/.github/workflows/p0-espidf55-macos-verify.yml b/.github/workflows/p0-espidf55-macos-verify.yml new file mode 100644 index 000000000..0a13cd60d --- /dev/null +++ b/.github/workflows/p0-espidf55-macos-verify.yml @@ -0,0 +1,109 @@ +name: P0 ESP-IDF 5.5.4 macOS Verify + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + paths: + - '.github/workflows/p0-espidf55-macos-verify.yml' + +permissions: + contents: read + +concurrency: + group: p0-espidf55-macos-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-source-and-receiver: + runs-on: macos-14 + timeout-minutes: 35 + steps: + - name: Checkout ARStack exact head + uses: actions/checkout@v4 + with: + path: arstack + fetch-depth: 1 + + - name: Checkout ESP-IDF v5.5.4 + uses: actions/checkout@v4 + with: + repository: espressif/esp-idf + ref: v5.5.4 + path: esp-idf + submodules: recursive + fetch-depth: 1 + + - name: Install ESP-IDF toolchain for ESP32-P4 + shell: bash + run: | + set -euo pipefail + ./esp-idf/install.sh esp32p4 + + - name: Verify corrected ESP-IDF 5.5 varargs contract + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + root = Path('arstack/embedded/esp32p4_smv_injector/main') + for name in ('ptp_lab_task.cpp', 'ptp_receiver_task.cpp'): + text = (root / name).read_text(encoding='utf-8') + call = 'esp_eth_transmit_ctrl_vargs(' + if call not in text: + raise SystemExit(f'{name}: timestamped TX call missing') + if '\n 2U,\n' not in text and '\n 2U,\r\n' not in text: + raise SystemExit(f'{name}: corrected argc=2 contract missing') + if 'argc counts buffer/length pairs' in text: + raise SystemExit(f'{name}: obsolete argc interpretation returned') + driver = Path('esp-idf/components/esp_eth/src/mac/esp_eth_mac_esp.c').read_text(encoding='utf-8') + if 'uint32_t buf_num = argc / 2;' not in driver: + raise SystemExit('ESP-IDF v5.5.4 driver contract changed unexpectedly') + print('P0 contract PASS: one frame = 2 variadic args, driver buf_num = argc / 2') + PY + + - name: Build production SOURCE firmware + shell: bash + run: | + set -euo pipefail + source "$GITHUB_WORKSPACE/esp-idf/export.sh" + cd "$GITHUB_WORKSPACE/arstack/embedded/esp32p4_smv_injector" + idf.py set-target esp32p4 + idf.py reconfigure + grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig + grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig + grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig + idf.py build + mkdir -p "$GITHUB_WORKSPACE/p0-source-artifact" + cp build/bootloader/bootloader.bin "$GITHUB_WORKSPACE/p0-source-artifact/" + cp build/partition_table/partition-table.bin "$GITHUB_WORKSPACE/p0-source-artifact/" + cp build/*.bin "$GITHUB_WORKSPACE/p0-source-artifact/" + cp sdkconfig "$GITHUB_WORKSPACE/p0-source-artifact/sdkconfig.source" + + - name: Build TIME_RECEIVER firmware + shell: bash + run: | + set -euo pipefail + source "$GITHUB_WORKSPACE/esp-idf/export.sh" + cd "$GITHUB_WORKSPACE/arstack/embedded/esp32p4_smv_injector" + python3 - <<'PY' + from pathlib import Path + p = Path('sdkconfig') + text = p.read_text(encoding='utf-8') + old = 'CONFIG_AR_PTP_OPERATING_ROLE=0' + new = 'CONFIG_AR_PTP_OPERATING_ROLE=1' + if old not in text: + raise SystemExit('SOURCE role config missing before receiver build') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + PY + idf.py reconfigure + grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=1' sdkconfig + idf.py build + + - name: Upload exact SOURCE candidate + uses: actions/upload-artifact@v4 + with: + name: p0-esp32p4-source-v0.1.1-${{ github.sha }} + path: p0-source-artifact + if-no-files-found: error + retention-days: 7 From 688e6719618ba4fdd289b7ec72bd0f7adbb80852 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:52:04 +0700 Subject: [PATCH 106/170] ci(p0): bound and serialize ESP32-P4 firmware builds --- .github/workflows/esp32p4-smv-injector.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index f571a80f3..5a734db97 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -34,6 +34,10 @@ on: 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.1' ARSTACK_PROTOCOL_VERSION: '1' @@ -41,6 +45,7 @@ env: jobs: build-esp32p4: runs-on: ubuntu-latest + timeout-minutes: 25 container: image: espressif/idf:v5.5.4 defaults: From f84dff660ae741c5dbf3faa372c3c195eb93c9af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 18:52:30 +0700 Subject: [PATCH 107/170] ci(p0): bound and serialize ESP32-P4 PTP builds --- .github/workflows/esp32p4-ptp-lab.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index fc7bc4923..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: From bc1899625943d05734ae539ee34509523ce9c5ca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:53:35 +0700 Subject: [PATCH 108/170] ci: apply P0 firmware build identity contract --- .../p0-firmware-build-identity-repair.yml | 601 ++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 .github/workflows/p0-firmware-build-identity-repair.yml diff --git a/.github/workflows/p0-firmware-build-identity-repair.yml b/.github/workflows/p0-firmware-build-identity-repair.yml new file mode 100644 index 000000000..2767507a4 --- /dev/null +++ b/.github/workflows/p0-firmware-build-identity-repair.yml @@ -0,0 +1,601 @@ +name: P0 Firmware Build Identity Repair + +on: + push: + branches: + - feature/ptp-production-v0.1.1 + paths: + - '.github/workflows/p0-firmware-build-identity-repair.yml' + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + + - name: Apply production build identity contract + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + + def replace_once(path, old, new, label): + p = Path(path) + text = p.read_text(encoding='utf-8') + if old not in text: + raise SystemExit(f'{label}: expected source block not found in {path}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + # Embedded build identity: one source-commit-derived ID, validated at configure time. + replace_once( + 'embedded/esp32p4_smv_injector/CMakeLists.txt', + 'set(PROJECT_VER "0.1.1")\n\ninclude($ENV{IDF_PATH}/tools/cmake/project.cmake)\n', + '''set(PROJECT_VER "0.1.1") + +# Semantic firmware provenance. CI supplies the exact checkout commit; local +# builds fall back to the repository HEAD. The value is intentionally short, +# fixed-size hex so it is safe to expose on the bounded serial identity line. +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}") +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 + RESULT_VARIABLE ARSTACK_FIRMWARE_BUILD_ID_RESULT) + if(NOT ARSTACK_FIRMWARE_BUILD_ID_RESULT EQUAL 0) + set(ARSTACK_FIRMWARE_BUILD_ID "0000000000000000") + endif() +endif() +string(TOLOWER "${ARSTACK_FIRMWARE_BUILD_ID}" ARSTACK_FIRMWARE_BUILD_ID) +if(NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]{16}$") + 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") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +''', + 'embedded build identity configure contract') + + replace_once( + 'embedded/esp32p4_smv_injector/main/CMakeLists.txt', + 'target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20)\n', + '''target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) +target_compile_definitions(${COMPONENT_LIB} PRIVATE + ARSTACK_FIRMWARE_BUILD_ID="${ARSTACK_FIRMWARE_BUILD_ID}") +''', + 'embedded build identity compile definition') + + live = Path('embedded/esp32p4_smv_injector/main/live_control.cpp') + text = live.read_text(encoding='utf-8') + text = text.replace('"SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"', + '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"') + text = text.replace('"SMV-4I4V,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"', + '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"') + text = text.replace('"SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"', + '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE"') + text = text.replace('"SMV-4I4V,LIVE-SETPOINTS"', + '"SMV-4I4V,PROFILE,LIVE-SETPOINTS"') + old = ''' "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", + 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, + static_cast(g_boot_id), + capabilities);''' + new = ''' "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);''' + if old not in text: + raise SystemExit('live identity format block not found') + live.write_text(text.replace(old, new, 1), encoding='utf-8') + + # Build workflow: compile the same ID that is written to the manifest. + replace_once( + '.github/workflows/esp32p4-smv-injector.yml', + ' - name: Build firmware\n run: source /opt/esp/idf/export.sh && idf.py build\n', + ''' - name: Build firmware + run: | + set -euo pipefail + export ARSTACK_FIRMWARE_BUILD_ID="${GITHUB_SHA:0:16}" + source /opt/esp/idf/export.sh + idf.py build + strings build/arstack_esp32p4_smv_injector.elf | grep -F "$ARSTACK_FIRMWARE_BUILD_ID" >/dev/null + echo "Semantic firmware build ID: $ARSTACK_FIRMWARE_BUILD_ID" +''', + 'firmware build identity workflow') + replace_once( + '.github/workflows/esp32p4-smv-injector.yml', + ' "version": os.environ["ARSTACK_FIRMWARE_VERSION"],\n "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),\n', + ' "version": os.environ["ARSTACK_FIRMWARE_VERSION"],\n "buildId": source_commit[:16].lower(),\n "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),\n', + 'manifest buildId') + replace_once( + '.github/workflows/esp32p4-smv-injector.yml', + " assert manifest['protocol'] == 1\n assert 'PTP-P2' in manifest['capabilities']\n", + " assert manifest['protocol'] == 1\n assert len(manifest['buildId']) == 16\n assert manifest['sourceCommit'].lower().startswith(manifest['buildId'])\n assert 'PROFILE' in manifest['capabilities']\n assert 'PTP-P2' in manifest['capabilities']\n", + 'manifest buildId validation') + + # Typed device identity. Old identity lines remain parseable but are not + # current when the package carries a non-empty expected build ID. + replace_once( + 'apps/arstack_studio/src/DeviceController.hpp', + ' QString firmwareVersion;\n QString bootId;\n', + ' QString firmwareVersion;\n QString buildId;\n QString bootId;\n', + 'DeviceIdentity buildId field') + replace_once( + 'apps/arstack_studio/src/DeviceController.hpp', + ' deviceId.isEmpty() && firmwareVersion.isEmpty() && bootId.isEmpty() &&\n', + ' deviceId.isEmpty() && firmwareVersion.isEmpty() && buildId.isEmpty() && bootId.isEmpty() &&\n', + 'DeviceIdentity empty buildId') + replace_once( + 'apps/arstack_studio/src/DeviceController.hpp', + ' Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged)\n', + ' Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged)\n', + 'DeviceController buildId property') + replace_once( + 'apps/arstack_studio/src/DeviceController.hpp', + ' [[nodiscard]] QString firmwareVersion() const;\n [[nodiscard]] QString bootId() const;\n', + ' [[nodiscard]] QString firmwareVersion() const;\n [[nodiscard]] QString firmwareBuildId() const;\n [[nodiscard]] QString bootId() const;\n', + 'DeviceController buildId getter') + replace_once( + 'apps/arstack_studio/src/DeviceController.hpp', + ' [[nodiscard]] static bool identitySupportsCurrentContract(\n const DeviceIdentity& identity,\n const QString& expectedFirmwareVersion);\n', + ' [[nodiscard]] static bool identitySupportsCurrentContract(\n const DeviceIdentity& identity,\n const QString& expectedFirmwareVersion,\n const QString& expectedBuildId = {});\n', + 'DeviceController current contract signature') + + replace_once( + 'apps/arstack_studio/src/DeviceController.cpp', + ' "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\\\-]+) "\n "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\\\-]+)"),\n', + ' "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\\\-]+) "\n "(?:build=([A-Fa-f0-9]{16}) )?"\n "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\\\-]+)"),\n', + 'DeviceController identity regex buildId') + replace_once( + 'apps/arstack_studio/src/DeviceController.cpp', + 'QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; }\nQString DeviceController::bootId() const { return identity_.bootId; }\n', + 'QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; }\nQString DeviceController::firmwareBuildId() const { return identity_.buildId; }\nQString DeviceController::bootId() const { return identity_.bootId; }\n', + 'DeviceController buildId getter implementation') + replace_once( + 'apps/arstack_studio/src/DeviceController.cpp', + ' parsed.firmwareVersion = match.captured(5);\n parsed.bootId = match.captured(6).toUpper();\n parsed.capabilities = match.captured(7).split(QLatin1Char(\',\'), Qt::SkipEmptyParts);\n', + ' parsed.firmwareVersion = match.captured(5);\n parsed.buildId = match.captured(6).toLower();\n parsed.bootId = match.captured(7).toUpper();\n parsed.capabilities = match.captured(8).split(QLatin1Char(\',\'), Qt::SkipEmptyParts);\n', + 'DeviceController identity captures') + replace_once( + 'apps/arstack_studio/src/DeviceController.cpp', + '''bool DeviceController::identitySupportsCurrentContract( + const DeviceIdentity& identity, + const QString& expectedFirmwareVersion) { + static const QStringList requiredCapabilities{ + QStringLiteral("SMV-4I4V"), + QStringLiteral("LIVE-SETPOINTS"), + QStringLiteral("SESSION-LEASE"), + QStringLiteral("PTP-P2"), + QStringLiteral("SMPSYNCH-AUTO")}; + if (identity.product != QStringLiteral("SMV-INJECTOR") || + identity.target != QStringLiteral("ESP32-P4") || + identity.protocolVersion != QStringLiteral("1") || + identity.firmwareVersion != expectedFirmwareVersion || + identity.bootId.size() != 16) { + return false; + } +''', + '''bool DeviceController::identitySupportsCurrentContract( + const DeviceIdentity& identity, + const QString& expectedFirmwareVersion, + const QString& expectedBuildId) { + static const QStringList requiredCapabilities{ + QStringLiteral("SMV-4I4V"), + QStringLiteral("PROFILE"), + QStringLiteral("LIVE-SETPOINTS"), + 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; + } +''', + 'DeviceController build-aware contract') + + # FirmwareManager reads buildId from the already-authoritative manifest. + replace_once( + 'apps/arstack_studio/src/FirmwareManager.hpp', + 'class FirmwareWorker;\n', + 'class DeterministicSessionHarness;\nclass FirmwareWorker;\n', + 'FirmwareManager harness forward declaration') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.hpp', + ' Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged)\n', + ' Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY stateChanged)\n Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged)\n', + 'FirmwareManager buildId property') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.hpp', + ' [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; }\n [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; }\n', + ' [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; }\n [[nodiscard]] QString firmwareBuildId() const { return firmwareBuildId_; }\n [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; }\n', + 'FirmwareManager buildId getter') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.hpp', + 'private:\n enum class Operation { none, probe, flash, reset };\n', + 'private:\n friend class DeterministicSessionHarness;\n enum class Operation { none, probe, flash, reset };\n', + 'FirmwareManager harness friend') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.hpp', + ' QString firmwareVersion_{QStringLiteral("-")};\n QString expectedProtocol_{QStringLiteral("-")};\n', + ' QString firmwareVersion_{QStringLiteral("-")};\n QString firmwareBuildId_;\n QString expectedProtocol_{QStringLiteral("-")};\n', + 'FirmwareManager buildId storage') + + replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + ' firmwareVersion_ = QStringLiteral("-");\n expectedProtocol_ = QStringLiteral("-");\n', + ' firmwareVersion_ = QStringLiteral("-");\n firmwareBuildId_.clear();\n expectedProtocol_ = QStringLiteral("-");\n', + 'FirmwareManager clear buildId') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n', + ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\n const QString buildId = object.value(QStringLiteral("buildId")).toString().trimmed().toLower();\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n', + 'FirmwareManager parse buildId') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + ' expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n', + ' expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n !QRegularExpression(QStringLiteral("^[0-9a-f]{16}$")).match(buildId).hasMatch() ||\n !sourceCommit.startsWith(buildId, Qt::CaseInsensitive) ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n', + 'FirmwareManager validate buildId') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + ' firmwareVersion_ = version;\n expectedProtocol_ = QString::number(protocol);\n', + ' firmwareVersion_ = version;\n firmwareBuildId_ = buildId;\n expectedProtocol_ = QString::number(protocol);\n', + 'FirmwareManager store buildId') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + ' bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")\n .arg(firmwareVersion_);\n', + ' bundleStatus_ = QStringLiteral("Ready · firmware v%1 · build %2 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")\n .arg(firmwareVersion_, firmwareBuildId_);\n', + 'FirmwareManager bundle status buildId') + + # Supervisor remains the single mutation authority. Reinstall is just + # another explicit operator intent into the existing firmware state machine. + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ' Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged)\n', + ' Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareReinstallAvailable READ firmwareReinstallAvailable NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged)\n', + 'SmartSession reinstall property') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ' Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT)\n Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged)\n', + ' Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT)\n Q_PROPERTY(QString expectedFirmwareBuildId READ expectedFirmwareBuildId NOTIFY stateChanged)\n Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString deviceFirmwareBuildId READ deviceFirmwareBuildId NOTIFY stateChanged)\n', + 'SmartSession buildId properties') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ' [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n [[nodiscard]] bool firmwareInstallRequired() const noexcept;\n', + ' [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n [[nodiscard]] bool firmwareReinstallAvailable() const noexcept;\n [[nodiscard]] bool firmwareInstallRequired() const noexcept;\n', + 'SmartSession reinstall getter') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ' [[nodiscard]] QString expectedFirmwareVersion() const;\n [[nodiscard]] QString deviceFirmwareVersion() const;\n', + ' [[nodiscard]] QString expectedFirmwareVersion() const;\n [[nodiscard]] QString expectedFirmwareBuildId() const;\n [[nodiscard]] QString deviceFirmwareVersion() const;\n [[nodiscard]] QString deviceFirmwareBuildId() const;\n', + 'SmartSession buildId getters') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ' Q_INVOKABLE bool beginFirmwareUpdate();\n Q_INVOKABLE bool beginFirmwareInstall();\n', + ' Q_INVOKABLE bool beginFirmwareUpdate();\n Q_INVOKABLE bool beginFirmwareReinstall();\n Q_INVOKABLE bool beginFirmwareInstall();\n', + 'SmartSession reinstall intent') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ' QString deviceFirmwareVersion_;\n QString updatePort_;\n', + ' QString deviceFirmwareVersion_;\n QString deviceFirmwareBuildId_;\n QString updatePort_;\n', + 'SmartSession device buildId storage') + + replace_once( + 'apps/arstack_studio/src/SmartSessionController.cpp', + 'bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; }\n', + '''bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } +bool SmartSessionController::firmwareReinstallAvailable() const noexcept { + return device_ != nullptr && firmware_ != nullptr && device_->deviceVerified() && + firmware_->bundleReady() && !firmware_->busy() && !updateRequested_ && firmwareIsCurrent(); +} +''', + 'SmartSession reinstall availability') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.cpp', + 'QString SmartSessionController::expectedFirmwareVersion() const { return QStringLiteral(ARSTACK_STUDIO_VERSION); }\nQString SmartSessionController::deviceFirmwareVersion() const { return deviceFirmwareVersion_; }\n', + '''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_; } +''', + 'SmartSession buildId getters implementation') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.cpp', + '''bool SmartSessionController::beginFirmwareUpdate() { + if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || + !firmware_->bundleReady() || firmware_->busy()) { + return false; + } + return beginFirmwareOperation(device_->portName()); +} +''', + '''bool SmartSessionController::beginFirmwareUpdate() { + if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || + !firmware_->bundleReady() || firmware_->busy()) { + return false; + } + return beginFirmwareOperation(device_->portName()); +} + +bool SmartSessionController::beginFirmwareReinstall() { + if (!firmwareReinstallAvailable() || device_ == nullptr) return false; + return beginFirmwareOperation(device_->portName()); +} +''', + 'SmartSession reinstall operation') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.cpp', + ' deviceFirmwareVersion_.clear();\n profileSyncBootId_.clear();\n', + ' deviceFirmwareVersion_.clear();\n deviceFirmwareBuildId_.clear();\n profileSyncBootId_.clear();\n', + 'SmartSession clear device buildId') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.cpp', + '''void SmartSessionController::refreshFirmwareIdentity() { + deviceFirmwareVersion_ = + (device_ != nullptr && device_->deviceVerified()) ? device_->firmwareVersion() : QString{}; +} + +bool SmartSessionController::firmwareIsCurrent() const { + return device_ != nullptr && device_->deviceVerified() && + DeviceController::identitySupportsCurrentContract( + device_->deviceIdentity(), expectedFirmwareVersion()); +} +''', + '''void SmartSessionController::refreshFirmwareIdentity() { + 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(), expectedFirmwareBuildId()); +} +''', + 'SmartSession build-aware current check') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.cpp', + ''' 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()), + false, + true); +''', + ''' const QString versionText = deviceFirmwareVersion_.isEmpty() + ? QStringLiteral("legacy firmware") + : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); + const QString observedBuild = deviceFirmwareBuildId_.isEmpty() + ? QStringLiteral("legacy/no build ID") + : deviceFirmwareBuildId_; + const QString packageBuild = expectedFirmwareBuildId().isEmpty() + ? QStringLiteral("unknown") + : expectedFirmwareBuildId(); + setPresentation( + QStringLiteral("FIRMWARE UPDATE"), + QStringLiteral("%1 build %2 detected. Studio package contains v%3 build %4; update is required before injection.") + .arg(versionText, observedBuild, expectedFirmwareVersion(), packageBuild), + false, + true); +''', + 'SmartSession build mismatch presentation') + + # Advanced firmware panel submits intent to the supervisor only. + panel = Path('apps/arstack_studio/qml/FirmwarePanel.qml') + text = panel.read_text(encoding='utf-8') + text = text.replace( + '? "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"') + old = ''' Rectangle { + implicitWidth: 150 + implicitHeight: 34 + radius: 7 + color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised + border.width: 1 + border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line + Label { + anchors.centerIn: parent + text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" + color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.Bold + } + } +''' + new = ''' CalmButton { + id: firmwareAction + theme: panel.theme + uiFont: panel.uiFont + implicitWidth: 172 + text: panel.session && panel.session.firmwareUpdateRequired + ? "Update firmware" + : "Reinstall current" + visible: panel.session && + (panel.session.firmwareUpdateRequired || panel.session.firmwareReinstallAvailable) + enabled: visible && !panel.firmware.busy + tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal" + onClicked: { + if (panel.session.firmwareUpdateRequired) + panel.session.beginFirmwareUpdate() + else + panel.session.beginFirmwareReinstall() + } + } + + Rectangle { + visible: !firmwareAction.visible + implicitWidth: 150 + implicitHeight: 34 + radius: 7 + color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised + border.width: 1 + border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line + Label { + anchors.centerIn: parent + text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" + color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.Bold + } + } +''' + if old not in text: + raise SystemExit('FirmwarePanel guided action block not found') + panel.write_text(text.replace(old, new, 1), encoding='utf-8') + + # Deterministic session regression: same version is not sufficient. + harness = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') + text = harness.read_text(encoding='utf-8') + text = text.replace( + '{"legacy identity -> firmware update", legacyIdentityOffersUpdate()},\n', + '{"legacy identity -> firmware update", legacyIdentityOffersUpdate()},\n {"same version missing build ID -> firmware update", sameVersionMissingBuildOffersUpdate()},\n {"same version stale build -> firmware update", sameVersionStaleBuildOffersUpdate()},\n') + text = text.replace( + ''' Fixture() { + profileReady = profiles.loadReferenceTemplate(); + session.setProfiles(&profiles); +''', + ''' Fixture() { + firmware.firmwareVersion_ = QStringLiteral(ARSTACK_STUDIO_VERSION); + firmware.firmwareBuildId_ = QStringLiteral("0123456789abcdef"); + firmware.bundleReady_ = true; + profileReady = profiles.loadReferenceTemplate(); + session.setProfiles(&profiles); +''') + text = text.replace( + ''' static DeviceIdentity identity( + const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), + const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), + const QString& bootId = QStringLiteral("0123456789ABCDEF")) { +''', + ''' static DeviceIdentity identity( + const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), + const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), + const QString& bootId = QStringLiteral("0123456789ABCDEF"), + const QString& buildId = QStringLiteral("0123456789abcdef")) { +''') + text = text.replace( + ' result.firmwareVersion = firmware;\n result.bootId = bootId;\n', + ' result.firmwareVersion = firmware;\n result.buildId = buildId;\n result.bootId = bootId;\n') + text = text.replace( + ' QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n', + ' QStringLiteral("SMV-4I4V"),\n QStringLiteral("PROFILE"),\n QStringLiteral("LIVE-SETPOINTS"),\n', 1) + text = text.replace( + ''' return fixture.session.state() == QStringLiteral("READY") && + fixture.session.startReady() && + !fixture.session.firmwareInstallVisible() && + !fixture.session.firmwareUpdateRequired(); + } + + static bool legacyIdentityOffersUpdate() { +''', + ''' return fixture.session.state() == QStringLiteral("READY") && + fixture.session.startReady() && + fixture.session.firmwareReinstallAvailable() && + !fixture.session.firmwareInstallVisible() && + !fixture.session.firmwareUpdateRequired(); + } + + static bool legacyIdentityOffersUpdate() { +''') + marker = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() { +''' + addition = ''' 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("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !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("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !fixture.session.firmwareReinstallAvailable() && + !fixture.session.startReady(); + } + +''' + if marker not in text: + raise SystemExit('Harness insertion marker not found') + harness.write_text(text.replace(marker, addition + marker, 1), encoding='utf-8') + + # Native packaged policy locks parser + build-aware acceptance. + main = Path('apps/arstack_studio/src/main.cpp') + text = main.read_text(encoding='utf-8') + text = text.replace( + ' firmware.expectedProtocol() == QStringLiteral("1") &&\n firmware.firmwareSha256().size() == 64 &&\n', + ' firmware.expectedProtocol() == QStringLiteral("1") &&\n firmware.firmwareBuildId().size() == 16 &&\n firmware.firmwareSha256().size() == 64 &&\n', 1) + text = text.replace( + ' "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF "\n "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', + ' "device_id=A1B2C3D4E5F6 firmware=%1 build=0123456789abcdef boot_id=0123456789ABCDEF "\n "capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', 1) + text = text.replace( + ' currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION));\n', + ' currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef"));\n', 1) + text = text.replace( + ' "device_id=A1B2C3D4E5F6 firmware=%1 capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', + ' "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', 1) + text = text.replace( + ' legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION));\n', + ' legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef"));\n', 1) + main.write_text(text, encoding='utf-8') + + print('P0 firmware build identity + guided reinstall contract applied') + PY + + - name: Review patch invariants + shell: bash + run: | + set -euo pipefail + grep -R "firmwareBuildId" -n apps/arstack_studio/src apps/arstack_studio/qml/FirmwarePanel.qml + grep -F 'build=%s boot_id=' embedded/esp32p4_smv_injector/main/live_control.cpp + grep -F '"buildId": source_commit[:16].lower()' .github/workflows/esp32p4-smv-injector.yml + grep -F 'same version stale build -> firmware update' apps/arstack_studio/src/DeterministicSessionHarness.cpp + + - name: Commit source repair + shell: bash + run: | + set -euo pipefail + git config user.name "ARStack P0 Repair" + git config user.email "actions@users.noreply.github.com" + git add embedded/esp32p4_smv_injector apps/arstack_studio .github/workflows/esp32p4-smv-injector.yml + if git diff --cached --quiet; then + echo 'No source changes to commit.' + exit 0 + fi + git commit -m "fix(p0): bind firmware updates to semantic build identity" + git push origin HEAD:feature/ptp-production-v0.1.1 From 51c954fa84c5019930fa70ff73ab87d2508459db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:56:13 +0700 Subject: [PATCH 109/170] ci: stage P0 firmware build identity repair script --- .../p0_firmware_build_identity_repair.py | 605 ++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 .github/scripts/p0_firmware_build_identity_repair.py diff --git a/.github/scripts/p0_firmware_build_identity_repair.py b/.github/scripts/p0_firmware_build_identity_repair.py new file mode 100644 index 000000000..f901fc5df --- /dev/null +++ b/.github/scripts/p0_firmware_build_identity_repair.py @@ -0,0 +1,605 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str, label: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + if old not in text: + raise SystemExit(f"{label}: expected source block not found in {path}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + +replace_once( + "embedded/esp32p4_smv_injector/CMakeLists.txt", + 'set(PROJECT_VER "0.1.1")\n\ninclude($ENV{IDF_PATH}/tools/cmake/project.cmake)\n', + '''set(PROJECT_VER "0.1.1") + +# Semantic firmware provenance. CI supplies the exact checkout commit; local +# builds fall back to the repository HEAD. The value is intentionally short, +# fixed-size hex so it is safe to expose on the bounded serial identity line. +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}") +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 + RESULT_VARIABLE ARSTACK_FIRMWARE_BUILD_ID_RESULT) + if(NOT ARSTACK_FIRMWARE_BUILD_ID_RESULT EQUAL 0) + set(ARSTACK_FIRMWARE_BUILD_ID "0000000000000000") + endif() +endif() +string(TOLOWER "${ARSTACK_FIRMWARE_BUILD_ID}" ARSTACK_FIRMWARE_BUILD_ID) +if(NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]{16}$") + 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") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +''', + "embedded build identity configure contract", +) + +replace_once( + "embedded/esp32p4_smv_injector/main/CMakeLists.txt", + "target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20)\n", + '''target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) +target_compile_definitions(${COMPONENT_LIB} PRIVATE + ARSTACK_FIRMWARE_BUILD_ID="${ARSTACK_FIRMWARE_BUILD_ID}") +''', + "embedded build identity compile definition", +) + +live = Path("embedded/esp32p4_smv_injector/main/live_control.cpp") +text = live.read_text(encoding="utf-8") +text = text.replace( + '"SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"', + '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"', +) +text = text.replace( + '"SMV-4I4V,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"', + '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"', +) +text = text.replace( + '"SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"', + '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE"', +) +text = text.replace( + '"SMV-4I4V,LIVE-SETPOINTS"', + '"SMV-4I4V,PROFILE,LIVE-SETPOINTS"', +) +old = ''' "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", + 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, + static_cast(g_boot_id), + capabilities);''' +new = ''' "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);''' +if old not in text: + raise SystemExit("live identity format block not found") +live.write_text(text.replace(old, new, 1), encoding="utf-8") + +replace_once( + ".github/workflows/esp32p4-smv-injector.yml", + " - name: Build firmware\n run: source /opt/esp/idf/export.sh && idf.py build\n", + ''' - name: Build firmware + run: | + set -euo pipefail + export ARSTACK_FIRMWARE_BUILD_ID="${GITHUB_SHA:0:16}" + source /opt/esp/idf/export.sh + idf.py build + strings build/arstack_esp32p4_smv_injector.elf | grep -F "$ARSTACK_FIRMWARE_BUILD_ID" >/dev/null + echo "Semantic firmware build ID: $ARSTACK_FIRMWARE_BUILD_ID" +''', + "firmware build identity workflow", +) +replace_once( + ".github/workflows/esp32p4-smv-injector.yml", + ' "version": os.environ["ARSTACK_FIRMWARE_VERSION"],\n "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),\n', + ' "version": os.environ["ARSTACK_FIRMWARE_VERSION"],\n "buildId": source_commit[:16].lower(),\n "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),\n', + "manifest buildId", +) +replace_once( + ".github/workflows/esp32p4-smv-injector.yml", + " assert manifest['protocol'] == 1\n assert 'PTP-P2' in manifest['capabilities']\n", + " assert manifest['protocol'] == 1\n assert len(manifest['buildId']) == 16\n assert manifest['sourceCommit'].lower().startswith(manifest['buildId'])\n assert 'PROFILE' in manifest['capabilities']\n assert 'PTP-P2' in manifest['capabilities']\n", + "manifest buildId validation", +) + +replace_once( + "apps/arstack_studio/src/DeviceController.hpp", + " QString firmwareVersion;\n QString bootId;\n", + " QString firmwareVersion;\n QString buildId;\n QString bootId;\n", + "DeviceIdentity buildId field", +) +replace_once( + "apps/arstack_studio/src/DeviceController.hpp", + " deviceId.isEmpty() && firmwareVersion.isEmpty() && bootId.isEmpty() &&\n", + " deviceId.isEmpty() && firmwareVersion.isEmpty() && buildId.isEmpty() && bootId.isEmpty() &&\n", + "DeviceIdentity empty buildId", +) +replace_once( + "apps/arstack_studio/src/DeviceController.hpp", + " Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged)\n", + " Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged)\n", + "DeviceController buildId property", +) +replace_once( + "apps/arstack_studio/src/DeviceController.hpp", + " [[nodiscard]] QString firmwareVersion() const;\n [[nodiscard]] QString bootId() const;\n", + " [[nodiscard]] QString firmwareVersion() const;\n [[nodiscard]] QString firmwareBuildId() const;\n [[nodiscard]] QString bootId() const;\n", + "DeviceController buildId getter", +) +replace_once( + "apps/arstack_studio/src/DeviceController.hpp", + " [[nodiscard]] static bool identitySupportsCurrentContract(\n const DeviceIdentity& identity,\n const QString& expectedFirmwareVersion);\n", + " [[nodiscard]] static bool identitySupportsCurrentContract(\n const DeviceIdentity& identity,\n const QString& expectedFirmwareVersion,\n const QString& expectedBuildId = {});\n", + "DeviceController current contract signature", +) + +replace_once( + "apps/arstack_studio/src/DeviceController.cpp", + ' "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\\\-]+) "\n "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\\\-]+)"),\n', + ' "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\\\-]+) "\n "(?:build=([A-Fa-f0-9]{16}) )?"\n "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\\\-]+)"),\n', + "DeviceController identity regex buildId", +) +replace_once( + "apps/arstack_studio/src/DeviceController.cpp", + "QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; }\nQString DeviceController::bootId() const { return identity_.bootId; }\n", + "QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; }\nQString DeviceController::firmwareBuildId() const { return identity_.buildId; }\nQString DeviceController::bootId() const { return identity_.bootId; }\n", + "DeviceController buildId getter implementation", +) +replace_once( + "apps/arstack_studio/src/DeviceController.cpp", + " parsed.firmwareVersion = match.captured(5);\n parsed.bootId = match.captured(6).toUpper();\n parsed.capabilities = match.captured(7).split(QLatin1Char(','), Qt::SkipEmptyParts);\n", + " parsed.firmwareVersion = match.captured(5);\n parsed.buildId = match.captured(6).toLower();\n parsed.bootId = match.captured(7).toUpper();\n parsed.capabilities = match.captured(8).split(QLatin1Char(','), Qt::SkipEmptyParts);\n", + "DeviceController identity captures", +) +replace_once( + "apps/arstack_studio/src/DeviceController.cpp", + '''bool DeviceController::identitySupportsCurrentContract( + const DeviceIdentity& identity, + const QString& expectedFirmwareVersion) { + static const QStringList requiredCapabilities{ + QStringLiteral("SMV-4I4V"), + QStringLiteral("LIVE-SETPOINTS"), + QStringLiteral("SESSION-LEASE"), + QStringLiteral("PTP-P2"), + QStringLiteral("SMPSYNCH-AUTO")}; + if (identity.product != QStringLiteral("SMV-INJECTOR") || + identity.target != QStringLiteral("ESP32-P4") || + identity.protocolVersion != QStringLiteral("1") || + identity.firmwareVersion != expectedFirmwareVersion || + identity.bootId.size() != 16) { + return false; + } +''', + '''bool DeviceController::identitySupportsCurrentContract( + const DeviceIdentity& identity, + const QString& expectedFirmwareVersion, + const QString& expectedBuildId) { + static const QStringList requiredCapabilities{ + QStringLiteral("SMV-4I4V"), + QStringLiteral("PROFILE"), + QStringLiteral("LIVE-SETPOINTS"), + 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; + } +''', + "DeviceController build-aware contract", +) + +replace_once( + "apps/arstack_studio/src/FirmwareManager.hpp", + "class FirmwareWorker;\n", + "class DeterministicSessionHarness;\nclass FirmwareWorker;\n", + "FirmwareManager harness forward declaration", +) +replace_once( + "apps/arstack_studio/src/FirmwareManager.hpp", + " Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged)\n", + " Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY stateChanged)\n Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged)\n", + "FirmwareManager buildId property", +) +replace_once( + "apps/arstack_studio/src/FirmwareManager.hpp", + " [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; }\n [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; }\n", + " [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; }\n [[nodiscard]] QString firmwareBuildId() const { return firmwareBuildId_; }\n [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; }\n", + "FirmwareManager buildId getter", +) +replace_once( + "apps/arstack_studio/src/FirmwareManager.hpp", + "private:\n enum class Operation { none, probe, flash, reset };\n", + "private:\n friend class DeterministicSessionHarness;\n enum class Operation { none, probe, flash, reset };\n", + "FirmwareManager harness friend", +) +replace_once( + "apps/arstack_studio/src/FirmwareManager.hpp", + ' QString firmwareVersion_{QStringLiteral("-")};\n QString expectedProtocol_{QStringLiteral("-")};\n', + ' QString firmwareVersion_{QStringLiteral("-")};\n QString firmwareBuildId_;\n QString expectedProtocol_{QStringLiteral("-")};\n', + "FirmwareManager buildId storage", +) + +replace_once( + "apps/arstack_studio/src/FirmwareManager.cpp", + ' firmwareVersion_ = QStringLiteral("-");\n expectedProtocol_ = QStringLiteral("-");\n', + ' firmwareVersion_ = QStringLiteral("-");\n firmwareBuildId_.clear();\n expectedProtocol_ = QStringLiteral("-");\n', + "FirmwareManager clear buildId", +) +replace_once( + "apps/arstack_studio/src/FirmwareManager.cpp", + ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n', + ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\n const QString buildId = object.value(QStringLiteral("buildId")).toString().trimmed().toLower();\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n', + "FirmwareManager parse buildId", +) +replace_once( + "apps/arstack_studio/src/FirmwareManager.cpp", + ' expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n', + ' expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n !QRegularExpression(QStringLiteral("^[0-9a-f]{16}$")).match(buildId).hasMatch() ||\n !sourceCommit.startsWith(buildId, Qt::CaseInsensitive) ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n', + "FirmwareManager validate buildId", +) +replace_once( + "apps/arstack_studio/src/FirmwareManager.cpp", + " firmwareVersion_ = version;\n expectedProtocol_ = QString::number(protocol);\n", + " firmwareVersion_ = version;\n firmwareBuildId_ = buildId;\n expectedProtocol_ = QString::number(protocol);\n", + "FirmwareManager store buildId", +) +replace_once( + "apps/arstack_studio/src/FirmwareManager.cpp", + ' bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")\n .arg(firmwareVersion_);\n', + ' bundleStatus_ = QStringLiteral("Ready · firmware v%1 · build %2 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")\n .arg(firmwareVersion_, firmwareBuildId_);\n', + "FirmwareManager bundle status buildId", +) + +replace_once( + "apps/arstack_studio/src/SmartSessionController.hpp", + " Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged)\n", + " Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareReinstallAvailable READ firmwareReinstallAvailable NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged)\n", + "SmartSession reinstall property", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.hpp", + " Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT)\n Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged)\n", + " Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT)\n Q_PROPERTY(QString expectedFirmwareBuildId READ expectedFirmwareBuildId NOTIFY stateChanged)\n Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString deviceFirmwareBuildId READ deviceFirmwareBuildId NOTIFY stateChanged)\n", + "SmartSession buildId properties", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.hpp", + " [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n [[nodiscard]] bool firmwareInstallRequired() const noexcept;\n", + " [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n [[nodiscard]] bool firmwareReinstallAvailable() const noexcept;\n [[nodiscard]] bool firmwareInstallRequired() const noexcept;\n", + "SmartSession reinstall getter", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.hpp", + " [[nodiscard]] QString expectedFirmwareVersion() const;\n [[nodiscard]] QString deviceFirmwareVersion() const;\n", + " [[nodiscard]] QString expectedFirmwareVersion() const;\n [[nodiscard]] QString expectedFirmwareBuildId() const;\n [[nodiscard]] QString deviceFirmwareVersion() const;\n [[nodiscard]] QString deviceFirmwareBuildId() const;\n", + "SmartSession buildId getters", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.hpp", + " Q_INVOKABLE bool beginFirmwareUpdate();\n Q_INVOKABLE bool beginFirmwareInstall();\n", + " Q_INVOKABLE bool beginFirmwareUpdate();\n Q_INVOKABLE bool beginFirmwareReinstall();\n Q_INVOKABLE bool beginFirmwareInstall();\n", + "SmartSession reinstall intent", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.hpp", + " QString deviceFirmwareVersion_;\n QString updatePort_;\n", + " QString deviceFirmwareVersion_;\n QString deviceFirmwareBuildId_;\n QString updatePort_;\n", + "SmartSession device buildId storage", +) + +replace_once( + "apps/arstack_studio/src/SmartSessionController.cpp", + "bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; }\n", + '''bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } +bool SmartSessionController::firmwareReinstallAvailable() const noexcept { + return device_ != nullptr && firmware_ != nullptr && device_->deviceVerified() && + firmware_->bundleReady() && !firmware_->busy() && !updateRequested_ && firmwareIsCurrent(); +} +''', + "SmartSession reinstall availability", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.cpp", + 'QString SmartSessionController::expectedFirmwareVersion() const { return QStringLiteral(ARSTACK_STUDIO_VERSION); }\nQString SmartSessionController::deviceFirmwareVersion() const { return deviceFirmwareVersion_; }\n', + '''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_; } +''', + "SmartSession buildId getters implementation", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.cpp", + '''bool SmartSessionController::beginFirmwareUpdate() { + if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || + !firmware_->bundleReady() || firmware_->busy()) { + return false; + } + return beginFirmwareOperation(device_->portName()); +} +''', + '''bool SmartSessionController::beginFirmwareUpdate() { + if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || + !firmware_->bundleReady() || firmware_->busy()) { + return false; + } + return beginFirmwareOperation(device_->portName()); +} + +bool SmartSessionController::beginFirmwareReinstall() { + if (!firmwareReinstallAvailable() || device_ == nullptr) return false; + return beginFirmwareOperation(device_->portName()); +} +''', + "SmartSession reinstall operation", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.cpp", + " deviceFirmwareVersion_.clear();\n profileSyncBootId_.clear();\n", + " deviceFirmwareVersion_.clear();\n deviceFirmwareBuildId_.clear();\n profileSyncBootId_.clear();\n", + "SmartSession clear device buildId", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.cpp", + '''void SmartSessionController::refreshFirmwareIdentity() { + deviceFirmwareVersion_ = + (device_ != nullptr && device_->deviceVerified()) ? device_->firmwareVersion() : QString{}; +} + +bool SmartSessionController::firmwareIsCurrent() const { + return device_ != nullptr && device_->deviceVerified() && + DeviceController::identitySupportsCurrentContract( + device_->deviceIdentity(), expectedFirmwareVersion()); +} +''', + '''void SmartSessionController::refreshFirmwareIdentity() { + 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(), expectedFirmwareBuildId()); +} +''', + "SmartSession build-aware current check", +) +replace_once( + "apps/arstack_studio/src/SmartSessionController.cpp", + ''' 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()), + false, + true); +''', + ''' const QString versionText = deviceFirmwareVersion_.isEmpty() + ? QStringLiteral("legacy firmware") + : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); + const QString observedBuild = deviceFirmwareBuildId_.isEmpty() + ? QStringLiteral("legacy/no build ID") + : deviceFirmwareBuildId_; + const QString packageBuild = expectedFirmwareBuildId().isEmpty() + ? QStringLiteral("unknown") + : expectedFirmwareBuildId(); + setPresentation( + QStringLiteral("FIRMWARE UPDATE"), + QStringLiteral("%1 build %2 detected. Studio package contains v%3 build %4; update is required before injection.") + .arg(versionText, observedBuild, expectedFirmwareVersion(), packageBuild), + false, + true); +''', + "SmartSession build mismatch presentation", +) + +panel = Path("apps/arstack_studio/qml/FirmwarePanel.qml") +text = panel.read_text(encoding="utf-8") +text = text.replace( + '? "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"', +) +old = ''' Rectangle { + implicitWidth: 150 + implicitHeight: 34 + radius: 7 + color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised + border.width: 1 + border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line + Label { + anchors.centerIn: parent + text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" + color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.Bold + } + } +''' +new = ''' CalmButton { + id: firmwareAction + theme: panel.theme + uiFont: panel.uiFont + implicitWidth: 172 + text: panel.session && panel.session.firmwareUpdateRequired + ? "Update firmware" + : "Reinstall current" + visible: panel.session && + (panel.session.firmwareUpdateRequired || panel.session.firmwareReinstallAvailable) + enabled: visible && !panel.firmware.busy + tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal" + onClicked: { + if (panel.session.firmwareUpdateRequired) + panel.session.beginFirmwareUpdate() + else + panel.session.beginFirmwareReinstall() + } + } + + Rectangle { + visible: !firmwareAction.visible + implicitWidth: 150 + implicitHeight: 34 + radius: 7 + color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised + border.width: 1 + border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line + Label { + anchors.centerIn: parent + text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" + color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.Bold + } + } +''' +if old not in text: + raise SystemExit("FirmwarePanel guided action block not found") +panel.write_text(text.replace(old, new, 1), encoding="utf-8") + +harness = Path("apps/arstack_studio/src/DeterministicSessionHarness.cpp") +text = harness.read_text(encoding="utf-8") +text = text.replace( + '{"legacy identity -> firmware update", legacyIdentityOffersUpdate()},\n', + '{"legacy identity -> firmware update", legacyIdentityOffersUpdate()},\n {"same version missing build ID -> firmware update", sameVersionMissingBuildOffersUpdate()},\n {"same version stale build -> firmware update", sameVersionStaleBuildOffersUpdate()},\n', +) +text = text.replace( + ''' Fixture() { + profileReady = profiles.loadReferenceTemplate(); + session.setProfiles(&profiles); +''', + ''' Fixture() { + firmware.firmwareVersion_ = QStringLiteral(ARSTACK_STUDIO_VERSION); + firmware.firmwareBuildId_ = QStringLiteral("0123456789abcdef"); + firmware.bundleReady_ = true; + profileReady = profiles.loadReferenceTemplate(); + session.setProfiles(&profiles); +''', +) +text = text.replace( + ''' static DeviceIdentity identity( + const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), + const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), + const QString& bootId = QStringLiteral("0123456789ABCDEF")) { +''', + ''' static DeviceIdentity identity( + const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), + const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), + const QString& bootId = QStringLiteral("0123456789ABCDEF"), + const QString& buildId = QStringLiteral("0123456789abcdef")) { +''', +) +text = text.replace( + " result.firmwareVersion = firmware;\n result.bootId = bootId;\n", + " result.firmwareVersion = firmware;\n result.buildId = buildId;\n result.bootId = bootId;\n", +) +text = text.replace( + ' QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n', + ' QStringLiteral("SMV-4I4V"),\n QStringLiteral("PROFILE"),\n QStringLiteral("LIVE-SETPOINTS"),\n', + 1, +) +text = text.replace( + ''' return fixture.session.state() == QStringLiteral("READY") && + fixture.session.startReady() && + !fixture.session.firmwareInstallVisible() && + !fixture.session.firmwareUpdateRequired(); + } + + static bool legacyIdentityOffersUpdate() { +''', + ''' return fixture.session.state() == QStringLiteral("READY") && + fixture.session.startReady() && + fixture.session.firmwareReinstallAvailable() && + !fixture.session.firmwareInstallVisible() && + !fixture.session.firmwareUpdateRequired(); + } + + static bool legacyIdentityOffersUpdate() { +''', +) +marker = " static bool automaticIdentityTimeoutOffersFirmwareRecovery() {\n" +addition = ''' 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("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !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("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !fixture.session.firmwareReinstallAvailable() && + !fixture.session.startReady(); + } + +''' +if marker not in text: + raise SystemExit("Harness insertion marker not found") +harness.write_text(text.replace(marker, addition + marker, 1), encoding="utf-8") + +main = Path("apps/arstack_studio/src/main.cpp") +text = main.read_text(encoding="utf-8") +text = text.replace( + ' firmware.expectedProtocol() == QStringLiteral("1") &&\n firmware.firmwareSha256().size() == 64 &&\n', + ' firmware.expectedProtocol() == QStringLiteral("1") &&\n firmware.firmwareBuildId().size() == 16 &&\n firmware.firmwareSha256().size() == 64 &&\n', + 1, +) +text = text.replace( + ' "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF "\n "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', + ' "device_id=A1B2C3D4E5F6 firmware=%1 build=0123456789abcdef boot_id=0123456789ABCDEF "\n "capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', + 1, +) +text = text.replace( + " currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION));\n", + ' currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef"));\n', + 1, +) +text = text.replace( + ' "device_id=A1B2C3D4E5F6 firmware=%1 capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', + ' "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', + 1, +) +text = text.replace( + " legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION));\n", + ' legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef"));\n', + 1, +) +main.write_text(text, encoding="utf-8") + +print("P0 firmware build identity + guided reinstall contract applied") From f3bda8a8ac232d140e70cc7035930c69482ffa1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:56:43 +0700 Subject: [PATCH 110/170] ci: run one-shot P0 build identity repair --- .../arstack-studio-windows-lifecycle.yml | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 04d93998e..2471323e1 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -11,13 +11,48 @@ on: - '.github/workflows/arstack-studio-windows-lifecycle.yml' permissions: - contents: read + contents: write concurrency: group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: + repair-source: + name: Apply one-shot P0 firmware build identity repair + if: github.event_name == 'push' && github.ref == 'refs/heads/feature/ptp-production-v0.1.1' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout feature branch + uses: actions/checkout@v4 + with: + ref: feature/ptp-production-v0.1.1 + fetch-depth: 0 + - name: Apply P0 repair + run: python3 .github/scripts/p0_firmware_build_identity_repair.py + - name: Verify repair invariants + shell: bash + run: | + set -euo pipefail + grep -R "firmwareBuildId" -n apps/arstack_studio/src apps/arstack_studio/qml/FirmwarePanel.qml + grep -F 'build=%s boot_id=' embedded/esp32p4_smv_injector/main/live_control.cpp + grep -F '"buildId": source_commit[:16].lower()' .github/workflows/esp32p4-smv-injector.yml + grep -F 'same version stale build -> firmware update' apps/arstack_studio/src/DeterministicSessionHarness.cpp + - name: Commit source repair + shell: bash + run: | + set -euo pipefail + git config user.name "ARStack P0 Repair" + git config user.email "actions@users.noreply.github.com" + git add embedded/esp32p4_smv_injector apps/arstack_studio .github/workflows/esp32p4-smv-injector.yml + if git diff --cached --quiet; then + echo 'No source changes to commit.' + exit 0 + fi + git commit -m "fix(p0): bind firmware updates to semantic build identity" + git push origin HEAD:feature/ptp-production-v0.1.1 + close-exit-reopen: name: Close -> process exit -> immediate reopen runs-on: windows-2022 From 7c3f5b87a0869d8549c4c5718e081474344b0a44 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 19:58:43 +0700 Subject: [PATCH 111/170] ci: allow one-shot P0 repair on PR runner --- .github/workflows/arstack-studio-windows-lifecycle.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 2471323e1..60615a4e2 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -20,7 +20,6 @@ concurrency: jobs: repair-source: name: Apply one-shot P0 firmware build identity repair - if: github.event_name == 'push' && github.ref == 'refs/heads/feature/ptp-production-v0.1.1' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -30,7 +29,14 @@ jobs: ref: feature/ptp-production-v0.1.1 fetch-depth: 0 - name: Apply P0 repair - run: python3 .github/scripts/p0_firmware_build_identity_repair.py + shell: bash + run: | + set -euo pipefail + if grep -q 'firmwareBuildId' apps/arstack_studio/src/DeviceController.hpp; then + echo 'P0 build identity repair already applied; no-op.' + exit 0 + fi + python3 .github/scripts/p0_firmware_build_identity_repair.py - name: Verify repair invariants shell: bash run: | From b48e56a6446bb14a53bb873b6dfaa22de019fc6a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:03:14 +0700 Subject: [PATCH 112/170] ci: harden one-shot build identity repair --- .../arstack-studio-windows-lifecycle.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 60615a4e2..33105c724 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -36,6 +36,21 @@ jobs: echo 'P0 build identity repair already applied; no-op.' exit 0 fi + python3 - <<'PY' + from pathlib import Path + p = Path('.github/scripts/p0_firmware_build_identity_repair.py') + text = p.read_text(encoding='utf-8') + old = '''if(NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]{16}$") + message(FATAL_ERROR "ARSTACK_FIRMWARE_BUILD_ID must be exactly 16 hexadecimal characters") +endif()''' + new = '''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()''' + if old not in text: + raise SystemExit('repair-script CMake validation block not found') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + PY python3 .github/scripts/p0_firmware_build_identity_repair.py - name: Verify repair invariants shell: bash From 5ffa3f318bc2541fa571ab514834dd296b02e1ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:07:54 +0700 Subject: [PATCH 113/170] ci: apply P0 build identity without workflow mutation --- .../arstack-studio-windows-lifecycle.yml | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 33105c724..e979b94a6 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -40,6 +40,9 @@ jobs: from pathlib import Path p = Path('.github/scripts/p0_firmware_build_identity_repair.py') text = p.read_text(encoding='utf-8') + + # CMake's regex dialect is deliberately kept simple: exact length is + # checked independently, then characters are constrained to hex. old = '''if(NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]{16}$") message(FATAL_ERROR "ARSTACK_FIRMWARE_BUILD_ID must be exactly 16 hexadecimal characters") endif()''' @@ -49,7 +52,25 @@ if(NOT ARSTACK_FIRMWARE_BUILD_ID_LENGTH EQUAL 16 OR NOT ARSTACK_FIRMWARE_BUILD_I endif()''' if old not in text: raise SystemExit('repair-script CMake validation block not found') - p.write_text(text.replace(old, new, 1), encoding='utf-8') + text = text.replace(old, new, 1) + + # AGENTS.md requires one provenance source of truth. The existing + # manifest sourceCommit remains authoritative, so do not mutate the + # firmware workflow or add a second manifest buildId field. + start = text.index('replace_once(\n ".github/workflows/esp32p4-smv-injector.yml",') + end = text.index('replace_once(\n "apps/arstack_studio/src/DeviceController.hpp",', start) + text = text[:start] + text[end:] + + # FirmwareManager derives the short semantic build ID from the + # already-authoritative sourceCommit field in the manifest. + old = ''' ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\\n const QString buildId = object.value(QStringLiteral("buildId")).toString().trimmed().toLower();\\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\\n', +''' + new = ''' ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\\n const QString buildId = sourceCommit.left(16);\\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\\n', +''' + if old not in text: + raise SystemExit('repair-script manifest provenance block not found') + text = text.replace(old, new, 1) + p.write_text(text, encoding='utf-8') PY python3 .github/scripts/p0_firmware_build_identity_repair.py - name: Verify repair invariants @@ -58,7 +79,8 @@ endif()''' set -euo pipefail grep -R "firmwareBuildId" -n apps/arstack_studio/src apps/arstack_studio/qml/FirmwarePanel.qml grep -F 'build=%s boot_id=' embedded/esp32p4_smv_injector/main/live_control.cpp - grep -F '"buildId": source_commit[:16].lower()' .github/workflows/esp32p4-smv-injector.yml + grep -F 'sourceCommit' apps/arstack_studio/src/FirmwareManager.cpp + grep -F 'const QString buildId = sourceCommit.left(16);' apps/arstack_studio/src/FirmwareManager.cpp grep -F 'same version stale build -> firmware update' apps/arstack_studio/src/DeterministicSessionHarness.cpp - name: Commit source repair shell: bash @@ -66,7 +88,7 @@ endif()''' set -euo pipefail git config user.name "ARStack P0 Repair" git config user.email "actions@users.noreply.github.com" - git add embedded/esp32p4_smv_injector apps/arstack_studio .github/workflows/esp32p4-smv-injector.yml + git add embedded/esp32p4_smv_injector apps/arstack_studio if git diff --cached --quiet; then echo 'No source changes to commit.' exit 0 From 09eced1ca25e9967cc2e287c09f7361efc782f85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:10:25 +0700 Subject: [PATCH 114/170] ci: make P0 build identity repair source-only --- .../p0_firmware_build_identity_repair.py | 641 ++---------------- 1 file changed, 45 insertions(+), 596 deletions(-) diff --git a/.github/scripts/p0_firmware_build_identity_repair.py b/.github/scripts/p0_firmware_build_identity_repair.py index f901fc5df..3a95115ab 100644 --- a/.github/scripts/p0_firmware_build_identity_repair.py +++ b/.github/scripts/p0_firmware_build_identity_repair.py @@ -1,605 +1,54 @@ from pathlib import Path - - -def replace_once(path: str, old: str, new: str, label: str) -> None: - p = Path(path) - text = p.read_text(encoding="utf-8") - if old not in text: - raise SystemExit(f"{label}: expected source block not found in {path}") - p.write_text(text.replace(old, new, 1), encoding="utf-8") - - -replace_once( - "embedded/esp32p4_smv_injector/CMakeLists.txt", - 'set(PROJECT_VER "0.1.1")\n\ninclude($ENV{IDF_PATH}/tools/cmake/project.cmake)\n', - '''set(PROJECT_VER "0.1.1") - -# Semantic firmware provenance. CI supplies the exact checkout commit; local -# builds fall back to the repository HEAD. The value is intentionally short, -# fixed-size hex so it is safe to expose on the bounded serial identity line. -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}") -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 - RESULT_VARIABLE ARSTACK_FIRMWARE_BUILD_ID_RESULT) - if(NOT ARSTACK_FIRMWARE_BUILD_ID_RESULT EQUAL 0) - set(ARSTACK_FIRMWARE_BUILD_ID "0000000000000000") - endif() -endif() -string(TOLOWER "${ARSTACK_FIRMWARE_BUILD_ID}" ARSTACK_FIRMWARE_BUILD_ID) -if(NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]{16}$") +import subprocess + +PATH = ".github/scripts/p0_firmware_build_identity_repair.py" + + +def historical_patch_source() -> str: + commits = subprocess.check_output( + ["git", "rev-list", "HEAD", "--", PATH], text=True + ).splitlines() + for commit in commits[1:]: + try: + candidate = subprocess.check_output( + ["git", "show", f"{commit}:{PATH}"], text=True + ) + except subprocess.CalledProcessError: + continue + if ( + candidate.count("replace_once(") > 10 + and "embedded/esp32p4_smv_injector/CMakeLists.txt" in candidate + and ".github/workflows/esp32p4-smv-injector.yml" in candidate + ): + return candidate + raise SystemExit("historical P0 patch implementation not found") + + +text = historical_patch_source() + +old = '''if(NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]{16}$") 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") - -include($ENV{IDF_PATH}/tools/cmake/project.cmake) -''', - "embedded build identity configure contract", -) - -replace_once( - "embedded/esp32p4_smv_injector/main/CMakeLists.txt", - "target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20)\n", - '''target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) -target_compile_definitions(${COMPONENT_LIB} PRIVATE - ARSTACK_FIRMWARE_BUILD_ID="${ARSTACK_FIRMWARE_BUILD_ID}") -''', - "embedded build identity compile definition", -) - -live = Path("embedded/esp32p4_smv_injector/main/live_control.cpp") -text = live.read_text(encoding="utf-8") -text = text.replace( - '"SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"', - '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"', -) -text = text.replace( - '"SMV-4I4V,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"', - '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"', -) -text = text.replace( - '"SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"', - '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE"', -) -text = text.replace( - '"SMV-4I4V,LIVE-SETPOINTS"', - '"SMV-4I4V,PROFILE,LIVE-SETPOINTS"', -) -old = ''' "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", - 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, - static_cast(g_boot_id), - capabilities);''' -new = ''' "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);''' +endif()''' +new = '''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()''' if old not in text: - raise SystemExit("live identity format block not found") -live.write_text(text.replace(old, new, 1), encoding="utf-8") - -replace_once( - ".github/workflows/esp32p4-smv-injector.yml", - " - name: Build firmware\n run: source /opt/esp/idf/export.sh && idf.py build\n", - ''' - name: Build firmware - run: | - set -euo pipefail - export ARSTACK_FIRMWARE_BUILD_ID="${GITHUB_SHA:0:16}" - source /opt/esp/idf/export.sh - idf.py build - strings build/arstack_esp32p4_smv_injector.elf | grep -F "$ARSTACK_FIRMWARE_BUILD_ID" >/dev/null - echo "Semantic firmware build ID: $ARSTACK_FIRMWARE_BUILD_ID" -''', - "firmware build identity workflow", -) -replace_once( - ".github/workflows/esp32p4-smv-injector.yml", - ' "version": os.environ["ARSTACK_FIRMWARE_VERSION"],\n "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),\n', - ' "version": os.environ["ARSTACK_FIRMWARE_VERSION"],\n "buildId": source_commit[:16].lower(),\n "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),\n', - "manifest buildId", -) -replace_once( - ".github/workflows/esp32p4-smv-injector.yml", - " assert manifest['protocol'] == 1\n assert 'PTP-P2' in manifest['capabilities']\n", - " assert manifest['protocol'] == 1\n assert len(manifest['buildId']) == 16\n assert manifest['sourceCommit'].lower().startswith(manifest['buildId'])\n assert 'PROFILE' in manifest['capabilities']\n assert 'PTP-P2' in manifest['capabilities']\n", - "manifest buildId validation", -) - -replace_once( - "apps/arstack_studio/src/DeviceController.hpp", - " QString firmwareVersion;\n QString bootId;\n", - " QString firmwareVersion;\n QString buildId;\n QString bootId;\n", - "DeviceIdentity buildId field", -) -replace_once( - "apps/arstack_studio/src/DeviceController.hpp", - " deviceId.isEmpty() && firmwareVersion.isEmpty() && bootId.isEmpty() &&\n", - " deviceId.isEmpty() && firmwareVersion.isEmpty() && buildId.isEmpty() && bootId.isEmpty() &&\n", - "DeviceIdentity empty buildId", -) -replace_once( - "apps/arstack_studio/src/DeviceController.hpp", - " Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged)\n", - " Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged)\n", - "DeviceController buildId property", -) -replace_once( - "apps/arstack_studio/src/DeviceController.hpp", - " [[nodiscard]] QString firmwareVersion() const;\n [[nodiscard]] QString bootId() const;\n", - " [[nodiscard]] QString firmwareVersion() const;\n [[nodiscard]] QString firmwareBuildId() const;\n [[nodiscard]] QString bootId() const;\n", - "DeviceController buildId getter", -) -replace_once( - "apps/arstack_studio/src/DeviceController.hpp", - " [[nodiscard]] static bool identitySupportsCurrentContract(\n const DeviceIdentity& identity,\n const QString& expectedFirmwareVersion);\n", - " [[nodiscard]] static bool identitySupportsCurrentContract(\n const DeviceIdentity& identity,\n const QString& expectedFirmwareVersion,\n const QString& expectedBuildId = {});\n", - "DeviceController current contract signature", -) - -replace_once( - "apps/arstack_studio/src/DeviceController.cpp", - ' "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\\\-]+) "\n "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\\\-]+)"),\n', - ' "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\\\-]+) "\n "(?:build=([A-Fa-f0-9]{16}) )?"\n "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\\\-]+)"),\n', - "DeviceController identity regex buildId", -) -replace_once( - "apps/arstack_studio/src/DeviceController.cpp", - "QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; }\nQString DeviceController::bootId() const { return identity_.bootId; }\n", - "QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; }\nQString DeviceController::firmwareBuildId() const { return identity_.buildId; }\nQString DeviceController::bootId() const { return identity_.bootId; }\n", - "DeviceController buildId getter implementation", -) -replace_once( - "apps/arstack_studio/src/DeviceController.cpp", - " parsed.firmwareVersion = match.captured(5);\n parsed.bootId = match.captured(6).toUpper();\n parsed.capabilities = match.captured(7).split(QLatin1Char(','), Qt::SkipEmptyParts);\n", - " parsed.firmwareVersion = match.captured(5);\n parsed.buildId = match.captured(6).toLower();\n parsed.bootId = match.captured(7).toUpper();\n parsed.capabilities = match.captured(8).split(QLatin1Char(','), Qt::SkipEmptyParts);\n", - "DeviceController identity captures", -) -replace_once( - "apps/arstack_studio/src/DeviceController.cpp", - '''bool DeviceController::identitySupportsCurrentContract( - const DeviceIdentity& identity, - const QString& expectedFirmwareVersion) { - static const QStringList requiredCapabilities{ - QStringLiteral("SMV-4I4V"), - QStringLiteral("LIVE-SETPOINTS"), - QStringLiteral("SESSION-LEASE"), - QStringLiteral("PTP-P2"), - QStringLiteral("SMPSYNCH-AUTO")}; - if (identity.product != QStringLiteral("SMV-INJECTOR") || - identity.target != QStringLiteral("ESP32-P4") || - identity.protocolVersion != QStringLiteral("1") || - identity.firmwareVersion != expectedFirmwareVersion || - identity.bootId.size() != 16) { - return false; - } -''', - '''bool DeviceController::identitySupportsCurrentContract( - const DeviceIdentity& identity, - const QString& expectedFirmwareVersion, - const QString& expectedBuildId) { - static const QStringList requiredCapabilities{ - QStringLiteral("SMV-4I4V"), - QStringLiteral("PROFILE"), - QStringLiteral("LIVE-SETPOINTS"), - 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; - } -''', - "DeviceController build-aware contract", -) - -replace_once( - "apps/arstack_studio/src/FirmwareManager.hpp", - "class FirmwareWorker;\n", - "class DeterministicSessionHarness;\nclass FirmwareWorker;\n", - "FirmwareManager harness forward declaration", -) -replace_once( - "apps/arstack_studio/src/FirmwareManager.hpp", - " Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged)\n", - " Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY stateChanged)\n Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged)\n", - "FirmwareManager buildId property", -) -replace_once( - "apps/arstack_studio/src/FirmwareManager.hpp", - " [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; }\n [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; }\n", - " [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; }\n [[nodiscard]] QString firmwareBuildId() const { return firmwareBuildId_; }\n [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; }\n", - "FirmwareManager buildId getter", -) -replace_once( - "apps/arstack_studio/src/FirmwareManager.hpp", - "private:\n enum class Operation { none, probe, flash, reset };\n", - "private:\n friend class DeterministicSessionHarness;\n enum class Operation { none, probe, flash, reset };\n", - "FirmwareManager harness friend", -) -replace_once( - "apps/arstack_studio/src/FirmwareManager.hpp", - ' QString firmwareVersion_{QStringLiteral("-")};\n QString expectedProtocol_{QStringLiteral("-")};\n', - ' QString firmwareVersion_{QStringLiteral("-")};\n QString firmwareBuildId_;\n QString expectedProtocol_{QStringLiteral("-")};\n', - "FirmwareManager buildId storage", -) + raise SystemExit("historical CMake validation block not found") +text = text.replace(old, new, 1) -replace_once( - "apps/arstack_studio/src/FirmwareManager.cpp", - ' firmwareVersion_ = QStringLiteral("-");\n expectedProtocol_ = QStringLiteral("-");\n', - ' firmwareVersion_ = QStringLiteral("-");\n firmwareBuildId_.clear();\n expectedProtocol_ = QStringLiteral("-");\n', - "FirmwareManager clear buildId", -) -replace_once( - "apps/arstack_studio/src/FirmwareManager.cpp", - ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n', - ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\n const QString buildId = object.value(QStringLiteral("buildId")).toString().trimmed().toLower();\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n', - "FirmwareManager parse buildId", -) -replace_once( - "apps/arstack_studio/src/FirmwareManager.cpp", - ' expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n', - ' expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n !QRegularExpression(QStringLiteral("^[0-9a-f]{16}$")).match(buildId).hasMatch() ||\n !sourceCommit.startsWith(buildId, Qt::CaseInsensitive) ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n', - "FirmwareManager validate buildId", -) -replace_once( - "apps/arstack_studio/src/FirmwareManager.cpp", - " firmwareVersion_ = version;\n expectedProtocol_ = QString::number(protocol);\n", - " firmwareVersion_ = version;\n firmwareBuildId_ = buildId;\n expectedProtocol_ = QString::number(protocol);\n", - "FirmwareManager store buildId", -) -replace_once( - "apps/arstack_studio/src/FirmwareManager.cpp", - ' bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")\n .arg(firmwareVersion_);\n', - ' bundleStatus_ = QStringLiteral("Ready · firmware v%1 · build %2 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")\n .arg(firmwareVersion_, firmwareBuildId_);\n', - "FirmwareManager bundle status buildId", -) +# AGENTS.md: sourceCommit in the existing manifest remains the sole provenance +# source of truth. Do not mutate the firmware workflow or add parallel metadata. +start = text.index('replace_once(\n ".github/workflows/esp32p4-smv-injector.yml",') +end = text.index('replace_once(\n "apps/arstack_studio/src/DeviceController.hpp",', start) +text = text[:start] + text[end:] -replace_once( - "apps/arstack_studio/src/SmartSessionController.hpp", - " Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged)\n", - " Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareReinstallAvailable READ firmwareReinstallAvailable NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged)\n", - "SmartSession reinstall property", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.hpp", - " Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT)\n Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged)\n", - " Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT)\n Q_PROPERTY(QString expectedFirmwareBuildId READ expectedFirmwareBuildId NOTIFY stateChanged)\n Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString deviceFirmwareBuildId READ deviceFirmwareBuildId NOTIFY stateChanged)\n", - "SmartSession buildId properties", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.hpp", - " [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n [[nodiscard]] bool firmwareInstallRequired() const noexcept;\n", - " [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n [[nodiscard]] bool firmwareReinstallAvailable() const noexcept;\n [[nodiscard]] bool firmwareInstallRequired() const noexcept;\n", - "SmartSession reinstall getter", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.hpp", - " [[nodiscard]] QString expectedFirmwareVersion() const;\n [[nodiscard]] QString deviceFirmwareVersion() const;\n", - " [[nodiscard]] QString expectedFirmwareVersion() const;\n [[nodiscard]] QString expectedFirmwareBuildId() const;\n [[nodiscard]] QString deviceFirmwareVersion() const;\n [[nodiscard]] QString deviceFirmwareBuildId() const;\n", - "SmartSession buildId getters", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.hpp", - " Q_INVOKABLE bool beginFirmwareUpdate();\n Q_INVOKABLE bool beginFirmwareInstall();\n", - " Q_INVOKABLE bool beginFirmwareUpdate();\n Q_INVOKABLE bool beginFirmwareReinstall();\n Q_INVOKABLE bool beginFirmwareInstall();\n", - "SmartSession reinstall intent", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.hpp", - " QString deviceFirmwareVersion_;\n QString updatePort_;\n", - " QString deviceFirmwareVersion_;\n QString deviceFirmwareBuildId_;\n QString updatePort_;\n", - "SmartSession device buildId storage", -) - -replace_once( - "apps/arstack_studio/src/SmartSessionController.cpp", - "bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; }\n", - '''bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } -bool SmartSessionController::firmwareReinstallAvailable() const noexcept { - return device_ != nullptr && firmware_ != nullptr && device_->deviceVerified() && - firmware_->bundleReady() && !firmware_->busy() && !updateRequested_ && firmwareIsCurrent(); -} -''', - "SmartSession reinstall availability", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.cpp", - 'QString SmartSessionController::expectedFirmwareVersion() const { return QStringLiteral(ARSTACK_STUDIO_VERSION); }\nQString SmartSessionController::deviceFirmwareVersion() const { return deviceFirmwareVersion_; }\n', - '''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_; } -''', - "SmartSession buildId getters implementation", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.cpp", - '''bool SmartSessionController::beginFirmwareUpdate() { - if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || - !firmware_->bundleReady() || firmware_->busy()) { - return false; - } - return beginFirmwareOperation(device_->portName()); -} -''', - '''bool SmartSessionController::beginFirmwareUpdate() { - if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || - !firmware_->bundleReady() || firmware_->busy()) { - return false; - } - return beginFirmwareOperation(device_->portName()); -} - -bool SmartSessionController::beginFirmwareReinstall() { - if (!firmwareReinstallAvailable() || device_ == nullptr) return false; - return beginFirmwareOperation(device_->portName()); -} -''', - "SmartSession reinstall operation", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.cpp", - " deviceFirmwareVersion_.clear();\n profileSyncBootId_.clear();\n", - " deviceFirmwareVersion_.clear();\n deviceFirmwareBuildId_.clear();\n profileSyncBootId_.clear();\n", - "SmartSession clear device buildId", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.cpp", - '''void SmartSessionController::refreshFirmwareIdentity() { - deviceFirmwareVersion_ = - (device_ != nullptr && device_->deviceVerified()) ? device_->firmwareVersion() : QString{}; -} - -bool SmartSessionController::firmwareIsCurrent() const { - return device_ != nullptr && device_->deviceVerified() && - DeviceController::identitySupportsCurrentContract( - device_->deviceIdentity(), expectedFirmwareVersion()); -} -''', - '''void SmartSessionController::refreshFirmwareIdentity() { - 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(), expectedFirmwareBuildId()); -} -''', - "SmartSession build-aware current check", -) -replace_once( - "apps/arstack_studio/src/SmartSessionController.cpp", - ''' 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()), - false, - true); -''', - ''' const QString versionText = deviceFirmwareVersion_.isEmpty() - ? QStringLiteral("legacy firmware") - : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); - const QString observedBuild = deviceFirmwareBuildId_.isEmpty() - ? QStringLiteral("legacy/no build ID") - : deviceFirmwareBuildId_; - const QString packageBuild = expectedFirmwareBuildId().isEmpty() - ? QStringLiteral("unknown") - : expectedFirmwareBuildId(); - setPresentation( - QStringLiteral("FIRMWARE UPDATE"), - QStringLiteral("%1 build %2 detected. Studio package contains v%3 build %4; update is required before injection.") - .arg(versionText, observedBuild, expectedFirmwareVersion(), packageBuild), - false, - true); -''', - "SmartSession build mismatch presentation", -) - -panel = Path("apps/arstack_studio/qml/FirmwarePanel.qml") -text = panel.read_text(encoding="utf-8") -text = text.replace( - '? "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"', -) -old = ''' Rectangle { - implicitWidth: 150 - implicitHeight: 34 - radius: 7 - color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised - border.width: 1 - border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line - Label { - anchors.centerIn: parent - text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" - color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 9 - font.weight: Font.Bold - } - } +old = ''' ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\\n const QString buildId = object.value(QStringLiteral("buildId")).toString().trimmed().toLower();\\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\\n', ''' -new = ''' CalmButton { - id: firmwareAction - theme: panel.theme - uiFont: panel.uiFont - implicitWidth: 172 - text: panel.session && panel.session.firmwareUpdateRequired - ? "Update firmware" - : "Reinstall current" - visible: panel.session && - (panel.session.firmwareUpdateRequired || panel.session.firmwareReinstallAvailable) - enabled: visible && !panel.firmware.busy - tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal" - onClicked: { - if (panel.session.firmwareUpdateRequired) - panel.session.beginFirmwareUpdate() - else - panel.session.beginFirmwareReinstall() - } - } - - Rectangle { - visible: !firmwareAction.visible - implicitWidth: 150 - implicitHeight: 34 - radius: 7 - color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised - border.width: 1 - border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line - Label { - anchors.centerIn: parent - text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" - color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 9 - font.weight: Font.Bold - } - } +new = ''' ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\\n const QString buildId = sourceCommit.left(16);\\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\\n', ''' if old not in text: - raise SystemExit("FirmwarePanel guided action block not found") -panel.write_text(text.replace(old, new, 1), encoding="utf-8") - -harness = Path("apps/arstack_studio/src/DeterministicSessionHarness.cpp") -text = harness.read_text(encoding="utf-8") -text = text.replace( - '{"legacy identity -> firmware update", legacyIdentityOffersUpdate()},\n', - '{"legacy identity -> firmware update", legacyIdentityOffersUpdate()},\n {"same version missing build ID -> firmware update", sameVersionMissingBuildOffersUpdate()},\n {"same version stale build -> firmware update", sameVersionStaleBuildOffersUpdate()},\n', -) -text = text.replace( - ''' Fixture() { - profileReady = profiles.loadReferenceTemplate(); - session.setProfiles(&profiles); -''', - ''' Fixture() { - firmware.firmwareVersion_ = QStringLiteral(ARSTACK_STUDIO_VERSION); - firmware.firmwareBuildId_ = QStringLiteral("0123456789abcdef"); - firmware.bundleReady_ = true; - profileReady = profiles.loadReferenceTemplate(); - session.setProfiles(&profiles); -''', -) -text = text.replace( - ''' static DeviceIdentity identity( - const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), - const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), - const QString& bootId = QStringLiteral("0123456789ABCDEF")) { -''', - ''' static DeviceIdentity identity( - const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), - const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), - const QString& bootId = QStringLiteral("0123456789ABCDEF"), - const QString& buildId = QStringLiteral("0123456789abcdef")) { -''', -) -text = text.replace( - " result.firmwareVersion = firmware;\n result.bootId = bootId;\n", - " result.firmwareVersion = firmware;\n result.buildId = buildId;\n result.bootId = bootId;\n", -) -text = text.replace( - ' QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n', - ' QStringLiteral("SMV-4I4V"),\n QStringLiteral("PROFILE"),\n QStringLiteral("LIVE-SETPOINTS"),\n', - 1, -) -text = text.replace( - ''' return fixture.session.state() == QStringLiteral("READY") && - fixture.session.startReady() && - !fixture.session.firmwareInstallVisible() && - !fixture.session.firmwareUpdateRequired(); - } - - static bool legacyIdentityOffersUpdate() { -''', - ''' return fixture.session.state() == QStringLiteral("READY") && - fixture.session.startReady() && - fixture.session.firmwareReinstallAvailable() && - !fixture.session.firmwareInstallVisible() && - !fixture.session.firmwareUpdateRequired(); - } - - static bool legacyIdentityOffersUpdate() { -''', -) -marker = " static bool automaticIdentityTimeoutOffersFirmwareRecovery() {\n" -addition = ''' 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("FIRMWARE UPDATE") && - fixture.session.firmwareUpdateRequired() && - !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("FIRMWARE UPDATE") && - fixture.session.firmwareUpdateRequired() && - !fixture.session.firmwareReinstallAvailable() && - !fixture.session.startReady(); - } - -''' -if marker not in text: - raise SystemExit("Harness insertion marker not found") -harness.write_text(text.replace(marker, addition + marker, 1), encoding="utf-8") - -main = Path("apps/arstack_studio/src/main.cpp") -text = main.read_text(encoding="utf-8") -text = text.replace( - ' firmware.expectedProtocol() == QStringLiteral("1") &&\n firmware.firmwareSha256().size() == 64 &&\n', - ' firmware.expectedProtocol() == QStringLiteral("1") &&\n firmware.firmwareBuildId().size() == 16 &&\n firmware.firmwareSha256().size() == 64 &&\n', - 1, -) -text = text.replace( - ' "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF "\n "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', - ' "device_id=A1B2C3D4E5F6 firmware=%1 build=0123456789abcdef boot_id=0123456789ABCDEF "\n "capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', - 1, -) -text = text.replace( - " currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION));\n", - ' currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef"));\n', - 1, -) -text = text.replace( - ' "device_id=A1B2C3D4E5F6 firmware=%1 capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', - ' "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', - 1, -) -text = text.replace( - " legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION));\n", - ' legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef"));\n', - 1, -) -main.write_text(text, encoding="utf-8") + raise SystemExit("historical manifest provenance block not found") +text = text.replace(old, new, 1) -print("P0 firmware build identity + guided reinstall contract applied") +exec(compile(text, "", "exec"), {"__name__": "__main__"}) From 501bd4229bf54857a1d6edc86142c368c6928bd5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:12:55 +0700 Subject: [PATCH 115/170] ci: move one-shot P0 repair to macOS runner --- .../arstack-studio-windows-lifecycle.yml | 38 +------------------ 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index e979b94a6..41c370546 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -20,7 +20,7 @@ concurrency: jobs: repair-source: name: Apply one-shot P0 firmware build identity repair - runs-on: ubuntu-latest + runs-on: macos-latest timeout-minutes: 5 steps: - name: Checkout feature branch @@ -36,42 +36,6 @@ jobs: echo 'P0 build identity repair already applied; no-op.' exit 0 fi - python3 - <<'PY' - from pathlib import Path - p = Path('.github/scripts/p0_firmware_build_identity_repair.py') - text = p.read_text(encoding='utf-8') - - # CMake's regex dialect is deliberately kept simple: exact length is - # checked independently, then characters are constrained to hex. - old = '''if(NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]{16}$") - message(FATAL_ERROR "ARSTACK_FIRMWARE_BUILD_ID must be exactly 16 hexadecimal characters") -endif()''' - new = '''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()''' - if old not in text: - raise SystemExit('repair-script CMake validation block not found') - text = text.replace(old, new, 1) - - # AGENTS.md requires one provenance source of truth. The existing - # manifest sourceCommit remains authoritative, so do not mutate the - # firmware workflow or add a second manifest buildId field. - start = text.index('replace_once(\n ".github/workflows/esp32p4-smv-injector.yml",') - end = text.index('replace_once(\n "apps/arstack_studio/src/DeviceController.hpp",', start) - text = text[:start] + text[end:] - - # FirmwareManager derives the short semantic build ID from the - # already-authoritative sourceCommit field in the manifest. - old = ''' ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\\n const QString buildId = object.value(QStringLiteral("buildId")).toString().trimmed().toLower();\\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\\n', -''' - new = ''' ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\\n const QString buildId = sourceCommit.left(16);\\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\\n', -''' - if old not in text: - raise SystemExit('repair-script manifest provenance block not found') - text = text.replace(old, new, 1) - p.write_text(text, encoding='utf-8') - PY python3 .github/scripts/p0_firmware_build_identity_repair.py - name: Verify repair invariants shell: bash From 6b81cb4b2a7fa4cbc555a714fdc2a23cb1edb3c7 Mon Sep 17 00:00:00 2001 From: ARStack P0 Repair Date: Wed, 16 Sep 2026 13:13:14 +0000 Subject: [PATCH 116/170] fix(p0): bind firmware updates to semantic build identity --- apps/arstack_studio/qml/FirmwarePanel.qml | 23 +++++++++++- .../src/DeterministicSessionHarness.cpp | 35 ++++++++++++++++++- apps/arstack_studio/src/DeviceController.cpp | 13 +++++-- apps/arstack_studio/src/DeviceController.hpp | 8 +++-- apps/arstack_studio/src/FirmwareManager.cpp | 10 ++++-- apps/arstack_studio/src/FirmwareManager.hpp | 5 +++ .../src/SmartSessionController.cpp | 31 +++++++++++++--- .../src/SmartSessionController.hpp | 8 +++++ apps/arstack_studio/src/main.cpp | 11 +++--- embedded/esp32p4_smv_injector/CMakeLists.txt | 23 ++++++++++++ .../esp32p4_smv_injector/main/CMakeLists.txt | 2 ++ .../main/live_control.cpp | 11 +++--- 12 files changed, 156 insertions(+), 24 deletions(-) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 64a813992..71ce9df48 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 @@ -273,7 +273,28 @@ SurfacePanel { } } + CalmButton { + id: firmwareAction + theme: panel.theme + uiFont: panel.uiFont + implicitWidth: 172 + text: panel.session && panel.session.firmwareUpdateRequired + ? "Update firmware" + : "Reinstall current" + visible: panel.session && + (panel.session.firmwareUpdateRequired || panel.session.firmwareReinstallAvailable) + enabled: visible && !panel.firmware.busy + tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal" + onClicked: { + if (panel.session.firmwareUpdateRequired) + panel.session.beginFirmwareUpdate() + else + panel.session.beginFirmwareReinstall() + } + } + Rectangle { + visible: !firmwareAction.visible implicitWidth: 150 implicitHeight: 34 radius: 7 diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index ee44086a7..b7bab42fc 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -28,6 +28,8 @@ class DeterministicSessionHarness final { const CaseResult cases[] = { {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, + {"same version missing build ID -> firmware update", sameVersionMissingBuildOffersUpdate()}, + {"same version stale build -> firmware update", sameVersionStaleBuildOffersUpdate()}, {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, @@ -72,6 +74,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); @@ -95,16 +100,19 @@ 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("PTP-P2"), @@ -249,6 +257,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(); } @@ -266,6 +275,30 @@ class DeterministicSessionHarness final { !fixture.session.startReady(); } + 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("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !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("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !fixture.session.firmwareReinstallAvailable() && + !fixture.session.startReady(); + } + static bool automaticIdentityTimeoutOffersFirmwareRecovery() { Fixture fixture; if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 2b1f16c73..ad6eb65a1 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -36,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{ @@ -323,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_; } @@ -365,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(); @@ -383,17 +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("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; } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index e85be5332..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) @@ -121,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; @@ -183,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; } diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index a0174e3cc..3c64e8040 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -163,6 +163,7 @@ void FirmwareManager::refreshBundle() { if (busy_ || shuttingDown_) return; bundleReady_ = false; firmwareVersion_ = QStringLiteral("-"); + firmwareBuildId_.clear(); expectedProtocol_ = QStringLiteral("-"); revisionPolicy_.clear(); firmwareSha256_.clear(); @@ -181,8 +182,8 @@ void FirmwareManager::refreshBundle() { return; } bundleReady_ = true; - bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · PTP-P2 · 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(); } @@ -211,6 +212,8 @@ 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(); @@ -230,6 +233,8 @@ bool FirmwareManager::loadManifest() { if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName || expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 || + !QRegularExpression(QStringLiteral("^[0-9a-f]{16}$")).match(buildId).hasMatch() || + !sourceCommit.startsWith(buildId, Qt::CaseInsensitive) || revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) { bundleStatus_ = QStringLiteral("Firmware manifest fields/capabilities are incomplete or unsafe."); return false; @@ -254,6 +259,7 @@ bool FirmwareManager::loadManifest() { } firmwareVersion_ = version; + firmwareBuildId_ = buildId; expectedProtocol_ = QString::number(protocol); revisionPolicy_ = revisionPolicy; firmwareSha256_ = actualHash; diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 259490eb2..25318ad00 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_; } @@ -84,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; @@ -109,6 +113,7 @@ class FirmwareManager : public QObject { 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 ec821cc9f..7c315940d 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -148,6 +148,10 @@ bool SmartSessionController::engineeringEditable() const noexcept { portOwner_ != PortOwner::firmwareTool && (firmware_ == nullptr || !firmware_->busy()); } bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } +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_; } @@ -193,7 +197,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_); @@ -425,6 +433,11 @@ bool SmartSessionController::beginFirmwareUpdate() { return beginFirmwareOperation(device_->portName()); } +bool SmartSessionController::beginFirmwareReinstall() { + if (!firmwareReinstallAvailable() || device_ == nullptr) return false; + return beginFirmwareOperation(device_->portName()); +} + bool SmartSessionController::beginFirmwareInstall() { refreshRecoveryOfferFromIdentity(); if (device_ == nullptr || firmware_ == nullptr || !blankBoardDetected_ || @@ -579,6 +592,7 @@ void SmartSessionController::reconnectDeviceSignals() { prepareTimer_.start(); } else { deviceFirmwareVersion_.clear(); + deviceFirmwareBuildId_.clear(); profileSyncBootId_.clear(); resetProfileSync(true); } @@ -938,14 +952,15 @@ 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::deviceControlAvailable() const noexcept { @@ -1061,10 +1076,16 @@ void SmartSessionController::reconcile() { const QString versionText = deviceFirmwareVersion_.isEmpty() ? QStringLiteral("legacy firmware") : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); + const QString observedBuild = deviceFirmwareBuildId_.isEmpty() + ? QStringLiteral("legacy/no build ID") + : deviceFirmwareBuildId_; + const QString packageBuild = expectedFirmwareBuildId().isEmpty() + ? QStringLiteral("unknown") + : expectedFirmwareBuildId(); setPresentation( QStringLiteral("FIRMWARE UPDATE"), - QStringLiteral("%1 detected. ARStack Studio v%2 is ready to update it.") - .arg(versionText, expectedFirmwareVersion()), + QStringLiteral("%1 build %2 detected. Studio package contains v%3 build %4; update is required before injection.") + .arg(versionText, observedBuild, expectedFirmwareVersion(), packageBuild), false, true); return; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index e07b23068..a562fc376 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -25,6 +25,7 @@ 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 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) @@ -35,7 +36,9 @@ class SmartSessionController : public QObject { 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) @@ -62,6 +65,7 @@ class SmartSessionController : public QObject { [[nodiscard]] bool liveControlReady() const noexcept; [[nodiscard]] bool engineeringEditable() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; + [[nodiscard]] bool firmwareReinstallAvailable() const noexcept; [[nodiscard]] bool firmwareInstallRequired() const noexcept; [[nodiscard]] bool firmwareInstallVisible() const noexcept { return !recoveryPending_ && firmwareInstallRequired(); @@ -75,7 +79,9 @@ class SmartSessionController : public QObject { [[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_; } @@ -158,6 +164,7 @@ 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 retryFirmwareSetup(); @@ -342,6 +349,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_; diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index ec95d3eb4..45bb96be0 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -159,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; @@ -193,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,PTP-P2,SMPSYNCH-AUTO") + "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,PTP-P2,SMPSYNCH-AUTO") + "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( diff --git a/embedded/esp32p4_smv_injector/CMakeLists.txt b/embedded/esp32p4_smv_injector/CMakeLists.txt index 17b0eb373..c315273a1 100644 --- a/embedded/esp32p4_smv_injector/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/CMakeLists.txt @@ -4,5 +4,28 @@ cmake_minimum_required(VERSION 3.16) # ESP-IDF exports this as the application version through esp_app_desc. set(PROJECT_VER "0.1.1") +# Semantic firmware provenance. CI supplies the exact checkout commit; local +# builds fall back to the repository HEAD. The value is intentionally short, +# fixed-size hex so it is safe to expose on the bounded serial identity line. +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}") +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 + RESULT_VARIABLE ARSTACK_FIRMWARE_BUILD_ID_RESULT) + if(NOT ARSTACK_FIRMWARE_BUILD_ID_RESULT EQUAL 0) + set(ARSTACK_FIRMWARE_BUILD_ID "0000000000000000") + 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") + include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(arstack_esp32p4_smv_injector) 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/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index c5466754a..5e962448b 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -238,19 +238,20 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO" - : "SMV-4I4V,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"; + ? "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); } From 6774f74bca2ba603d06362cf6dc99624fae7c0fa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:25:26 +0700 Subject: [PATCH 117/170] chore(p0): remove one-shot firmware repair scaffolding --- .../p0_firmware_build_identity_repair.py | 54 -- .../arstack-studio-windows-lifecycle.yml | 44 +- .../p0-firmware-build-identity-repair.yml | 601 ------------------ 3 files changed, 1 insertion(+), 698 deletions(-) delete mode 100644 .github/scripts/p0_firmware_build_identity_repair.py delete mode 100644 .github/workflows/p0-firmware-build-identity-repair.yml diff --git a/.github/scripts/p0_firmware_build_identity_repair.py b/.github/scripts/p0_firmware_build_identity_repair.py deleted file mode 100644 index 3a95115ab..000000000 --- a/.github/scripts/p0_firmware_build_identity_repair.py +++ /dev/null @@ -1,54 +0,0 @@ -from pathlib import Path -import subprocess - -PATH = ".github/scripts/p0_firmware_build_identity_repair.py" - - -def historical_patch_source() -> str: - commits = subprocess.check_output( - ["git", "rev-list", "HEAD", "--", PATH], text=True - ).splitlines() - for commit in commits[1:]: - try: - candidate = subprocess.check_output( - ["git", "show", f"{commit}:{PATH}"], text=True - ) - except subprocess.CalledProcessError: - continue - if ( - candidate.count("replace_once(") > 10 - and "embedded/esp32p4_smv_injector/CMakeLists.txt" in candidate - and ".github/workflows/esp32p4-smv-injector.yml" in candidate - ): - return candidate - raise SystemExit("historical P0 patch implementation not found") - - -text = historical_patch_source() - -old = '''if(NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]{16}$") - message(FATAL_ERROR "ARSTACK_FIRMWARE_BUILD_ID must be exactly 16 hexadecimal characters") -endif()''' -new = '''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()''' -if old not in text: - raise SystemExit("historical CMake validation block not found") -text = text.replace(old, new, 1) - -# AGENTS.md: sourceCommit in the existing manifest remains the sole provenance -# source of truth. Do not mutate the firmware workflow or add parallel metadata. -start = text.index('replace_once(\n ".github/workflows/esp32p4-smv-injector.yml",') -end = text.index('replace_once(\n "apps/arstack_studio/src/DeviceController.hpp",', start) -text = text[:start] + text[end:] - -old = ''' ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\\n const QString buildId = object.value(QStringLiteral("buildId")).toString().trimmed().toLower();\\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\\n', -''' -new = ''' ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\\n const QString buildId = sourceCommit.left(16);\\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\\n', -''' -if old not in text: - raise SystemExit("historical manifest provenance block not found") -text = text.replace(old, new, 1) - -exec(compile(text, "", "exec"), {"__name__": "__main__"}) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 41c370546..04d93998e 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -11,55 +11,13 @@ on: - '.github/workflows/arstack-studio-windows-lifecycle.yml' permissions: - contents: write + contents: read concurrency: group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - repair-source: - name: Apply one-shot P0 firmware build identity repair - runs-on: macos-latest - timeout-minutes: 5 - steps: - - name: Checkout feature branch - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - name: Apply P0 repair - shell: bash - run: | - set -euo pipefail - if grep -q 'firmwareBuildId' apps/arstack_studio/src/DeviceController.hpp; then - echo 'P0 build identity repair already applied; no-op.' - exit 0 - fi - python3 .github/scripts/p0_firmware_build_identity_repair.py - - name: Verify repair invariants - shell: bash - run: | - set -euo pipefail - grep -R "firmwareBuildId" -n apps/arstack_studio/src apps/arstack_studio/qml/FirmwarePanel.qml - grep -F 'build=%s boot_id=' embedded/esp32p4_smv_injector/main/live_control.cpp - grep -F 'sourceCommit' apps/arstack_studio/src/FirmwareManager.cpp - grep -F 'const QString buildId = sourceCommit.left(16);' apps/arstack_studio/src/FirmwareManager.cpp - grep -F 'same version stale build -> firmware update' apps/arstack_studio/src/DeterministicSessionHarness.cpp - - name: Commit source repair - shell: bash - run: | - set -euo pipefail - git config user.name "ARStack P0 Repair" - git config user.email "actions@users.noreply.github.com" - git add embedded/esp32p4_smv_injector apps/arstack_studio - if git diff --cached --quiet; then - echo 'No source changes to commit.' - exit 0 - fi - git commit -m "fix(p0): bind firmware updates to semantic build identity" - git push origin HEAD:feature/ptp-production-v0.1.1 - close-exit-reopen: name: Close -> process exit -> immediate reopen runs-on: windows-2022 diff --git a/.github/workflows/p0-firmware-build-identity-repair.yml b/.github/workflows/p0-firmware-build-identity-repair.yml deleted file mode 100644 index 2767507a4..000000000 --- a/.github/workflows/p0-firmware-build-identity-repair.yml +++ /dev/null @@ -1,601 +0,0 @@ -name: P0 Firmware Build Identity Repair - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - paths: - - '.github/workflows/p0-firmware-build-identity-repair.yml' - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/ptp-production-v0.1.1 - fetch-depth: 0 - - - name: Apply production build identity contract - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - - def replace_once(path, old, new, label): - p = Path(path) - text = p.read_text(encoding='utf-8') - if old not in text: - raise SystemExit(f'{label}: expected source block not found in {path}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - # Embedded build identity: one source-commit-derived ID, validated at configure time. - replace_once( - 'embedded/esp32p4_smv_injector/CMakeLists.txt', - 'set(PROJECT_VER "0.1.1")\n\ninclude($ENV{IDF_PATH}/tools/cmake/project.cmake)\n', - '''set(PROJECT_VER "0.1.1") - -# Semantic firmware provenance. CI supplies the exact checkout commit; local -# builds fall back to the repository HEAD. The value is intentionally short, -# fixed-size hex so it is safe to expose on the bounded serial identity line. -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}") -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 - RESULT_VARIABLE ARSTACK_FIRMWARE_BUILD_ID_RESULT) - if(NOT ARSTACK_FIRMWARE_BUILD_ID_RESULT EQUAL 0) - set(ARSTACK_FIRMWARE_BUILD_ID "0000000000000000") - endif() -endif() -string(TOLOWER "${ARSTACK_FIRMWARE_BUILD_ID}" ARSTACK_FIRMWARE_BUILD_ID) -if(NOT ARSTACK_FIRMWARE_BUILD_ID MATCHES "^[0-9a-f]{16}$") - 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") - -include($ENV{IDF_PATH}/tools/cmake/project.cmake) -''', - 'embedded build identity configure contract') - - replace_once( - 'embedded/esp32p4_smv_injector/main/CMakeLists.txt', - 'target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20)\n', - '''target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) -target_compile_definitions(${COMPONENT_LIB} PRIVATE - ARSTACK_FIRMWARE_BUILD_ID="${ARSTACK_FIRMWARE_BUILD_ID}") -''', - 'embedded build identity compile definition') - - live = Path('embedded/esp32p4_smv_injector/main/live_control.cpp') - text = live.read_text(encoding='utf-8') - text = text.replace('"SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"', - '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"') - text = text.replace('"SMV-4I4V,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"', - '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"') - text = text.replace('"SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"', - '"SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE"') - text = text.replace('"SMV-4I4V,LIVE-SETPOINTS"', - '"SMV-4I4V,PROFILE,LIVE-SETPOINTS"') - old = ''' "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", - 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, - static_cast(g_boot_id), - capabilities);''' - new = ''' "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);''' - if old not in text: - raise SystemExit('live identity format block not found') - live.write_text(text.replace(old, new, 1), encoding='utf-8') - - # Build workflow: compile the same ID that is written to the manifest. - replace_once( - '.github/workflows/esp32p4-smv-injector.yml', - ' - name: Build firmware\n run: source /opt/esp/idf/export.sh && idf.py build\n', - ''' - name: Build firmware - run: | - set -euo pipefail - export ARSTACK_FIRMWARE_BUILD_ID="${GITHUB_SHA:0:16}" - source /opt/esp/idf/export.sh - idf.py build - strings build/arstack_esp32p4_smv_injector.elf | grep -F "$ARSTACK_FIRMWARE_BUILD_ID" >/dev/null - echo "Semantic firmware build ID: $ARSTACK_FIRMWARE_BUILD_ID" -''', - 'firmware build identity workflow') - replace_once( - '.github/workflows/esp32p4-smv-injector.yml', - ' "version": os.environ["ARSTACK_FIRMWARE_VERSION"],\n "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),\n', - ' "version": os.environ["ARSTACK_FIRMWARE_VERSION"],\n "buildId": source_commit[:16].lower(),\n "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),\n', - 'manifest buildId') - replace_once( - '.github/workflows/esp32p4-smv-injector.yml', - " assert manifest['protocol'] == 1\n assert 'PTP-P2' in manifest['capabilities']\n", - " assert manifest['protocol'] == 1\n assert len(manifest['buildId']) == 16\n assert manifest['sourceCommit'].lower().startswith(manifest['buildId'])\n assert 'PROFILE' in manifest['capabilities']\n assert 'PTP-P2' in manifest['capabilities']\n", - 'manifest buildId validation') - - # Typed device identity. Old identity lines remain parseable but are not - # current when the package carries a non-empty expected build ID. - replace_once( - 'apps/arstack_studio/src/DeviceController.hpp', - ' QString firmwareVersion;\n QString bootId;\n', - ' QString firmwareVersion;\n QString buildId;\n QString bootId;\n', - 'DeviceIdentity buildId field') - replace_once( - 'apps/arstack_studio/src/DeviceController.hpp', - ' deviceId.isEmpty() && firmwareVersion.isEmpty() && bootId.isEmpty() &&\n', - ' deviceId.isEmpty() && firmwareVersion.isEmpty() && buildId.isEmpty() && bootId.isEmpty() &&\n', - 'DeviceIdentity empty buildId') - replace_once( - 'apps/arstack_studio/src/DeviceController.hpp', - ' Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged)\n', - ' Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY deviceIdentityChanged)\n Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged)\n', - 'DeviceController buildId property') - replace_once( - 'apps/arstack_studio/src/DeviceController.hpp', - ' [[nodiscard]] QString firmwareVersion() const;\n [[nodiscard]] QString bootId() const;\n', - ' [[nodiscard]] QString firmwareVersion() const;\n [[nodiscard]] QString firmwareBuildId() const;\n [[nodiscard]] QString bootId() const;\n', - 'DeviceController buildId getter') - replace_once( - 'apps/arstack_studio/src/DeviceController.hpp', - ' [[nodiscard]] static bool identitySupportsCurrentContract(\n const DeviceIdentity& identity,\n const QString& expectedFirmwareVersion);\n', - ' [[nodiscard]] static bool identitySupportsCurrentContract(\n const DeviceIdentity& identity,\n const QString& expectedFirmwareVersion,\n const QString& expectedBuildId = {});\n', - 'DeviceController current contract signature') - - replace_once( - 'apps/arstack_studio/src/DeviceController.cpp', - ' "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\\\-]+) "\n "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\\\-]+)"),\n', - ' "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\\\-]+) "\n "(?:build=([A-Fa-f0-9]{16}) )?"\n "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\\\-]+)"),\n', - 'DeviceController identity regex buildId') - replace_once( - 'apps/arstack_studio/src/DeviceController.cpp', - 'QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; }\nQString DeviceController::bootId() const { return identity_.bootId; }\n', - 'QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; }\nQString DeviceController::firmwareBuildId() const { return identity_.buildId; }\nQString DeviceController::bootId() const { return identity_.bootId; }\n', - 'DeviceController buildId getter implementation') - replace_once( - 'apps/arstack_studio/src/DeviceController.cpp', - ' parsed.firmwareVersion = match.captured(5);\n parsed.bootId = match.captured(6).toUpper();\n parsed.capabilities = match.captured(7).split(QLatin1Char(\',\'), Qt::SkipEmptyParts);\n', - ' parsed.firmwareVersion = match.captured(5);\n parsed.buildId = match.captured(6).toLower();\n parsed.bootId = match.captured(7).toUpper();\n parsed.capabilities = match.captured(8).split(QLatin1Char(\',\'), Qt::SkipEmptyParts);\n', - 'DeviceController identity captures') - replace_once( - 'apps/arstack_studio/src/DeviceController.cpp', - '''bool DeviceController::identitySupportsCurrentContract( - const DeviceIdentity& identity, - const QString& expectedFirmwareVersion) { - static const QStringList requiredCapabilities{ - QStringLiteral("SMV-4I4V"), - QStringLiteral("LIVE-SETPOINTS"), - QStringLiteral("SESSION-LEASE"), - QStringLiteral("PTP-P2"), - QStringLiteral("SMPSYNCH-AUTO")}; - if (identity.product != QStringLiteral("SMV-INJECTOR") || - identity.target != QStringLiteral("ESP32-P4") || - identity.protocolVersion != QStringLiteral("1") || - identity.firmwareVersion != expectedFirmwareVersion || - identity.bootId.size() != 16) { - return false; - } -''', - '''bool DeviceController::identitySupportsCurrentContract( - const DeviceIdentity& identity, - const QString& expectedFirmwareVersion, - const QString& expectedBuildId) { - static const QStringList requiredCapabilities{ - QStringLiteral("SMV-4I4V"), - QStringLiteral("PROFILE"), - QStringLiteral("LIVE-SETPOINTS"), - 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; - } -''', - 'DeviceController build-aware contract') - - # FirmwareManager reads buildId from the already-authoritative manifest. - replace_once( - 'apps/arstack_studio/src/FirmwareManager.hpp', - 'class FirmwareWorker;\n', - 'class DeterministicSessionHarness;\nclass FirmwareWorker;\n', - 'FirmwareManager harness forward declaration') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.hpp', - ' Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged)\n', - ' Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString firmwareBuildId READ firmwareBuildId NOTIFY stateChanged)\n Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged)\n', - 'FirmwareManager buildId property') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.hpp', - ' [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; }\n [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; }\n', - ' [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; }\n [[nodiscard]] QString firmwareBuildId() const { return firmwareBuildId_; }\n [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; }\n', - 'FirmwareManager buildId getter') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.hpp', - 'private:\n enum class Operation { none, probe, flash, reset };\n', - 'private:\n friend class DeterministicSessionHarness;\n enum class Operation { none, probe, flash, reset };\n', - 'FirmwareManager harness friend') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.hpp', - ' QString firmwareVersion_{QStringLiteral("-")};\n QString expectedProtocol_{QStringLiteral("-")};\n', - ' QString firmwareVersion_{QStringLiteral("-")};\n QString firmwareBuildId_;\n QString expectedProtocol_{QStringLiteral("-")};\n', - 'FirmwareManager buildId storage') - - replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - ' firmwareVersion_ = QStringLiteral("-");\n expectedProtocol_ = QStringLiteral("-");\n', - ' firmwareVersion_ = QStringLiteral("-");\n firmwareBuildId_.clear();\n expectedProtocol_ = QStringLiteral("-");\n', - 'FirmwareManager clear buildId') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n', - ' const QString version = object.value(QStringLiteral("version")).toString().trimmed();\n const QString buildId = object.value(QStringLiteral("buildId")).toString().trimmed().toLower();\n const QString sourceCommit = object.value(QStringLiteral("sourceCommit")).toString().trimmed().toLower();\n const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower();\n', - 'FirmwareManager parse buildId') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - ' expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n', - ' expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 ||\n !QRegularExpression(QStringLiteral("^[0-9a-f]{16}$")).match(buildId).hasMatch() ||\n !sourceCommit.startsWith(buildId, Qt::CaseInsensitive) ||\n revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) {\n', - 'FirmwareManager validate buildId') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - ' firmwareVersion_ = version;\n expectedProtocol_ = QString::number(protocol);\n', - ' firmwareVersion_ = version;\n firmwareBuildId_ = buildId;\n expectedProtocol_ = QString::number(protocol);\n', - 'FirmwareManager store buildId') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - ' bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")\n .arg(firmwareVersion_);\n', - ' bundleStatus_ = QStringLiteral("Ready · firmware v%1 · build %2 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified")\n .arg(firmwareVersion_, firmwareBuildId_);\n', - 'FirmwareManager bundle status buildId') - - # Supervisor remains the single mutation authority. Reinstall is just - # another explicit operator intent into the existing firmware state machine. - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ' Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged)\n', - ' Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareReinstallAvailable READ firmwareReinstallAvailable NOTIFY stateChanged)\n Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged)\n', - 'SmartSession reinstall property') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ' Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT)\n Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged)\n', - ' Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT)\n Q_PROPERTY(QString expectedFirmwareBuildId READ expectedFirmwareBuildId NOTIFY stateChanged)\n Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged)\n Q_PROPERTY(QString deviceFirmwareBuildId READ deviceFirmwareBuildId NOTIFY stateChanged)\n', - 'SmartSession buildId properties') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ' [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n [[nodiscard]] bool firmwareInstallRequired() const noexcept;\n', - ' [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n [[nodiscard]] bool firmwareReinstallAvailable() const noexcept;\n [[nodiscard]] bool firmwareInstallRequired() const noexcept;\n', - 'SmartSession reinstall getter') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ' [[nodiscard]] QString expectedFirmwareVersion() const;\n [[nodiscard]] QString deviceFirmwareVersion() const;\n', - ' [[nodiscard]] QString expectedFirmwareVersion() const;\n [[nodiscard]] QString expectedFirmwareBuildId() const;\n [[nodiscard]] QString deviceFirmwareVersion() const;\n [[nodiscard]] QString deviceFirmwareBuildId() const;\n', - 'SmartSession buildId getters') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ' Q_INVOKABLE bool beginFirmwareUpdate();\n Q_INVOKABLE bool beginFirmwareInstall();\n', - ' Q_INVOKABLE bool beginFirmwareUpdate();\n Q_INVOKABLE bool beginFirmwareReinstall();\n Q_INVOKABLE bool beginFirmwareInstall();\n', - 'SmartSession reinstall intent') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ' QString deviceFirmwareVersion_;\n QString updatePort_;\n', - ' QString deviceFirmwareVersion_;\n QString deviceFirmwareBuildId_;\n QString updatePort_;\n', - 'SmartSession device buildId storage') - - replace_once( - 'apps/arstack_studio/src/SmartSessionController.cpp', - 'bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; }\n', - '''bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } -bool SmartSessionController::firmwareReinstallAvailable() const noexcept { - return device_ != nullptr && firmware_ != nullptr && device_->deviceVerified() && - firmware_->bundleReady() && !firmware_->busy() && !updateRequested_ && firmwareIsCurrent(); -} -''', - 'SmartSession reinstall availability') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.cpp', - 'QString SmartSessionController::expectedFirmwareVersion() const { return QStringLiteral(ARSTACK_STUDIO_VERSION); }\nQString SmartSessionController::deviceFirmwareVersion() const { return deviceFirmwareVersion_; }\n', - '''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_; } -''', - 'SmartSession buildId getters implementation') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.cpp', - '''bool SmartSessionController::beginFirmwareUpdate() { - if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || - !firmware_->bundleReady() || firmware_->busy()) { - return false; - } - return beginFirmwareOperation(device_->portName()); -} -''', - '''bool SmartSessionController::beginFirmwareUpdate() { - if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || - !firmware_->bundleReady() || firmware_->busy()) { - return false; - } - return beginFirmwareOperation(device_->portName()); -} - -bool SmartSessionController::beginFirmwareReinstall() { - if (!firmwareReinstallAvailable() || device_ == nullptr) return false; - return beginFirmwareOperation(device_->portName()); -} -''', - 'SmartSession reinstall operation') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.cpp', - ' deviceFirmwareVersion_.clear();\n profileSyncBootId_.clear();\n', - ' deviceFirmwareVersion_.clear();\n deviceFirmwareBuildId_.clear();\n profileSyncBootId_.clear();\n', - 'SmartSession clear device buildId') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.cpp', - '''void SmartSessionController::refreshFirmwareIdentity() { - deviceFirmwareVersion_ = - (device_ != nullptr && device_->deviceVerified()) ? device_->firmwareVersion() : QString{}; -} - -bool SmartSessionController::firmwareIsCurrent() const { - return device_ != nullptr && device_->deviceVerified() && - DeviceController::identitySupportsCurrentContract( - device_->deviceIdentity(), expectedFirmwareVersion()); -} -''', - '''void SmartSessionController::refreshFirmwareIdentity() { - 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(), expectedFirmwareBuildId()); -} -''', - 'SmartSession build-aware current check') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.cpp', - ''' 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()), - false, - true); -''', - ''' const QString versionText = deviceFirmwareVersion_.isEmpty() - ? QStringLiteral("legacy firmware") - : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); - const QString observedBuild = deviceFirmwareBuildId_.isEmpty() - ? QStringLiteral("legacy/no build ID") - : deviceFirmwareBuildId_; - const QString packageBuild = expectedFirmwareBuildId().isEmpty() - ? QStringLiteral("unknown") - : expectedFirmwareBuildId(); - setPresentation( - QStringLiteral("FIRMWARE UPDATE"), - QStringLiteral("%1 build %2 detected. Studio package contains v%3 build %4; update is required before injection.") - .arg(versionText, observedBuild, expectedFirmwareVersion(), packageBuild), - false, - true); -''', - 'SmartSession build mismatch presentation') - - # Advanced firmware panel submits intent to the supervisor only. - panel = Path('apps/arstack_studio/qml/FirmwarePanel.qml') - text = panel.read_text(encoding='utf-8') - text = text.replace( - '? "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"') - old = ''' Rectangle { - implicitWidth: 150 - implicitHeight: 34 - radius: 7 - color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised - border.width: 1 - border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line - Label { - anchors.centerIn: parent - text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" - color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 9 - font.weight: Font.Bold - } - } -''' - new = ''' CalmButton { - id: firmwareAction - theme: panel.theme - uiFont: panel.uiFont - implicitWidth: 172 - text: panel.session && panel.session.firmwareUpdateRequired - ? "Update firmware" - : "Reinstall current" - visible: panel.session && - (panel.session.firmwareUpdateRequired || panel.session.firmwareReinstallAvailable) - enabled: visible && !panel.firmware.busy - tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal" - onClicked: { - if (panel.session.firmwareUpdateRequired) - panel.session.beginFirmwareUpdate() - else - panel.session.beginFirmwareReinstall() - } - } - - Rectangle { - visible: !firmwareAction.visible - implicitWidth: 150 - implicitHeight: 34 - radius: 7 - color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised - border.width: 1 - border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line - Label { - anchors.centerIn: parent - text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" - color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 9 - font.weight: Font.Bold - } - } -''' - if old not in text: - raise SystemExit('FirmwarePanel guided action block not found') - panel.write_text(text.replace(old, new, 1), encoding='utf-8') - - # Deterministic session regression: same version is not sufficient. - harness = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') - text = harness.read_text(encoding='utf-8') - text = text.replace( - '{"legacy identity -> firmware update", legacyIdentityOffersUpdate()},\n', - '{"legacy identity -> firmware update", legacyIdentityOffersUpdate()},\n {"same version missing build ID -> firmware update", sameVersionMissingBuildOffersUpdate()},\n {"same version stale build -> firmware update", sameVersionStaleBuildOffersUpdate()},\n') - text = text.replace( - ''' Fixture() { - profileReady = profiles.loadReferenceTemplate(); - session.setProfiles(&profiles); -''', - ''' Fixture() { - firmware.firmwareVersion_ = QStringLiteral(ARSTACK_STUDIO_VERSION); - firmware.firmwareBuildId_ = QStringLiteral("0123456789abcdef"); - firmware.bundleReady_ = true; - profileReady = profiles.loadReferenceTemplate(); - session.setProfiles(&profiles); -''') - text = text.replace( - ''' static DeviceIdentity identity( - const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), - const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), - const QString& bootId = QStringLiteral("0123456789ABCDEF")) { -''', - ''' static DeviceIdentity identity( - const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), - const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), - const QString& bootId = QStringLiteral("0123456789ABCDEF"), - const QString& buildId = QStringLiteral("0123456789abcdef")) { -''') - text = text.replace( - ' result.firmwareVersion = firmware;\n result.bootId = bootId;\n', - ' result.firmwareVersion = firmware;\n result.buildId = buildId;\n result.bootId = bootId;\n') - text = text.replace( - ' QStringLiteral("SMV-4I4V"),\n QStringLiteral("LIVE-SETPOINTS"),\n', - ' QStringLiteral("SMV-4I4V"),\n QStringLiteral("PROFILE"),\n QStringLiteral("LIVE-SETPOINTS"),\n', 1) - text = text.replace( - ''' return fixture.session.state() == QStringLiteral("READY") && - fixture.session.startReady() && - !fixture.session.firmwareInstallVisible() && - !fixture.session.firmwareUpdateRequired(); - } - - static bool legacyIdentityOffersUpdate() { -''', - ''' return fixture.session.state() == QStringLiteral("READY") && - fixture.session.startReady() && - fixture.session.firmwareReinstallAvailable() && - !fixture.session.firmwareInstallVisible() && - !fixture.session.firmwareUpdateRequired(); - } - - static bool legacyIdentityOffersUpdate() { -''') - marker = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() { -''' - addition = ''' 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("FIRMWARE UPDATE") && - fixture.session.firmwareUpdateRequired() && - !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("FIRMWARE UPDATE") && - fixture.session.firmwareUpdateRequired() && - !fixture.session.firmwareReinstallAvailable() && - !fixture.session.startReady(); - } - -''' - if marker not in text: - raise SystemExit('Harness insertion marker not found') - harness.write_text(text.replace(marker, addition + marker, 1), encoding='utf-8') - - # Native packaged policy locks parser + build-aware acceptance. - main = Path('apps/arstack_studio/src/main.cpp') - text = main.read_text(encoding='utf-8') - text = text.replace( - ' firmware.expectedProtocol() == QStringLiteral("1") &&\n firmware.firmwareSha256().size() == 64 &&\n', - ' firmware.expectedProtocol() == QStringLiteral("1") &&\n firmware.firmwareBuildId().size() == 16 &&\n firmware.firmwareSha256().size() == 64 &&\n', 1) - text = text.replace( - ' "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF "\n "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', - ' "device_id=A1B2C3D4E5F6 firmware=%1 build=0123456789abcdef boot_id=0123456789ABCDEF "\n "capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', 1) - text = text.replace( - ' currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION));\n', - ' currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef"));\n', 1) - text = text.replace( - ' "device_id=A1B2C3D4E5F6 firmware=%1 capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', - ' "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO")', 1) - text = text.replace( - ' legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION));\n', - ' legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef"));\n', 1) - main.write_text(text, encoding='utf-8') - - print('P0 firmware build identity + guided reinstall contract applied') - PY - - - name: Review patch invariants - shell: bash - run: | - set -euo pipefail - grep -R "firmwareBuildId" -n apps/arstack_studio/src apps/arstack_studio/qml/FirmwarePanel.qml - grep -F 'build=%s boot_id=' embedded/esp32p4_smv_injector/main/live_control.cpp - grep -F '"buildId": source_commit[:16].lower()' .github/workflows/esp32p4-smv-injector.yml - grep -F 'same version stale build -> firmware update' apps/arstack_studio/src/DeterministicSessionHarness.cpp - - - name: Commit source repair - shell: bash - run: | - set -euo pipefail - git config user.name "ARStack P0 Repair" - git config user.email "actions@users.noreply.github.com" - git add embedded/esp32p4_smv_injector apps/arstack_studio .github/workflows/esp32p4-smv-injector.yml - if git diff --cached --quiet; then - echo 'No source changes to commit.' - exit 0 - fi - git commit -m "fix(p0): bind firmware updates to semantic build identity" - git push origin HEAD:feature/ptp-production-v0.1.1 From 8558857f17c6f030da582ba5ae258e3cfeb3f567 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:30:10 +0700 Subject: [PATCH 118/170] chore(ptp): retire one-shot varargs repair scaffolding --- .github/scripts/p0_ptp_vargs_fix.py | 124 ------------------ .../workflows/p0-espidf55-macos-verify.yml | 109 --------------- .github/workflows/p0-ptp-vargs-fix.yml | 35 ----- 3 files changed, 268 deletions(-) delete mode 100644 .github/scripts/p0_ptp_vargs_fix.py delete mode 100644 .github/workflows/p0-espidf55-macos-verify.yml delete mode 100644 .github/workflows/p0-ptp-vargs-fix.yml diff --git a/.github/scripts/p0_ptp_vargs_fix.py b/.github/scripts/p0_ptp_vargs_fix.py deleted file mode 100644 index a7856e0fb..000000000 --- a/.github/scripts/p0_ptp_vargs_fix.py +++ /dev/null @@ -1,124 +0,0 @@ -from pathlib import 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) - -lab_path = Path('embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp') -lab = lab_path.read_text(encoding='utf-8') -old = '''[[nodiscard]] bool send_frame( - 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; -} - -[[nodiscard]] bool transmit_with_hw_timestamp( - const esp_eth_handle_t eth_handle, - std::vector& frame, - 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. - const auto result = esp_eth_transmit_ctrl_vargs( - eth_handle, - &tx_timestamp, - 1U, - frame.data(), - frame.size()); - if (result != ESP_OK || !valid_hw_timestamp(tx_timestamp)) { - return false; - } - timestamp = to_ptp_timestamp(tx_timestamp); - return true; -} -''' -new = '''[[nodiscard]] bool send_frame( - const esp_eth_handle_t eth_handle, - std::vector& frame) noexcept { - if (frame.empty()) return false; - 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( - const esp_eth_handle_t eth_handle, - std::vector& frame, - PtpTimestamp& timestamp) noexcept { - if (frame.empty()) return false; - eth_mac_time_t tx_timestamp{}; - // 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, - 2U, - frame.data(), - frame.size()); - 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); - return true; -} -''' -lab = replace_once(lab, old, new, 'PTP source varargs contract') -lab_path.write_text(lab, encoding='utf-8') - -receiver_path = Path('embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp') -receiver = receiver_path.read_text(encoding='utf-8') -old = ''' eth_mac_time_t tx_timestamp{}; - // esp_eth_transmit_ctrl_vargs argc counts buffer/length pairs. One PTP - // Ethernet frame is exactly one pair. - const auto result = esp_eth_transmit_ctrl_vargs( - handle, - &tx_timestamp, - 1U, - frame.data(), - frame.size()); - if (result != ESP_OK || !valid_hw_timestamp(tx_timestamp)) return false; - timestamp = to_ptp_timestamp(tx_timestamp); -''' -new = ''' eth_mac_time_t tx_timestamp{}; - // 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, - 2U, - frame.data(), - frame.size()); - 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); -''' -receiver = replace_once(receiver, old, new, 'PTP receiver varargs contract') -receiver_path.write_text(receiver, encoding='utf-8') - -print('PTP ESP-IDF 5.5 varargs contract + diagnostics patch applied') diff --git a/.github/workflows/p0-espidf55-macos-verify.yml b/.github/workflows/p0-espidf55-macos-verify.yml deleted file mode 100644 index 0a13cd60d..000000000 --- a/.github/workflows/p0-espidf55-macos-verify.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: P0 ESP-IDF 5.5.4 macOS Verify - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - paths: - - '.github/workflows/p0-espidf55-macos-verify.yml' - -permissions: - contents: read - -concurrency: - group: p0-espidf55-macos-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-source-and-receiver: - runs-on: macos-14 - timeout-minutes: 35 - steps: - - name: Checkout ARStack exact head - uses: actions/checkout@v4 - with: - path: arstack - fetch-depth: 1 - - - name: Checkout ESP-IDF v5.5.4 - uses: actions/checkout@v4 - with: - repository: espressif/esp-idf - ref: v5.5.4 - path: esp-idf - submodules: recursive - fetch-depth: 1 - - - name: Install ESP-IDF toolchain for ESP32-P4 - shell: bash - run: | - set -euo pipefail - ./esp-idf/install.sh esp32p4 - - - name: Verify corrected ESP-IDF 5.5 varargs contract - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - root = Path('arstack/embedded/esp32p4_smv_injector/main') - for name in ('ptp_lab_task.cpp', 'ptp_receiver_task.cpp'): - text = (root / name).read_text(encoding='utf-8') - call = 'esp_eth_transmit_ctrl_vargs(' - if call not in text: - raise SystemExit(f'{name}: timestamped TX call missing') - if '\n 2U,\n' not in text and '\n 2U,\r\n' not in text: - raise SystemExit(f'{name}: corrected argc=2 contract missing') - if 'argc counts buffer/length pairs' in text: - raise SystemExit(f'{name}: obsolete argc interpretation returned') - driver = Path('esp-idf/components/esp_eth/src/mac/esp_eth_mac_esp.c').read_text(encoding='utf-8') - if 'uint32_t buf_num = argc / 2;' not in driver: - raise SystemExit('ESP-IDF v5.5.4 driver contract changed unexpectedly') - print('P0 contract PASS: one frame = 2 variadic args, driver buf_num = argc / 2') - PY - - - name: Build production SOURCE firmware - shell: bash - run: | - set -euo pipefail - source "$GITHUB_WORKSPACE/esp-idf/export.sh" - cd "$GITHUB_WORKSPACE/arstack/embedded/esp32p4_smv_injector" - idf.py set-target esp32p4 - idf.py reconfigure - grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig - grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig - grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig - idf.py build - mkdir -p "$GITHUB_WORKSPACE/p0-source-artifact" - cp build/bootloader/bootloader.bin "$GITHUB_WORKSPACE/p0-source-artifact/" - cp build/partition_table/partition-table.bin "$GITHUB_WORKSPACE/p0-source-artifact/" - cp build/*.bin "$GITHUB_WORKSPACE/p0-source-artifact/" - cp sdkconfig "$GITHUB_WORKSPACE/p0-source-artifact/sdkconfig.source" - - - name: Build TIME_RECEIVER firmware - shell: bash - run: | - set -euo pipefail - source "$GITHUB_WORKSPACE/esp-idf/export.sh" - cd "$GITHUB_WORKSPACE/arstack/embedded/esp32p4_smv_injector" - python3 - <<'PY' - from pathlib import Path - p = Path('sdkconfig') - text = p.read_text(encoding='utf-8') - old = 'CONFIG_AR_PTP_OPERATING_ROLE=0' - new = 'CONFIG_AR_PTP_OPERATING_ROLE=1' - if old not in text: - raise SystemExit('SOURCE role config missing before receiver build') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - PY - idf.py reconfigure - grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=1' sdkconfig - idf.py build - - - name: Upload exact SOURCE candidate - uses: actions/upload-artifact@v4 - with: - name: p0-esp32p4-source-v0.1.1-${{ github.sha }} - path: p0-source-artifact - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/p0-ptp-vargs-fix.yml b/.github/workflows/p0-ptp-vargs-fix.yml deleted file mode 100644 index 474827d73..000000000 --- a/.github/workflows/p0-ptp-vargs-fix.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: P0 PTP Vargs Fix Apply - -on: - push: - branches: - - feature/ptp-production-v0.1.1 - paths: - - '.github/workflows/p0-ptp-vargs-fix.yml' - - '.github/scripts/p0_ptp_vargs_fix.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 ESP-IDF 5.5 varargs correction - run: python3 .github/scripts/p0_ptp_vargs_fix.py - - name: Commit source changes - run: | - if git diff --quiet; then - echo 'PTP varargs patch already present.' - exit 0 - fi - git config user.name 'ARStack CI Repair' - git config user.email 'actions@users.noreply.github.com' - git add embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp - git commit -m 'fix(ptp): honor ESP-IDF 5.5 varargs contract and expose TX failures' - git push origin HEAD:feature/ptp-production-v0.1.1 From decd9cd53bdae17fea3c822ddf964e048a6e159d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:36:59 +0700 Subject: [PATCH 119/170] test(studio): include firmware provenance in bundle fixture --- .github/workflows/arstack-studio-qt.yml | 82 ++++++++++++++----------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index eb83f37e6..92c9df16b 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -34,8 +34,8 @@ jobs: shell: powershell run: | $paths = @( - '.\apps\arstack_studio\run-windows.ps1', - '.\apps\arstack_studio\packaging\verify-single-instance.ps1' + '.\\apps\\arstack_studio\\run-windows.ps1', + '.\\apps\\arstack_studio\\packaging\\verify-single-instance.ps1' ) foreach ($candidate in $paths) { $path = Resolve-Path $candidate @@ -83,7 +83,7 @@ jobs: timeout-minutes: 1 shell: powershell run: | - $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' + $exe = Join-Path $PWD 'build-arstack-studio\\Release\\arstack_session_harness.exe' $out = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.out.log' $err = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.err.log' & $exe 1> $out 2> $err @@ -98,7 +98,7 @@ jobs: timeout-minutes: 1 shell: powershell run: | - $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_windows_ownership_harness.exe' + $exe = Join-Path $PWD 'build-arstack-studio\\Release\\arstack_windows_ownership_harness.exe' $out = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.out.log' $err = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.err.log' & $exe 1> $out 2> $err @@ -130,11 +130,11 @@ jobs: shell: bash run: | apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ca-certificates git cmake ninja-build g++ \ - qt6-base-dev qt6-declarative-dev qt6-serialport-dev \ - qml6-module-qtquick qml6-module-qtquick-controls \ - qml6-module-qtquick-layouts qml6-module-qtquick-dialogs \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\ + ca-certificates git cmake ninja-build g++ \\ + qt6-base-dev qt6-declarative-dev qt6-serialport-dev \\ + qml6-module-qtquick qml6-module-qtquick-controls \\ + qml6-module-qtquick-layouts qml6-module-qtquick-dialogs \\ qml6-module-qtquick-window - name: Checkout @@ -145,9 +145,9 @@ jobs: run: | set -euo pipefail qml_root='apps/arstack_studio/qml' - device_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*device\.(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\(' - firmware_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*(firmware|FirmwareService)\.(probeTarget|installFirmware)[[:space:]]*\(' - device_meta_pattern='Q_INVOKABLE[[:space:]].*(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\(' + device_pattern='([A-Za-z_][A-Za-z0-9_]*\\.)*device\\.(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\\(' + firmware_pattern='([A-Za-z_][A-Za-z0-9_]*\\.)*(firmware|FirmwareService)\\.(probeTarget|installFirmware)[[:space:]]*\\(' + device_meta_pattern='Q_INVOKABLE[[:space:]].*(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\\(' if grep -R -nE "$device_pattern" "$qml_root" --include='*.qml'; then echo 'S7 regression: QML bypasses SmartSessionController with direct DeviceController authority.' @@ -157,8 +157,8 @@ jobs: echo 'S7 regression: QML bypasses SmartSessionController with direct firmware execution.' exit 1 fi - if grep -nE "$device_meta_pattern" \ - apps/arstack_studio/src/DeviceController.hpp \ + if grep -nE "$device_meta_pattern" \\ + apps/arstack_studio/src/DeviceController.hpp \\ apps/arstack_studio/src/StudioDeviceController.hpp; then echo 'S7 regression: DeviceController mutation leaked back into the QML meta-object.' exit 1 @@ -169,37 +169,37 @@ jobs: shell: bash run: | set -o pipefail - cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ + cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja \\ + -DCMAKE_BUILD_TYPE=Release \\ -DARIEC61850_WARNINGS_AS_ERRORS=ON 2>&1 | tee /tmp/arstack-studio-configure.log - name: Build shell: bash run: | set -o pipefail - cmake --build build-arstack-studio \ - --target arstack_studio arstack_session_harness --parallel 2 \ + cmake --build build-arstack-studio \\ + --target arstack_studio arstack_session_harness --parallel 2 \\ 2>&1 | tee /tmp/arstack-studio-build.log - name: 4I+4V reference template regression shell: bash run: | set -o pipefail - ./build-arstack-studio/arstack_studio --check-reference-template \ + ./build-arstack-studio/arstack_studio --check-reference-template \\ 2>&1 | tee /tmp/arstack-studio-reference-template.log - name: Native P0 controller policy regression shell: bash run: | set -o pipefail - ./build-arstack-studio/arstack_studio --check-p0-policy \ + ./build-arstack-studio/arstack_studio --check-p0-policy \\ 2>&1 | tee /tmp/arstack-studio-p0-policy.log - name: S8 deterministic session regression shell: bash run: | set -o pipefail - timeout 20s ./build-arstack-studio/arstack_session_harness \ + timeout 20s ./build-arstack-studio/arstack_session_harness \\ 2>&1 | tee /tmp/arstack-studio-session-harness.log - name: Firmware bundle contract regression @@ -218,6 +218,7 @@ jobs: "chip": "esp32p4", "chipRevisionPolicy": "pre-v3", "version": "0.1.1", + "sourceCommit": "0123456789abcdef0123456789abcdef01234567", "protocol": 1, "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], "flashOffset": 0, @@ -231,18 +232,29 @@ jobs: EOF chmod +x "$root/tools/espflash" - ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ - ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ - ./build-arstack-studio/arstack_studio --check-firmware-contract \ + ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \\ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \\ + ./build-arstack-studio/arstack_studio --check-firmware-contract \\ 2>&1 | tee /tmp/arstack-studio-firmware-contract.log cp "$root/firmware/firmware-manifest.json" "$root/firmware/firmware-manifest.valid.json" + sed -i 's/"sourceCommit": "0123456789abcdef0123456789abcdef01234567"/"sourceCommit": "invalid"/' "$root/firmware/firmware-manifest.json" + if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \\ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \\ + ./build-arstack-studio/arstack_studio --check-firmware-contract \\ + >> /tmp/arstack-studio-firmware-contract.log 2>&1; then + echo 'Invalid firmware provenance was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log + exit 1 + fi + echo 'Invalid firmware provenance: correctly blocked' | tee -a /tmp/arstack-studio-firmware-contract.log + + cp "$root/firmware/firmware-manifest.valid.json" "$root/firmware/firmware-manifest.json" zeros=0000000000000000000000000000000000000000000000000000000000000000 sed -i "s/\"sha256\": \"$sha\"/\"sha256\": \"$zeros\"/" "$root/firmware/firmware-manifest.json" - if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ - ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ - ./build-arstack-studio/arstack_studio --check-firmware-contract \ + if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \\ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \\ + ./build-arstack-studio/arstack_studio --check-firmware-contract \\ >> /tmp/arstack-studio-firmware-contract.log 2>&1; then echo 'Corrupted firmware hash was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log exit 1 @@ -251,9 +263,9 @@ jobs: cp "$root/firmware/firmware-manifest.valid.json" "$root/firmware/firmware-manifest.json" sed -i 's/"chip": "esp32p4"/"chip": "esp32s3"/' "$root/firmware/firmware-manifest.json" - if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ - ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ - ./build-arstack-studio/arstack_studio --check-firmware-contract \ + if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \\ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \\ + ./build-arstack-studio/arstack_studio --check-firmware-contract \\ >> /tmp/arstack-studio-firmware-contract.log 2>&1; then echo 'Wrong-chip firmware manifest was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log exit 1 @@ -264,15 +276,15 @@ jobs: shell: bash run: | set -o pipefail - timeout 5s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ - ./build-arstack-studio/arstack_studio --check-app-lifecycle \ + timeout 5s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \\ + ./build-arstack-studio/arstack_studio --check-app-lifecycle \\ 2>&1 | tee /tmp/arstack-studio-lifecycle.log - name: Headless QML launch smoke shell: bash run: | set +e - timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \\ ./build-arstack-studio/arstack_studio > /tmp/arstack-studio-launch.log 2>&1 status=$? set -e @@ -286,7 +298,7 @@ jobs: shell: bash run: | set +e - timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_SCALE_FACTOR=1.75 \ + timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_SCALE_FACTOR=1.75 \\ ./build-arstack-studio/arstack_studio > /tmp/arstack-studio-hidpi.log 2>&1 status=$? set -e @@ -312,4 +324,4 @@ jobs: /tmp/arstack-studio-launch.log /tmp/arstack-studio-hidpi.log if-no-files-found: ignore - retention-days: 7 \ No newline at end of file + retention-days: 7 From 368e5cc2404909cbf0699a79d449a5ee74a244bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:38:36 +0700 Subject: [PATCH 120/170] chore(ci): restore canonical Studio workflow before provenance fixture patch --- .github/workflows/arstack-studio-qt.yml | 82 +++++++++++-------------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 92c9df16b..eb83f37e6 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -34,8 +34,8 @@ jobs: shell: powershell run: | $paths = @( - '.\\apps\\arstack_studio\\run-windows.ps1', - '.\\apps\\arstack_studio\\packaging\\verify-single-instance.ps1' + '.\apps\arstack_studio\run-windows.ps1', + '.\apps\arstack_studio\packaging\verify-single-instance.ps1' ) foreach ($candidate in $paths) { $path = Resolve-Path $candidate @@ -83,7 +83,7 @@ jobs: timeout-minutes: 1 shell: powershell run: | - $exe = Join-Path $PWD 'build-arstack-studio\\Release\\arstack_session_harness.exe' + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' $out = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.out.log' $err = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.err.log' & $exe 1> $out 2> $err @@ -98,7 +98,7 @@ jobs: timeout-minutes: 1 shell: powershell run: | - $exe = Join-Path $PWD 'build-arstack-studio\\Release\\arstack_windows_ownership_harness.exe' + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_windows_ownership_harness.exe' $out = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.out.log' $err = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.err.log' & $exe 1> $out 2> $err @@ -130,11 +130,11 @@ jobs: shell: bash run: | apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\ - ca-certificates git cmake ninja-build g++ \\ - qt6-base-dev qt6-declarative-dev qt6-serialport-dev \\ - qml6-module-qtquick qml6-module-qtquick-controls \\ - qml6-module-qtquick-layouts qml6-module-qtquick-dialogs \\ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates git cmake ninja-build g++ \ + qt6-base-dev qt6-declarative-dev qt6-serialport-dev \ + qml6-module-qtquick qml6-module-qtquick-controls \ + qml6-module-qtquick-layouts qml6-module-qtquick-dialogs \ qml6-module-qtquick-window - name: Checkout @@ -145,9 +145,9 @@ jobs: run: | set -euo pipefail qml_root='apps/arstack_studio/qml' - device_pattern='([A-Za-z_][A-Za-z0-9_]*\\.)*device\\.(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\\(' - firmware_pattern='([A-Za-z_][A-Za-z0-9_]*\\.)*(firmware|FirmwareService)\\.(probeTarget|installFirmware)[[:space:]]*\\(' - device_meta_pattern='Q_INVOKABLE[[:space:]].*(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\\(' + device_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*device\.(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\(' + firmware_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*(firmware|FirmwareService)\.(probeTarget|installFirmware)[[:space:]]*\(' + device_meta_pattern='Q_INVOKABLE[[:space:]].*(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\(' if grep -R -nE "$device_pattern" "$qml_root" --include='*.qml'; then echo 'S7 regression: QML bypasses SmartSessionController with direct DeviceController authority.' @@ -157,8 +157,8 @@ jobs: echo 'S7 regression: QML bypasses SmartSessionController with direct firmware execution.' exit 1 fi - if grep -nE "$device_meta_pattern" \\ - apps/arstack_studio/src/DeviceController.hpp \\ + if grep -nE "$device_meta_pattern" \ + apps/arstack_studio/src/DeviceController.hpp \ apps/arstack_studio/src/StudioDeviceController.hpp; then echo 'S7 regression: DeviceController mutation leaked back into the QML meta-object.' exit 1 @@ -169,37 +169,37 @@ jobs: shell: bash run: | set -o pipefail - cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja \\ - -DCMAKE_BUILD_TYPE=Release \\ + cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ -DARIEC61850_WARNINGS_AS_ERRORS=ON 2>&1 | tee /tmp/arstack-studio-configure.log - name: Build shell: bash run: | set -o pipefail - cmake --build build-arstack-studio \\ - --target arstack_studio arstack_session_harness --parallel 2 \\ + cmake --build build-arstack-studio \ + --target arstack_studio arstack_session_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log - name: 4I+4V reference template regression shell: bash run: | set -o pipefail - ./build-arstack-studio/arstack_studio --check-reference-template \\ + ./build-arstack-studio/arstack_studio --check-reference-template \ 2>&1 | tee /tmp/arstack-studio-reference-template.log - name: Native P0 controller policy regression shell: bash run: | set -o pipefail - ./build-arstack-studio/arstack_studio --check-p0-policy \\ + ./build-arstack-studio/arstack_studio --check-p0-policy \ 2>&1 | tee /tmp/arstack-studio-p0-policy.log - name: S8 deterministic session regression shell: bash run: | set -o pipefail - timeout 20s ./build-arstack-studio/arstack_session_harness \\ + timeout 20s ./build-arstack-studio/arstack_session_harness \ 2>&1 | tee /tmp/arstack-studio-session-harness.log - name: Firmware bundle contract regression @@ -218,7 +218,6 @@ jobs: "chip": "esp32p4", "chipRevisionPolicy": "pre-v3", "version": "0.1.1", - "sourceCommit": "0123456789abcdef0123456789abcdef01234567", "protocol": 1, "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], "flashOffset": 0, @@ -232,29 +231,18 @@ jobs: EOF chmod +x "$root/tools/espflash" - ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \\ - ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \\ - ./build-arstack-studio/arstack_studio --check-firmware-contract \\ + ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ + ./build-arstack-studio/arstack_studio --check-firmware-contract \ 2>&1 | tee /tmp/arstack-studio-firmware-contract.log cp "$root/firmware/firmware-manifest.json" "$root/firmware/firmware-manifest.valid.json" - sed -i 's/"sourceCommit": "0123456789abcdef0123456789abcdef01234567"/"sourceCommit": "invalid"/' "$root/firmware/firmware-manifest.json" - if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \\ - ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \\ - ./build-arstack-studio/arstack_studio --check-firmware-contract \\ - >> /tmp/arstack-studio-firmware-contract.log 2>&1; then - echo 'Invalid firmware provenance was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log - exit 1 - fi - echo 'Invalid firmware provenance: correctly blocked' | tee -a /tmp/arstack-studio-firmware-contract.log - - cp "$root/firmware/firmware-manifest.valid.json" "$root/firmware/firmware-manifest.json" zeros=0000000000000000000000000000000000000000000000000000000000000000 sed -i "s/\"sha256\": \"$sha\"/\"sha256\": \"$zeros\"/" "$root/firmware/firmware-manifest.json" - if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \\ - ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \\ - ./build-arstack-studio/arstack_studio --check-firmware-contract \\ + if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ + ./build-arstack-studio/arstack_studio --check-firmware-contract \ >> /tmp/arstack-studio-firmware-contract.log 2>&1; then echo 'Corrupted firmware hash was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log exit 1 @@ -263,9 +251,9 @@ jobs: cp "$root/firmware/firmware-manifest.valid.json" "$root/firmware/firmware-manifest.json" sed -i 's/"chip": "esp32p4"/"chip": "esp32s3"/' "$root/firmware/firmware-manifest.json" - if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \\ - ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \\ - ./build-arstack-studio/arstack_studio --check-firmware-contract \\ + if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ + ./build-arstack-studio/arstack_studio --check-firmware-contract \ >> /tmp/arstack-studio-firmware-contract.log 2>&1; then echo 'Wrong-chip firmware manifest was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log exit 1 @@ -276,15 +264,15 @@ jobs: shell: bash run: | set -o pipefail - timeout 5s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \\ - ./build-arstack-studio/arstack_studio --check-app-lifecycle \\ + timeout 5s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + ./build-arstack-studio/arstack_studio --check-app-lifecycle \ 2>&1 | tee /tmp/arstack-studio-lifecycle.log - name: Headless QML launch smoke shell: bash run: | set +e - timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \\ + timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ ./build-arstack-studio/arstack_studio > /tmp/arstack-studio-launch.log 2>&1 status=$? set -e @@ -298,7 +286,7 @@ jobs: shell: bash run: | set +e - timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_SCALE_FACTOR=1.75 \\ + timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_SCALE_FACTOR=1.75 \ ./build-arstack-studio/arstack_studio > /tmp/arstack-studio-hidpi.log 2>&1 status=$? set -e @@ -324,4 +312,4 @@ jobs: /tmp/arstack-studio-launch.log /tmp/arstack-studio-hidpi.log if-no-files-found: ignore - retention-days: 7 + retention-days: 7 \ No newline at end of file From b2f9f5d7083e617ee018dc229aa4f8b178790410 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:39:58 +0700 Subject: [PATCH 121/170] test(studio): include firmware provenance in bundle fixture --- .github/workflows/arstack-studio-qt.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index eb83f37e6..761bc5044 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -218,6 +218,7 @@ jobs: "chip": "esp32p4", "chipRevisionPolicy": "pre-v3", "version": "0.1.1", + "sourceCommit": "0123456789abcdef0123456789abcdef01234567", "protocol": 1, "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], "flashOffset": 0, From b9e5f083de8d51f439379a66ec91a1e3dcd986ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:47:55 +0700 Subject: [PATCH 122/170] fix(firmware): fail closed semantic build provenance --- embedded/esp32p4_smv_injector/CMakeLists.txt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/embedded/esp32p4_smv_injector/CMakeLists.txt b/embedded/esp32p4_smv_injector/CMakeLists.txt index c315273a1..4a750412c 100644 --- a/embedded/esp32p4_smv_injector/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/CMakeLists.txt @@ -4,20 +4,24 @@ cmake_minimum_required(VERSION 3.16) # ESP-IDF exports this as the application version through esp_app_desc. set(PROJECT_VER "0.1.1") -# Semantic firmware provenance. CI supplies the exact checkout commit; local -# builds fall back to the repository HEAD. The value is intentionally short, -# fixed-size hex so it is safe to expose on the bounded serial identity line. +# 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) - set(ARSTACK_FIRMWARE_BUILD_ID "0000000000000000") + message(FATAL_ERROR "Unable to establish semantic firmware build provenance") endif() endif() string(TOLOWER "${ARSTACK_FIRMWARE_BUILD_ID}" ARSTACK_FIRMWARE_BUILD_ID) @@ -26,6 +30,7 @@ if(NOT ARSTACK_FIRMWARE_BUILD_ID_LENGTH EQUAL 16 OR NOT ARSTACK_FIRMWARE_BUILD_I 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 From bc7d4162f1b19e2e1ac5e0051d4d805d60b5ae85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 21:22:33 +0700 Subject: [PATCH 123/170] chore(p0): stage firmware advisory state-machine repair --- .github/scripts/p0_firmware_advisory_fix.py | 546 ++++++++++++++++++++ 1 file changed, 546 insertions(+) create mode 100644 .github/scripts/p0_firmware_advisory_fix.py diff --git a/.github/scripts/p0_firmware_advisory_fix.py b/.github/scripts/p0_firmware_advisory_fix.py new file mode 100644 index 000000000..04a41c674 --- /dev/null +++ b/.github/scripts/p0_firmware_advisory_fix.py @@ -0,0 +1,546 @@ +from pathlib import Path + +ROOT = Path('.') + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + if old not in text: + if new in text: + return text + raise SystemExit(f'{label}: expected source block not found') + return text.replace(old, new, 1) + + +# --- SmartSessionController.hpp: explicit advisory/update cancellation contract. +path = ROOT / 'apps/arstack_studio/src/SmartSessionController.hpp' +text = path.read_text(encoding='utf-8') +text = replace_once( + text, + ' Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n', + ' Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n' + ' Q_PROPERTY(bool firmwareUpdateAvailable READ firmwareUpdateAvailable NOTIFY stateChanged)\n' + ' Q_PROPERTY(bool firmwareUpdateCanCancel READ firmwareUpdateCanCancel NOTIFY stateChanged)\n', + 'SmartSession advisory properties') +text = replace_once( + text, + ' [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n' + ' [[nodiscard]] bool firmwareReinstallAvailable() const noexcept;\n', + ' [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n' + ' [[nodiscard]] bool firmwareUpdateAvailable() const noexcept;\n' + ' [[nodiscard]] bool firmwareUpdateCanCancel() const noexcept;\n' + ' [[nodiscard]] bool firmwareReinstallAvailable() const noexcept;\n', + 'SmartSession advisory getters') +text = replace_once( + text, + ' Q_INVOKABLE bool retryFirmwareUpdate();\n' + ' Q_INVOKABLE bool retryFirmwareSetup();\n', + ' Q_INVOKABLE bool retryFirmwareUpdate();\n' + ' Q_INVOKABLE bool cancelFirmwareUpdate();\n' + ' Q_INVOKABLE bool retryFirmwareSetup();\n', + 'SmartSession cancel action') +text = replace_once( + text, + ' bool firmwareIsCurrent() const;\n' + ' bool deviceControlAvailable() const noexcept;\n', + ' bool firmwareIsCurrent() const;\n' + ' bool firmwareIsCompatible() const;\n' + ' bool deviceControlAvailable() const noexcept;\n', + 'SmartSession compatibility helper') +text = replace_once( + text, + ' bool updateRequested_{false};\n' + ' bool blankBoardDetected_{false};\n', + ' bool updateRequested_{false};\n' + ' bool updateWasOptional_{false};\n' + ' bool blankBoardDetected_{false};\n', + 'SmartSession optional-operation state') +path.write_text(text, encoding='utf-8') + + +# --- SmartSessionController.cpp: compatible current firmware remains usable. +path = ROOT / 'apps/arstack_studio/src/SmartSessionController.cpp' +text = path.read_text(encoding='utf-8') +text = replace_once( + text, + 'bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; }\n' + 'bool SmartSessionController::firmwareReinstallAvailable() const noexcept {\n', + 'bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; }\n' + 'bool SmartSessionController::firmwareUpdateAvailable() const noexcept {\n' + ' return device_ != nullptr && firmware_ != nullptr && device_->deviceVerified() &&\n' + ' firmware_->bundleReady() && !firmware_->busy() && !updateRequested_ &&\n' + ' firmwareIsCompatible() && !firmwareIsCurrent();\n' + '}\n' + 'bool SmartSessionController::firmwareUpdateCanCancel() const noexcept {\n' + ' return updateRequested_ && updateWasOptional_ && updateStage_ == UpdateStage::waitingForBootloader &&\n' + ' firmware_ != nullptr && !firmware_->busy();\n' + '}\n' + 'bool SmartSessionController::firmwareReinstallAvailable() const noexcept {\n', + 'SmartSession advisory implementations') +text = replace_once( + text, + 'bool SmartSessionController::beginFirmwareUpdate() {\n' + ' if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() ||\n' + ' !firmware_->bundleReady() || firmware_->busy()) {\n' + ' return false;\n' + ' }\n' + ' return beginFirmwareOperation(device_->portName());\n' + '}\n\n' + 'bool SmartSessionController::beginFirmwareReinstall() {\n' + ' if (!firmwareReinstallAvailable() || device_ == nullptr) return false;\n' + ' return beginFirmwareOperation(device_->portName());\n' + '}\n\n' + 'bool SmartSessionController::beginFirmwareInstall() {\n', + 'bool SmartSessionController::beginFirmwareUpdate() {\n' + ' if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() ||\n' + ' !firmware_->bundleReady() || firmware_->busy()) {\n' + ' return false;\n' + ' }\n' + ' updateWasOptional_ = firmwareUpdateAvailable() && !firmwareUpdateRequired_;\n' + ' return beginFirmwareOperation(device_->portName());\n' + '}\n\n' + 'bool SmartSessionController::beginFirmwareReinstall() {\n' + ' if (!firmwareReinstallAvailable() || device_ == nullptr) return false;\n' + ' updateWasOptional_ = true;\n' + ' return beginFirmwareOperation(device_->portName());\n' + '}\n\n' + 'bool SmartSessionController::beginFirmwareInstall() {\n' + ' updateWasOptional_ = false;\n', + 'SmartSession optional update classification') +text = replace_once( + text, + 'bool SmartSessionController::retryFirmwareUpdate() {\n' + ' if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false;\n' + ' setupError_ = false;\n' + ' setupErrorStatus_.clear();\n' + ' blankBoardDetected_ = false;\n' + ' updateRequested_ = true;\n' + ' updateReconnectAttempts_ = 0;\n' + ' pendingReleaseGeneration_ = 0;\n' + ' continueFirmwareUpdate();\n' + ' return updateRequested_;\n' + '}\n\n' + 'bool SmartSessionController::retryFirmwareSetup() {\n', + 'bool SmartSessionController::retryFirmwareUpdate() {\n' + ' if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false;\n' + ' setupError_ = false;\n' + ' setupErrorStatus_.clear();\n' + ' blankBoardDetected_ = false;\n' + ' updateRequested_ = true;\n' + ' updateReconnectAttempts_ = 0;\n' + ' pendingReleaseGeneration_ = 0;\n' + ' continueFirmwareUpdate();\n' + ' return updateRequested_;\n' + '}\n\n' + 'bool SmartSessionController::cancelFirmwareUpdate() {\n' + ' if (!firmwareUpdateCanCancel() || device_ == nullptr) return false;\n' + ' reconnectTimer_.stop();\n' + ' updateRequested_ = false;\n' + ' updateWasOptional_ = false;\n' + ' updateStage_ = UpdateStage::idle;\n' + ' updateReconnectAttempts_ = 0;\n' + ' pendingReleaseGeneration_ = 0;\n' + ' setupError_ = false;\n' + ' setupErrorStatus_.clear();\n' + ' blankBoardDetected_ = false;\n' + ' blankBoardPort_.clear();\n' + ' if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none);\n' + ' if (started_ && !device_->connected() && !device_->discovering()) {\n' + ' QTimer::singleShot(0, this, [this] {\n' + ' if (!started_ || updateRequested_ || device_ == nullptr || device_->connected() || device_->discovering()) return;\n' + ' static_cast(startDeviceDiscovery());\n' + ' reconcile();\n' + ' });\n' + ' } else {\n' + ' reconcile();\n' + ' }\n' + ' return true;\n' + '}\n\n' + 'bool SmartSessionController::retryFirmwareSetup() {\n', + 'SmartSession cancel firmware operation') +text = replace_once( + text, + 'void SmartSessionController::latchFirmwareFailure(QString message) {\n' + ' reconnectTimer_.stop();\n' + ' updateRequested_ = false;\n' + ' updateStage_ = UpdateStage::idle;\n' + ' updateReconnectAttempts_ = 0;\n' + ' pendingReleaseGeneration_ = 0;\n' + ' blankBoardDetected_ = false;\n' + ' if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none);\n' + ' setupError_ = true;\n' + ' message = message.trimmed();\n' + ' setupErrorStatus_ = message.isEmpty()\n' + ' ? QStringLiteral("Firmware setup did not complete. Retry explicitly when the board is ready.")\n' + ' : std::move(message);\n' + '}\n', + 'void SmartSessionController::latchFirmwareFailure(QString message) {\n' + ' reconnectTimer_.stop();\n' + ' const bool optionalOperation = updateWasOptional_;\n' + ' updateRequested_ = false;\n' + ' updateWasOptional_ = false;\n' + ' updateStage_ = UpdateStage::idle;\n' + ' updateReconnectAttempts_ = 0;\n' + ' pendingReleaseGeneration_ = 0;\n' + ' blankBoardDetected_ = false;\n' + ' if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none);\n' + ' message = message.trimmed();\n' + ' setupError_ = !optionalOperation;\n' + ' setupErrorStatus_ = optionalOperation ? QString{} : (message.isEmpty()\n' + ' ? QStringLiteral("Firmware setup did not complete. Retry explicitly when the board is ready.")\n' + ' : std::move(message));\n' + ' if (optionalOperation && started_ && device_ != nullptr && !device_->connected() && !device_->discovering()) {\n' + ' QTimer::singleShot(0, this, [this] {\n' + ' if (!started_ || updateRequested_ || device_ == nullptr || device_->connected() || device_->discovering()) return;\n' + ' static_cast(startDeviceDiscovery());\n' + ' reconcile();\n' + ' });\n' + ' }\n' + '}\n', + 'SmartSession optional update failure recovery') +text = replace_once( + text, + 'bool SmartSessionController::firmwareIsCurrent() const {\n' + ' return device_ != nullptr && device_->deviceVerified() &&\n' + ' DeviceController::identitySupportsCurrentContract(\n' + ' device_->deviceIdentity(), expectedFirmwareVersion(), expectedFirmwareBuildId());\n' + '}\n\n' + 'bool SmartSessionController::deviceControlAvailable() const noexcept {\n' + ' return device_ != nullptr && device_->deviceVerified() && firmwareIsCurrent() &&\n', + 'bool SmartSessionController::firmwareIsCurrent() const {\n' + ' return device_ != nullptr && device_->deviceVerified() &&\n' + ' DeviceController::identitySupportsCurrentContract(\n' + ' device_->deviceIdentity(), expectedFirmwareVersion(), expectedFirmwareBuildId());\n' + '}\n\n' + 'bool SmartSessionController::firmwareIsCompatible() const {\n' + ' return device_ != nullptr && device_->deviceVerified() &&\n' + ' DeviceController::identitySupportsCurrentContract(\n' + ' device_->deviceIdentity(), expectedFirmwareVersion(), QString{});\n' + '}\n\n' + 'bool SmartSessionController::deviceControlAvailable() const noexcept {\n' + ' return device_ != nullptr && device_->deviceVerified() && firmwareIsCompatible() &&\n', + 'SmartSession compatible-runtime gate') +old_block = ''' refreshFirmwareIdentity(); + if (!firmwareIsCurrent()) { + if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { + 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.") + .arg(observed, expectedFirmwareVersion())); + emit firmwareUpdateFinished(false); + setPresentation(QStringLiteral("SETUP ERROR"), setupErrorStatus_, false, false); + return; + } + const QString versionText = deviceFirmwareVersion_.isEmpty() + ? QStringLiteral("legacy firmware") + : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); + const QString observedBuild = deviceFirmwareBuildId_.isEmpty() + ? QStringLiteral("legacy/no build ID") + : deviceFirmwareBuildId_; + const QString packageBuild = expectedFirmwareBuildId().isEmpty() + ? QStringLiteral("unknown") + : expectedFirmwareBuildId(); + setPresentation( + QStringLiteral("FIRMWARE UPDATE"), + QStringLiteral("%1 build %2 detected. Studio package contains v%3 build %4; update is required before injection.") + .arg(versionText, observedBuild, expectedFirmwareVersion(), packageBuild), + false, + true); + return; + } + + if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + clearBlankBoardContext(); + emit firmwareUpdateFinished(true); + } +''' +new_block = ''' refreshFirmwareIdentity(); + + // 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 exact bundled build for v%2.") + .arg(observed, expectedFirmwareVersion())); + emit firmwareUpdateFinished(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 does not satisfy the current protocol/capability contract. Update is required before injection.") + .arg(versionText), + false, + true); + return; + } +''' +text = replace_once(text, old_block, new_block, 'SmartSession reconcile firmware gate') +text = replace_once( + text, + ' if (device_->running()) {\n' + ' setPresentation(\n' + ' QStringLiteral("RUNNING"),\n' + ' QStringLiteral("4I + 4V · 4000 samples/s · live value apply"),\n' + ' false,\n' + ' false);\n', + ' if (device_->running()) {\n' + ' setPresentation(\n' + ' QStringLiteral("RUNNING"),\n' + ' firmwareUpdateAvailable()\n' + ' ? QStringLiteral("4I + 4V · 4000 samples/s · live value apply · firmware update available")\n' + ' : QStringLiteral("4I + 4V · 4000 samples/s · live value apply"),\n' + ' false,\n' + ' false);\n', + 'SmartSession running advisory status') +text = replace_once( + text, + ' setPresentation(\n' + ' QStringLiteral("READY"),\n' + ' QStringLiteral("4I + 4V · 4000 samples/s · ready to start"),\n' + ' true,\n' + ' false);\n', + ' setPresentation(\n' + ' QStringLiteral("READY"),\n' + ' firmwareUpdateAvailable()\n' + ' ? QStringLiteral("4I + 4V · 4000 samples/s · ready · firmware update available")\n' + ' : QStringLiteral("4I + 4V · 4000 samples/s · ready to start"),\n' + ' true,\n' + ' false);\n', + 'SmartSession ready advisory status') +path.write_text(text, encoding='utf-8') + + +# --- WorkflowBar.qml: suggestions do not gate Start; Download-mode wait is cancellable. +path = ROOT / 'apps/arstack_studio/qml/WorkflowBar.qml' +text = path.read_text(encoding='utf-8') +text = replace_once( + text, + ' if (smartSession.state === "READY") return "Ready to inject"\n', + ' if (smartSession.state === "READY") return smartSession.firmwareUpdateAvailable ? "Ready · update available" : "Ready to inject"\n', + 'Workflow display advisory') +text = replace_once( + text, + ' title: "Firmware update required"\n', + ' title: smartSession.firmwareUpdateRequired ? "Firmware update required" : "Firmware update available"\n', + 'Workflow update dialog title') +text = replace_once( + text, + ' text: "Update firmware to start injection"\n', + ' text: smartSession.firmwareUpdateRequired ? "Update firmware to start injection" : "A newer firmware build is available"\n', + 'Workflow update dialog heading') +text = replace_once( + text, + ' 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."\n', + ' text: smartSession.firmwareUpdateRequired\n' + ' ? "This ESP32-P4 does not satisfy the current runtime contract. Update is required before injection."\n' + ' : "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."\n', + 'Workflow update dialog message') +text = replace_once( + text, + ' RowLayout {\n' + ' visible: smartSession.updateNeedsBootloaderHelp\n' + ' Layout.fillWidth: true\n' + ' Item { Layout.fillWidth: true }\n' + ' CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Retry"; tone: "accent"; implicitWidth: 110; onClicked: smartSession.retryFirmwareUpdate() }\n' + ' }\n', + ' RowLayout {\n' + ' visible: smartSession.updateNeedsBootloaderHelp\n' + ' Layout.fillWidth: true\n' + ' CalmButton {\n' + ' visible: smartSession.firmwareUpdateCanCancel\n' + ' theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Use current firmware"; implicitWidth: 150\n' + ' onClicked: {\n' + ' if (smartSession.cancelFirmwareUpdate()) {\n' + ' progressDialog.close()\n' + ' controller.showMessage("Firmware update skipped. Reconnecting to the compatible firmware already on the board.", false)\n' + ' }\n' + ' }\n' + ' }\n' + ' Item { Layout.fillWidth: true }\n' + ' CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Retry"; tone: "accent"; implicitWidth: 110; onClicked: smartSession.retryFirmwareUpdate() }\n' + ' }\n', + 'Workflow bootloader cancel action') +path.write_text(text, encoding='utf-8') + + +# --- ModernRibbon.qml: surface advisory update as a non-blocking attention action. +path = ROOT / 'apps/arstack_studio/qml/ModernRibbon.qml' +text = path.read_text(encoding='utf-8') +text = replace_once( + text, + ' visible: bar.session.firmwareUpdateRequired || bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable\n', + ' visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable || bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable\n', + 'Ribbon advisory group visibility') +text = replace_once( + text, + ' visible: bar.session.firmwareUpdateRequired\n' + ' theme: bar.theme; uiFont: bar.uiFont; text: "Update firmware"; tone: "accent"\n', + ' visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable\n' + ' theme: bar.theme; uiFont: bar.uiFont\n' + ' text: bar.session.firmwareUpdateRequired ? "Update firmware" : "Update available"\n' + ' tone: bar.session.firmwareUpdateRequired ? "accent" : "neutral"\n', + 'Ribbon advisory update action') +path.write_text(text, encoding='utf-8') + + +# --- FirmwarePanel.qml: allow optional update from Advanced without calling it required. +path = ROOT / 'apps/arstack_studio/qml/FirmwarePanel.qml' +text = path.read_text(encoding='utf-8') +text = replace_once( + text, + ' text: panel.session && panel.session.firmwareUpdateRequired\n' + ' ? "Update firmware"\n' + ' : "Reinstall current"\n' + ' visible: panel.session &&\n' + ' (panel.session.firmwareUpdateRequired || panel.session.firmwareReinstallAvailable)\n' + ' enabled: visible && !panel.firmware.busy\n' + ' tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal"\n' + ' onClicked: {\n' + ' if (panel.session.firmwareUpdateRequired)\n' + ' panel.session.beginFirmwareUpdate()\n' + ' else\n' + ' panel.session.beginFirmwareReinstall()\n' + ' }\n', + ' text: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable)\n' + ' ? (panel.session.firmwareUpdateRequired ? "Update firmware" : "Update available")\n' + ' : "Reinstall current"\n' + ' visible: panel.session &&\n' + ' (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable || panel.session.firmwareReinstallAvailable)\n' + ' enabled: visible && !panel.firmware.busy\n' + ' tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal"\n' + ' onClicked: {\n' + ' if (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable)\n' + ' panel.session.beginFirmwareUpdate()\n' + ' else\n' + ' panel.session.beginFirmwareReinstall()\n' + ' }\n', + 'FirmwarePanel advisory action') +text = replace_once( + text, + ' : "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."\n', + ' : (panel.session && panel.session.firmwareUpdateAvailable\n' + ' ? "The board firmware is compatible and remains usable. A newer build is available as an optional guided update."\n' + ' : "When Studio reports Firmware required, use the guided action. It will stop output, obtain an acknowledged serial release, verify the ESP32-P4, then write.")\n', + 'FirmwarePanel guidance text') +path.write_text(text, encoding='utf-8') + + +# --- DeterministicSessionHarness.cpp: lock the exact regression. +path = ROOT / 'apps/arstack_studio/src/DeterministicSessionHarness.cpp' +text = path.read_text(encoding='utf-8') +text = replace_once( + text, + ' {"same version missing build ID -> firmware update", sameVersionMissingBuildOffersUpdate()},\n' + ' {"same version stale build -> firmware update", sameVersionStaleBuildOffersUpdate()},\n', + ' {"same version missing build ID -> READY with update suggestion", sameVersionMissingBuildOffersUpdate()},\n' + ' {"same version stale build -> READY with update suggestion", sameVersionStaleBuildOffersUpdate()},\n' + ' {"optional bootloader wait -> cancel restores supervisor", optionalBootloaderWaitCanCancel()},\n', + 'Harness advisory cases') +text = replace_once( + text, + ' session.updateRequested_ = false;\n' + ' session.updateStage_ = SmartSessionController::UpdateStage::idle;\n', + ' session.updateRequested_ = false;\n' + ' session.updateWasOptional_ = false;\n' + ' session.updateStage_ = SmartSessionController::UpdateStage::idle;\n', + 'Harness seed optional reset') +old_missing = ''' 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("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !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("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !fixture.session.firmwareReinstallAvailable() && + !fixture.session.startReady(); + } +''' +new_missing = ''' 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_; + } +''' +text = replace_once(text, old_missing, new_missing, 'Harness advisory behavior') +path.write_text(text, encoding='utf-8') + +print('P0 firmware advisory/cancel fix applied successfully') From 6ae0b1aa19271b372cb9af568f553521a5a04eb5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 21:22:57 +0700 Subject: [PATCH 124/170] chore(p0): run firmware advisory repair once --- .../arstack-studio-windows-lifecycle.yml | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 04d93998e..f1e420d99 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -11,15 +11,45 @@ on: - '.github/workflows/arstack-studio-windows-lifecycle.yml' permissions: - contents: read + contents: write concurrency: group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: + apply-firmware-advisory-fix: + name: Apply P0 firmware advisory fix + if: github.event_name == 'pull_request' + runs-on: macos-15 + timeout-minutes: 5 + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + - name: Apply coherent state-machine patch + run: python3 .github/scripts/p0_firmware_advisory_fix.py + - name: Verify and push source-only repair + shell: bash + run: | + set -euo pipefail + git diff --check + if git diff --quiet -- apps/arstack_studio; then + echo 'P0 firmware advisory source already applied.' + 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 'fix(studio): make firmware updates advisory and recoverable' + git push origin HEAD:${{ github.event.pull_request.head.ref }} + close-exit-reopen: name: Close -> process exit -> immediate reopen + needs: apply-firmware-advisory-fix + if: always() && (needs.apply-firmware-advisory-fix.result == 'success' || needs.apply-firmware-advisory-fix.result == 'skipped') runs-on: windows-2022 timeout-minutes: 12 steps: From 2015884b2d3a923f97ce045a23c85ee0d6874b99 Mon Sep 17 00:00:00 2001 From: ARStack CI Repair Date: Wed, 16 Sep 2026 14:23:39 +0000 Subject: [PATCH 125/170] fix(studio): make firmware updates advisory and recoverable --- apps/arstack_studio/qml/FirmwarePanel.qml | 12 +- apps/arstack_studio/qml/ModernRibbon.qml | 8 +- apps/arstack_studio/qml/WorkflowBar.qml | 20 +++- .../src/DeterministicSessionHarness.cpp | 44 +++++-- .../src/SmartSessionController.cpp | 107 ++++++++++++++---- .../src/SmartSessionController.hpp | 7 ++ 6 files changed, 153 insertions(+), 45 deletions(-) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 71ce9df48..a151ce9db 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -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 @@ -278,15 +280,15 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont implicitWidth: 172 - text: panel.session && panel.session.firmwareUpdateRequired - ? "Update firmware" + 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.firmwareReinstallAvailable) + (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) + if (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable) panel.session.beginFirmwareUpdate() else panel.session.beginFirmwareReinstall() diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index fc9bb6874..daf494a32 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -149,11 +149,13 @@ Rectangle { } RibbonGroup { - visible: bar.session.firmwareUpdateRequired || bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable + visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable || bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable theme: bar.theme; uiFont: bar.uiFont; title: "Attention" RibbonAction { - visible: bar.session.firmwareUpdateRequired - theme: bar.theme; uiFont: bar.uiFont; text: "Update firmware"; tone: "accent" + visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable + theme: bar.theme; uiFont: bar.uiFont + text: bar.session.firmwareUpdateRequired ? "Update firmware" : "Update available" + tone: bar.session.firmwareUpdateRequired ? "accent" : "neutral" onClicked: bar.workflow.openUpdatePrompt() } RibbonAction { diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index b9b2cdf61..876af66ec 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -162,7 +162,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" @@ -363,14 +363,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 @@ -379,7 +379,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 @@ -459,6 +461,16 @@ SurfacePanel { 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() } } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index b7bab42fc..41d4ec875 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -28,8 +28,9 @@ class DeterministicSessionHarness final { const CaseResult cases[] = { {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, - {"same version missing build ID -> firmware update", sameVersionMissingBuildOffersUpdate()}, - {"same version stale build -> firmware update", sameVersionStaleBuildOffersUpdate()}, + {"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()}, {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, @@ -135,6 +136,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; @@ -281,10 +283,11 @@ class DeterministicSessionHarness final { auto stale = identity(); stale.buildId.clear(); seedVerified(fixture, stale, QStringLiteral("COM7"), false); - return fixture.session.state() == QStringLiteral("FIRMWARE UPDATE") && - fixture.session.firmwareUpdateRequired() && + return fixture.session.state() == QStringLiteral("READY") && + !fixture.session.firmwareUpdateRequired() && + fixture.session.firmwareUpdateAvailable() && !fixture.session.firmwareReinstallAvailable() && - !fixture.session.startReady(); + fixture.session.startReady(); } static bool sameVersionStaleBuildOffersUpdate() { @@ -293,10 +296,35 @@ class DeterministicSessionHarness final { auto stale = identity(); stale.buildId = QStringLiteral("fedcba9876543210"); seedVerified(fixture, stale, QStringLiteral("COM7"), false); - return fixture.session.state() == QStringLiteral("FIRMWARE UPDATE") && - fixture.session.firmwareUpdateRequired() && + return fixture.session.state() == QStringLiteral("READY") && + !fixture.session.firmwareUpdateRequired() && + fixture.session.firmwareUpdateAvailable() && !fixture.session.firmwareReinstallAvailable() && - !fixture.session.startReady(); + 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 automaticIdentityTimeoutOffersFirmwareRecovery() { diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 7c315940d..b6d7a1076 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -148,6 +148,15 @@ 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(); @@ -430,15 +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()) { @@ -492,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(); @@ -814,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) { @@ -963,8 +1009,14 @@ bool SmartSessionController::firmwareIsCurrent() const { 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()); } @@ -1061,47 +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_); - const QString observedBuild = deviceFirmwareBuildId_.isEmpty() - ? QStringLiteral("legacy/no build ID") - : deviceFirmwareBuildId_; - const QString packageBuild = expectedFirmwareBuildId().isEmpty() - ? QStringLiteral("unknown") - : expectedFirmwareBuildId(); setPresentation( QStringLiteral("FIRMWARE UPDATE"), - QStringLiteral("%1 build %2 detected. Studio package contains v%3 build %4; update is required before injection.") - .arg(versionText, observedBuild, expectedFirmwareVersion(), packageBuild), + 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; @@ -1167,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 a562fc376..adbee77b8 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -25,6 +25,8 @@ 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) @@ -65,6 +67,8 @@ 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 { @@ -167,6 +171,7 @@ class SmartSessionController : public QObject { 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(); @@ -332,6 +337,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); @@ -364,6 +370,7 @@ 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}; From 1188a1b2ad5eef95a0336f12bc12c92cc168dcb8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 21:24:58 +0700 Subject: [PATCH 126/170] chore(p0): restore canonical Windows lifecycle gate --- .../arstack-studio-windows-lifecycle.yml | 32 +------------------ 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index f1e420d99..04d93998e 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -11,45 +11,15 @@ on: - '.github/workflows/arstack-studio-windows-lifecycle.yml' permissions: - contents: write + contents: read concurrency: group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - apply-firmware-advisory-fix: - name: Apply P0 firmware advisory fix - if: github.event_name == 'pull_request' - runs-on: macos-15 - timeout-minutes: 5 - steps: - - name: Checkout PR head - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - name: Apply coherent state-machine patch - run: python3 .github/scripts/p0_firmware_advisory_fix.py - - name: Verify and push source-only repair - shell: bash - run: | - set -euo pipefail - git diff --check - if git diff --quiet -- apps/arstack_studio; then - echo 'P0 firmware advisory source already applied.' - 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 'fix(studio): make firmware updates advisory and recoverable' - git push origin HEAD:${{ github.event.pull_request.head.ref }} - close-exit-reopen: name: Close -> process exit -> immediate reopen - needs: apply-firmware-advisory-fix - if: always() && (needs.apply-firmware-advisory-fix.result == 'success' || needs.apply-firmware-advisory-fix.result == 'skipped') runs-on: windows-2022 timeout-minutes: 12 steps: From badc8c77be0926c8f863f0441dd99973502350f2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 21:25:17 +0700 Subject: [PATCH 127/170] chore(p0): remove one-shot firmware advisory patcher --- .github/scripts/p0_firmware_advisory_fix.py | 546 -------------------- 1 file changed, 546 deletions(-) delete mode 100644 .github/scripts/p0_firmware_advisory_fix.py diff --git a/.github/scripts/p0_firmware_advisory_fix.py b/.github/scripts/p0_firmware_advisory_fix.py deleted file mode 100644 index 04a41c674..000000000 --- a/.github/scripts/p0_firmware_advisory_fix.py +++ /dev/null @@ -1,546 +0,0 @@ -from pathlib import Path - -ROOT = Path('.') - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - if old not in text: - if new in text: - return text - raise SystemExit(f'{label}: expected source block not found') - return text.replace(old, new, 1) - - -# --- SmartSessionController.hpp: explicit advisory/update cancellation contract. -path = ROOT / 'apps/arstack_studio/src/SmartSessionController.hpp' -text = path.read_text(encoding='utf-8') -text = replace_once( - text, - ' Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n', - ' Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged)\n' - ' Q_PROPERTY(bool firmwareUpdateAvailable READ firmwareUpdateAvailable NOTIFY stateChanged)\n' - ' Q_PROPERTY(bool firmwareUpdateCanCancel READ firmwareUpdateCanCancel NOTIFY stateChanged)\n', - 'SmartSession advisory properties') -text = replace_once( - text, - ' [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n' - ' [[nodiscard]] bool firmwareReinstallAvailable() const noexcept;\n', - ' [[nodiscard]] bool firmwareUpdateRequired() const noexcept;\n' - ' [[nodiscard]] bool firmwareUpdateAvailable() const noexcept;\n' - ' [[nodiscard]] bool firmwareUpdateCanCancel() const noexcept;\n' - ' [[nodiscard]] bool firmwareReinstallAvailable() const noexcept;\n', - 'SmartSession advisory getters') -text = replace_once( - text, - ' Q_INVOKABLE bool retryFirmwareUpdate();\n' - ' Q_INVOKABLE bool retryFirmwareSetup();\n', - ' Q_INVOKABLE bool retryFirmwareUpdate();\n' - ' Q_INVOKABLE bool cancelFirmwareUpdate();\n' - ' Q_INVOKABLE bool retryFirmwareSetup();\n', - 'SmartSession cancel action') -text = replace_once( - text, - ' bool firmwareIsCurrent() const;\n' - ' bool deviceControlAvailable() const noexcept;\n', - ' bool firmwareIsCurrent() const;\n' - ' bool firmwareIsCompatible() const;\n' - ' bool deviceControlAvailable() const noexcept;\n', - 'SmartSession compatibility helper') -text = replace_once( - text, - ' bool updateRequested_{false};\n' - ' bool blankBoardDetected_{false};\n', - ' bool updateRequested_{false};\n' - ' bool updateWasOptional_{false};\n' - ' bool blankBoardDetected_{false};\n', - 'SmartSession optional-operation state') -path.write_text(text, encoding='utf-8') - - -# --- SmartSessionController.cpp: compatible current firmware remains usable. -path = ROOT / 'apps/arstack_studio/src/SmartSessionController.cpp' -text = path.read_text(encoding='utf-8') -text = replace_once( - text, - 'bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; }\n' - 'bool SmartSessionController::firmwareReinstallAvailable() const noexcept {\n', - 'bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; }\n' - 'bool SmartSessionController::firmwareUpdateAvailable() const noexcept {\n' - ' return device_ != nullptr && firmware_ != nullptr && device_->deviceVerified() &&\n' - ' firmware_->bundleReady() && !firmware_->busy() && !updateRequested_ &&\n' - ' firmwareIsCompatible() && !firmwareIsCurrent();\n' - '}\n' - 'bool SmartSessionController::firmwareUpdateCanCancel() const noexcept {\n' - ' return updateRequested_ && updateWasOptional_ && updateStage_ == UpdateStage::waitingForBootloader &&\n' - ' firmware_ != nullptr && !firmware_->busy();\n' - '}\n' - 'bool SmartSessionController::firmwareReinstallAvailable() const noexcept {\n', - 'SmartSession advisory implementations') -text = replace_once( - text, - 'bool SmartSessionController::beginFirmwareUpdate() {\n' - ' if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() ||\n' - ' !firmware_->bundleReady() || firmware_->busy()) {\n' - ' return false;\n' - ' }\n' - ' return beginFirmwareOperation(device_->portName());\n' - '}\n\n' - 'bool SmartSessionController::beginFirmwareReinstall() {\n' - ' if (!firmwareReinstallAvailable() || device_ == nullptr) return false;\n' - ' return beginFirmwareOperation(device_->portName());\n' - '}\n\n' - 'bool SmartSessionController::beginFirmwareInstall() {\n', - 'bool SmartSessionController::beginFirmwareUpdate() {\n' - ' if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() ||\n' - ' !firmware_->bundleReady() || firmware_->busy()) {\n' - ' return false;\n' - ' }\n' - ' updateWasOptional_ = firmwareUpdateAvailable() && !firmwareUpdateRequired_;\n' - ' return beginFirmwareOperation(device_->portName());\n' - '}\n\n' - 'bool SmartSessionController::beginFirmwareReinstall() {\n' - ' if (!firmwareReinstallAvailable() || device_ == nullptr) return false;\n' - ' updateWasOptional_ = true;\n' - ' return beginFirmwareOperation(device_->portName());\n' - '}\n\n' - 'bool SmartSessionController::beginFirmwareInstall() {\n' - ' updateWasOptional_ = false;\n', - 'SmartSession optional update classification') -text = replace_once( - text, - 'bool SmartSessionController::retryFirmwareUpdate() {\n' - ' if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false;\n' - ' setupError_ = false;\n' - ' setupErrorStatus_.clear();\n' - ' blankBoardDetected_ = false;\n' - ' updateRequested_ = true;\n' - ' updateReconnectAttempts_ = 0;\n' - ' pendingReleaseGeneration_ = 0;\n' - ' continueFirmwareUpdate();\n' - ' return updateRequested_;\n' - '}\n\n' - 'bool SmartSessionController::retryFirmwareSetup() {\n', - 'bool SmartSessionController::retryFirmwareUpdate() {\n' - ' if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false;\n' - ' setupError_ = false;\n' - ' setupErrorStatus_.clear();\n' - ' blankBoardDetected_ = false;\n' - ' updateRequested_ = true;\n' - ' updateReconnectAttempts_ = 0;\n' - ' pendingReleaseGeneration_ = 0;\n' - ' continueFirmwareUpdate();\n' - ' return updateRequested_;\n' - '}\n\n' - 'bool SmartSessionController::cancelFirmwareUpdate() {\n' - ' if (!firmwareUpdateCanCancel() || device_ == nullptr) return false;\n' - ' reconnectTimer_.stop();\n' - ' updateRequested_ = false;\n' - ' updateWasOptional_ = false;\n' - ' updateStage_ = UpdateStage::idle;\n' - ' updateReconnectAttempts_ = 0;\n' - ' pendingReleaseGeneration_ = 0;\n' - ' setupError_ = false;\n' - ' setupErrorStatus_.clear();\n' - ' blankBoardDetected_ = false;\n' - ' blankBoardPort_.clear();\n' - ' if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none);\n' - ' if (started_ && !device_->connected() && !device_->discovering()) {\n' - ' QTimer::singleShot(0, this, [this] {\n' - ' if (!started_ || updateRequested_ || device_ == nullptr || device_->connected() || device_->discovering()) return;\n' - ' static_cast(startDeviceDiscovery());\n' - ' reconcile();\n' - ' });\n' - ' } else {\n' - ' reconcile();\n' - ' }\n' - ' return true;\n' - '}\n\n' - 'bool SmartSessionController::retryFirmwareSetup() {\n', - 'SmartSession cancel firmware operation') -text = replace_once( - text, - 'void SmartSessionController::latchFirmwareFailure(QString message) {\n' - ' reconnectTimer_.stop();\n' - ' updateRequested_ = false;\n' - ' updateStage_ = UpdateStage::idle;\n' - ' updateReconnectAttempts_ = 0;\n' - ' pendingReleaseGeneration_ = 0;\n' - ' blankBoardDetected_ = false;\n' - ' if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none);\n' - ' setupError_ = true;\n' - ' message = message.trimmed();\n' - ' setupErrorStatus_ = message.isEmpty()\n' - ' ? QStringLiteral("Firmware setup did not complete. Retry explicitly when the board is ready.")\n' - ' : std::move(message);\n' - '}\n', - 'void SmartSessionController::latchFirmwareFailure(QString message) {\n' - ' reconnectTimer_.stop();\n' - ' const bool optionalOperation = updateWasOptional_;\n' - ' updateRequested_ = false;\n' - ' updateWasOptional_ = false;\n' - ' updateStage_ = UpdateStage::idle;\n' - ' updateReconnectAttempts_ = 0;\n' - ' pendingReleaseGeneration_ = 0;\n' - ' blankBoardDetected_ = false;\n' - ' if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none);\n' - ' message = message.trimmed();\n' - ' setupError_ = !optionalOperation;\n' - ' setupErrorStatus_ = optionalOperation ? QString{} : (message.isEmpty()\n' - ' ? QStringLiteral("Firmware setup did not complete. Retry explicitly when the board is ready.")\n' - ' : std::move(message));\n' - ' if (optionalOperation && started_ && device_ != nullptr && !device_->connected() && !device_->discovering()) {\n' - ' QTimer::singleShot(0, this, [this] {\n' - ' if (!started_ || updateRequested_ || device_ == nullptr || device_->connected() || device_->discovering()) return;\n' - ' static_cast(startDeviceDiscovery());\n' - ' reconcile();\n' - ' });\n' - ' }\n' - '}\n', - 'SmartSession optional update failure recovery') -text = replace_once( - text, - 'bool SmartSessionController::firmwareIsCurrent() const {\n' - ' return device_ != nullptr && device_->deviceVerified() &&\n' - ' DeviceController::identitySupportsCurrentContract(\n' - ' device_->deviceIdentity(), expectedFirmwareVersion(), expectedFirmwareBuildId());\n' - '}\n\n' - 'bool SmartSessionController::deviceControlAvailable() const noexcept {\n' - ' return device_ != nullptr && device_->deviceVerified() && firmwareIsCurrent() &&\n', - 'bool SmartSessionController::firmwareIsCurrent() const {\n' - ' return device_ != nullptr && device_->deviceVerified() &&\n' - ' DeviceController::identitySupportsCurrentContract(\n' - ' device_->deviceIdentity(), expectedFirmwareVersion(), expectedFirmwareBuildId());\n' - '}\n\n' - 'bool SmartSessionController::firmwareIsCompatible() const {\n' - ' return device_ != nullptr && device_->deviceVerified() &&\n' - ' DeviceController::identitySupportsCurrentContract(\n' - ' device_->deviceIdentity(), expectedFirmwareVersion(), QString{});\n' - '}\n\n' - 'bool SmartSessionController::deviceControlAvailable() const noexcept {\n' - ' return device_ != nullptr && device_->deviceVerified() && firmwareIsCompatible() &&\n', - 'SmartSession compatible-runtime gate') -old_block = ''' refreshFirmwareIdentity(); - if (!firmwareIsCurrent()) { - if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { - 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.") - .arg(observed, expectedFirmwareVersion())); - emit firmwareUpdateFinished(false); - setPresentation(QStringLiteral("SETUP ERROR"), setupErrorStatus_, false, false); - return; - } - const QString versionText = deviceFirmwareVersion_.isEmpty() - ? QStringLiteral("legacy firmware") - : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); - const QString observedBuild = deviceFirmwareBuildId_.isEmpty() - ? QStringLiteral("legacy/no build ID") - : deviceFirmwareBuildId_; - const QString packageBuild = expectedFirmwareBuildId().isEmpty() - ? QStringLiteral("unknown") - : expectedFirmwareBuildId(); - setPresentation( - QStringLiteral("FIRMWARE UPDATE"), - QStringLiteral("%1 build %2 detected. Studio package contains v%3 build %4; update is required before injection.") - .arg(versionText, observedBuild, expectedFirmwareVersion(), packageBuild), - false, - true); - return; - } - - if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; - clearBlankBoardContext(); - emit firmwareUpdateFinished(true); - } -''' -new_block = ''' refreshFirmwareIdentity(); - - // 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 exact bundled build for v%2.") - .arg(observed, expectedFirmwareVersion())); - emit firmwareUpdateFinished(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 does not satisfy the current protocol/capability contract. Update is required before injection.") - .arg(versionText), - false, - true); - return; - } -''' -text = replace_once(text, old_block, new_block, 'SmartSession reconcile firmware gate') -text = replace_once( - text, - ' if (device_->running()) {\n' - ' setPresentation(\n' - ' QStringLiteral("RUNNING"),\n' - ' QStringLiteral("4I + 4V · 4000 samples/s · live value apply"),\n' - ' false,\n' - ' false);\n', - ' if (device_->running()) {\n' - ' setPresentation(\n' - ' QStringLiteral("RUNNING"),\n' - ' firmwareUpdateAvailable()\n' - ' ? QStringLiteral("4I + 4V · 4000 samples/s · live value apply · firmware update available")\n' - ' : QStringLiteral("4I + 4V · 4000 samples/s · live value apply"),\n' - ' false,\n' - ' false);\n', - 'SmartSession running advisory status') -text = replace_once( - text, - ' setPresentation(\n' - ' QStringLiteral("READY"),\n' - ' QStringLiteral("4I + 4V · 4000 samples/s · ready to start"),\n' - ' true,\n' - ' false);\n', - ' setPresentation(\n' - ' QStringLiteral("READY"),\n' - ' firmwareUpdateAvailable()\n' - ' ? QStringLiteral("4I + 4V · 4000 samples/s · ready · firmware update available")\n' - ' : QStringLiteral("4I + 4V · 4000 samples/s · ready to start"),\n' - ' true,\n' - ' false);\n', - 'SmartSession ready advisory status') -path.write_text(text, encoding='utf-8') - - -# --- WorkflowBar.qml: suggestions do not gate Start; Download-mode wait is cancellable. -path = ROOT / 'apps/arstack_studio/qml/WorkflowBar.qml' -text = path.read_text(encoding='utf-8') -text = replace_once( - text, - ' if (smartSession.state === "READY") return "Ready to inject"\n', - ' if (smartSession.state === "READY") return smartSession.firmwareUpdateAvailable ? "Ready · update available" : "Ready to inject"\n', - 'Workflow display advisory') -text = replace_once( - text, - ' title: "Firmware update required"\n', - ' title: smartSession.firmwareUpdateRequired ? "Firmware update required" : "Firmware update available"\n', - 'Workflow update dialog title') -text = replace_once( - text, - ' text: "Update firmware to start injection"\n', - ' text: smartSession.firmwareUpdateRequired ? "Update firmware to start injection" : "A newer firmware build is available"\n', - 'Workflow update dialog heading') -text = replace_once( - text, - ' 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."\n', - ' text: smartSession.firmwareUpdateRequired\n' - ' ? "This ESP32-P4 does not satisfy the current runtime contract. Update is required before injection."\n' - ' : "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."\n', - 'Workflow update dialog message') -text = replace_once( - text, - ' RowLayout {\n' - ' visible: smartSession.updateNeedsBootloaderHelp\n' - ' Layout.fillWidth: true\n' - ' Item { Layout.fillWidth: true }\n' - ' CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Retry"; tone: "accent"; implicitWidth: 110; onClicked: smartSession.retryFirmwareUpdate() }\n' - ' }\n', - ' RowLayout {\n' - ' visible: smartSession.updateNeedsBootloaderHelp\n' - ' Layout.fillWidth: true\n' - ' CalmButton {\n' - ' visible: smartSession.firmwareUpdateCanCancel\n' - ' theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Use current firmware"; implicitWidth: 150\n' - ' onClicked: {\n' - ' if (smartSession.cancelFirmwareUpdate()) {\n' - ' progressDialog.close()\n' - ' controller.showMessage("Firmware update skipped. Reconnecting to the compatible firmware already on the board.", false)\n' - ' }\n' - ' }\n' - ' }\n' - ' Item { Layout.fillWidth: true }\n' - ' CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Retry"; tone: "accent"; implicitWidth: 110; onClicked: smartSession.retryFirmwareUpdate() }\n' - ' }\n', - 'Workflow bootloader cancel action') -path.write_text(text, encoding='utf-8') - - -# --- ModernRibbon.qml: surface advisory update as a non-blocking attention action. -path = ROOT / 'apps/arstack_studio/qml/ModernRibbon.qml' -text = path.read_text(encoding='utf-8') -text = replace_once( - text, - ' visible: bar.session.firmwareUpdateRequired || bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable\n', - ' visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable || bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable\n', - 'Ribbon advisory group visibility') -text = replace_once( - text, - ' visible: bar.session.firmwareUpdateRequired\n' - ' theme: bar.theme; uiFont: bar.uiFont; text: "Update firmware"; tone: "accent"\n', - ' visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable\n' - ' theme: bar.theme; uiFont: bar.uiFont\n' - ' text: bar.session.firmwareUpdateRequired ? "Update firmware" : "Update available"\n' - ' tone: bar.session.firmwareUpdateRequired ? "accent" : "neutral"\n', - 'Ribbon advisory update action') -path.write_text(text, encoding='utf-8') - - -# --- FirmwarePanel.qml: allow optional update from Advanced without calling it required. -path = ROOT / 'apps/arstack_studio/qml/FirmwarePanel.qml' -text = path.read_text(encoding='utf-8') -text = replace_once( - text, - ' text: panel.session && panel.session.firmwareUpdateRequired\n' - ' ? "Update firmware"\n' - ' : "Reinstall current"\n' - ' visible: panel.session &&\n' - ' (panel.session.firmwareUpdateRequired || panel.session.firmwareReinstallAvailable)\n' - ' enabled: visible && !panel.firmware.busy\n' - ' tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal"\n' - ' onClicked: {\n' - ' if (panel.session.firmwareUpdateRequired)\n' - ' panel.session.beginFirmwareUpdate()\n' - ' else\n' - ' panel.session.beginFirmwareReinstall()\n' - ' }\n', - ' text: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable)\n' - ' ? (panel.session.firmwareUpdateRequired ? "Update firmware" : "Update available")\n' - ' : "Reinstall current"\n' - ' visible: panel.session &&\n' - ' (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable || panel.session.firmwareReinstallAvailable)\n' - ' enabled: visible && !panel.firmware.busy\n' - ' tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal"\n' - ' onClicked: {\n' - ' if (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable)\n' - ' panel.session.beginFirmwareUpdate()\n' - ' else\n' - ' panel.session.beginFirmwareReinstall()\n' - ' }\n', - 'FirmwarePanel advisory action') -text = replace_once( - text, - ' : "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."\n', - ' : (panel.session && panel.session.firmwareUpdateAvailable\n' - ' ? "The board firmware is compatible and remains usable. A newer build is available as an optional guided update."\n' - ' : "When Studio reports Firmware required, use the guided action. It will stop output, obtain an acknowledged serial release, verify the ESP32-P4, then write.")\n', - 'FirmwarePanel guidance text') -path.write_text(text, encoding='utf-8') - - -# --- DeterministicSessionHarness.cpp: lock the exact regression. -path = ROOT / 'apps/arstack_studio/src/DeterministicSessionHarness.cpp' -text = path.read_text(encoding='utf-8') -text = replace_once( - text, - ' {"same version missing build ID -> firmware update", sameVersionMissingBuildOffersUpdate()},\n' - ' {"same version stale build -> firmware update", sameVersionStaleBuildOffersUpdate()},\n', - ' {"same version missing build ID -> READY with update suggestion", sameVersionMissingBuildOffersUpdate()},\n' - ' {"same version stale build -> READY with update suggestion", sameVersionStaleBuildOffersUpdate()},\n' - ' {"optional bootloader wait -> cancel restores supervisor", optionalBootloaderWaitCanCancel()},\n', - 'Harness advisory cases') -text = replace_once( - text, - ' session.updateRequested_ = false;\n' - ' session.updateStage_ = SmartSessionController::UpdateStage::idle;\n', - ' session.updateRequested_ = false;\n' - ' session.updateWasOptional_ = false;\n' - ' session.updateStage_ = SmartSessionController::UpdateStage::idle;\n', - 'Harness seed optional reset') -old_missing = ''' 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("FIRMWARE UPDATE") && - fixture.session.firmwareUpdateRequired() && - !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("FIRMWARE UPDATE") && - fixture.session.firmwareUpdateRequired() && - !fixture.session.firmwareReinstallAvailable() && - !fixture.session.startReady(); - } -''' -new_missing = ''' 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_; - } -''' -text = replace_once(text, old_missing, new_missing, 'Harness advisory behavior') -path.write_text(text, encoding='utf-8') - -print('P0 firmware advisory/cancel fix applied successfully') From f3305c5df49b3a69988af96f820c351050866c23 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 22:26:11 +0700 Subject: [PATCH 128/170] fix(studio): make firmware flash progress stream-safe --- apps/arstack_studio/src/FirmwareManager.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 25318ad00..1616b42cc 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -110,6 +110,10 @@ 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("-")}; From e680c60c43a0cfc4c67e234c626234a51fa34dbd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 22:27:35 +0700 Subject: [PATCH 129/170] fix(studio): parse espflash progress across output chunks --- apps/arstack_studio/src/FirmwareManager.cpp | 52 +++++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 3c64e8040..876ba9701 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -29,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) { @@ -106,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); @@ -133,8 +148,13 @@ 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 the firmware contract harness can exercise the + // exact production token rules. Stream reassembly lives in + // updateProgressFromOutput() and remains bounded. + const QString normalized = normalizedTerminalProgress(output); + static const QRegularExpression expression{ + QStringLiteral(R"((? 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(); } From d7ed5a5e360575ae3b4eab62a1f71ab17a59b2a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 22:28:58 +0700 Subject: [PATCH 130/170] feat(studio): present firmware update as staged instrument workflow --- apps/arstack_studio/qml/WorkflowBar.qml | 212 +++++++++++++++++++++--- 1 file changed, 190 insertions(+), 22 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 876af66ec..eae9c9ce9 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -26,6 +26,31 @@ SurfacePanel { smartSession.firmwareInstallRequired || smartSession.firmwareUpdateRequired || smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp readonly property bool injectionRunning: smartSession.state === "RUNNING" + readonly property int firmwareStageIndex: { + if (smartSession.updateNeedsBootloaderHelp) return 1 + if (!smartSession.updatingFirmware) return -1 + if (FirmwareService.busy) { + if (!FirmwareService.targetVerified) return 1 + if (FirmwareService.flashProgress >= 100) return 3 + if (FirmwareService.flashProgress >= 0) return 2 + return 1 + } + return FirmwareService.flashProgress >= 100 ? 4 : 0 + } + 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 firmware", detail: "Program the verified ARStack image" }, + { title: "Restart board", detail: "Reset after the flash completes" }, + { 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 @@ -411,9 +436,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 @@ -421,18 +463,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…" @@ -441,23 +566,49 @@ 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…" + 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 { + height: parent.height + radius: parent.radius + color: ribbon.theme.accent + width: smartSession.firmwareProgress < 0 + ? 0 + : parent.width * Math.max(0, Math.min(1, smartSession.firmwareProgress / 100.0)) + Behavior on width { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } } + RowLayout { visible: smartSession.updateNeedsBootloaderHelp Layout.fillWidth: true @@ -482,8 +633,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 From 1ec0fc84824de2c114f02cf90f1cdb29ec46fde9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:32:51 +0700 Subject: [PATCH 131/170] chore(p0): add one-shot firmware progress finalizer --- .../scripts/p0_finalize_firmware_progress.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/scripts/p0_finalize_firmware_progress.py diff --git a/.github/scripts/p0_finalize_firmware_progress.py b/.github/scripts/p0_finalize_firmware_progress.py new file mode 100644 index 000000000..e00c50d7e --- /dev/null +++ b/.github/scripts/p0_finalize_firmware_progress.py @@ -0,0 +1,101 @@ +from pathlib import Path + +ROOT = Path('.') + + +def replace_once(path: str, old: str, new: str, label: str) -> None: + p = ROOT / path + text = p.read_text(encoding='utf-8') + if new in text: + print(f'{label}: already applied') + return + if old not in text: + raise SystemExit(f'{label}: expected source block not found') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + print(f'{label}: applied') + + +# 1) Real progress stays unknown until espflash emits telemetry. +replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + ''' bootloaderHelpNeeded_ = false;\n flashProgress_ = 0;\n progressOutputTail_.clear();\n''', + ''' bootloaderHelpNeeded_ = false;\n // Do not invent 0%. Progress remains unknown until espflash emits real\n // write telemetry. The UI renders this as an indeterminate write state.\n flashProgress_ = -1;\n progressOutputTail_.clear();\n''', + 'FirmwareManager unknown initial progress') + +old_parser = '''int FirmwareManager::parseFlashProgress(const QString& output) {\n // Keep this parser pure so the firmware contract harness can exercise the\n // exact production token rules. Stream reassembly lives in\n // updateProgressFromOutput() and remains bounded.\n const QString normalized = normalizedTerminalProgress(output);\n static const QRegularExpression expression{\n QStringLiteral(R\"((?= 0 && value <= 100) latest = value;\n }\n return latest;\n}\n''' +new_parser = '''int FirmwareManager::parseFlashProgress(const QString& output) {\n // Keep this parser pure so deterministic fixtures exercise the exact\n // production token rules. Stream reassembly remains bounded in\n // updateProgressFromOutput(). espflash has used both percentage output and\n // indicatif-style \"current/total segment ...\" progress bars.\n const QString normalized = normalizedTerminalProgress(output);\n int latest = -1;\n\n static const QRegularExpression percentExpression{\n QStringLiteral(R\"((?= 0 && value <= 100) latest = value;\n }\n\n static const QRegularExpression segmentExpression{\n QStringLiteral(R\"((? total) continue;\n const int value = static_cast((current * 100 + total / 2) / total);\n latest = std::clamp(value, 0, 100);\n }\n return latest;\n}\n''' +replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + old_parser, + new_parser, + 'FirmwareManager espflash v4 progress parser') + +# 2) Expose the supervisor's explicit firmware state to presentation. +replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ''' Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged)\n Q_PROPERTY(QString updateStatus READ updateStatus NOTIFY stateChanged)\n''', + ''' Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged)\n Q_PROPERTY(QString firmwareUpdateStage READ firmwareUpdateStage NOTIFY stateChanged)\n Q_PROPERTY(QString updateStatus READ updateStatus NOTIFY stateChanged)\n''', + 'SmartSession stage Q_PROPERTY') + +replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ''' Q_ENUM(PortOwner)\n\n explicit SmartSessionController(QObject* parent = nullptr);\n''', + ''' Q_ENUM(PortOwner)\n\n enum class UpdateStage {\n idle,\n stopping,\n releasingPort,\n probing,\n flashing,\n reconnecting,\n waitingForBootloader,\n };\n Q_ENUM(UpdateStage)\n\n explicit SmartSessionController(QObject* parent = nullptr);\n''', + 'SmartSession public UpdateStage') + +replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ''' [[nodiscard]] int firmwareProgress() const noexcept;\n [[nodiscard]] QString updateStatus() const;\n''', + ''' [[nodiscard]] int firmwareProgress() const noexcept;\n [[nodiscard]] QString firmwareUpdateStage() const {\n switch (updateStage_) {\n case UpdateStage::stopping:\n case UpdateStage::releasingPort:\n return QStringLiteral(\"prepare\");\n case UpdateStage::probing:\n case UpdateStage::waitingForBootloader:\n return QStringLiteral(\"verify\");\n case UpdateStage::flashing:\n return QStringLiteral(\"write\");\n case UpdateStage::reconnecting:\n return QStringLiteral(\"reconnect\");\n case UpdateStage::idle:\n return QStringLiteral(\"idle\");\n }\n return QStringLiteral(\"idle\");\n }\n [[nodiscard]] QString updateStatus() const;\n''', + 'SmartSession stage projection') + +replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ''' enum class UpdateStage {\n idle,\n stopping,\n releasingPort,\n probing,\n flashing,\n reconnecting,\n waitingForBootloader,\n };\n enum class ProfileSyncStage { idle, deploying, failed };\n''', + ''' enum class ProfileSyncStage { idle, deploying, failed };\n''', + 'Remove duplicate private UpdateStage') + +# 3) QML consumes supervisor stage only. Write+reset are one truthful stage; +# reconnect+semantic verification remains the final stage. +replace_once( + 'apps/arstack_studio/qml/WorkflowBar.qml', + ''' readonly property int firmwareStageIndex: {\n if (smartSession.updateNeedsBootloaderHelp) return 1\n if (!smartSession.updatingFirmware) return -1\n if (FirmwareService.busy) {\n if (!FirmwareService.targetVerified) return 1\n if (FirmwareService.flashProgress >= 100) return 3\n if (FirmwareService.flashProgress >= 0) return 2\n return 1\n }\n return FirmwareService.flashProgress >= 100 ? 4 : 0\n }\n readonly property var firmwareStages: [\n { title: \"Prepare session\", detail: \"Stop output and release the serial port\" },\n { title: \"Verify board\", detail: \"Confirm ESP32-P4 ROM identity\" },\n { title: \"Write firmware\", detail: \"Program the verified ARStack image\" },\n { title: \"Restart board\", detail: \"Reset after the flash completes\" },\n { title: \"Reconnect & verify\", detail: \"Confirm the new ARStack semantic identity\" }\n ]\n''', + ''' readonly property int firmwareStageIndex: {\n switch (smartSession.firmwareUpdateStage) {\n case \"prepare\": return 0\n case \"verify\": return 1\n case \"write\": return 2\n case \"reconnect\": return 3\n default: return -1\n }\n }\n readonly property var firmwareStages: [\n { title: \"Prepare session\", detail: \"Stop output and release the serial port\" },\n { title: \"Verify board\", detail: \"Confirm ESP32-P4 ROM identity\" },\n { title: \"Write & restart\", detail: \"Program the verified image and reset the board\" },\n { title: \"Reconnect & verify\", detail: \"Confirm the new ARStack semantic identity\" }\n ]\n''', + 'WorkflowBar supervisor-owned stages') + +replace_once( + 'apps/arstack_studio/qml/WorkflowBar.qml', + ''' text: smartSession.firmwareProgress >= 0 ? smartSession.firmwareProgress + \"%\" : \"Starting…\"\n''', + ''' text: smartSession.firmwareProgress >= 0 ? smartSession.firmwareProgress + \"%\" : \"Starting write…\"\n''', + 'WorkflowBar unknown progress label') + +old_track = ''' Rectangle {\n height: parent.height\n radius: parent.radius\n color: ribbon.theme.accent\n width: smartSession.firmwareProgress < 0\n ? 0\n : parent.width * Math.max(0, Math.min(1, smartSession.firmwareProgress / 100.0))\n Behavior on width { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }\n }\n''' +new_track = ''' Rectangle {\n visible: smartSession.firmwareProgress >= 0\n height: parent.height\n radius: parent.radius\n color: ribbon.theme.accent\n width: parent.width * Math.max(0, Math.min(1, smartSession.firmwareProgress / 100.0))\n Behavior on width { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }\n }\n Rectangle {\n id: indeterminateFlash\n visible: smartSession.firmwareProgress < 0\n height: parent.height\n width: Math.max(28, parent.width * 0.24)\n radius: parent.radius\n color: ribbon.theme.accent\n opacity: 0.72\n NumberAnimation on x {\n running: indeterminateFlash.visible\n loops: Animation.Infinite\n from: -indeterminateFlash.width\n to: flashTrack.width\n duration: 900\n easing.type: Easing.InOutQuad\n }\n }\n''' +replace_once( + 'apps/arstack_studio/qml/WorkflowBar.qml', + old_track, + new_track, + 'WorkflowBar indeterminate write progress') + +# 4) Deterministic regressions: real espflash progress-bar format, arbitrary +# chunk split, monotonicity, and supervisor-owned stage projection. +replace_once( + 'apps/arstack_studio/src/DeterministicSessionHarness.cpp', + ''' {\"optional bootloader wait -> cancel restores supervisor\", optionalBootloaderWaitCanCancel()},\n {\"trusted ESP32-P4 identity timeout -> firmware required\", automaticIdentityTimeoutOffersFirmwareRecovery()},\n''', + ''' {\"optional bootloader wait -> cancel restores supervisor\", optionalBootloaderWaitCanCancel()},\n {\"firmware stage projection -> supervisor-owned\", firmwareStageProjectionIsExplicit()},\n {\"fragmented espflash progress -> monotonic\", fragmentedEspflashProgressIsMonotonic()},\n {\"trusted ESP32-P4 identity timeout -> firmware required\", automaticIdentityTimeoutOffersFirmwareRecovery()},\n''', + 'Harness P0 progress cases') + +anchor = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() {\n''' +insert = ''' static bool firmwareStageProjectionIsExplicit() {\n Fixture fixture;\n auto& session = fixture.session;\n session.updateStage_ = SmartSessionController::UpdateStage::idle;\n const bool idle = session.firmwareUpdateStage() == QStringLiteral(\"idle\");\n session.updateStage_ = SmartSessionController::UpdateStage::stopping;\n const bool stopping = session.firmwareUpdateStage() == QStringLiteral(\"prepare\");\n session.updateStage_ = SmartSessionController::UpdateStage::releasingPort;\n const bool releasing = session.firmwareUpdateStage() == QStringLiteral(\"prepare\");\n session.updateStage_ = SmartSessionController::UpdateStage::probing;\n const bool probing = session.firmwareUpdateStage() == QStringLiteral(\"verify\");\n session.updateStage_ = SmartSessionController::UpdateStage::flashing;\n const bool flashing = session.firmwareUpdateStage() == QStringLiteral(\"write\");\n session.updateStage_ = SmartSessionController::UpdateStage::reconnecting;\n const bool reconnecting = session.firmwareUpdateStage() == QStringLiteral(\"reconnect\");\n session.updateStage_ = SmartSessionController::UpdateStage::waitingForBootloader;\n const bool bootloader = session.firmwareUpdateStage() == QStringLiteral(\"verify\");\n return idle && stopping && releasing && probing && flashing && reconnecting && bootloader;\n }\n\n static bool fragmentedEspflashProgressIsMonotonic() {\n Fixture fixture;\n auto& firmware = fixture.firmware;\n firmware.operation_ = FirmwareManager::Operation::flash;\n firmware.flashProgress_ = -1;\n firmware.progressOutputTail_.clear();\n\n firmware.updateProgressFromOutput(QString::fromLatin1(\"\\x1B[2K\\r[00:00:01] [================] 4\"));\n const bool remainsUnknown = firmware.flashProgress_ == -1;\n firmware.updateProgressFromOutput(QStringLiteral(\"2/100 segment 0x0\\r\"));\n const bool reconstructsSplitCounter = firmware.flashProgress_ == 42;\n firmware.updateProgressFromOutput(QStringLiteral(\"[00:00:02] [========] 21/100 segment 0x0\\r\"));\n const bool neverMovesBackward = firmware.flashProgress_ == 42;\n firmware.updateProgressFromOutput(QStringLiteral(\"[00:00:03] [========================================] 100/100 segment 0x0\\r\"));\n const bool reachesCompletion = firmware.flashProgress_ == 100;\n\n return remainsUnknown && reconstructsSplitCounter && neverMovesBackward && reachesCompletion &&\n FirmwareManager::parseFlashProgress(QStringLiteral(\"Writing 64%\\r\")) == 64 &&\n FirmwareManager::parseFlashProgress(QStringLiteral(\"[00:00:02] [================] 17/20 segment 0x10000\")) == 85 &&\n FirmwareManager::parseFlashProgress(QStringLiteral(\"21/0 segment 0x0\")) == -1;\n }\n\n''' +p = ROOT / 'apps/arstack_studio/src/DeterministicSessionHarness.cpp' +text = p.read_text(encoding='utf-8') +if 'fragmentedEspflashProgressIsMonotonic()' not in text.split(anchor)[0]: + if anchor not in text: + raise SystemExit('Harness insertion anchor not found') + text = text.replace(anchor, insert + anchor, 1) + p.write_text(text, encoding='utf-8') + print('Harness P0 progress functions: applied') +else: + print('Harness P0 progress functions: already applied') + +print('P0 firmware progress finalization patch applied') From 46a254359b103814dc91574926eae689aa3d7c30 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:33:11 +0700 Subject: [PATCH 132/170] chore(p0): add one-shot firmware progress repair job --- .../p0-finalize-firmware-progress.yml | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/p0-finalize-firmware-progress.yml diff --git a/.github/workflows/p0-finalize-firmware-progress.yml b/.github/workflows/p0-finalize-firmware-progress.yml new file mode 100644 index 000000000..0c6911662 --- /dev/null +++ b/.github/workflows/p0-finalize-firmware-progress.yml @@ -0,0 +1,54 @@ +name: P0 Finalize Firmware Progress + +on: + pull_request: + paths: + - 'apps/arstack_studio/**' + - '.github/scripts/p0_finalize_firmware_progress.py' + - '.github/workflows/p0-finalize-firmware-progress.yml' + +permissions: + contents: write + +concurrency: + group: p0-finalize-firmware-progress-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + repair-source: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: macos-15 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + - name: Apply P0 firmware progress finalizer + run: python3 .github/scripts/p0_finalize_firmware_progress.py + - name: Verify focused invariants + shell: bash + run: | + set -euo pipefail + grep -q 'flashProgress_ = -1;' apps/arstack_studio/src/FirmwareManager.cpp + grep -q 'segmentExpression' apps/arstack_studio/src/FirmwareManager.cpp + grep -q 'firmwareUpdateStage READ firmwareUpdateStage' apps/arstack_studio/src/SmartSessionController.hpp + grep -q 'smartSession.firmwareUpdateStage' apps/arstack_studio/qml/WorkflowBar.qml + grep -q 'fragmented espflash progress -> monotonic' apps/arstack_studio/src/DeterministicSessionHarness.cpp + grep -q 'firmware stage projection -> supervisor-owned' apps/arstack_studio/src/DeterministicSessionHarness.cpp + - name: Commit source-only repair + shell: bash + run: | + set -euo pipefail + git add apps/arstack_studio/src/FirmwareManager.cpp \ + apps/arstack_studio/src/SmartSessionController.hpp \ + apps/arstack_studio/src/DeterministicSessionHarness.cpp \ + apps/arstack_studio/qml/WorkflowBar.qml + if git diff --cached --quiet; then + echo 'Source already finalized.' + exit 0 + fi + git config user.name 'ARStack P0 Repair' + git config user.email 'actions@users.noreply.github.com' + git commit -m 'fix(studio): finalize firmware progress observability' + git push origin HEAD:${{ github.event.pull_request.head.ref }} From 8fd76f3c0c0abdfc950965fb3fa305ca8e633fc5 Mon Sep 17 00:00:00 2001 From: ARStack P0 Repair Date: Wed, 16 Sep 2026 16:33:52 +0000 Subject: [PATCH 133/170] fix(studio): finalize firmware progress observability --- apps/arstack_studio/qml/WorkflowBar.qml | 41 ++++++++++++------- .../src/DeterministicSessionHarness.cpp | 2 + apps/arstack_studio/src/FirmwareManager.cpp | 37 +++++++++++++---- .../src/SmartSessionController.hpp | 29 +++++++++++++ 4 files changed, 86 insertions(+), 23 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index eae9c9ce9..72bf3987a 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -27,21 +27,18 @@ SurfacePanel { smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp readonly property bool injectionRunning: smartSession.state === "RUNNING" readonly property int firmwareStageIndex: { - if (smartSession.updateNeedsBootloaderHelp) return 1 - if (!smartSession.updatingFirmware) return -1 - if (FirmwareService.busy) { - if (!FirmwareService.targetVerified) return 1 - if (FirmwareService.flashProgress >= 100) return 3 - if (FirmwareService.flashProgress >= 0) return 2 - return 1 + switch (smartSession.firmwareUpdateStage) { + case "prepare": return 0 + case "verify": return 1 + case "write": return 2 + case "reconnect": return 3 + default: return -1 } - return FirmwareService.flashProgress >= 100 ? 4 : 0 } 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 firmware", detail: "Program the verified ARStack image" }, - { title: "Restart board", detail: "Reset after the flash completes" }, + { title: "Write & restart", detail: "Program the verified image and reset the board" }, { title: "Reconnect & verify", detail: "Confirm the new ARStack semantic identity" } ] @@ -582,7 +579,7 @@ SurfacePanel { } Item { Layout.fillWidth: true } Label { - text: smartSession.firmwareProgress >= 0 ? smartSession.firmwareProgress + "%" : "Starting…" + 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 @@ -598,14 +595,30 @@ SurfacePanel { color: ribbon.theme.lineSoft clip: true Rectangle { + visible: smartSession.firmwareProgress >= 0 height: parent.height radius: parent.radius color: ribbon.theme.accent - width: smartSession.firmwareProgress < 0 - ? 0 - : parent.width * Math.max(0, Math.min(1, smartSession.firmwareProgress / 100.0)) + 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 + } + } } } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 41d4ec875..f6334e23f 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -31,6 +31,8 @@ class DeterministicSessionHarness final { {"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()}, diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 876ba9701..f59cb7b92 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -148,18 +148,35 @@ bool FirmwareManager::supportsEsp32P4Revision(const int major, const int minor) } int FirmwareManager::parseFlashProgress(const QString& output) { - // Keep this parser pure so the firmware contract harness can exercise the - // exact production token rules. Stream reassembly lives in - // updateProgressFromOutput() and remains bounded. + // 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); - static const QRegularExpression expression{ - 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; } @@ -338,7 +355,9 @@ 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; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index adbee77b8..fdbf85c49 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -35,6 +35,7 @@ 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) @@ -52,6 +53,17 @@ 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; @@ -80,6 +92,23 @@ 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; From 6f572fb5d32803899986db222d30cde28961f4c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:34:56 +0700 Subject: [PATCH 134/170] chore(p0): complete firmware progress regression fixture --- .../p0_finalize_firmware_progress_functions.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/scripts/p0_finalize_firmware_progress_functions.py diff --git a/.github/scripts/p0_finalize_firmware_progress_functions.py b/.github/scripts/p0_finalize_firmware_progress_functions.py new file mode 100644 index 000000000..f345371a4 --- /dev/null +++ b/.github/scripts/p0_finalize_firmware_progress_functions.py @@ -0,0 +1,14 @@ +from pathlib import Path + +p = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') +text = p.read_text(encoding='utf-8') +if 'static bool firmwareStageProjectionIsExplicit()' in text: + print('P0 regression functions already present') + raise SystemExit(0) + +anchor = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() {\n''' +insert = ''' static bool firmwareStageProjectionIsExplicit() {\n Fixture fixture;\n auto& session = fixture.session;\n session.updateStage_ = SmartSessionController::UpdateStage::idle;\n const bool idle = session.firmwareUpdateStage() == QStringLiteral("idle");\n session.updateStage_ = SmartSessionController::UpdateStage::stopping;\n const bool stopping = session.firmwareUpdateStage() == QStringLiteral("prepare");\n session.updateStage_ = SmartSessionController::UpdateStage::releasingPort;\n const bool releasing = session.firmwareUpdateStage() == QStringLiteral("prepare");\n session.updateStage_ = SmartSessionController::UpdateStage::probing;\n const bool probing = session.firmwareUpdateStage() == QStringLiteral("verify");\n session.updateStage_ = SmartSessionController::UpdateStage::flashing;\n const bool flashing = session.firmwareUpdateStage() == QStringLiteral("write");\n session.updateStage_ = SmartSessionController::UpdateStage::reconnecting;\n const bool reconnecting = session.firmwareUpdateStage() == QStringLiteral("reconnect");\n session.updateStage_ = SmartSessionController::UpdateStage::waitingForBootloader;\n const bool bootloader = session.firmwareUpdateStage() == QStringLiteral("verify");\n return idle && stopping && releasing && probing && flashing && reconnecting && bootloader;\n }\n\n static bool fragmentedEspflashProgressIsMonotonic() {\n Fixture fixture;\n auto& firmware = fixture.firmware;\n firmware.operation_ = FirmwareManager::Operation::flash;\n firmware.flashProgress_ = -1;\n firmware.progressOutputTail_.clear();\n\n firmware.updateProgressFromOutput(QString::fromLatin1("\\x1B[2K\\r[00:00:01] [================] 4"));\n const bool remainsUnknown = firmware.flashProgress_ == -1;\n firmware.updateProgressFromOutput(QStringLiteral("2/100 segment 0x0\\r"));\n const bool reconstructsSplitCounter = firmware.flashProgress_ == 42;\n firmware.updateProgressFromOutput(QStringLiteral("[00:00:02] [========] 21/100 segment 0x0\\r"));\n const bool neverMovesBackward = firmware.flashProgress_ == 42;\n firmware.updateProgressFromOutput(QStringLiteral("[00:00:03] [========================================] 100/100 segment 0x0\\r"));\n const bool reachesCompletion = firmware.flashProgress_ == 100;\n\n return remainsUnknown && reconstructsSplitCounter && neverMovesBackward && reachesCompletion &&\n FirmwareManager::parseFlashProgress(QStringLiteral("Writing 64%\\r")) == 64 &&\n FirmwareManager::parseFlashProgress(QStringLiteral("[00:00:02] [================] 17/20 segment 0x10000")) == 85 &&\n FirmwareManager::parseFlashProgress(QStringLiteral("21/0 segment 0x0")) == -1;\n }\n\n''' +if anchor not in text: + raise SystemExit('P0 regression insertion anchor not found') +p.write_text(text.replace(anchor, insert + anchor, 1), encoding='utf-8') +print('P0 regression functions inserted') From d1ed7a4a1dd12bb7791ebea3619a1734bc157d82 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:35:24 +0700 Subject: [PATCH 135/170] chore(p0): complete regression fixture in finalizer --- .github/workflows/p0-finalize-firmware-progress.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/p0-finalize-firmware-progress.yml b/.github/workflows/p0-finalize-firmware-progress.yml index 0c6911662..120ea9285 100644 --- a/.github/workflows/p0-finalize-firmware-progress.yml +++ b/.github/workflows/p0-finalize-firmware-progress.yml @@ -5,6 +5,7 @@ on: paths: - 'apps/arstack_studio/**' - '.github/scripts/p0_finalize_firmware_progress.py' + - '.github/scripts/p0_finalize_firmware_progress_functions.py' - '.github/workflows/p0-finalize-firmware-progress.yml' permissions: @@ -25,7 +26,9 @@ jobs: ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 - name: Apply P0 firmware progress finalizer - run: python3 .github/scripts/p0_finalize_firmware_progress.py + run: | + python3 .github/scripts/p0_finalize_firmware_progress.py + python3 .github/scripts/p0_finalize_firmware_progress_functions.py - name: Verify focused invariants shell: bash run: | @@ -36,6 +39,8 @@ jobs: grep -q 'smartSession.firmwareUpdateStage' apps/arstack_studio/qml/WorkflowBar.qml grep -q 'fragmented espflash progress -> monotonic' apps/arstack_studio/src/DeterministicSessionHarness.cpp grep -q 'firmware stage projection -> supervisor-owned' apps/arstack_studio/src/DeterministicSessionHarness.cpp + grep -q 'static bool fragmentedEspflashProgressIsMonotonic()' apps/arstack_studio/src/DeterministicSessionHarness.cpp + grep -q 'static bool firmwareStageProjectionIsExplicit()' apps/arstack_studio/src/DeterministicSessionHarness.cpp - name: Commit source-only repair shell: bash run: | @@ -50,5 +55,5 @@ jobs: fi git config user.name 'ARStack P0 Repair' git config user.email 'actions@users.noreply.github.com' - git commit -m 'fix(studio): finalize firmware progress observability' + git commit -m 'fix(studio): finalize firmware progress regressions' git push origin HEAD:${{ github.event.pull_request.head.ref }} From ebfa398c11e8c5cd57aea5baf9c1f71997876fb9 Mon Sep 17 00:00:00 2001 From: ARStack P0 Repair Date: Wed, 16 Sep 2026 16:35:54 +0000 Subject: [PATCH 136/170] fix(studio): finalize firmware progress regressions --- .../src/DeterministicSessionHarness.cpp | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index f6334e23f..5c72ebe0a 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -329,6 +329,48 @@ class DeterministicSessionHarness final { !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; From f98d9631d86b9b049597b33cda71c8dfcdbfb850 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:37:27 +0700 Subject: [PATCH 137/170] chore(p0): remove one-shot firmware progress helper --- .../p0_finalize_firmware_progress_functions.py | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .github/scripts/p0_finalize_firmware_progress_functions.py diff --git a/.github/scripts/p0_finalize_firmware_progress_functions.py b/.github/scripts/p0_finalize_firmware_progress_functions.py deleted file mode 100644 index f345371a4..000000000 --- a/.github/scripts/p0_finalize_firmware_progress_functions.py +++ /dev/null @@ -1,14 +0,0 @@ -from pathlib import Path - -p = Path('apps/arstack_studio/src/DeterministicSessionHarness.cpp') -text = p.read_text(encoding='utf-8') -if 'static bool firmwareStageProjectionIsExplicit()' in text: - print('P0 regression functions already present') - raise SystemExit(0) - -anchor = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() {\n''' -insert = ''' static bool firmwareStageProjectionIsExplicit() {\n Fixture fixture;\n auto& session = fixture.session;\n session.updateStage_ = SmartSessionController::UpdateStage::idle;\n const bool idle = session.firmwareUpdateStage() == QStringLiteral("idle");\n session.updateStage_ = SmartSessionController::UpdateStage::stopping;\n const bool stopping = session.firmwareUpdateStage() == QStringLiteral("prepare");\n session.updateStage_ = SmartSessionController::UpdateStage::releasingPort;\n const bool releasing = session.firmwareUpdateStage() == QStringLiteral("prepare");\n session.updateStage_ = SmartSessionController::UpdateStage::probing;\n const bool probing = session.firmwareUpdateStage() == QStringLiteral("verify");\n session.updateStage_ = SmartSessionController::UpdateStage::flashing;\n const bool flashing = session.firmwareUpdateStage() == QStringLiteral("write");\n session.updateStage_ = SmartSessionController::UpdateStage::reconnecting;\n const bool reconnecting = session.firmwareUpdateStage() == QStringLiteral("reconnect");\n session.updateStage_ = SmartSessionController::UpdateStage::waitingForBootloader;\n const bool bootloader = session.firmwareUpdateStage() == QStringLiteral("verify");\n return idle && stopping && releasing && probing && flashing && reconnecting && bootloader;\n }\n\n static bool fragmentedEspflashProgressIsMonotonic() {\n Fixture fixture;\n auto& firmware = fixture.firmware;\n firmware.operation_ = FirmwareManager::Operation::flash;\n firmware.flashProgress_ = -1;\n firmware.progressOutputTail_.clear();\n\n firmware.updateProgressFromOutput(QString::fromLatin1("\\x1B[2K\\r[00:00:01] [================] 4"));\n const bool remainsUnknown = firmware.flashProgress_ == -1;\n firmware.updateProgressFromOutput(QStringLiteral("2/100 segment 0x0\\r"));\n const bool reconstructsSplitCounter = firmware.flashProgress_ == 42;\n firmware.updateProgressFromOutput(QStringLiteral("[00:00:02] [========] 21/100 segment 0x0\\r"));\n const bool neverMovesBackward = firmware.flashProgress_ == 42;\n firmware.updateProgressFromOutput(QStringLiteral("[00:00:03] [========================================] 100/100 segment 0x0\\r"));\n const bool reachesCompletion = firmware.flashProgress_ == 100;\n\n return remainsUnknown && reconstructsSplitCounter && neverMovesBackward && reachesCompletion &&\n FirmwareManager::parseFlashProgress(QStringLiteral("Writing 64%\\r")) == 64 &&\n FirmwareManager::parseFlashProgress(QStringLiteral("[00:00:02] [================] 17/20 segment 0x10000")) == 85 &&\n FirmwareManager::parseFlashProgress(QStringLiteral("21/0 segment 0x0")) == -1;\n }\n\n''' -if anchor not in text: - raise SystemExit('P0 regression insertion anchor not found') -p.write_text(text.replace(anchor, insert + anchor, 1), encoding='utf-8') -print('P0 regression functions inserted') From bf20f1a8d6f0c0580320b1e24369d1ca8cc68c85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:37:41 +0700 Subject: [PATCH 138/170] chore(p0): remove one-shot firmware progress patcher --- .../scripts/p0_finalize_firmware_progress.py | 101 ------------------ 1 file changed, 101 deletions(-) delete mode 100644 .github/scripts/p0_finalize_firmware_progress.py diff --git a/.github/scripts/p0_finalize_firmware_progress.py b/.github/scripts/p0_finalize_firmware_progress.py deleted file mode 100644 index e00c50d7e..000000000 --- a/.github/scripts/p0_finalize_firmware_progress.py +++ /dev/null @@ -1,101 +0,0 @@ -from pathlib import Path - -ROOT = Path('.') - - -def replace_once(path: str, old: str, new: str, label: str) -> None: - p = ROOT / path - text = p.read_text(encoding='utf-8') - if new in text: - print(f'{label}: already applied') - return - if old not in text: - raise SystemExit(f'{label}: expected source block not found') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - print(f'{label}: applied') - - -# 1) Real progress stays unknown until espflash emits telemetry. -replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - ''' bootloaderHelpNeeded_ = false;\n flashProgress_ = 0;\n progressOutputTail_.clear();\n''', - ''' bootloaderHelpNeeded_ = false;\n // Do not invent 0%. Progress remains unknown until espflash emits real\n // write telemetry. The UI renders this as an indeterminate write state.\n flashProgress_ = -1;\n progressOutputTail_.clear();\n''', - 'FirmwareManager unknown initial progress') - -old_parser = '''int FirmwareManager::parseFlashProgress(const QString& output) {\n // Keep this parser pure so the firmware contract harness can exercise the\n // exact production token rules. Stream reassembly lives in\n // updateProgressFromOutput() and remains bounded.\n const QString normalized = normalizedTerminalProgress(output);\n static const QRegularExpression expression{\n QStringLiteral(R\"((?= 0 && value <= 100) latest = value;\n }\n return latest;\n}\n''' -new_parser = '''int FirmwareManager::parseFlashProgress(const QString& output) {\n // Keep this parser pure so deterministic fixtures exercise the exact\n // production token rules. Stream reassembly remains bounded in\n // updateProgressFromOutput(). espflash has used both percentage output and\n // indicatif-style \"current/total segment ...\" progress bars.\n const QString normalized = normalizedTerminalProgress(output);\n int latest = -1;\n\n static const QRegularExpression percentExpression{\n QStringLiteral(R\"((?= 0 && value <= 100) latest = value;\n }\n\n static const QRegularExpression segmentExpression{\n QStringLiteral(R\"((? total) continue;\n const int value = static_cast((current * 100 + total / 2) / total);\n latest = std::clamp(value, 0, 100);\n }\n return latest;\n}\n''' -replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - old_parser, - new_parser, - 'FirmwareManager espflash v4 progress parser') - -# 2) Expose the supervisor's explicit firmware state to presentation. -replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ''' Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged)\n Q_PROPERTY(QString updateStatus READ updateStatus NOTIFY stateChanged)\n''', - ''' Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged)\n Q_PROPERTY(QString firmwareUpdateStage READ firmwareUpdateStage NOTIFY stateChanged)\n Q_PROPERTY(QString updateStatus READ updateStatus NOTIFY stateChanged)\n''', - 'SmartSession stage Q_PROPERTY') - -replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ''' Q_ENUM(PortOwner)\n\n explicit SmartSessionController(QObject* parent = nullptr);\n''', - ''' Q_ENUM(PortOwner)\n\n enum class UpdateStage {\n idle,\n stopping,\n releasingPort,\n probing,\n flashing,\n reconnecting,\n waitingForBootloader,\n };\n Q_ENUM(UpdateStage)\n\n explicit SmartSessionController(QObject* parent = nullptr);\n''', - 'SmartSession public UpdateStage') - -replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ''' [[nodiscard]] int firmwareProgress() const noexcept;\n [[nodiscard]] QString updateStatus() const;\n''', - ''' [[nodiscard]] int firmwareProgress() const noexcept;\n [[nodiscard]] QString firmwareUpdateStage() const {\n switch (updateStage_) {\n case UpdateStage::stopping:\n case UpdateStage::releasingPort:\n return QStringLiteral(\"prepare\");\n case UpdateStage::probing:\n case UpdateStage::waitingForBootloader:\n return QStringLiteral(\"verify\");\n case UpdateStage::flashing:\n return QStringLiteral(\"write\");\n case UpdateStage::reconnecting:\n return QStringLiteral(\"reconnect\");\n case UpdateStage::idle:\n return QStringLiteral(\"idle\");\n }\n return QStringLiteral(\"idle\");\n }\n [[nodiscard]] QString updateStatus() const;\n''', - 'SmartSession stage projection') - -replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ''' enum class UpdateStage {\n idle,\n stopping,\n releasingPort,\n probing,\n flashing,\n reconnecting,\n waitingForBootloader,\n };\n enum class ProfileSyncStage { idle, deploying, failed };\n''', - ''' enum class ProfileSyncStage { idle, deploying, failed };\n''', - 'Remove duplicate private UpdateStage') - -# 3) QML consumes supervisor stage only. Write+reset are one truthful stage; -# reconnect+semantic verification remains the final stage. -replace_once( - 'apps/arstack_studio/qml/WorkflowBar.qml', - ''' readonly property int firmwareStageIndex: {\n if (smartSession.updateNeedsBootloaderHelp) return 1\n if (!smartSession.updatingFirmware) return -1\n if (FirmwareService.busy) {\n if (!FirmwareService.targetVerified) return 1\n if (FirmwareService.flashProgress >= 100) return 3\n if (FirmwareService.flashProgress >= 0) return 2\n return 1\n }\n return FirmwareService.flashProgress >= 100 ? 4 : 0\n }\n readonly property var firmwareStages: [\n { title: \"Prepare session\", detail: \"Stop output and release the serial port\" },\n { title: \"Verify board\", detail: \"Confirm ESP32-P4 ROM identity\" },\n { title: \"Write firmware\", detail: \"Program the verified ARStack image\" },\n { title: \"Restart board\", detail: \"Reset after the flash completes\" },\n { title: \"Reconnect & verify\", detail: \"Confirm the new ARStack semantic identity\" }\n ]\n''', - ''' readonly property int firmwareStageIndex: {\n switch (smartSession.firmwareUpdateStage) {\n case \"prepare\": return 0\n case \"verify\": return 1\n case \"write\": return 2\n case \"reconnect\": return 3\n default: return -1\n }\n }\n readonly property var firmwareStages: [\n { title: \"Prepare session\", detail: \"Stop output and release the serial port\" },\n { title: \"Verify board\", detail: \"Confirm ESP32-P4 ROM identity\" },\n { title: \"Write & restart\", detail: \"Program the verified image and reset the board\" },\n { title: \"Reconnect & verify\", detail: \"Confirm the new ARStack semantic identity\" }\n ]\n''', - 'WorkflowBar supervisor-owned stages') - -replace_once( - 'apps/arstack_studio/qml/WorkflowBar.qml', - ''' text: smartSession.firmwareProgress >= 0 ? smartSession.firmwareProgress + \"%\" : \"Starting…\"\n''', - ''' text: smartSession.firmwareProgress >= 0 ? smartSession.firmwareProgress + \"%\" : \"Starting write…\"\n''', - 'WorkflowBar unknown progress label') - -old_track = ''' Rectangle {\n height: parent.height\n radius: parent.radius\n color: ribbon.theme.accent\n width: smartSession.firmwareProgress < 0\n ? 0\n : parent.width * Math.max(0, Math.min(1, smartSession.firmwareProgress / 100.0))\n Behavior on width { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }\n }\n''' -new_track = ''' Rectangle {\n visible: smartSession.firmwareProgress >= 0\n height: parent.height\n radius: parent.radius\n color: ribbon.theme.accent\n width: parent.width * Math.max(0, Math.min(1, smartSession.firmwareProgress / 100.0))\n Behavior on width { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }\n }\n Rectangle {\n id: indeterminateFlash\n visible: smartSession.firmwareProgress < 0\n height: parent.height\n width: Math.max(28, parent.width * 0.24)\n radius: parent.radius\n color: ribbon.theme.accent\n opacity: 0.72\n NumberAnimation on x {\n running: indeterminateFlash.visible\n loops: Animation.Infinite\n from: -indeterminateFlash.width\n to: flashTrack.width\n duration: 900\n easing.type: Easing.InOutQuad\n }\n }\n''' -replace_once( - 'apps/arstack_studio/qml/WorkflowBar.qml', - old_track, - new_track, - 'WorkflowBar indeterminate write progress') - -# 4) Deterministic regressions: real espflash progress-bar format, arbitrary -# chunk split, monotonicity, and supervisor-owned stage projection. -replace_once( - 'apps/arstack_studio/src/DeterministicSessionHarness.cpp', - ''' {\"optional bootloader wait -> cancel restores supervisor\", optionalBootloaderWaitCanCancel()},\n {\"trusted ESP32-P4 identity timeout -> firmware required\", automaticIdentityTimeoutOffersFirmwareRecovery()},\n''', - ''' {\"optional bootloader wait -> cancel restores supervisor\", optionalBootloaderWaitCanCancel()},\n {\"firmware stage projection -> supervisor-owned\", firmwareStageProjectionIsExplicit()},\n {\"fragmented espflash progress -> monotonic\", fragmentedEspflashProgressIsMonotonic()},\n {\"trusted ESP32-P4 identity timeout -> firmware required\", automaticIdentityTimeoutOffersFirmwareRecovery()},\n''', - 'Harness P0 progress cases') - -anchor = ''' static bool automaticIdentityTimeoutOffersFirmwareRecovery() {\n''' -insert = ''' static bool firmwareStageProjectionIsExplicit() {\n Fixture fixture;\n auto& session = fixture.session;\n session.updateStage_ = SmartSessionController::UpdateStage::idle;\n const bool idle = session.firmwareUpdateStage() == QStringLiteral(\"idle\");\n session.updateStage_ = SmartSessionController::UpdateStage::stopping;\n const bool stopping = session.firmwareUpdateStage() == QStringLiteral(\"prepare\");\n session.updateStage_ = SmartSessionController::UpdateStage::releasingPort;\n const bool releasing = session.firmwareUpdateStage() == QStringLiteral(\"prepare\");\n session.updateStage_ = SmartSessionController::UpdateStage::probing;\n const bool probing = session.firmwareUpdateStage() == QStringLiteral(\"verify\");\n session.updateStage_ = SmartSessionController::UpdateStage::flashing;\n const bool flashing = session.firmwareUpdateStage() == QStringLiteral(\"write\");\n session.updateStage_ = SmartSessionController::UpdateStage::reconnecting;\n const bool reconnecting = session.firmwareUpdateStage() == QStringLiteral(\"reconnect\");\n session.updateStage_ = SmartSessionController::UpdateStage::waitingForBootloader;\n const bool bootloader = session.firmwareUpdateStage() == QStringLiteral(\"verify\");\n return idle && stopping && releasing && probing && flashing && reconnecting && bootloader;\n }\n\n static bool fragmentedEspflashProgressIsMonotonic() {\n Fixture fixture;\n auto& firmware = fixture.firmware;\n firmware.operation_ = FirmwareManager::Operation::flash;\n firmware.flashProgress_ = -1;\n firmware.progressOutputTail_.clear();\n\n firmware.updateProgressFromOutput(QString::fromLatin1(\"\\x1B[2K\\r[00:00:01] [================] 4\"));\n const bool remainsUnknown = firmware.flashProgress_ == -1;\n firmware.updateProgressFromOutput(QStringLiteral(\"2/100 segment 0x0\\r\"));\n const bool reconstructsSplitCounter = firmware.flashProgress_ == 42;\n firmware.updateProgressFromOutput(QStringLiteral(\"[00:00:02] [========] 21/100 segment 0x0\\r\"));\n const bool neverMovesBackward = firmware.flashProgress_ == 42;\n firmware.updateProgressFromOutput(QStringLiteral(\"[00:00:03] [========================================] 100/100 segment 0x0\\r\"));\n const bool reachesCompletion = firmware.flashProgress_ == 100;\n\n return remainsUnknown && reconstructsSplitCounter && neverMovesBackward && reachesCompletion &&\n FirmwareManager::parseFlashProgress(QStringLiteral(\"Writing 64%\\r\")) == 64 &&\n FirmwareManager::parseFlashProgress(QStringLiteral(\"[00:00:02] [================] 17/20 segment 0x10000\")) == 85 &&\n FirmwareManager::parseFlashProgress(QStringLiteral(\"21/0 segment 0x0\")) == -1;\n }\n\n''' -p = ROOT / 'apps/arstack_studio/src/DeterministicSessionHarness.cpp' -text = p.read_text(encoding='utf-8') -if 'fragmentedEspflashProgressIsMonotonic()' not in text.split(anchor)[0]: - if anchor not in text: - raise SystemExit('Harness insertion anchor not found') - text = text.replace(anchor, insert + anchor, 1) - p.write_text(text, encoding='utf-8') - print('Harness P0 progress functions: applied') -else: - print('Harness P0 progress functions: already applied') - -print('P0 firmware progress finalization patch applied') From 52eb18ca4bf37206960fb57729a28bec19680999 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:37:59 +0700 Subject: [PATCH 139/170] chore(p0): remove one-shot firmware progress workflow --- .../p0-finalize-firmware-progress.yml | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 .github/workflows/p0-finalize-firmware-progress.yml diff --git a/.github/workflows/p0-finalize-firmware-progress.yml b/.github/workflows/p0-finalize-firmware-progress.yml deleted file mode 100644 index 120ea9285..000000000 --- a/.github/workflows/p0-finalize-firmware-progress.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: P0 Finalize Firmware Progress - -on: - pull_request: - paths: - - 'apps/arstack_studio/**' - - '.github/scripts/p0_finalize_firmware_progress.py' - - '.github/scripts/p0_finalize_firmware_progress_functions.py' - - '.github/workflows/p0-finalize-firmware-progress.yml' - -permissions: - contents: write - -concurrency: - group: p0-finalize-firmware-progress-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - repair-source: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: macos-15 - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - name: Apply P0 firmware progress finalizer - run: | - python3 .github/scripts/p0_finalize_firmware_progress.py - python3 .github/scripts/p0_finalize_firmware_progress_functions.py - - name: Verify focused invariants - shell: bash - run: | - set -euo pipefail - grep -q 'flashProgress_ = -1;' apps/arstack_studio/src/FirmwareManager.cpp - grep -q 'segmentExpression' apps/arstack_studio/src/FirmwareManager.cpp - grep -q 'firmwareUpdateStage READ firmwareUpdateStage' apps/arstack_studio/src/SmartSessionController.hpp - grep -q 'smartSession.firmwareUpdateStage' apps/arstack_studio/qml/WorkflowBar.qml - grep -q 'fragmented espflash progress -> monotonic' apps/arstack_studio/src/DeterministicSessionHarness.cpp - grep -q 'firmware stage projection -> supervisor-owned' apps/arstack_studio/src/DeterministicSessionHarness.cpp - grep -q 'static bool fragmentedEspflashProgressIsMonotonic()' apps/arstack_studio/src/DeterministicSessionHarness.cpp - grep -q 'static bool firmwareStageProjectionIsExplicit()' apps/arstack_studio/src/DeterministicSessionHarness.cpp - - name: Commit source-only repair - shell: bash - run: | - set -euo pipefail - git add apps/arstack_studio/src/FirmwareManager.cpp \ - apps/arstack_studio/src/SmartSessionController.hpp \ - apps/arstack_studio/src/DeterministicSessionHarness.cpp \ - apps/arstack_studio/qml/WorkflowBar.qml - if git diff --cached --quiet; then - echo 'Source already finalized.' - exit 0 - fi - git config user.name 'ARStack P0 Repair' - git config user.email 'actions@users.noreply.github.com' - git commit -m 'fix(studio): finalize firmware progress regressions' - git push origin HEAD:${{ github.event.pull_request.head.ref }} From 69449846a8a83b7b7c8e2675181c4c4fd8c0a03f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:44:38 +0700 Subject: [PATCH 140/170] chore(p0): add one-shot duplicate stage cleanup --- .github/scripts/p0_remove_duplicate_update_stage.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/scripts/p0_remove_duplicate_update_stage.py diff --git a/.github/scripts/p0_remove_duplicate_update_stage.py b/.github/scripts/p0_remove_duplicate_update_stage.py new file mode 100644 index 000000000..d551fd256 --- /dev/null +++ b/.github/scripts/p0_remove_duplicate_update_stage.py @@ -0,0 +1,13 @@ +from pathlib import Path +p = Path('apps/arstack_studio/src/SmartSessionController.hpp') +text = p.read_text(encoding='utf-8') +old = ''' enum class UpdateStage {\n idle,\n stopping,\n releasingPort,\n probing,\n flashing,\n reconnecting,\n waitingForBootloader,\n };\n enum class ProfileSyncStage { idle, deploying, failed };\n''' +new = ''' enum class ProfileSyncStage { idle, deploying, failed };\n''' +# Remove only the private duplicate. Public declaration must remain. +if text.count(old) != 1: + raise SystemExit(f'expected exactly one private duplicate block, found {text.count(old)}') +text = text.replace(old, new, 1) +if text.count('enum class UpdateStage {') != 1: + raise SystemExit('public UpdateStage declaration invariant failed') +p.write_text(text, encoding='utf-8') +print('private duplicate UpdateStage removed; public supervisor enum retained') From 283c395fe5c9e5ea2c292898320f2377f2e1348c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:44:57 +0700 Subject: [PATCH 141/170] chore(p0): add one-shot duplicate stage repair job --- .../p0-remove-duplicate-update-stage.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/p0-remove-duplicate-update-stage.yml diff --git a/.github/workflows/p0-remove-duplicate-update-stage.yml b/.github/workflows/p0-remove-duplicate-update-stage.yml new file mode 100644 index 000000000..dc66bd366 --- /dev/null +++ b/.github/workflows/p0-remove-duplicate-update-stage.yml @@ -0,0 +1,45 @@ +name: P0 Remove Duplicate Update Stage + +on: + pull_request: + paths: + - 'apps/arstack_studio/**' + - '.github/scripts/p0_remove_duplicate_update_stage.py' + - '.github/workflows/p0-remove-duplicate-update-stage.yml' + +permissions: + contents: write + +concurrency: + group: p0-remove-duplicate-update-stage-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + repair-source: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: macos-15 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + - name: Remove duplicate private UpdateStage + run: python3 .github/scripts/p0_remove_duplicate_update_stage.py + - name: Verify stage declaration invariant + shell: bash + run: | + set -euo pipefail + test "$(grep -c 'enum class UpdateStage {' apps/arstack_studio/src/SmartSessionController.hpp)" -eq 1 + grep -q 'Q_ENUM(UpdateStage)' apps/arstack_studio/src/SmartSessionController.hpp + grep -q 'firmwareUpdateStage READ firmwareUpdateStage' apps/arstack_studio/src/SmartSessionController.hpp + - name: Commit source-only fix + shell: bash + run: | + set -euo pipefail + git add apps/arstack_studio/src/SmartSessionController.hpp + if git diff --cached --quiet; then exit 0; fi + git config user.name 'ARStack P0 Repair' + git config user.email 'actions@users.noreply.github.com' + git commit -m 'fix(studio): keep one supervisor update stage enum' + git push origin HEAD:${{ github.event.pull_request.head.ref }} From 37a3ca63d2c3c3cb14413acec545e2aab2a3316b Mon Sep 17 00:00:00 2001 From: ARStack P0 Repair Date: Wed, 16 Sep 2026 16:45:41 +0000 Subject: [PATCH 142/170] fix(studio): keep one supervisor update stage enum --- apps/arstack_studio/src/SmartSessionController.hpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index fdbf85c49..f75cf1b93 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -214,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 { From 3832a2c631856a743343568ed0266f3875047ae3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:46:31 +0700 Subject: [PATCH 143/170] chore(p0): remove duplicate-stage one-shot patcher --- .github/scripts/p0_remove_duplicate_update_stage.py | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .github/scripts/p0_remove_duplicate_update_stage.py diff --git a/.github/scripts/p0_remove_duplicate_update_stage.py b/.github/scripts/p0_remove_duplicate_update_stage.py deleted file mode 100644 index d551fd256..000000000 --- a/.github/scripts/p0_remove_duplicate_update_stage.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path -p = Path('apps/arstack_studio/src/SmartSessionController.hpp') -text = p.read_text(encoding='utf-8') -old = ''' enum class UpdateStage {\n idle,\n stopping,\n releasingPort,\n probing,\n flashing,\n reconnecting,\n waitingForBootloader,\n };\n enum class ProfileSyncStage { idle, deploying, failed };\n''' -new = ''' enum class ProfileSyncStage { idle, deploying, failed };\n''' -# Remove only the private duplicate. Public declaration must remain. -if text.count(old) != 1: - raise SystemExit(f'expected exactly one private duplicate block, found {text.count(old)}') -text = text.replace(old, new, 1) -if text.count('enum class UpdateStage {') != 1: - raise SystemExit('public UpdateStage declaration invariant failed') -p.write_text(text, encoding='utf-8') -print('private duplicate UpdateStage removed; public supervisor enum retained') From 1189ffb6eae46ce19772b16bb33771d90aae3b10 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:46:41 +0700 Subject: [PATCH 144/170] chore(p0): remove duplicate-stage one-shot workflow --- .../p0-remove-duplicate-update-stage.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/p0-remove-duplicate-update-stage.yml diff --git a/.github/workflows/p0-remove-duplicate-update-stage.yml b/.github/workflows/p0-remove-duplicate-update-stage.yml deleted file mode 100644 index dc66bd366..000000000 --- a/.github/workflows/p0-remove-duplicate-update-stage.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: P0 Remove Duplicate Update Stage - -on: - pull_request: - paths: - - 'apps/arstack_studio/**' - - '.github/scripts/p0_remove_duplicate_update_stage.py' - - '.github/workflows/p0-remove-duplicate-update-stage.yml' - -permissions: - contents: write - -concurrency: - group: p0-remove-duplicate-update-stage-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - repair-source: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: macos-15 - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - name: Remove duplicate private UpdateStage - run: python3 .github/scripts/p0_remove_duplicate_update_stage.py - - name: Verify stage declaration invariant - shell: bash - run: | - set -euo pipefail - test "$(grep -c 'enum class UpdateStage {' apps/arstack_studio/src/SmartSessionController.hpp)" -eq 1 - grep -q 'Q_ENUM(UpdateStage)' apps/arstack_studio/src/SmartSessionController.hpp - grep -q 'firmwareUpdateStage READ firmwareUpdateStage' apps/arstack_studio/src/SmartSessionController.hpp - - name: Commit source-only fix - shell: bash - run: | - set -euo pipefail - git add apps/arstack_studio/src/SmartSessionController.hpp - if git diff --cached --quiet; then exit 0; fi - git config user.name 'ARStack P0 Repair' - git config user.email 'actions@users.noreply.github.com' - git commit -m 'fix(studio): keep one supervisor update stage enum' - git push origin HEAD:${{ github.event.pull_request.head.ref }} From 1f4f130be233d60f89a6febf845d6a5814d88df8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:07:15 +0700 Subject: [PATCH 145/170] ui(studio): simplify command bar actions --- apps/arstack_studio/qml/RibbonAction.qml | 93 +++++++++++++----------- 1 file changed, 50 insertions(+), 43 deletions(-) diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index 9a57ea812..2f0b99517 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later import QtQuick import QtQuick.Controls +import QtQuick.Layouts Button { id: control @@ -10,72 +11,78 @@ Button { property string tone: "neutral" property url iconSource property bool large: false + property bool iconOnly: false property string toolTipText: "" - implicitWidth: large ? 72 : Math.max(70, label.implicitWidth + 28) - implicitHeight: large ? 54 : 28 + implicitWidth: iconOnly ? 36 : large ? Math.max(76, label.implicitWidth + 42) : Math.max(58, label.implicitWidth + (icon.visible ? 36 : 22)) + implicitHeight: large ? 40 : 34 hoverEnabled: true activeFocusOnTab: true font.family: uiFont - font.pixelSize: large ? 10 : 9 + font.pixelSize: large ? 11 : 10 font.weight: Font.DemiBold - readonly property color fillColor: !enabled ? theme.surface2 - : tone === "success" ? (hovered ? "#1e5c43" : "#194d38") - : tone === "danger" ? (hovered ? "#523039" : theme.redSoft) - : tone === "accent" ? (hovered ? "#1d3c5e" : theme.accentSoft) + readonly property color fillColor: !enabled ? "transparent" + : tone === "success" ? (hovered ? "#1b4b39" : "#163c2f") + : tone === "danger" ? (hovered ? "#4a2830" : "#392127") + : tone === "accent" ? (hovered ? "#173654" : "#142b42") + : tone === "warning" ? (hovered ? "#46391f" : "#362d1c") + : checked ? theme.accentSoft : hovered ? theme.raisedHover : "transparent" - readonly property color edgeColor: !enabled ? theme.lineSoft - : tone === "success" ? "#347a59" - : tone === "danger" ? "#86434b" + readonly property color edgeColor: !enabled ? "transparent" + : tone === "success" ? "#2e7254" + : tone === "danger" ? "#7a3d47" : tone === "accent" ? "#315f8d" + : tone === "warning" ? "#7b6231" + : checked ? "#315f8d" : activeFocus ? theme.accent : "transparent" readonly property color textColor: !enabled ? theme.muted2 - : tone === "success" ? "#d6f4e6" - : tone === "danger" ? "#ffdce0" - : tone === "accent" ? "#dcebff" : theme.textSoft + : tone === "success" ? "#d9f7e9" + : tone === "danger" ? "#ffdce1" + : tone === "accent" ? "#ddebff" + : tone === "warning" ? "#f2d79c" + : checked ? theme.text : theme.textSoft contentItem: Item { - implicitWidth: control.large - ? Math.max(label.implicitWidth, control.iconSource.toString().length ? 22 : 0) - : label.implicitWidth + (control.iconSource.toString().length ? 22 : 0) - implicitHeight: control.large ? 46 : Math.max(18, label.implicitHeight) + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight - Image { - id: icon - visible: control.iconSource.toString().length > 0 - source: control.iconSource - width: control.large ? 20 : 14 - height: width - sourceSize.width: width * 2 - sourceSize.height: height * 2 - opacity: control.enabled ? 0.94 : 0.30 - anchors.horizontalCenter: control.large ? parent.horizontalCenter : undefined - anchors.left: control.large ? undefined : parent.left - anchors.top: control.large ? parent.top : undefined - anchors.verticalCenter: control.large ? undefined : parent.verticalCenter - } + RowLayout { + id: row + anchors.centerIn: parent + spacing: control.iconOnly ? 0 : 7 + + Image { + id: icon + visible: control.iconSource.toString().length > 0 + source: control.iconSource + Layout.preferredWidth: control.large ? 17 : 15 + Layout.preferredHeight: Layout.preferredWidth + sourceSize.width: Layout.preferredWidth * 2 + sourceSize.height: Layout.preferredHeight * 2 + opacity: control.enabled ? 0.96 : 0.30 + } - Text { - id: label - text: control.text - color: control.textColor - font: control.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: control.large ? parent.bottom : undefined - anchors.verticalCenter: control.large ? undefined : parent.verticalCenter + 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 + radius: 7 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 From d0166fdd8a8e18074e609335481132a00bf10c88 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:07:39 +0700 Subject: [PATCH 146/170] ui(studio): replace tab ribbon with compact command bar --- apps/arstack_studio/qml/ModernRibbon.qml | 461 +++++++++++------------ 1 file changed, 221 insertions(+), 240 deletions(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index daf494a32..cda4cc2c4 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -13,289 +13,270 @@ Rectangle { property var device property string uiFont: "Inter" property bool compact: false - property int currentTab: 0 - implicitHeight: compact ? 70 : 96 - color: theme.surface2 + implicitHeight: 62 + radius: 9 + color: theme.chrome border.width: 1 - border.color: theme.line + border.color: theme.lineSoft - function chooseTab(index) { currentTab = index } + function reconnectOrOpenDevice() { + if (device.deviceVerified) + controller.openConfiguration() + else + session.requestConnect() + } - ColumnLayout { + RowLayout { anchors.fill: parent - spacing: 0 + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 7 RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 30 - Layout.leftMargin: 8 - Layout.rightMargin: 8 - spacing: 2 + Layout.preferredWidth: bar.compact ? 138 : 164 + Layout.alignment: Qt.AlignVCenter + spacing: 8 - Repeater { - model: ["Home", "Injection", "View", "Device"] - delegate: Button { - required property int index - required property string modelData - implicitHeight: 28 - implicitWidth: Math.max(70, tabText.implicitWidth + 24) - hoverEnabled: true - onClicked: bar.chooseTab(index) - contentItem: Text { - id: tabText - text: modelData - color: bar.currentTab === index ? bar.theme.text : bar.theme.muted - font.family: bar.uiFont - font.pixelSize: 10 - font.weight: bar.currentTab === index ? Font.DemiBold : Font.Medium - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - radius: 5 - color: parent.hovered ? bar.theme.raisedHover : "transparent" - Rectangle { - visible: bar.currentTab === index - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 2 - radius: 1 - color: bar.theme.accent - } - } + Rectangle { + width: 30 + height: 30 + radius: 7 + color: "#101a24" + border.width: 1 + border.color: "#294761" + Image { + anchors.centerIn: parent + width: 16 + height: 16 + source: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") + sourceSize.width: 32 + sourceSize.height: 32 } } - Item { Layout.fillWidth: true } - - Rectangle { - implicitWidth: Math.max(142, stateText.implicitWidth + 34) - implicitHeight: 24 - radius: 6 - color: "#0d141c" - border.width: 1 - border.color: bar.workflow.smartStateColor - RowLayout { - anchors.fill: parent - anchors.leftMargin: 9 - anchors.rightMargin: 9 - spacing: 7 - Rectangle { width: 7; height: 7; radius: 4; color: bar.workflow.smartStateColor } - Label { - id: stateText - Layout.fillWidth: true - text: bar.workflow.displayState - color: bar.workflow.smartStateColor - font.family: bar.uiFont - font.pixelSize: 9 - font.weight: Font.DemiBold - elide: Text.ElideRight - } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + Layout.fillWidth: true + text: "ARStack Studio" + color: bar.theme.text + font.family: bar.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: bar.compact ? "SMV + PTP" : "SMV Injector · v" + Qt.application.version + color: bar.theme.muted + font.family: bar.uiFont + font.pixelSize: 8 + font.weight: Font.Medium + elide: Text.ElideRight } } } - Rectangle { Layout.fillWidth: true; height: 1; color: bar.theme.lineSoft } + Rectangle { width: 1; height: 30; color: bar.theme.lineSoft } - Item { - Layout.fillWidth: true - Layout.fillHeight: true + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Start" + large: true + 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() + } - RowLayout { - anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - spacing: 0 - visible: bar.currentTab === 0 + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Stop" + large: true + 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() + } - RibbonGroup { - theme: bar.theme; uiFont: bar.uiFont; title: "Session" - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; large: !bar.compact - 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; large: !bar.compact - 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: 30; color: bar.theme.lineSoft } - RibbonGroup { - theme: bar.theme; uiFont: bar.uiFont; title: "Engineering" - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Open file" - iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") - enabled: bar.session.engineeringEditable - toolTipText: "Open IEC 61850 engineering configuration" - onClicked: bar.workflow.openEngineeringDialog() - } - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "4I + 4V" - iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") - enabled: bar.session.engineeringEditable - toolTipText: "Use built-in 4I + 4V / 4000 fps profile" - onClicked: bar.workflow.useBuiltInProfile() - } - } + 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() + } - RibbonGroup { - visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable || bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable - theme: bar.theme; uiFont: bar.uiFont; title: "Attention" - RibbonAction { - visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable - theme: bar.theme; uiFont: bar.uiFont - text: bar.session.firmwareUpdateRequired ? "Update firmware" : "Update available" - tone: bar.session.firmwareUpdateRequired ? "accent" : "neutral" - onClicked: bar.workflow.openUpdatePrompt() - } - RibbonAction { - visible: bar.session.firmwareInstallRequired - theme: bar.theme; uiFont: bar.uiFont; text: "Install firmware"; tone: "accent" - onClicked: bar.workflow.openInstallPrompt() - } - RibbonAction { - visible: bar.session.profileSyncRetryAvailable - theme: bar.theme; uiFont: bar.uiFont; text: "Retry profile"; tone: "accent" - onClicked: bar.workflow.retryProfileSync() - } - } + RibbonAction { + 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() + } - Item { Layout.fillWidth: true } + RibbonAction { + visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable || + 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" + : bar.session.firmwareUpdateAvailable ? "Update available" + : "Retry profile" + toolTipText: bar.session.firmwareInstallRequired ? "Install the bundled ARStack firmware" + : bar.session.firmwareUpdateRequired ? "Firmware update is required before injection" + : bar.session.firmwareUpdateAvailable ? "A compatible newer firmware build is available" + : "Retry the 4I + 4V profile synchronization" + onClicked: { + if (bar.session.firmwareInstallRequired) bar.workflow.openInstallPrompt() + else if (bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable) bar.workflow.openUpdatePrompt() + else bar.workflow.retryProfileSync() } + } - RowLayout { - anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - spacing: 0 - visible: bar.currentTab === 1 + Item { Layout.fillWidth: true; Layout.minimumWidth: 6 } + + RowLayout { + Layout.alignment: Qt.AlignVCenter + spacing: 3 - RibbonGroup { - theme: bar.theme; uiFont: bar.uiFont; title: "Frequency" - RibbonAction { theme: bar.theme; uiFont: bar.uiFont; text: "0 Hz · DC"; onClicked: bar.controller.setFrequencyValue(0) } - RibbonAction { theme: bar.theme; uiFont: bar.uiFont; text: "50 Hz"; onClicked: bar.controller.setFrequencyValue(50) } - RibbonAction { theme: bar.theme; uiFont: bar.uiFont; text: "60 Hz"; onClicked: bar.controller.setFrequencyValue(60) } + 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 the phasor preview" + onClicked: { + if (bar.controller.phasorDetached) bar.controller.phasorDetached = false + bar.controller.phasorDockVisible = checked } + } - RibbonGroup { - theme: bar.theme; uiFont: bar.uiFont; title: "Three phase" - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Balanced" - iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") - toolTipText: "Reset three-phase magnitudes and angles to the balanced reference" - onClicked: bar.controller.balanced() - } - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Zero all" - iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") - onClicked: bar.controller.zeroAll() - } - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Link phases" - checkable: true - checked: bar.controller.phaseLink - tone: checked ? "accent" : "neutral" - onClicked: bar.controller.phaseLink = checked - } + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Wave" + checkable: true + checked: bar.controller.waveformDockVisible || bar.controller.waveformDetached + tone: checked ? "accent" : "neutral" + toolTipText: "Show or hide the waveform preview" + onClicked: { + if (bar.controller.waveformDetached) bar.controller.waveformDetached = false + bar.controller.waveformDockVisible = checked } + } - Item { Layout.fillWidth: true } + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Monitor" + visible: !bar.compact + checkable: true + checked: bar.controller.telemetryDockVisible && bar.controller.telemetryExpanded + tone: checked ? "accent" : "neutral" + toolTipText: "Open the detailed telemetry monitor" + onClicked: { + bar.controller.telemetryDockVisible = checked + bar.controller.telemetryExpanded = checked + } } + } - RowLayout { - anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - spacing: 0 - visible: bar.currentTab === 2 + Rectangle { width: 1; height: 30; color: bar.theme.lineSoft } - RibbonGroup { - theme: bar.theme; uiFont: bar.uiFont; title: "Preview" - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Phasor" - checkable: true - checked: bar.controller.phasorDockVisible || bar.controller.phasorDetached - tone: checked ? "accent" : "neutral" - onClicked: { - if (bar.controller.phasorDetached) bar.controller.phasorDetached = false - bar.controller.phasorDockVisible = checked - } - } - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Waveform" - checkable: true - checked: bar.controller.waveformDockVisible || bar.controller.waveformDetached - tone: checked ? "accent" : "neutral" - onClicked: { - if (bar.controller.waveformDetached) bar.controller.waveformDetached = false - bar.controller.waveformDockVisible = checked - } - } - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Monitor" - checkable: true - checked: bar.controller.telemetryDockVisible - tone: checked ? "accent" : "neutral" - onClicked: bar.controller.telemetryDockVisible = checked - } - } + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Advanced" + iconOnly: bar.compact + iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + toolTipText: "Advanced device, firmware, waveform and timing controls" + onClicked: bar.controller.openConfiguration() + } - RibbonGroup { - theme: bar.theme; uiFont: bar.uiFont; title: "Layout" - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Reset layout" - iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") - onClicked: bar.workflow.resetDockLayout() - } - } + RibbonAction { + visible: !bar.compact + theme: bar.theme + uiFont: bar.uiFont + text: "Diagnostics" + iconOnly: true + iconSource: Qt.resolvedUrl("../assets/lucide/scan-search.svg") + toolTipText: "Open device diagnostics" + onClicked: bar.controller.openDiagnostics() + } - Item { Layout.fillWidth: true } - } + Rectangle { + Layout.preferredWidth: bar.compact ? 128 : 158 + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignVCenter + radius: 8 + color: "#0b1117" + border.width: 1 + border.color: bar.workflow.smartStateColor RowLayout { anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - spacing: 0 - visible: bar.currentTab === 3 + anchors.leftMargin: 10 + anchors.rightMargin: 9 + spacing: 8 - RibbonGroup { - theme: bar.theme; uiFont: bar.uiFont; title: "Device" - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Advanced" - iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") - onClicked: bar.controller.openConfiguration() - } - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont; text: "Diagnostics" - iconSource: Qt.resolvedUrl("../assets/lucide/scan-search.svg") - onClicked: bar.controller.openDiagnostics() - } + Rectangle { + width: 7 + height: 7 + radius: 4 + color: bar.workflow.smartStateColor } - RibbonGroup { - theme: bar.theme; uiFont: bar.uiFont; title: "Connection" - RibbonAction { - theme: bar.theme; uiFont: bar.uiFont - text: bar.device.deviceVerified ? bar.device.portName : "Reconnect" - tone: bar.device.deviceVerified ? "success" : "accent" - toolTipText: bar.device.deviceVerified ? "Verified ARStack ESP32-P4 device" : "Run smart device discovery" - onClicked: if (!bar.device.deviceVerified) bar.session.requestConnect() + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + Layout.fillWidth: true + text: bar.workflow.displayState + color: bar.workflow.smartStateColor + font.family: bar.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: bar.device.deviceVerified ? bar.device.portName + " · ESP32-P4" : "Device discovery" + color: bar.theme.muted + font.family: bar.uiFont + font.pixelSize: 8 + elide: Text.ElideRight } } + } - Item { Layout.fillWidth: true } + 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.session.statusText + ToolTip.delay: 420 } } } From 9c4e78a7a1eaf9155d1226eab03c8ad8a0e8f0df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:07:53 +0700 Subject: [PATCH 147/170] ui(studio): refine numeric field density and focus treatment --- apps/arstack_studio/qml/NumericField.qml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 From 936f321b9c984708fa8680222ecb386e73c6dab9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:08:22 +0700 Subject: [PATCH 148/170] ui(studio): align signal matrix and reduce visual noise --- apps/arstack_studio/qml/SignalMatrix.qml | 88 ++++++++++++++---------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index afc735280..0d50779e5 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -19,7 +19,7 @@ Rectangle { property bool compact: false color: theme.surface2 - radius: 9 + radius: 8 border.width: 1 border.color: theme.lineSoft @@ -42,26 +42,16 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 44 + Layout.preferredHeight: 40 Layout.leftMargin: 12 Layout.rightMargin: 12 - spacing: 9 + spacing: 8 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 - } + width: 3 + height: 18 + radius: 2 + color: matrix.theme.accent } Label { text: matrix.titleText @@ -76,7 +66,7 @@ Rectangle { text: matrix.unitText color: matrix.theme.muted font.family: matrix.uiFont - font.pixelSize: 10 + font.pixelSize: 9 font.weight: Font.Medium verticalAlignment: Text.AlignVCenter } @@ -86,14 +76,40 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 28 + Layout.preferredHeight: 25 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 } + spacing: 7 + Item { Layout.preferredWidth: 26 } + Label { + text: "Channel" + Layout.preferredWidth: 50 + color: matrix.theme.muted + font.family: matrix.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + Label { + text: "Value" + Layout.fillWidth: true + color: matrix.theme.muted + font.family: matrix.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + Label { + text: "Phase" + Layout.preferredWidth: matrix.compact ? 88 : 104 + color: matrix.theme.muted + font.family: matrix.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } } Repeater { @@ -116,10 +132,10 @@ Rectangle { property real pendingPhase: angle Layout.fillWidth: true - Layout.preferredHeight: matrix.compact ? 46 : 50 - color: signalRow.selected ? "#111c27" - : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#131f2b" - : rowHover.hovered ? "#101820" : "transparent" + Layout.preferredHeight: matrix.compact ? 43 : 45 + color: signalRow.selected ? "#101a24" + : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#111c27" + : rowHover.hovered ? "#0f171f" : "transparent" Behavior on color { ColorAnimation { duration: 90 } } HoverHandler { id: rowHover } @@ -142,7 +158,7 @@ Rectangle { Rectangle { visible: signalRow.selected - width: 3 + width: 2 anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom @@ -153,10 +169,10 @@ Rectangle { anchors.fill: parent anchors.leftMargin: 10 anchors.rightMargin: 10 - spacing: 8 + spacing: 7 CheckBox { - Layout.preferredWidth: 28 + Layout.preferredWidth: 26 Layout.alignment: Qt.AlignVCenter checked: signalRow.channelEnabled ToolTip.visible: hovered @@ -171,15 +187,15 @@ Rectangle { } RowLayout { - Layout.preferredWidth: 46 + Layout.preferredWidth: 50 Layout.alignment: Qt.AlignVCenter - spacing: 6 - Rectangle { width: 8; height: 8; radius: 4; color: signalRow.phaseColor } + spacing: 7 + Rectangle { width: 7; height: 7; radius: 4; 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 } @@ -237,7 +253,7 @@ Rectangle { NumericField { id: phaseField enabled: matrix.controller.signalFrequency > 0 - Layout.preferredWidth: matrix.compact ? 92 : 108 + Layout.preferredWidth: matrix.compact ? 88 : 104 theme: matrix.theme monoFont: matrix.monoFont compact: matrix.compact From b78e5237397025292cfee209ddc83c129c9a40b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:08:51 +0700 Subject: [PATCH 149/170] ci(ui): stage one-shot demo shell refinement --- .github/scripts/phase1_demo_shell.py | 134 +++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 .github/scripts/phase1_demo_shell.py diff --git a/.github/scripts/phase1_demo_shell.py b/.github/scripts/phase1_demo_shell.py new file mode 100644 index 000000000..c329d268f --- /dev/null +++ b/.github/scripts/phase1_demo_shell.py @@ -0,0 +1,134 @@ +from pathlib import Path + + +def remove_qml_object(text: str, marker: str) -> str: + start = text.find(marker) + if start < 0: + raise SystemExit(f'marker not found: {marker!r}') + brace = text.find('{', start) + if brace < 0: + raise SystemExit(f'opening brace not found: {marker!r}') + depth = 0 + in_string = False + escaped = False + i = brace + while i < len(text): + ch = text[i] + if in_string: + if escaped: + escaped = False + elif ch == '\\': + escaped = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0: + end = i + 1 + while end < len(text) and text[end] in ' \t': + end += 1 + if end < len(text) and text[end] == '\n': + end += 1 + return text[:start] + text[end:] + i += 1 + raise SystemExit(f'unbalanced QML object: {marker!r}') + + +main_path = Path('apps/arstack_studio/qml/Main.qml') +main = main_path.read_text(encoding='utf-8') + +# Remove the legacy brand/status header. The command bar now owns the entire +# application chrome below the native window title bar. +main = remove_qml_object(main, ' header: Rectangle {') + +# Replace the verbose footer with one compact connection/telemetry strip. +footer_start = main.find(' footer: Rectangle {') +if footer_start < 0: + raise SystemExit('footer block not found') +footer_removed = remove_qml_object(main, ' footer: Rectangle {') +insert_at = footer_removed.find(' Window {\n id: configurationWindow') +if insert_at < 0: + raise SystemExit('configuration window anchor not found') +footer = ''' footer: Rectangle { + height: 28 + color: studioTheme.chrome + border.width: 1 + border.color: studioTheme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + + Rectangle { width: 6; height: 6; radius: 3; color: workflowBar.smartStateColor } + Label { + text: device.deviceVerified ? device.portName + " · ESP32-P4" : workflowBar.displayState + color: device.deviceVerified ? studioTheme.textSoft : workflowBar.smartStateColor + font.family: root.uiFont + font.pixelSize: 9 + font.weight: Font.Medium + } + Rectangle { width: 1; height: 14; color: studioTheme.lineSoft } + Label { + text: workflowBar.injectionRunning ? "SMV output active" : "4I + 4V · 4000 samples/s" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 + } + Item { Layout.fillWidth: true } + Label { + visible: device.deviceVerified + text: device.running + ? "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures + : "READY" + color: (Number(device.missed) > 0 || Number(device.txFailures) > 0) ? studioTheme.amber : studioTheme.textSoft + font.family: root.monoFont + font.pixelSize: 9 + font.weight: Font.Medium + } + } + } + +''' +main = footer_removed[:insert_at] + footer + footer_removed[insert_at:] + +main = main.replace( + ' anchors.margins: root.compactLayout ? 9 : 11\n spacing: root.compactLayout ? 9 : 11\n', + ' anchors.margins: 8\n spacing: 8\n', + 1) + +# One command bar, fixed instrument density. +main = main.replace( + ' Layout.preferredHeight: workflowBar.implicitHeight\n Layout.minimumHeight: workflowBar.implicitHeight\n', + ' Layout.preferredHeight: 62\n Layout.minimumHeight: 62\n', + 1) + +# Remove redundant eyebrow copy above the injection title. +noise = ' Label { text: "INJECTION"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 }\n' +if noise not in main: + raise SystemExit('INJECTION eyebrow not found') +main = main.replace(noise, '', 1) + +# The monitor becomes a deliberate drawer instead of a permanent collapsed bar. +main = main.replace( + ' visible: root.telemetryDockVisible\n', + ' visible: root.telemetryDockVisible && root.telemetryExpanded\n', + 1) +main = main.replace(' anchors.bottomMargin: 48\n', ' anchors.bottomMargin: 38\n', 1) +main_path.write_text(main, encoding='utf-8') + +workflow_path = Path('apps/arstack_studio/qml/WorkflowBar.qml') +workflow = workflow_path.read_text(encoding='utf-8') +old = ' implicitHeight: ribbon.compact ? 70 : 96\n' +if workflow.count(old) != 1: + raise SystemExit(f'expected one legacy workflow height, got {workflow.count(old)}') +workflow = workflow.replace(old, ' implicitHeight: 62\n', 1) +workflow_path.write_text(workflow, encoding='utf-8') + +print('Phase 1 demo shell patch applied') From 2d2eccd14b985c633331e0da211dc432f64d1f93 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:09:04 +0700 Subject: [PATCH 150/170] ci(ui): run one-shot demo shell refinement --- .github/workflows/phase1-demo-shell.yml | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/phase1-demo-shell.yml diff --git a/.github/workflows/phase1-demo-shell.yml b/.github/workflows/phase1-demo-shell.yml new file mode 100644 index 000000000..6e45ea505 --- /dev/null +++ b/.github/workflows/phase1-demo-shell.yml @@ -0,0 +1,47 @@ +name: Phase 1 Demo Shell Refinement + +on: + pull_request: + paths: + - 'apps/arstack_studio/**' + - '.github/scripts/phase1_demo_shell.py' + - '.github/workflows/phase1-demo-shell.yml' + +permissions: + contents: write + +concurrency: + group: phase1-demo-shell-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + apply-shell: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: macos-15 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + - name: Apply demo shell refinement + run: python3 .github/scripts/phase1_demo_shell.py + - name: Verify presentation-only scope + shell: bash + run: | + set -euo pipefail + git diff -- apps/arstack_studio/qml/Main.qml apps/arstack_studio/qml/WorkflowBar.qml + test "$(grep -c 'header: Rectangle {' apps/arstack_studio/qml/Main.qml || true)" -eq 0 + grep -q 'Layout.preferredHeight: 62' apps/arstack_studio/qml/Main.qml + grep -q 'implicitHeight: 62' apps/arstack_studio/qml/WorkflowBar.qml + grep -q 'visible: root.telemetryDockVisible && root.telemetryExpanded' apps/arstack_studio/qml/Main.qml + - name: Commit presentation source + shell: bash + run: | + set -euo pipefail + git add apps/arstack_studio/qml/Main.qml apps/arstack_studio/qml/WorkflowBar.qml + if git diff --cached --quiet; then exit 0; fi + git config user.name 'ARStack UI Refinement' + git config user.email 'actions@users.noreply.github.com' + git commit -m 'ui(studio): consolidate shell for demo-grade layout' + git push origin HEAD:${{ github.event.pull_request.head.ref }} From a9f629965c1904dab504bd04a34ba0a84a79cad8 Mon Sep 17 00:00:00 2001 From: ARStack UI Refinement Date: Thu, 17 Sep 2026 01:09:59 +0000 Subject: [PATCH 151/170] ui(studio): consolidate shell for demo-grade layout --- apps/arstack_studio/qml/Main.qml | 133 +++++------------------- apps/arstack_studio/qml/WorkflowBar.qml | 2 +- 2 files changed, 29 insertions(+), 106 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index b0d3e0ab8..9124f6afb 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -429,121 +429,45 @@ 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 + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + Rectangle { width: 6; height: 6; radius: 3; color: workflowBar.smartStateColor } Label { - Layout.fillWidth: true - text: workflowBar.session.statusText + text: device.deviceVerified ? device.portName + " · ESP32-P4" : workflowBar.displayState + color: device.deviceVerified ? studioTheme.textSoft : workflowBar.smartStateColor + font.family: root.uiFont + font.pixelSize: 9 + font.weight: Font.Medium + } + Rectangle { width: 1; height: 14; color: studioTheme.lineSoft } + Label { + text: workflowBar.injectionRunning ? "SMV output active" : "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: device.running + ? "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures + : "READY" 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 } } } @@ -673,14 +597,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: workflowBar.implicitHeight - Layout.minimumHeight: workflowBar.implicitHeight + Layout.preferredHeight: 62 + Layout.minimumHeight: 62 theme: studioTheme controller: root device: device @@ -743,7 +667,6 @@ ApplicationWindow { 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 } } Item { Layout.fillWidth: true } @@ -986,7 +909,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 @@ -1009,7 +932,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/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 72bf3987a..a2302191b 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -710,7 +710,7 @@ SurfacePanel { } } - implicitHeight: ribbon.compact ? 70 : 96 + implicitHeight: 62 color: "transparent" border.width: 0 From b34d74dad7ff3732df358a825eb648fb6afc0c16 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:11:27 +0700 Subject: [PATCH 152/170] chore(ui): remove one-shot demo shell patcher --- .github/scripts/phase1_demo_shell.py | 134 --------------------------- 1 file changed, 134 deletions(-) delete mode 100644 .github/scripts/phase1_demo_shell.py diff --git a/.github/scripts/phase1_demo_shell.py b/.github/scripts/phase1_demo_shell.py deleted file mode 100644 index c329d268f..000000000 --- a/.github/scripts/phase1_demo_shell.py +++ /dev/null @@ -1,134 +0,0 @@ -from pathlib import Path - - -def remove_qml_object(text: str, marker: str) -> str: - start = text.find(marker) - if start < 0: - raise SystemExit(f'marker not found: {marker!r}') - brace = text.find('{', start) - if brace < 0: - raise SystemExit(f'opening brace not found: {marker!r}') - depth = 0 - in_string = False - escaped = False - i = brace - while i < len(text): - ch = text[i] - if in_string: - if escaped: - escaped = False - elif ch == '\\': - escaped = True - elif ch == '"': - in_string = False - else: - if ch == '"': - in_string = True - elif ch == '{': - depth += 1 - elif ch == '}': - depth -= 1 - if depth == 0: - end = i + 1 - while end < len(text) and text[end] in ' \t': - end += 1 - if end < len(text) and text[end] == '\n': - end += 1 - return text[:start] + text[end:] - i += 1 - raise SystemExit(f'unbalanced QML object: {marker!r}') - - -main_path = Path('apps/arstack_studio/qml/Main.qml') -main = main_path.read_text(encoding='utf-8') - -# Remove the legacy brand/status header. The command bar now owns the entire -# application chrome below the native window title bar. -main = remove_qml_object(main, ' header: Rectangle {') - -# Replace the verbose footer with one compact connection/telemetry strip. -footer_start = main.find(' footer: Rectangle {') -if footer_start < 0: - raise SystemExit('footer block not found') -footer_removed = remove_qml_object(main, ' footer: Rectangle {') -insert_at = footer_removed.find(' Window {\n id: configurationWindow') -if insert_at < 0: - raise SystemExit('configuration window anchor not found') -footer = ''' footer: Rectangle { - height: 28 - color: studioTheme.chrome - border.width: 1 - border.color: studioTheme.lineSoft - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - spacing: 8 - - Rectangle { width: 6; height: 6; radius: 3; color: workflowBar.smartStateColor } - Label { - text: device.deviceVerified ? device.portName + " · ESP32-P4" : workflowBar.displayState - color: device.deviceVerified ? studioTheme.textSoft : workflowBar.smartStateColor - font.family: root.uiFont - font.pixelSize: 9 - font.weight: Font.Medium - } - Rectangle { width: 1; height: 14; color: studioTheme.lineSoft } - Label { - text: workflowBar.injectionRunning ? "SMV output active" : "4I + 4V · 4000 samples/s" - color: studioTheme.muted - font.family: root.uiFont - font.pixelSize: 9 - } - Item { Layout.fillWidth: true } - Label { - visible: device.deviceVerified - text: device.running - ? "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures - : "READY" - color: (Number(device.missed) > 0 || Number(device.txFailures) > 0) ? studioTheme.amber : studioTheme.textSoft - font.family: root.monoFont - font.pixelSize: 9 - font.weight: Font.Medium - } - } - } - -''' -main = footer_removed[:insert_at] + footer + footer_removed[insert_at:] - -main = main.replace( - ' anchors.margins: root.compactLayout ? 9 : 11\n spacing: root.compactLayout ? 9 : 11\n', - ' anchors.margins: 8\n spacing: 8\n', - 1) - -# One command bar, fixed instrument density. -main = main.replace( - ' Layout.preferredHeight: workflowBar.implicitHeight\n Layout.minimumHeight: workflowBar.implicitHeight\n', - ' Layout.preferredHeight: 62\n Layout.minimumHeight: 62\n', - 1) - -# Remove redundant eyebrow copy above the injection title. -noise = ' Label { text: "INJECTION"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 }\n' -if noise not in main: - raise SystemExit('INJECTION eyebrow not found') -main = main.replace(noise, '', 1) - -# The monitor becomes a deliberate drawer instead of a permanent collapsed bar. -main = main.replace( - ' visible: root.telemetryDockVisible\n', - ' visible: root.telemetryDockVisible && root.telemetryExpanded\n', - 1) -main = main.replace(' anchors.bottomMargin: 48\n', ' anchors.bottomMargin: 38\n', 1) -main_path.write_text(main, encoding='utf-8') - -workflow_path = Path('apps/arstack_studio/qml/WorkflowBar.qml') -workflow = workflow_path.read_text(encoding='utf-8') -old = ' implicitHeight: ribbon.compact ? 70 : 96\n' -if workflow.count(old) != 1: - raise SystemExit(f'expected one legacy workflow height, got {workflow.count(old)}') -workflow = workflow.replace(old, ' implicitHeight: 62\n', 1) -workflow_path.write_text(workflow, encoding='utf-8') - -print('Phase 1 demo shell patch applied') From 7380a627db4e6b7e502a7b77d90f0ff990b6979d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:11:37 +0700 Subject: [PATCH 153/170] chore(ui): remove one-shot demo shell workflow --- .github/workflows/phase1-demo-shell.yml | 47 ------------------------- 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/phase1-demo-shell.yml diff --git a/.github/workflows/phase1-demo-shell.yml b/.github/workflows/phase1-demo-shell.yml deleted file mode 100644 index 6e45ea505..000000000 --- a/.github/workflows/phase1-demo-shell.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Phase 1 Demo Shell Refinement - -on: - pull_request: - paths: - - 'apps/arstack_studio/**' - - '.github/scripts/phase1_demo_shell.py' - - '.github/workflows/phase1-demo-shell.yml' - -permissions: - contents: write - -concurrency: - group: phase1-demo-shell-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - apply-shell: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: macos-15 - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - name: Apply demo shell refinement - run: python3 .github/scripts/phase1_demo_shell.py - - name: Verify presentation-only scope - shell: bash - run: | - set -euo pipefail - git diff -- apps/arstack_studio/qml/Main.qml apps/arstack_studio/qml/WorkflowBar.qml - test "$(grep -c 'header: Rectangle {' apps/arstack_studio/qml/Main.qml || true)" -eq 0 - grep -q 'Layout.preferredHeight: 62' apps/arstack_studio/qml/Main.qml - grep -q 'implicitHeight: 62' apps/arstack_studio/qml/WorkflowBar.qml - grep -q 'visible: root.telemetryDockVisible && root.telemetryExpanded' apps/arstack_studio/qml/Main.qml - - name: Commit presentation source - shell: bash - run: | - set -euo pipefail - git add apps/arstack_studio/qml/Main.qml apps/arstack_studio/qml/WorkflowBar.qml - if git diff --cached --quiet; then exit 0; fi - git config user.name 'ARStack UI Refinement' - git config user.email 'actions@users.noreply.github.com' - git commit -m 'ui(studio): consolidate shell for demo-grade layout' - git push origin HEAD:${{ github.event.pull_request.head.ref }} From 0fc9339b85b3e7ebf42e1ae01964ae2d1df68c3b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:39:21 +0700 Subject: [PATCH 154/170] feat(studio): replace ribbon with premium single command bar --- apps/arstack_studio/qml/ModernRibbon.qml | 157 +++++++++-------------- 1 file changed, 61 insertions(+), 96 deletions(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index cda4cc2c4..27f1ce178 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -14,7 +14,7 @@ Rectangle { property string uiFont: "Inter" property bool compact: false - implicitHeight: 62 + implicitHeight: 54 radius: 9 color: theme.chrome border.width: 1 @@ -31,10 +31,12 @@ Rectangle { anchors.fill: parent anchors.leftMargin: 10 anchors.rightMargin: 10 - spacing: 7 + spacing: 8 + // Product identity is intentionally compact. The command bar is a tool, + // not a second title bar. RowLayout { - Layout.preferredWidth: bar.compact ? 138 : 164 + Layout.preferredWidth: bar.compact ? 132 : 158 Layout.alignment: Qt.AlignVCenter spacing: 8 @@ -42,9 +44,9 @@ Rectangle { width: 30 height: 30 radius: 7 - color: "#101a24" + color: "#0f1821" border.width: 1 - border.color: "#294761" + border.color: "#27445d" Image { anchors.centerIn: parent width: 16 @@ -57,10 +59,10 @@ Rectangle { ColumnLayout { Layout.fillWidth: true - spacing: 0 + spacing: -1 Label { Layout.fillWidth: true - text: "ARStack Studio" + text: "ARStack" color: bar.theme.text font.family: bar.uiFont font.pixelSize: 12 @@ -69,7 +71,7 @@ Rectangle { } Label { Layout.fillWidth: true - text: bar.compact ? "SMV + PTP" : "SMV Injector · v" + Qt.application.version + text: "SMV + PTP" color: bar.theme.muted font.family: bar.uiFont font.pixelSize: 8 @@ -79,13 +81,13 @@ Rectangle { } } - Rectangle { width: 1; height: 30; color: bar.theme.lineSoft } + Rectangle { width: 1; height: 26; color: bar.theme.lineSoft } + // Primary operator actions. These are the only visually dominant controls. RibbonAction { theme: bar.theme uiFont: bar.uiFont text: "Start" - large: true tone: bar.session.startReady ? "success" : "neutral" iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") enabled: !bar.workflow.injectionRunning && !bar.session.updatingFirmware && !bar.session.updateNeedsBootloaderHelp @@ -97,7 +99,6 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: "Stop" - large: true tone: bar.workflow.injectionRunning ? "danger" : "neutral" iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") enabled: bar.workflow.injectionRunning && !bar.session.updatingFirmware @@ -105,8 +106,9 @@ Rectangle { onClicked: bar.workflow.requestStop() } - Rectangle { width: 1; height: 30; color: bar.theme.lineSoft } + Rectangle { width: 1; height: 26; color: bar.theme.lineSoft } + // Engineering actions remain available, but no longer compete with Start/Stop. RibbonAction { theme: bar.theme uiFont: bar.uiFont @@ -118,9 +120,10 @@ Rectangle { } RibbonAction { + visible: !bar.compact theme: bar.theme uiFont: bar.uiFont - text: "4I + 4V" + text: "Default 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" @@ -148,58 +151,38 @@ Rectangle { } } - Item { Layout.fillWidth: true; Layout.minimumWidth: 6 } - - RowLayout { - Layout.alignment: Qt.AlignVCenter - spacing: 3 - - 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 the phasor preview" - onClicked: { - if (bar.controller.phasorDetached) bar.controller.phasorDetached = false - bar.controller.phasorDockVisible = checked - } - } + Item { Layout.fillWidth: true; Layout.minimumWidth: 8 } - RibbonAction { - theme: bar.theme - uiFont: bar.uiFont - text: "Wave" - checkable: true - checked: bar.controller.waveformDockVisible || bar.controller.waveformDetached - tone: checked ? "accent" : "neutral" - toolTipText: "Show or hide the waveform preview" - onClicked: { - if (bar.controller.waveformDetached) bar.controller.waveformDetached = false - bar.controller.waveformDockVisible = checked - } + // Secondary view controls are deliberately quiet and compact. + 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: "Monitor" - visible: !bar.compact - checkable: true - checked: bar.controller.telemetryDockVisible && bar.controller.telemetryExpanded - tone: checked ? "accent" : "neutral" - toolTipText: "Open the detailed telemetry monitor" - onClicked: { - bar.controller.telemetryDockVisible = checked - bar.controller.telemetryExpanded = checked - } + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Waveform" + 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 } } - Rectangle { width: 1; height: 30; color: bar.theme.lineSoft } - RibbonAction { theme: bar.theme uiFont: bar.uiFont @@ -210,31 +193,22 @@ Rectangle { onClicked: bar.controller.openConfiguration() } - RibbonAction { - visible: !bar.compact - theme: bar.theme - uiFont: bar.uiFont - text: "Diagnostics" - iconOnly: true - iconSource: Qt.resolvedUrl("../assets/lucide/scan-search.svg") - toolTipText: "Open device diagnostics" - onClicked: bar.controller.openDiagnostics() - } - Rectangle { - Layout.preferredWidth: bar.compact ? 128 : 158 - Layout.preferredHeight: 40 + Layout.preferredWidth: bar.compact ? 122 : 148 + Layout.preferredHeight: 34 Layout.alignment: Qt.AlignVCenter - radius: 8 + radius: 17 color: "#0b1117" border.width: 1 - border.color: bar.workflow.smartStateColor + border.color: Qt.rgba(bar.workflow.smartStateColor.r, + bar.workflow.smartStateColor.g, + bar.workflow.smartStateColor.b, 0.65) RowLayout { anchors.fill: parent anchors.leftMargin: 10 - anchors.rightMargin: 9 - spacing: 8 + anchors.rightMargin: 10 + spacing: 7 Rectangle { width: 7 @@ -243,26 +217,15 @@ Rectangle { color: bar.workflow.smartStateColor } - ColumnLayout { + Label { Layout.fillWidth: true - spacing: 0 - Label { - Layout.fillWidth: true - text: bar.workflow.displayState - color: bar.workflow.smartStateColor - font.family: bar.uiFont - font.pixelSize: 9 - font.weight: Font.DemiBold - elide: Text.ElideRight - } - Label { - Layout.fillWidth: true - text: bar.device.deviceVerified ? bar.device.portName + " · ESP32-P4" : "Device discovery" - color: bar.theme.muted - font.family: bar.uiFont - font.pixelSize: 8 - elide: Text.ElideRight - } + text: bar.workflow.displayState + color: bar.workflow.smartStateColor + font.family: bar.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter } } @@ -275,7 +238,9 @@ Rectangle { onClicked: bar.reconnectOrOpenDevice() } ToolTip.visible: stateMouse.containsMouse - ToolTip.text: bar.session.statusText + ToolTip.text: bar.device.deviceVerified + ? bar.device.portName + " · ESP32-P4\n" + bar.session.statusText + : bar.session.statusText ToolTip.delay: 420 } } From 6ff726a9ecbec113c2c05f54aa44f9e399a511a2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:39:48 +0700 Subject: [PATCH 155/170] ci(studio): stage phase 1A premium shell patch --- .github/scripts/phase1a_premium_shell.py | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/scripts/phase1a_premium_shell.py diff --git a/.github/scripts/phase1a_premium_shell.py b/.github/scripts/phase1a_premium_shell.py new file mode 100644 index 000000000..d73e88247 --- /dev/null +++ b/.github/scripts/phase1a_premium_shell.py @@ -0,0 +1,37 @@ +from pathlib import Path + + +def replace_once(path, old, new, label): + p = Path(path) + text = p.read_text(encoding='utf-8') + if old not in text: + raise SystemExit(f'{label}: expected source block not found') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + +# End-to-end command-bar height follows ModernRibbon's 54 px contract. +replace_once( + 'apps/arstack_studio/qml/WorkflowBar.qml', + ' implicitHeight: 62\n color: "transparent"\n', + ' implicitHeight: 54\n color: "transparent"\n', + 'WorkflowBar compact height') + +replace_once( + 'apps/arstack_studio/qml/Main.qml', + ' Layout.preferredHeight: 62\n Layout.minimumHeight: 62\n', + ' Layout.preferredHeight: 54\n Layout.minimumHeight: 54\n', + 'Main command-bar height') + +# Bottom bar becomes telemetry-only. Session state lives in one place: the command-bar chip. +main = Path('apps/arstack_studio/qml/Main.qml') +text = main.read_text(encoding='utf-8') +old = ''' Rectangle { width: 6; height: 6; radius: 3; color: workflowBar.smartStateColor }\n Label {\n text: device.deviceVerified ? device.portName + " · ESP32-P4" : workflowBar.displayState\n color: device.deviceVerified ? studioTheme.textSoft : workflowBar.smartStateColor\n font.family: root.uiFont\n font.pixelSize: 9\n font.weight: Font.Medium\n }\n Rectangle { width: 1; height: 14; color: studioTheme.lineSoft }\n Label {\n text: workflowBar.injectionRunning ? "SMV output active" : "4I + 4V · 4000 samples/s"\n color: studioTheme.muted\n font.family: root.uiFont\n font.pixelSize: 9\n }\n Item { Layout.fillWidth: true }\n Label {\n visible: device.deviceVerified\n text: device.running\n ? "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures\n : "READY"\n color: (Number(device.missed) > 0 || Number(device.txFailures) > 0) ? studioTheme.amber : studioTheme.textSoft\n font.family: root.monoFont\n font.pixelSize: 9\n font.weight: Font.Medium\n }\n''' +new = ''' Label {\n text: device.deviceVerified ? device.portName + " · ESP32-P4" : "No device"\n color: device.deviceVerified ? studioTheme.textSoft : studioTheme.muted\n font.family: root.uiFont\n font.pixelSize: 9\n font.weight: Font.Medium\n }\n Rectangle { width: 1; height: 12; color: studioTheme.lineSoft }\n Label {\n text: "4I + 4V · 4000 samples/s"\n color: studioTheme.muted\n font.family: root.uiFont\n font.pixelSize: 9\n }\n Item { Layout.fillWidth: true }\n Label {\n visible: device.deviceVerified\n text: "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures\n color: (Number(device.missed) > 0 || Number(device.txFailures) > 0) ? studioTheme.amber : studioTheme.textSoft\n font.family: root.monoFont\n font.pixelSize: 9\n font.weight: Font.Medium\n }\n''' +if old not in text: + raise SystemExit('Main telemetry footer block not found') +text = text.replace(old, new, 1) + +# Remove decorative status words that do not add engineering information. +text = text.replace(' statusText: "GENERATED"\n', ' statusText: ""\n', 1) +main.write_text(text, encoding='utf-8') + +print('Phase 1A premium shell patch applied') From 6cde21e33901f764b3333da87a6312f81324114a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:40:00 +0700 Subject: [PATCH 156/170] ci(studio): apply phase 1A premium shell patch --- .github/workflows/phase1a-premium-shell.yml | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/phase1a-premium-shell.yml diff --git a/.github/workflows/phase1a-premium-shell.yml b/.github/workflows/phase1a-premium-shell.yml new file mode 100644 index 000000000..5c9619629 --- /dev/null +++ b/.github/workflows/phase1a-premium-shell.yml @@ -0,0 +1,46 @@ +name: Phase 1A Premium Shell + +on: + pull_request: + paths: + - 'apps/arstack_studio/qml/**' + - '.github/scripts/phase1a_premium_shell.py' + - '.github/workflows/phase1a-premium-shell.yml' + +permissions: + contents: write + +concurrency: + group: phase1a-premium-shell-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + apply-source: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: macos-15 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + - name: Apply premium shell patch + run: python3 .github/scripts/phase1a_premium_shell.py + - name: Verify presentation invariants + shell: bash + run: | + set -euo pipefail + grep -q 'implicitHeight: 54' apps/arstack_studio/qml/ModernRibbon.qml + grep -q 'implicitHeight: 54' apps/arstack_studio/qml/WorkflowBar.qml + grep -q 'Layout.preferredHeight: 54' apps/arstack_studio/qml/Main.qml + ! grep -q 'statusText: "GENERATED"' apps/arstack_studio/qml/Main.qml + - name: Commit presentation-only source + shell: bash + run: | + set -euo pipefail + git add apps/arstack_studio/qml/WorkflowBar.qml apps/arstack_studio/qml/Main.qml + if git diff --cached --quiet; then exit 0; fi + git config user.name 'ARStack UX' + git config user.email 'actions@users.noreply.github.com' + git commit -m 'feat(studio): simplify premium demo shell' + git push origin HEAD:${{ github.event.pull_request.head.ref }} From 670c4b5e7a246e0b28599a975acdc5a63ed64238 Mon Sep 17 00:00:00 2001 From: ARStack UX Date: Thu, 17 Sep 2026 01:40:45 +0000 Subject: [PATCH 157/170] feat(studio): simplify premium demo shell --- apps/arstack_studio/qml/Main.qml | 19 ++++++++----------- apps/arstack_studio/qml/WorkflowBar.qml | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 9124f6afb..c6c5b6f33 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -443,17 +443,16 @@ ApplicationWindow { anchors.rightMargin: 12 spacing: 8 - Rectangle { width: 6; height: 6; radius: 3; color: workflowBar.smartStateColor } Label { - text: device.deviceVerified ? device.portName + " · ESP32-P4" : workflowBar.displayState - color: device.deviceVerified ? studioTheme.textSoft : workflowBar.smartStateColor + 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: 14; color: studioTheme.lineSoft } + Rectangle { width: 1; height: 12; color: studioTheme.lineSoft } Label { - text: workflowBar.injectionRunning ? "SMV output active" : "4I + 4V · 4000 samples/s" + text: "4I + 4V · 4000 samples/s" color: studioTheme.muted font.family: root.uiFont font.pixelSize: 9 @@ -461,9 +460,7 @@ ApplicationWindow { Item { Layout.fillWidth: true } Label { visible: device.deviceVerified - text: device.running - ? "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures - : "READY" + 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: 9 @@ -603,8 +600,8 @@ ApplicationWindow { WorkflowBar { id: workflowBar Layout.fillWidth: true - Layout.preferredHeight: 62 - Layout.minimumHeight: 62 + Layout.preferredHeight: 54 + Layout.minimumHeight: 54 theme: studioTheme controller: root device: device @@ -838,7 +835,7 @@ ApplicationWindow { SplitView.preferredHeight: 300 theme: studioTheme titleText: "Phasor" - statusText: "GENERATED" + statusText: "" uiFont: root.uiFont monoFont: root.monoFont detachable: true diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index a2302191b..7ad54c4df 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -710,7 +710,7 @@ SurfacePanel { } } - implicitHeight: 62 + implicitHeight: 54 color: "transparent" border.width: 0 From 63a50e23e294ea4437276c1ace4d7b85b0ebe954 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:42:22 +0700 Subject: [PATCH 158/170] chore(studio): remove phase 1A patch scaffold --- .github/scripts/phase1a_premium_shell.py | 37 ------------------------ 1 file changed, 37 deletions(-) delete mode 100644 .github/scripts/phase1a_premium_shell.py diff --git a/.github/scripts/phase1a_premium_shell.py b/.github/scripts/phase1a_premium_shell.py deleted file mode 100644 index d73e88247..000000000 --- a/.github/scripts/phase1a_premium_shell.py +++ /dev/null @@ -1,37 +0,0 @@ -from pathlib import Path - - -def replace_once(path, old, new, label): - p = Path(path) - text = p.read_text(encoding='utf-8') - if old not in text: - raise SystemExit(f'{label}: expected source block not found') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - -# End-to-end command-bar height follows ModernRibbon's 54 px contract. -replace_once( - 'apps/arstack_studio/qml/WorkflowBar.qml', - ' implicitHeight: 62\n color: "transparent"\n', - ' implicitHeight: 54\n color: "transparent"\n', - 'WorkflowBar compact height') - -replace_once( - 'apps/arstack_studio/qml/Main.qml', - ' Layout.preferredHeight: 62\n Layout.minimumHeight: 62\n', - ' Layout.preferredHeight: 54\n Layout.minimumHeight: 54\n', - 'Main command-bar height') - -# Bottom bar becomes telemetry-only. Session state lives in one place: the command-bar chip. -main = Path('apps/arstack_studio/qml/Main.qml') -text = main.read_text(encoding='utf-8') -old = ''' Rectangle { width: 6; height: 6; radius: 3; color: workflowBar.smartStateColor }\n Label {\n text: device.deviceVerified ? device.portName + " · ESP32-P4" : workflowBar.displayState\n color: device.deviceVerified ? studioTheme.textSoft : workflowBar.smartStateColor\n font.family: root.uiFont\n font.pixelSize: 9\n font.weight: Font.Medium\n }\n Rectangle { width: 1; height: 14; color: studioTheme.lineSoft }\n Label {\n text: workflowBar.injectionRunning ? "SMV output active" : "4I + 4V · 4000 samples/s"\n color: studioTheme.muted\n font.family: root.uiFont\n font.pixelSize: 9\n }\n Item { Layout.fillWidth: true }\n Label {\n visible: device.deviceVerified\n text: device.running\n ? "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures\n : "READY"\n color: (Number(device.missed) > 0 || Number(device.txFailures) > 0) ? studioTheme.amber : studioTheme.textSoft\n font.family: root.monoFont\n font.pixelSize: 9\n font.weight: Font.Medium\n }\n''' -new = ''' Label {\n text: device.deviceVerified ? device.portName + " · ESP32-P4" : "No device"\n color: device.deviceVerified ? studioTheme.textSoft : studioTheme.muted\n font.family: root.uiFont\n font.pixelSize: 9\n font.weight: Font.Medium\n }\n Rectangle { width: 1; height: 12; color: studioTheme.lineSoft }\n Label {\n text: "4I + 4V · 4000 samples/s"\n color: studioTheme.muted\n font.family: root.uiFont\n font.pixelSize: 9\n }\n Item { Layout.fillWidth: true }\n Label {\n visible: device.deviceVerified\n text: "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures\n color: (Number(device.missed) > 0 || Number(device.txFailures) > 0) ? studioTheme.amber : studioTheme.textSoft\n font.family: root.monoFont\n font.pixelSize: 9\n font.weight: Font.Medium\n }\n''' -if old not in text: - raise SystemExit('Main telemetry footer block not found') -text = text.replace(old, new, 1) - -# Remove decorative status words that do not add engineering information. -text = text.replace(' statusText: "GENERATED"\n', ' statusText: ""\n', 1) -main.write_text(text, encoding='utf-8') - -print('Phase 1A premium shell patch applied') From 014bffee1b0d0ddc16e0dd81f9f7b268382eb34f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:42:30 +0700 Subject: [PATCH 159/170] chore(studio): remove phase 1A workflow scaffold --- .github/workflows/phase1a-premium-shell.yml | 46 --------------------- 1 file changed, 46 deletions(-) delete mode 100644 .github/workflows/phase1a-premium-shell.yml diff --git a/.github/workflows/phase1a-premium-shell.yml b/.github/workflows/phase1a-premium-shell.yml deleted file mode 100644 index 5c9619629..000000000 --- a/.github/workflows/phase1a-premium-shell.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Phase 1A Premium Shell - -on: - pull_request: - paths: - - 'apps/arstack_studio/qml/**' - - '.github/scripts/phase1a_premium_shell.py' - - '.github/workflows/phase1a-premium-shell.yml' - -permissions: - contents: write - -concurrency: - group: phase1a-premium-shell-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - apply-source: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: macos-15 - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - name: Apply premium shell patch - run: python3 .github/scripts/phase1a_premium_shell.py - - name: Verify presentation invariants - shell: bash - run: | - set -euo pipefail - grep -q 'implicitHeight: 54' apps/arstack_studio/qml/ModernRibbon.qml - grep -q 'implicitHeight: 54' apps/arstack_studio/qml/WorkflowBar.qml - grep -q 'Layout.preferredHeight: 54' apps/arstack_studio/qml/Main.qml - ! grep -q 'statusText: "GENERATED"' apps/arstack_studio/qml/Main.qml - - name: Commit presentation-only source - shell: bash - run: | - set -euo pipefail - git add apps/arstack_studio/qml/WorkflowBar.qml apps/arstack_studio/qml/Main.qml - if git diff --cached --quiet; then exit 0; fi - git config user.name 'ARStack UX' - git config user.email 'actions@users.noreply.github.com' - git commit -m 'feat(studio): simplify premium demo shell' - git push origin HEAD:${{ github.event.pull_request.head.ref }} From 300b5144ecefdbd28ce7de60df7e45eca078fcc0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:43:14 +0700 Subject: [PATCH 160/170] ci(studio): stage phase 1B workspace polish --- .github/scripts/phase1b_workspace_polish.py | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/scripts/phase1b_workspace_polish.py diff --git a/.github/scripts/phase1b_workspace_polish.py b/.github/scripts/phase1b_workspace_polish.py new file mode 100644 index 000000000..164da98bd --- /dev/null +++ b/.github/scripts/phase1b_workspace_polish.py @@ -0,0 +1,51 @@ +from pathlib import Path + + +def patch(path, old, new, label): + p = Path(path) + text = p.read_text(encoding='utf-8') + if old not in text: + raise SystemExit(f'{label}: source block not found') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + +# Signal matrix: remove card-within-card chrome, tighten header and rows, keep all controls/logic intact. +patch('apps/arstack_studio/qml/SignalMatrix.qml', +''' color: theme.surface2\n radius: 8\n border.width: 1\n border.color: theme.lineSoft\n''', +''' color: "#0b1219"\n radius: 8\n border.width: 0\n''', 'matrix outer surface') + +patch('apps/arstack_studio/qml/SignalMatrix.qml', +''' Layout.preferredHeight: 40\n Layout.leftMargin: 12\n Layout.rightMargin: 12\n spacing: 8\n\n Rectangle {\n width: 3\n height: 18\n radius: 2\n color: matrix.theme.accent\n }\n''', +''' Layout.preferredHeight: 36\n Layout.leftMargin: 12\n Layout.rightMargin: 12\n spacing: 8\n''', 'matrix header density') + +patch('apps/arstack_studio/qml/SignalMatrix.qml', +''' Layout.preferredHeight: 25\n Layout.leftMargin: 10\n Layout.rightMargin: 10\n spacing: 7\n''', +''' Layout.preferredHeight: 22\n Layout.leftMargin: 10\n Layout.rightMargin: 10\n spacing: 7\n''', 'matrix column header density') + +patch('apps/arstack_studio/qml/SignalMatrix.qml', +''' Layout.preferredHeight: matrix.compact ? 43 : 45\n color: signalRow.selected ? "#101a24"\n : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#111c27"\n : rowHover.hovered ? "#0f171f" : "transparent"\n''', +''' Layout.preferredHeight: matrix.compact ? 42 : 44\n color: signalRow.selected ? "#101d28"\n : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#0f1922"\n : rowHover.hovered ? "#0d161e" : "transparent"\n''', 'matrix row density') + +# Make the selected phase marker less visually aggressive. +patch('apps/arstack_studio/qml/SignalMatrix.qml', +''' width: 2\n anchors.left: parent.left\n''', +''' width: 2\n opacity: 0.82\n anchors.left: parent.left\n''', 'selected marker opacity') + +# Workspace title and frequency strip: one compact hierarchy, fewer nested-card cues. +patch('apps/arstack_studio/qml/Main.qml', +''' Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold }\n''', +''' Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold }\n''', 'workspace title size') + +patch('apps/arstack_studio/qml/Main.qml', +''' height: 53\n radius: 7\n color: studioTheme.surface2\n border.width: 1\n border.color: studioTheme.lineSoft\n''', +''' height: 48\n radius: 8\n color: "#0b1219"\n border.width: 0\n''', 'frequency strip surface') + +patch('apps/arstack_studio/qml/Main.qml', +''' ColumnLayout {\n spacing: 0\n 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 }\n RowLayout {\n spacing: 5\n NumericField {\n id: frequencyField\n theme: studioTheme\n monoFont: root.monoFont\n compact: root.compactLayout\n implicitWidth: 84\n text: "50.000"\n suffixText: "Hz"\n enabled: true\n validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 }\n onTextEdited: {\n var value = root.parseOperatorNumber(text)\n if (root.validFrequency(value)) {\n invalidInput = false\n root.signalFrequency = value\n if (value > 0) root.previousAcFrequency = value\n root.refreshPreview()\n if (workflowBar.session && workflowBar.session.liveControlReady)\n workflowBar.session.requestSetFrequency(value)\n } else invalidInput = true\n }\n onEditingFinished: {\n var value = root.parseOperatorNumber(text)\n if (!root.validFrequency(value)) {\n text = root.signalFrequency.toFixed(3)\n invalidInput = false\n root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true)\n } else text = value.toFixed(3)\n }\n }\n }\n }\n''', +''' Label {\n text: "Frequency"\n color: studioTheme.textSoft\n font.family: root.uiFont\n font.pixelSize: 10\n font.weight: Font.DemiBold\n verticalAlignment: Text.AlignVCenter\n }\n NumericField {\n id: frequencyField\n theme: studioTheme\n monoFont: root.monoFont\n compact: root.compactLayout\n implicitWidth: 90\n text: "50.000"\n suffixText: "Hz"\n enabled: true\n validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 }\n onTextEdited: {\n var value = root.parseOperatorNumber(text)\n if (root.validFrequency(value)) {\n invalidInput = false\n root.signalFrequency = value\n if (value > 0) root.previousAcFrequency = value\n root.refreshPreview()\n if (workflowBar.session && workflowBar.session.liveControlReady)\n workflowBar.session.requestSetFrequency(value)\n } else invalidInput = true\n }\n onEditingFinished: {\n var value = root.parseOperatorNumber(text)\n if (!root.validFrequency(value)) {\n text = root.signalFrequency.toFixed(3)\n invalidInput = false\n root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true)\n } else text = value.toFixed(3)\n }\n }\n''', 'frequency horizontal hierarchy') + +# Reduce outer workspace padding slightly and keep consistent 8px rhythm. +patch('apps/arstack_studio/qml/Main.qml', +''' anchors.margins: root.compactLayout ? 12 : 15\n spacing: 9\n''', +''' anchors.margins: root.compactLayout ? 11 : 13\n spacing: 8\n''', 'workspace rhythm') + +print('Phase 1B workspace polish applied') From 7cd3b5256dd619d968c58672f2200b9df3931471 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:43:28 +0700 Subject: [PATCH 161/170] ci(studio): apply phase 1B workspace polish --- .../workflows/phase1b-workspace-polish.yml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/phase1b-workspace-polish.yml diff --git a/.github/workflows/phase1b-workspace-polish.yml b/.github/workflows/phase1b-workspace-polish.yml new file mode 100644 index 000000000..509b7c361 --- /dev/null +++ b/.github/workflows/phase1b-workspace-polish.yml @@ -0,0 +1,46 @@ +name: Phase 1B Workspace Polish + +on: + pull_request: + paths: + - 'apps/arstack_studio/qml/**' + - '.github/scripts/phase1b_workspace_polish.py' + - '.github/workflows/phase1b-workspace-polish.yml' + +permissions: + contents: write + +concurrency: + group: phase1b-workspace-polish-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + apply-source: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: macos-15 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + - name: Apply workspace polish + run: python3 .github/scripts/phase1b_workspace_polish.py + - name: Verify presentation-only invariants + shell: bash + run: | + set -euo pipefail + grep -q 'text: "Frequency"' apps/arstack_studio/qml/Main.qml + grep -q 'height: 48' apps/arstack_studio/qml/Main.qml + grep -q 'color: "#0b1219"' apps/arstack_studio/qml/SignalMatrix.qml + grep -q 'Layout.preferredHeight: matrix.compact ? 42 : 44' apps/arstack_studio/qml/SignalMatrix.qml + - name: Commit presentation-only source + shell: bash + run: | + set -euo pipefail + git add apps/arstack_studio/qml/Main.qml apps/arstack_studio/qml/SignalMatrix.qml + if git diff --cached --quiet; then exit 0; fi + git config user.name 'ARStack UX' + git config user.email 'actions@users.noreply.github.com' + git commit -m 'feat(studio): polish injection workspace for demo' + git push origin HEAD:${{ github.event.pull_request.head.ref }} From 1c0d5de4b4b59bd8d7139f4750a6a37fd774ec89 Mon Sep 17 00:00:00 2001 From: ARStack UX Date: Thu, 17 Sep 2026 01:43:55 +0000 Subject: [PATCH 162/170] feat(studio): polish injection workspace for demo --- apps/arstack_studio/qml/Main.qml | 86 ++++++++++++------------ apps/arstack_studio/qml/SignalMatrix.qml | 25 +++---- 2 files changed, 52 insertions(+), 59 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c6c5b6f33..775ac6adf 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -657,25 +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: "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 @@ -683,41 +682,42 @@ ApplicationWindow { anchors.rightMargin: 11 spacing: 8 - 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: 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) - } - } + 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) } } diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 0d50779e5..916eb5a4d 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -18,10 +18,9 @@ Rectangle { property string unitText: "A RMS" property bool compact: false - color: theme.surface2 + color: "#0b1219" radius: 8 - border.width: 1 - border.color: theme.lineSoft + border.width: 0 function phaseColorFor(index) { return [theme.phaseA, theme.phaseB, theme.phaseC, theme.phaseN][index] @@ -42,17 +41,10 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 40 + Layout.preferredHeight: 36 Layout.leftMargin: 12 Layout.rightMargin: 12 spacing: 8 - - Rectangle { - width: 3 - height: 18 - radius: 2 - color: matrix.theme.accent - } Label { text: matrix.titleText color: matrix.theme.text @@ -76,7 +68,7 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 25 + Layout.preferredHeight: 22 Layout.leftMargin: 10 Layout.rightMargin: 10 spacing: 7 @@ -132,10 +124,10 @@ Rectangle { property real pendingPhase: angle Layout.fillWidth: true - Layout.preferredHeight: matrix.compact ? 43 : 45 - color: signalRow.selected ? "#101a24" - : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#111c27" - : rowHover.hovered ? "#0f171f" : "transparent" + Layout.preferredHeight: matrix.compact ? 42 : 44 + color: signalRow.selected ? "#101d28" + : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#0f1922" + : rowHover.hovered ? "#0d161e" : "transparent" Behavior on color { ColorAnimation { duration: 90 } } HoverHandler { id: rowHover } @@ -159,6 +151,7 @@ Rectangle { Rectangle { visible: signalRow.selected width: 2 + opacity: 0.82 anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom From 718169e3e11bf8ab00fc45bd935c5ce34406adcc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:44:21 +0700 Subject: [PATCH 163/170] chore(studio): remove phase 1B patch scaffold --- .github/scripts/phase1b_workspace_polish.py | 51 --------------------- 1 file changed, 51 deletions(-) delete mode 100644 .github/scripts/phase1b_workspace_polish.py diff --git a/.github/scripts/phase1b_workspace_polish.py b/.github/scripts/phase1b_workspace_polish.py deleted file mode 100644 index 164da98bd..000000000 --- a/.github/scripts/phase1b_workspace_polish.py +++ /dev/null @@ -1,51 +0,0 @@ -from pathlib import Path - - -def patch(path, old, new, label): - p = Path(path) - text = p.read_text(encoding='utf-8') - if old not in text: - raise SystemExit(f'{label}: source block not found') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - -# Signal matrix: remove card-within-card chrome, tighten header and rows, keep all controls/logic intact. -patch('apps/arstack_studio/qml/SignalMatrix.qml', -''' color: theme.surface2\n radius: 8\n border.width: 1\n border.color: theme.lineSoft\n''', -''' color: "#0b1219"\n radius: 8\n border.width: 0\n''', 'matrix outer surface') - -patch('apps/arstack_studio/qml/SignalMatrix.qml', -''' Layout.preferredHeight: 40\n Layout.leftMargin: 12\n Layout.rightMargin: 12\n spacing: 8\n\n Rectangle {\n width: 3\n height: 18\n radius: 2\n color: matrix.theme.accent\n }\n''', -''' Layout.preferredHeight: 36\n Layout.leftMargin: 12\n Layout.rightMargin: 12\n spacing: 8\n''', 'matrix header density') - -patch('apps/arstack_studio/qml/SignalMatrix.qml', -''' Layout.preferredHeight: 25\n Layout.leftMargin: 10\n Layout.rightMargin: 10\n spacing: 7\n''', -''' Layout.preferredHeight: 22\n Layout.leftMargin: 10\n Layout.rightMargin: 10\n spacing: 7\n''', 'matrix column header density') - -patch('apps/arstack_studio/qml/SignalMatrix.qml', -''' Layout.preferredHeight: matrix.compact ? 43 : 45\n color: signalRow.selected ? "#101a24"\n : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#111c27"\n : rowHover.hovered ? "#0f171f" : "transparent"\n''', -''' Layout.preferredHeight: matrix.compact ? 42 : 44\n color: signalRow.selected ? "#101d28"\n : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#0f1922"\n : rowHover.hovered ? "#0d161e" : "transparent"\n''', 'matrix row density') - -# Make the selected phase marker less visually aggressive. -patch('apps/arstack_studio/qml/SignalMatrix.qml', -''' width: 2\n anchors.left: parent.left\n''', -''' width: 2\n opacity: 0.82\n anchors.left: parent.left\n''', 'selected marker opacity') - -# Workspace title and frequency strip: one compact hierarchy, fewer nested-card cues. -patch('apps/arstack_studio/qml/Main.qml', -''' Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold }\n''', -''' Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold }\n''', 'workspace title size') - -patch('apps/arstack_studio/qml/Main.qml', -''' height: 53\n radius: 7\n color: studioTheme.surface2\n border.width: 1\n border.color: studioTheme.lineSoft\n''', -''' height: 48\n radius: 8\n color: "#0b1219"\n border.width: 0\n''', 'frequency strip surface') - -patch('apps/arstack_studio/qml/Main.qml', -''' ColumnLayout {\n spacing: 0\n 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 }\n RowLayout {\n spacing: 5\n NumericField {\n id: frequencyField\n theme: studioTheme\n monoFont: root.monoFont\n compact: root.compactLayout\n implicitWidth: 84\n text: "50.000"\n suffixText: "Hz"\n enabled: true\n validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 }\n onTextEdited: {\n var value = root.parseOperatorNumber(text)\n if (root.validFrequency(value)) {\n invalidInput = false\n root.signalFrequency = value\n if (value > 0) root.previousAcFrequency = value\n root.refreshPreview()\n if (workflowBar.session && workflowBar.session.liveControlReady)\n workflowBar.session.requestSetFrequency(value)\n } else invalidInput = true\n }\n onEditingFinished: {\n var value = root.parseOperatorNumber(text)\n if (!root.validFrequency(value)) {\n text = root.signalFrequency.toFixed(3)\n invalidInput = false\n root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true)\n } else text = value.toFixed(3)\n }\n }\n }\n }\n''', -''' Label {\n text: "Frequency"\n color: studioTheme.textSoft\n font.family: root.uiFont\n font.pixelSize: 10\n font.weight: Font.DemiBold\n verticalAlignment: Text.AlignVCenter\n }\n NumericField {\n id: frequencyField\n theme: studioTheme\n monoFont: root.monoFont\n compact: root.compactLayout\n implicitWidth: 90\n text: "50.000"\n suffixText: "Hz"\n enabled: true\n validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 }\n onTextEdited: {\n var value = root.parseOperatorNumber(text)\n if (root.validFrequency(value)) {\n invalidInput = false\n root.signalFrequency = value\n if (value > 0) root.previousAcFrequency = value\n root.refreshPreview()\n if (workflowBar.session && workflowBar.session.liveControlReady)\n workflowBar.session.requestSetFrequency(value)\n } else invalidInput = true\n }\n onEditingFinished: {\n var value = root.parseOperatorNumber(text)\n if (!root.validFrequency(value)) {\n text = root.signalFrequency.toFixed(3)\n invalidInput = false\n root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true)\n } else text = value.toFixed(3)\n }\n }\n''', 'frequency horizontal hierarchy') - -# Reduce outer workspace padding slightly and keep consistent 8px rhythm. -patch('apps/arstack_studio/qml/Main.qml', -''' anchors.margins: root.compactLayout ? 12 : 15\n spacing: 9\n''', -''' anchors.margins: root.compactLayout ? 11 : 13\n spacing: 8\n''', 'workspace rhythm') - -print('Phase 1B workspace polish applied') From 16365e82aeb53773503afe58632bb8b08b93532b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:44:27 +0700 Subject: [PATCH 164/170] chore(studio): remove phase 1B workflow scaffold --- .../workflows/phase1b-workspace-polish.yml | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 .github/workflows/phase1b-workspace-polish.yml diff --git a/.github/workflows/phase1b-workspace-polish.yml b/.github/workflows/phase1b-workspace-polish.yml deleted file mode 100644 index 509b7c361..000000000 --- a/.github/workflows/phase1b-workspace-polish.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Phase 1B Workspace Polish - -on: - pull_request: - paths: - - 'apps/arstack_studio/qml/**' - - '.github/scripts/phase1b_workspace_polish.py' - - '.github/workflows/phase1b-workspace-polish.yml' - -permissions: - contents: write - -concurrency: - group: phase1b-workspace-polish-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - apply-source: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: macos-15 - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - name: Apply workspace polish - run: python3 .github/scripts/phase1b_workspace_polish.py - - name: Verify presentation-only invariants - shell: bash - run: | - set -euo pipefail - grep -q 'text: "Frequency"' apps/arstack_studio/qml/Main.qml - grep -q 'height: 48' apps/arstack_studio/qml/Main.qml - grep -q 'color: "#0b1219"' apps/arstack_studio/qml/SignalMatrix.qml - grep -q 'Layout.preferredHeight: matrix.compact ? 42 : 44' apps/arstack_studio/qml/SignalMatrix.qml - - name: Commit presentation-only source - shell: bash - run: | - set -euo pipefail - git add apps/arstack_studio/qml/Main.qml apps/arstack_studio/qml/SignalMatrix.qml - if git diff --cached --quiet; then exit 0; fi - git config user.name 'ARStack UX' - git config user.email 'actions@users.noreply.github.com' - git commit -m 'feat(studio): polish injection workspace for demo' - git push origin HEAD:${{ github.event.pull_request.head.ref }} From e3937388c64f7cfba7279875a7250ad4dae24390 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 08:45:04 +0700 Subject: [PATCH 165/170] feat(studio): tighten premium demo density tokens --- apps/arstack_studio/qml/StudioTheme.qml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml index f4f36966b..3f26d8c3d 100644 --- a/apps/arstack_studio/qml/StudioTheme.qml +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -35,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 } From cdda63227329298b72537046c7df902b5b87e98d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:44:23 +0700 Subject: [PATCH 166/170] ui(studio): refine premium command bar hierarchy --- apps/arstack_studio/qml/ModernRibbon.qml | 121 +++++++++++------------ 1 file changed, 58 insertions(+), 63 deletions(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 27f1ce178..31f809305 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -14,11 +14,10 @@ Rectangle { property string uiFont: "Inter" property bool compact: false - implicitHeight: 54 - radius: 9 - color: theme.chrome - border.width: 1 - border.color: theme.lineSoft + implicitHeight: 52 + radius: 0 + color: "transparent" + border.width: 0 function reconnectOrOpenDevice() { if (device.deviceVerified) @@ -27,63 +26,61 @@ Rectangle { 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: 8 + spacing: 6 - // Product identity is intentionally compact. The command bar is a tool, - // not a second title bar. RowLayout { - Layout.preferredWidth: bar.compact ? 132 : 158 + Layout.preferredWidth: bar.compact ? 112 : 132 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - width: 30 - height: 30 + width: 28 + height: 28 radius: 7 - color: "#0f1821" + color: "#0d151d" border.width: 1 - border.color: "#27445d" + border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 16 - height: 16 + width: 15 + height: 15 source: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") - sourceSize.width: 32 - sourceSize.height: 32 + sourceSize.width: 30 + sourceSize.height: 30 } } - ColumnLayout { + Label { Layout.fillWidth: true - spacing: -1 - Label { - Layout.fillWidth: true - text: "ARStack" - color: bar.theme.text - font.family: bar.uiFont - font.pixelSize: 12 - font.weight: Font.DemiBold - elide: Text.ElideRight - } - Label { - Layout.fillWidth: true - text: "SMV + PTP" - color: bar.theme.muted - font.family: bar.uiFont - font.pixelSize: 8 - font.weight: Font.Medium - elide: Text.ElideRight - } + 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: 26; color: bar.theme.lineSoft } + Rectangle { width: 1; height: 24; color: bar.theme.lineSoft } - // Primary operator actions. These are the only visually dominant controls. RibbonAction { theme: bar.theme uiFont: bar.uiFont @@ -106,9 +103,8 @@ Rectangle { onClicked: bar.workflow.requestStop() } - Rectangle { width: 1; height: 26; color: bar.theme.lineSoft } + Rectangle { width: 1; height: 24; color: bar.theme.lineSoft } - // Engineering actions remain available, but no longer compete with Start/Stop. RibbonAction { theme: bar.theme uiFont: bar.uiFont @@ -123,7 +119,7 @@ Rectangle { visible: !bar.compact theme: bar.theme uiFont: bar.uiFont - text: "Default 4I+4V" + 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" @@ -131,29 +127,25 @@ Rectangle { } RibbonAction { - visible: bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable || - bar.session.firmwareInstallRequired || bar.session.profileSyncRetryAvailable + 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" - : bar.session.firmwareUpdateAvailable ? "Update available" : "Retry profile" toolTipText: bar.session.firmwareInstallRequired ? "Install the bundled ARStack firmware" : bar.session.firmwareUpdateRequired ? "Firmware update is required before injection" - : bar.session.firmwareUpdateAvailable ? "A compatible newer firmware build is available" : "Retry the 4I + 4V profile synchronization" onClicked: { if (bar.session.firmwareInstallRequired) bar.workflow.openInstallPrompt() - else if (bar.session.firmwareUpdateRequired || bar.session.firmwareUpdateAvailable) bar.workflow.openUpdatePrompt() + else if (bar.session.firmwareUpdateRequired) bar.workflow.openUpdatePrompt() else bar.workflow.retryProfileSync() } } Item { Layout.fillWidth: true; Layout.minimumWidth: 8 } - // Secondary view controls are deliberately quiet and compact. RibbonAction { theme: bar.theme uiFont: bar.uiFont @@ -171,7 +163,7 @@ Rectangle { RibbonAction { theme: bar.theme uiFont: bar.uiFont - text: "Waveform" + text: "Wave" visible: !bar.compact checkable: true checked: bar.controller.waveformDockVisible || bar.controller.waveformDetached @@ -186,40 +178,43 @@ Rectangle { RibbonAction { theme: bar.theme uiFont: bar.uiFont - text: "Advanced" + text: bar.compact ? "" : "Advanced" iconOnly: bar.compact iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") - toolTipText: "Advanced device, firmware, waveform and timing controls" + 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 ? 122 : 148 - Layout.preferredHeight: 34 + Layout.preferredWidth: bar.compact ? 92 : 112 + Layout.preferredHeight: 30 Layout.alignment: Qt.AlignVCenter - radius: 17 - color: "#0b1117" + radius: 15 + color: "#0a1016" border.width: 1 border.color: Qt.rgba(bar.workflow.smartStateColor.r, bar.workflow.smartStateColor.g, - bar.workflow.smartStateColor.b, 0.65) + bar.workflow.smartStateColor.b, 0.48) RowLayout { anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 7 + anchors.leftMargin: 9 + anchors.rightMargin: 9 + spacing: 6 Rectangle { - width: 7 - height: 7 - radius: 4 + width: 6 + height: 6 + radius: 3 color: bar.workflow.smartStateColor } Label { Layout.fillWidth: true - text: bar.workflow.displayState + text: bar.conciseState() color: bar.workflow.smartStateColor font.family: bar.uiFont font.pixelSize: 9 From 93b92041acd94e26cd0f9ed5c63db2af3ea971c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:44:37 +0700 Subject: [PATCH 167/170] ui(studio): tighten command action geometry --- apps/arstack_studio/qml/RibbonAction.qml | 36 ++++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index 2f0b99517..5826cd4f7 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -14,33 +14,33 @@ Button { property bool iconOnly: false property string toolTipText: "" - implicitWidth: iconOnly ? 36 : large ? Math.max(76, label.implicitWidth + 42) : Math.max(58, label.implicitWidth + (icon.visible ? 36 : 22)) - implicitHeight: large ? 40 : 34 + 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 : 10 + font.pixelSize: large ? 11 : 9 font.weight: Font.DemiBold readonly property color fillColor: !enabled ? "transparent" - : tone === "success" ? (hovered ? "#1b4b39" : "#163c2f") - : tone === "danger" ? (hovered ? "#4a2830" : "#392127") - : tone === "accent" ? (hovered ? "#173654" : "#142b42") - : tone === "warning" ? (hovered ? "#46391f" : "#362d1c") - : checked ? theme.accentSoft + : 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" ? "#2e7254" - : tone === "danger" ? "#7a3d47" - : tone === "accent" ? "#315f8d" - : tone === "warning" ? "#7b6231" - : checked ? "#315f8d" + : 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" ? "#f2d79c" + : tone === "warning" ? "#f0d49a" : checked ? theme.text : theme.textSoft contentItem: Item { @@ -50,17 +50,17 @@ Button { RowLayout { id: row anchors.centerIn: parent - spacing: control.iconOnly ? 0 : 7 + spacing: control.iconOnly ? 0 : 6 Image { id: icon visible: control.iconSource.toString().length > 0 source: control.iconSource - Layout.preferredWidth: control.large ? 17 : 15 + Layout.preferredWidth: control.large ? 17 : 14 Layout.preferredHeight: Layout.preferredWidth sourceSize.width: Layout.preferredWidth * 2 sourceSize.height: Layout.preferredHeight * 2 - opacity: control.enabled ? 0.96 : 0.30 + opacity: control.enabled ? 0.92 : 0.28 } Text { @@ -77,7 +77,7 @@ Button { } background: Rectangle { - radius: 7 + radius: 6 color: control.fillColor border.width: control.edgeColor === "transparent" ? 0 : 1 border.color: control.edgeColor From 692925c8ece30c3d5012c57c13f8596b86ff782a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:44:56 +0700 Subject: [PATCH 168/170] ui(studio): reduce preview dock chrome --- apps/arstack_studio/qml/DockFrame.qml | 49 +++++++++------------------ 1 file changed, 16 insertions(+), 33 deletions(-) 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" } } From f6981ae2277ca44ed58a447146769c547ff41e1d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:45:23 +0700 Subject: [PATCH 169/170] ui(studio): refine signal matrix density and alignment --- apps/arstack_studio/qml/SignalMatrix.qml | 82 +++++++++++++----------- 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 916eb5a4d..f00843460 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -18,9 +18,10 @@ Rectangle { property string unitText: "A RMS" property bool compact: false - color: "#0b1219" - radius: 8 - border.width: 0 + color: matrix.theme.surface2 + radius: 7 + border.width: 1 + border.color: matrix.theme.lineSoft function phaseColorFor(index) { return [theme.phaseA, theme.phaseB, theme.phaseC, theme.phaseN][index] @@ -41,15 +42,15 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 36 - Layout.leftMargin: 12 - Layout.rightMargin: 12 - spacing: 8 + 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 } @@ -58,7 +59,7 @@ Rectangle { text: matrix.unitText color: matrix.theme.muted font.family: matrix.uiFont - font.pixelSize: 9 + font.pixelSize: 8 font.weight: Font.Medium verticalAlignment: Text.AlignVCenter } @@ -68,36 +69,36 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 22 - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 7 - Item { Layout.preferredWidth: 26 } + Layout.preferredHeight: 20 + Layout.leftMargin: 9 + Layout.rightMargin: 9 + spacing: 6 + Item { Layout.preferredWidth: 25 } Label { - text: "Channel" - Layout.preferredWidth: 50 - color: matrix.theme.muted - font.family: matrix.uiFont - font.pixelSize: 8 + 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: "Value" + text: "MAGNITUDE" Layout.fillWidth: true - color: matrix.theme.muted + color: matrix.theme.muted2 font.family: matrix.uiFont - font.pixelSize: 8 + font.pixelSize: 7 font.weight: Font.DemiBold horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } Label { - text: "Phase" - Layout.preferredWidth: matrix.compact ? 88 : 104 - color: matrix.theme.muted + text: "ANGLE" + Layout.preferredWidth: matrix.compact ? 84 : 100 + color: matrix.theme.muted2 font.family: matrix.uiFont - font.pixelSize: 8 + font.pixelSize: 7 font.weight: Font.DemiBold horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -124,8 +125,8 @@ Rectangle { property real pendingPhase: angle Layout.fillWidth: true - Layout.preferredHeight: matrix.compact ? 42 : 44 - color: signalRow.selected ? "#101d28" + 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 } } @@ -151,7 +152,7 @@ Rectangle { Rectangle { visible: signalRow.selected width: 2 - opacity: 0.82 + opacity: 0.86 anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom @@ -160,12 +161,12 @@ Rectangle { RowLayout { anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 7 + anchors.leftMargin: 9 + anchors.rightMargin: 9 + spacing: 6 CheckBox { - Layout.preferredWidth: 26 + Layout.preferredWidth: 25 Layout.alignment: Qt.AlignVCenter checked: signalRow.channelEnabled ToolTip.visible: hovered @@ -180,10 +181,10 @@ Rectangle { } RowLayout { - Layout.preferredWidth: 50 + Layout.preferredWidth: 46 Layout.alignment: Qt.AlignVCenter - spacing: 7 - Rectangle { width: 7; height: 7; radius: 4; color: signalRow.phaseColor } + spacing: 6 + Rectangle { width: 6; height: 6; radius: 3; color: signalRow.phaseColor } Label { text: signalRow.sid color: signalRow.selected ? matrix.theme.text : matrix.theme.textSoft @@ -199,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 @@ -246,10 +247,10 @@ Rectangle { NumericField { id: phaseField enabled: matrix.controller.signalFrequency > 0 - Layout.preferredWidth: matrix.compact ? 88 : 104 + 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 @@ -295,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 From bad404151d04445b67500da4737ab73fdcf44fd4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:45:39 +0700 Subject: [PATCH 170/170] ui(studio): reduce quality strip visual noise --- apps/arstack_studio/qml/QualityEditor.qml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 } }