Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
65b400c
fix(esp32): make Ethernet link an explicit publisher lifecycle input
masarray Oct 25, 2026
783302b
fix(esp32): atomic LAN START gate, fail-closed link/driver STOP and a…
masarray Oct 25, 2026
5959602
fix(esp32): handle driver STOP and link fault using nonblocking board…
masarray Oct 25, 2026
44c1ac8
fix(contract): align Studio, CI and bundle identity with autonomous S…
masarray Oct 25, 2026
4a9ce9c
fix(contract): align Studio, CI and bundle identity with autonomous S…
masarray Oct 25, 2026
65d2011
fix(contract): align Studio, CI and bundle identity with autonomous S…
masarray Oct 25, 2026
fbce4b4
fix(contract): align Studio, CI and bundle identity with autonomous S…
masarray Oct 25, 2026
a86990d
fix(contract): align Studio, CI and bundle identity with autonomous S…
masarray Oct 25, 2026
9c56cc3
fix(contract): align Studio, CI and bundle identity with autonomous S…
masarray Oct 25, 2026
684e25d
fix(contract): align Studio, CI and bundle identity with autonomous S…
masarray Oct 25, 2026
747c0bd
fix(contract): align Studio, CI and bundle identity with autonomous S…
masarray Oct 25, 2026
a2511df
feat(esp32): expose generation-checked 4I4V readback for Studio recon…
masarray Oct 25, 2026
41119f7
feat(studio): add verified board-scenario snapshot state for reconnect
masarray Oct 25, 2026
043aa2a
feat(studio): atomically adopt board 4I4V snapshot by boot and genera…
masarray Oct 25, 2026
a08b4db
feat(studio): read-only 4I4V editor reconciliation on autonomous boar…
masarray Oct 25, 2026
6860b5c
test(studio): verify autonomous running-board adoption and coherent s…
masarray Oct 25, 2026
75db29f
test(p0): guard PHY/driver STOP, atomic START, capability and coheren…
masarray Oct 25, 2026
c8e7f4d
fix(esp32): preserve early Ethernet CONNECTED callback across control…
masarray Oct 25, 2026
b3c5196
test(studio): reject legacy SESSION-LEASE boards despite otherwise ma…
masarray Oct 25, 2026
e8368b0
test(esp32): prevent LINK UP overwrite by late console initialization
masarray Oct 25, 2026
ec7a5e2
fix(reconnect): include CT waveform shape in coherent SVBANK readback
masarray Oct 25, 2026
b71a0af
fix(studio): retain CT shaping within coherent board snapshot
masarray Oct 25, 2026
a7c0d4d
fix(studio): validate CT waveform shaping as part of same-boot SVBANK…
masarray Oct 25, 2026
e6d22bc
fix(studio): adopt complete CT shaping on autonomous reconnect, preve…
masarray Oct 25, 2026
8b1b48c
test(studio): verify reconnect retains CT waveform shaping with eight…
masarray Oct 25, 2026
88f662c
test(p0): guard coherent CT saturation shape adoption across reconnect
masarray Oct 25, 2026
2231ac3
fix(ci): test public baseline against its own firmware and candidate …
masarray Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions .github/workflows/arstack-studio-native-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ jobs:
$manifest.broadcastMirrorEnabled -ne $false) {
throw 'FAT preview requires simulated Global GM BOOT + PTP-source autostart + measured SV phase/MAC evidence, never LOCAL-only firmware'
}
$expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SESSION-LEASE', 'PTP-P2', 'SMPSYNCH-AUTO')
$expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SV-LAN-AUTONOMY-V1', 'PTP-P2', 'SMPSYNCH-AUTO')
foreach ($capability in $expectedCapabilities) {
if ($manifest.capabilities -notcontains $capability) { throw "PR firmware lacks capability: $capability" }
}
Expand All @@ -137,30 +137,41 @@ jobs:
if ((Get-FileHash -Algorithm SHA256 -LiteralPath $image).Hash.ToLowerInvariant() -ne $manifest.sha256.ToLowerInvariant()) {
throw 'Matching PR firmware image SHA-256 mismatch'
}
$env:ARSTACK_STUDIO_FIRMWARE_DIR = $newFirmware
$env:ARSTACK_STUDIO_ESPFLASH = Join-Path $baseline 'tools\espflash.exe'
$native = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe'
$logDir = Join-Path $env:RUNNER_TEMP 'studio-firmware-diagnostics'
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
$baselineStdErr = Join-Path $logDir 'baseline.stderr.log'
$baselineStdOut = Join-Path $logDir 'baseline.stdout.log'

# Validate each immutable/verified pair in isolation. The old GUI
# knows the original v0.1.1 contract, NOT SV-LAN-AUTONOMY-V1.
# Do not weaken either contract to make the old executable accept
# the new firmware. Both checks remain fail-closed.
$env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $baseline 'firmware'
$baselineContract = Start-Process -FilePath $legacyGui -ArgumentList '--check-firmware-contract' -Wait -PassThru -RedirectStandardError $baselineStdErr -RedirectStandardOutput $baselineStdOut
Write-Host "PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($baselineContract.ExitCode)"
Write-Host "PUBLIC_BASELINE_WITH_ORIGINAL_FIRMWARE_CONTRACT_EXIT=$($baselineContract.ExitCode)"
Get-Content $baselineStdErr -ErrorAction SilentlyContinue
if ($baselineContract.ExitCode -ne 0) { throw 'Pinned public v0.1.1 GUI cannot validate its own firmware' }
if ($baselineContract.ExitCode -ne 0) {
throw 'Pinned public v0.1.1 GUI cannot validate its own original firmware'
}

# The new Studio is strictly paired with firmware from this PR HEAD.
# Firmware version, capability, source SHA, and image SHA-256 were
# checked above; the application's runtime manifest validation is
# still independently mandatory here and after packaging.
$env:ARSTACK_STUDIO_FIRMWARE_DIR = $newFirmware
$nativeStdErr = Join-Path $logDir 'native.stderr.log'
$nativeStdOut = Join-Path $logDir 'native.stdout.log'
$firmwareContract = Start-Process -FilePath $native -ArgumentList '--check-firmware-contract' -Wait -PassThru -RedirectStandardError $nativeStdErr -RedirectStandardOutput $nativeStdOut
Write-Host "NATIVE_WITH_PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($firmwareContract.ExitCode)"
Write-Host "NATIVE_WITH_MATCHED_PR_FIRMWARE_CONTRACT_EXIT=$($firmwareContract.ExitCode)"
Get-Content $nativeStdErr -ErrorAction SilentlyContinue
if ($firmwareContract.ExitCode -ne 0) {
$manifest = Get-Content -Raw (Join-Path $baseline 'firmware\firmware-manifest.json') | ConvertFrom-Json
Write-Host "PUBLIC_FIRMWARE_MANIFEST_VERSION=$($manifest.version) PROTOCOL=$($manifest.protocol) IMAGE=$($manifest.image)"
$file = Join-Path (Join-Path $baseline 'firmware') $manifest.image
Write-Host "PUBLIC_FIRMWARE_IMAGE_EXISTS=$(Test-Path $file) SHA_MATCH=$((Test-Path $file) -and ((Get-FileHash -Algorithm SHA256 $file).Hash.ToLowerInvariant() -eq $manifest.sha256.ToLowerInvariant())) FLASHER_EXISTS=$(Test-Path $env:ARSTACK_STUDIO_ESPFLASH)"
throw 'New native GUI disagrees with public firmware bundle; see stdout/stderr above'
Write-Host "PR_FIRMWARE_SOURCE_COMMIT=$($manifest.sourceCommit) CAPABILITIES=$($manifest.capabilities -join ',')"
Write-Host "PR_FIRMWARE_IMAGE_EXISTS=$(Test-Path $image) FLASHER_EXISTS=$(Test-Path $env:ARSTACK_STUDIO_ESPFLASH)"
throw 'New Studio rejected its matched PR firmware; inspect native stderr'
}
Write-Host 'Pinned public v0.1.1 flasher source + matched PR-head firmware: VERIFIED'
Write-Host 'Verified original public flasher + independently validated matched PR-head Studio/firmware: PASS'

- name: Stage isolated GUI preview with pinned release firmware
shell: pwsh
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/arstack-studio-qt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ jobs:
"version": "0.1.1",
"sourceCommit": "0123456789abcdef0123456789abcdef01234567",
"protocol": 1,
"capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],
"capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SV-LAN-AUTONOMY-V1", "PTP-P2", "SMPSYNCH-AUTO"],
"flashOffset": 0,
"image": "arstack-esp32p4-smv-0.1.1.bin",
"sha256": "$sha"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/esp32p4-smv-injector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ jobs:
"chipRevisionPolicy": "pre-v3",
"version": os.environ["ARSTACK_FIRMWARE_VERSION"],
"protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),
"capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],
"capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SV-LAN-AUTONOMY-V1", "PTP-P2", "SMPSYNCH-AUTO"],
"flashOffset": 0,
"image": image,
"sha256": sha256,
Expand Down
81 changes: 79 additions & 2 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,76 @@ ApplicationWindow {
interval: 400
onTriggered: scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot())
}
function adoptRunningBoardScenario(snapshot) {
if (!device.deviceVerified || !device.running ||
device.scenarioApplying || reconnectScenarioRevision < 0 ||
reconnectScenarioRevision !== scenarioRevision ||
restoringScenario || !snapshot || snapshot.boot !== device.bootId ||
!snapshot.current || snapshot.current.length !== 4 ||
!snapshot.voltage || snapshot.voltage.length !== 4)
return
if (typeof snapshot.hz !== "number" || !validFrequency(snapshot.hz))
return
var ct = snapshot.ct
// The complete board scenario includes waveform shaping. Omitting CT
// would make the next user edit silently overwrite live CT saturation.
if (!ct || typeof ct.enabled !== "boolean" ||
typeof ct.dc !== "number" || typeof ct.harmonic !== "number" ||
typeof ct.order !== "number" || typeof ct.clip !== "number" ||
!isFinite(ct.dc) || !isFinite(ct.harmonic) || !isFinite(ct.clip) ||
ct.dc < -300 || ct.dc > 300 ||
ct.harmonic < 0 || ct.harmonic > 300 ||
ct.order < 2 || ct.order > 63 || ct.order !== Math.floor(ct.order) ||
ct.clip < 1 || ct.clip > 1000 ||
(ct.enabled && snapshot.hz === 0))
return
var groups = [snapshot.current, snapshot.voltage]
var scales = [currentScale, voltageScale]
for (var group = 0; group < 2; ++group) {
for (var i = 0; i < 4; ++i) {
var channel = groups[group][i]
if (!channel || typeof channel.counts !== "number" ||
typeof channel.angleMdeg !== "number" ||
typeof channel.quality !== "number" ||
typeof channel.enabled !== "boolean" ||
!isFinite(channel.counts) || !isFinite(channel.angleMdeg) ||
!isFinite(channel.quality))
return
}
}
restoringScenario = true
for (var g = 0; g < 2; ++g) {
var model = g === 0 ? currentModel : voltageModel
for (var row = 0; row < 4; ++row) {
var b = groups[g][row]
model.setProperty(row, "magnitude", b.counts / scales[g])
model.setProperty(row, "phase", b.angleMdeg / 1000.0)
model.setProperty(row, "quality", b.quality)
model.setProperty(row, "enabled", b.enabled)
}
}
signalFrequency = snapshot.hz
ctSaturationEnabled = ct.enabled
ctDcOffsetPercent = ct.dc
ctHarmonicPercent = ct.harmonic
ctHarmonicOrder = ct.order
ctClipPercent = ct.clip
previousAcFrequency = signalFrequency > 0 ? signalFrequency : 50
frequencyField.text = signalFrequency.toFixed(3)
// Board phase angles are authoritative, not the locally persisted
// three-phase editor coupling preference.
phaseLink = false
selectSignal(activeGroup, activeRow)
refreshPreview()
restoringScenario = false
autoLiveQueuedRevision = -1
autoLiveApplyTimer.stop()
pendingApply = false
reconnectScenarioRevision = -1
// Reconnection is a read-only adoption, NEVER a CFG, START or STOP.
root.showMessage("4I+4V values synchronized from the running ESP32-P4.", false)
Comment thread
masarray marked this conversation as resolved.
}

function scenarioSnapshot() {
function values(model) {
var rows = []
Expand Down Expand Up @@ -161,6 +231,8 @@ ApplicationWindow {
property int scenarioRevision: 0
property int liveSubmissionRevision: -1
property int autoLiveQueuedRevision: -1
// Only adopt a complete same-boot bank if no operator edit intervened.
property int reconnectScenarioRevision: -1
// Session-only operator intent. Never persist across application launches:
// an explicit PTP ON can be restored after a firmware reflash/reconnect
// without introducing an uncontrolled power-on publisher.
Expand Down Expand Up @@ -200,8 +272,8 @@ ApplicationWindow {

font.family: root.uiFont

// Primary-window close owns application exit. C++ teardown owns STOP, COM
// close and worker retirement; QML must not race it with another async STOP.
// Closing Studio retires its serial observer; SV/PTP still belong to
// the LAN-connected board until explicit STOP or an actual hardware fault.
onClosing: function(close) {
close.accepted = true
if (root.applicationShutdownRequested) return
Expand Down Expand Up @@ -264,12 +336,17 @@ ApplicationWindow {
"). Open diagnostics for the current-board TX error.", true)
}
function onDeviceVerifiedChanged() {
root.reconnectScenarioRevision = device.deviceVerified
? root.scenarioRevision : -1
root.pendingApply = true
root.liveSubmissionRevision = -1
root.autoLiveQueuedRevision = -1
autoLiveApplyTimer.stop()
root.offlinePreviewRunning = false
}
function onBoardScenarioChanged() {
root.adoptRunningBoardScenario(device.boardScenario)
}
function onRunningChanged() {
// An operator STOP or a firmware fault cancels unsent live edits.
// Reconnect/START must never replay edits from a previous session.
Expand Down
86 changes: 84 additions & 2 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ class DeterministicSessionHarness final {
{"USB port loss requires two verified misses; replug emits one arrival", usbPhysicalPortLossAndArrivalGate()},
{"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()},
{"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()},
{"Reconnect adopts autonomous RUNNING board without START", runningReplugAdoptsLiveBoardState()},
{"4I4V board bank requires eight coherent boot/gen rows", coherentBoardBankReadback()},
{"wrong device during recovery -> SETUP ERROR", wrongDeviceRecoveryFailsClosed()},
{"CFG transport exclusivity cannot trigger false health loss", healthProbeDefersDuringScenario()},
{"control health misses -> bounded failure", healthTimeoutIsBounded()},
Expand Down Expand Up @@ -168,7 +170,7 @@ class DeterministicSessionHarness final {
QStringLiteral("PROFILE"),
QStringLiteral("PROFILE-BINARY-V1"),
QStringLiteral("LIVE-SETPOINTS"),
QStringLiteral("SESSION-LEASE"),
QStringLiteral("SV-LAN-AUTONOMY-V1"),
QStringLiteral("PTP-P2"),
QStringLiteral("SMPSYNCH-AUTO")};
return result;
Expand Down Expand Up @@ -1659,7 +1661,7 @@ class DeterministicSessionHarness final {
"firmware=0.1.1 build=0123456789abcdef "
"boot_id=FEDCBA9876543210 "
"capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1,"
"PROFILE-REUSE-V1,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO");
"PROFILE-REUSE-V1,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,PTP-P2,SMPSYNCH-AUTO");
device.processLine(replacement);
return device.bootId() == QStringLiteral("FEDCBA9876543210") &&
device.deviceVerified() &&
Expand Down Expand Up @@ -1854,6 +1856,86 @@ class DeterministicSessionHarness final {
!fixture.device.running();
}

static bool runningReplugAdoptsLiveBoardState() {
Fixture fixture;
if (!fixture.profileReady) return false;
const auto board = identity();
seedVerified(fixture, board, QStringLiteral("COM15"), true);
if (fixture.session.state() != QStringLiteral("RUNNING")) return false;
simulateDisconnect(fixture);

// The GUI no longer has a COM observation; it may show DISCONNECTED,
// but MUST NOT assume the hardware SV stopped or request a restart.
if (fixture.device.connected() || fixture.device.running()) return false;

// Reconnect to the same boot and obtain a fresh authoritative board
// RUNNING readback: adopt it without START and without a new STOP.
seedVerified(fixture, board, QStringLiteral("COM16"), true);
return fixture.device.connected() && fixture.device.running() &&
!fixture.device.svUnexpectedStopObserved() &&
fixture.session.state() == QStringLiteral("RUNNING") &&
fixture.device.portName() == QStringLiteral("COM16");
}

static bool coherentBoardBankReadback() {
Fixture fixture;
if (!fixture.profileReady) return false;
seedVerified(fixture, identity(), QStringLiteral("COM15"), true);
auto& d = fixture.device;
const QString boot = d.bootId();
d.processLine(QStringLiteral(
"SVBANK BEGIN generation=13 boot=%1 hz_millihz=50000 ct_enabled=1 ct_dc_permille=300 ct_harmonic_permille=280 ct_order=2 ct_clip_permille=600").arg(boot));
const QStringList rows{
QStringLiteral("IA rms_counts=1200 phase_mdeg=30000 quality=0x00000000 enabled=1"),
QStringLiteral("IB rms_counts=500 phase_mdeg=-120000 quality=0x00000000 enabled=1"),
QStringLiteral("IC rms_counts=1000 phase_mdeg=140000 quality=0x00000000 enabled=1"),
QStringLiteral("IN rms_counts=0 phase_mdeg=0 quality=0x00000000 enabled=1"),
QStringLiteral("UA rms_counts=2000 phase_mdeg=0 quality=0x00000000 enabled=1"),
QStringLiteral("UB rms_counts=6000 phase_mdeg=-120000 quality=0x00000000 enabled=1"),
QStringLiteral("UC rms_counts=3000 phase_mdeg=120000 quality=0x00000000 enabled=1"),
QStringLiteral("UN rms_counts=0 phase_mdeg=0 quality=0x00000000 enabled=0")
};
for (int i = 0; i < 7; ++i) d.processLine(rows[i]);
d.processLine(QStringLiteral("SVBANK END generation=13 boot=%1").arg(boot));
if (!d.boardScenario().isEmpty()) return false; // incomplete snapshot
d.processLine(QStringLiteral(
"SVBANK BEGIN generation=13 boot=%1 hz_millihz=50000 ct_enabled=1 ct_dc_permille=300 ct_harmonic_permille=280 ct_order=2 ct_clip_permille=600").arg(boot));
for (const QString& row : rows) d.processLine(row);
d.processLine(QStringLiteral(
"SVBANK END generation=14 boot=%1").arg(boot));
if (!d.boardScenario().isEmpty()) return false; // wrong generation
d.processLine(QStringLiteral(
"SVBANK BEGIN generation=13 boot=FEDCBA9876543210 hz_millihz=50000 ct_enabled=1 ct_dc_permille=300 ct_harmonic_permille=280 ct_order=2 ct_clip_permille=600"));
for (const QString& row : rows) d.processLine(row);
d.processLine(QStringLiteral(
"SVBANK END generation=13 boot=FEDCBA9876543210"));
if (!d.boardScenario().isEmpty()) return false; // foreign boot

d.processLine(QStringLiteral(
"SVBANK BEGIN generation=13 boot=%1 hz_millihz=50000 ct_enabled=1 ct_dc_permille=300 ct_harmonic_permille=280 ct_order=2 ct_clip_permille=600").arg(boot));
for (const QString& row : rows) d.processLine(row);
d.processLine(QStringLiteral("SVBANK END generation=13 boot=%1").arg(boot));
const auto data = d.boardScenario();
if (data.value(QStringLiteral("boot")).toString() != boot ||
data.value(QStringLiteral("generation")).toString() != QStringLiteral("13") ||
data.value(QStringLiteral("hz")).toDouble() != 50.0) return false;
const auto ct = data.value(QStringLiteral("ct")).toMap();
if (ct.value(QStringLiteral("enabled")).toBool() != true ||
ct.value(QStringLiteral("dc")).toDouble() != 30.0 ||
ct.value(QStringLiteral("harmonic")).toDouble() != 28.0 ||
ct.value(QStringLiteral("order")).toInt() != 2 ||
ct.value(QStringLiteral("clip")).toDouble() != 60.0) return false;
const auto currents = data.value(QStringLiteral("current")).toList();
const auto voltages = data.value(QStringLiteral("voltage")).toList();
if (currents.size() != 4 || voltages.size() != 4 ||
currents[0].toMap().value(QStringLiteral("counts")).toLongLong() != 1200 ||
currents[1].toMap().value(QStringLiteral("angleMdeg")).toLongLong() != -120000 ||
voltages[2].toMap().value(QStringLiteral("counts")).toLongLong() != 3000 ||
voltages[3].toMap().value(QStringLiteral("enabled")).toBool()) return false;
d.resetTelemetry();
return d.boardScenario().isEmpty(); // invalidate on COM/boot switch
}

static bool wrongDeviceRecoveryFailsClosed() {
Fixture fixture;
if (!fixture.profileReady) return false;
Expand Down
Loading
Loading