firmwareloop 0.0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tools/reset.ps1 ADDED
@@ -0,0 +1,141 @@
1
+ <#
2
+ .SYNOPSIS
3
+ DUT reset (Spec §26 M2). Preferred backend is Agentic HIL; the
4
+ simulator backend is a no-op acknowledgement (the HIL harness owns the
5
+ simulated DUT process and resets it in-test).
6
+
7
+ Usage:
8
+ .\tools\reset.ps1 -Backend simulator -Json
9
+ .\tools\reset.ps1 -Backend agentic-hil -Json
10
+
11
+ Exit codes: 0 ok, 1 backend failure, 2 config/identity error.
12
+ #>
13
+ [CmdletBinding()]
14
+ param(
15
+ [ValidateSet('simulator', 'agentic-hil', 'openocd', 'vendor')]
16
+ [string]$Backend = 'simulator',
17
+ [string]$ExpectedTarget, # verify probe identity before reset (Spec §9)
18
+ [switch]$Json
19
+ )
20
+
21
+ Set-StrictMode -Version Latest
22
+ $ErrorActionPreference = 'Stop'
23
+ Import-Module (Join-Path $PSScriptRoot 'common\fw.psm1') -Force
24
+
25
+ $repoRoot = Get-FwRepoRoot
26
+
27
+ function Exit-WithError {
28
+ param([string]$Class, [string]$Message, [string]$Detail)
29
+ $body = New-FwError -ErrorClass $Class -Message $Message -Detail $Detail
30
+ if ($Json) { Write-FwJson $body -Compact } else { Write-FwJson $body; Write-Error "$Class : $Message" -ErrorAction Continue }
31
+ exit 2
32
+ }
33
+
34
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
35
+ $logFile = Join-Path $repoRoot "artifacts\logs\reset-$stamp.log"
36
+ New-Item -ItemType Directory -Force -Path (Split-Path $logFile) | Out-Null
37
+
38
+ switch ($Backend) {
39
+ 'simulator' {
40
+ Save-FwLog -Path $logFile -Content "simulator backend: reset is managed by the HIL harness (pytest conftest SimulatedDut.reset)."
41
+ $result = [ordered]@{
42
+ schema = 'firmware-reset-result/v1'
43
+ ok = $true
44
+ backend = 'simulator'
45
+ target = 'simulated-dut'
46
+ log = (Resolve-Path -LiteralPath $logFile).Path
47
+ generated_at = Get-FwTimestamp
48
+ }
49
+ if ($Json) { Write-FwJson ([pscustomobject]$result) -Compact } else { Write-FwJson ([pscustomobject]$result) }
50
+ exit 0
51
+ }
52
+ 'pyocd' {
53
+ $pyocdExe = Join-Path $repoRoot '.venv\Scripts\pyocd.exe'
54
+ if (-not (Test-Path -LiteralPath $pyocdExe)) {
55
+ $cmd = Get-Command pyocd -ErrorAction SilentlyContinue
56
+ if ($cmd) { $pyocdExe = $cmd.Source }
57
+ }
58
+ if (-not (Test-Path -LiteralPath $pyocdExe)) {
59
+ Exit-WithError -Class 'TOOLCHAIN_NOT_FOUND' -Message 'pyocd 未找到。请在 Python 环境中安装 pyocd (`uv pip install pyocd`)。' -Detail 'pyOCD 用于通过 ST-LINK / CMSIS-DAP 复位芯片。'
60
+ }
61
+ $targetChip = if ($ExpectedTarget) { $ExpectedTarget } else { 'stm32f103rc' }
62
+ $res = Invoke-FwProcess -FilePath $pyocdExe -Arguments @('reset', '-t', $targetChip.ToLowerInvariant()) -WorkingDirectory $repoRoot -TimeoutMs 30000 -StdoutFile $logFile
63
+ if ($res.timed_out) {
64
+ Exit-WithError -Class 'TIMEOUT' -Message 'pyocd 芯片复位超时。' -Detail $logFile
65
+ }
66
+ if ($res.exit_code -ne 0) {
67
+ Exit-WithError -Class 'RESET_ERROR' -Message "pyocd 硬件复位失败:探针未连接或目标 MCU 无响应。" -Detail ($res.stdout + "`n" + $res.stderr)
68
+ }
69
+ $result = [ordered]@{
70
+ schema = 'firmware-reset-result/v1'
71
+ ok = $true
72
+ backend = 'pyocd'
73
+ target = $targetChip
74
+ log = (Resolve-Path -LiteralPath $logFile).Path
75
+ generated_at = Get-FwTimestamp
76
+ }
77
+ if ($Json) { Write-FwJson ([pscustomobject]$result) -Compact } else { Write-FwJson ([pscustomobject]$result) }
78
+ exit 0
79
+ }
80
+ 'jlink' {
81
+ $jlinkExe = (Get-Command JLink.exe -ErrorAction SilentlyContinue)?.Source
82
+ if (-not $jlinkExe) {
83
+ $candidates = @(
84
+ "C:\Program Files\SEGGER\JLink\JLink.exe",
85
+ "C:\Program Files (x86)\SEGGER\JLink\JLink.exe"
86
+ )
87
+ foreach ($c in $candidates) {
88
+ if (Test-Path -LiteralPath $c) { $jlinkExe = $c; break }
89
+ }
90
+ }
91
+ if (-not $jlinkExe) {
92
+ Exit-WithError -Class 'TOOLCHAIN_NOT_FOUND' -Message '未找到 SEGGER JLink.exe。' -Detail '请安装官方 SEGGER J-Link 驱动。'
93
+ }
94
+ $cmdScript = Join-Path $repoRoot "artifacts\logs\jlink_reset_$stamp.jlink"
95
+ Set-Content -LiteralPath $cmdScript -Value "r`ng`nq`n" -Encoding ASCII
96
+ $targetChip = if ($ExpectedTarget) { $ExpectedTarget } else { 'STM32F103C8' }
97
+ $res = Invoke-FwProcess -FilePath $jlinkExe -Arguments @('-device', $targetChip, '-if', 'SWD', '-speed', '4000', '-autoconnect', '1', '-CommanderScript', $cmdScript) -WorkingDirectory $repoRoot -TimeoutMs 30000 -StdoutFile $logFile
98
+ if ($res.timed_out) {
99
+ Exit-WithError -Class 'TIMEOUT' -Message 'J-Link 芯片复位超时。' -Detail $logFile
100
+ }
101
+ if ($res.exit_code -ne 0) {
102
+ Exit-WithError -Class 'RESET_ERROR' -Message "J-Link 硬件复位失败:探针未连接或板卡未供电。" -Detail ($res.stdout + "`n" + $res.stderr)
103
+ }
104
+ $result = [ordered]@{
105
+ schema = 'firmware-reset-result/v1'
106
+ ok = $true
107
+ backend = 'jlink'
108
+ target = $targetChip
109
+ log = (Resolve-Path -LiteralPath $logFile).Path
110
+ generated_at = Get-FwTimestamp
111
+ }
112
+ if ($Json) { Write-FwJson ([pscustomobject]$result) -Compact } else { Write-FwJson ([pscustomobject]$result) }
113
+ exit 0
114
+ }
115
+ 'agentic-hil' {
116
+ $cmd = Get-Command agentic-hil -ErrorAction SilentlyContinue
117
+ if (-not $cmd) {
118
+ Exit-WithError -Class 'PROBE_NOT_FOUND' -Message "'agentic-hil' 未安装;请安装 Agentic HIL。" -Detail 'See README.md -> M2'
119
+ }
120
+ $res = Invoke-FwProcess -FilePath $cmd.Source -Arguments @('reset', '--target', $ExpectedTarget, '--json') -WorkingDirectory $repoRoot -TimeoutMs 120000 -StdoutFile $logFile
121
+ if ($res.timed_out) {
122
+ Exit-WithError -Class 'RESET_ERROR' -Message 'reset backend timed out and was killed.' -Detail $logFile
123
+ }
124
+ if ($res.exit_code -ne 0) {
125
+ Exit-WithError -Class 'RESET_ERROR' -Message 'reset backend reported failure.' -Detail ($res.stdout + $res.stderr)
126
+ }
127
+ $result = [ordered]@{
128
+ schema = 'firmware-reset-result/v1'
129
+ ok = $true
130
+ backend = 'agentic-hil'
131
+ target = $ExpectedTarget
132
+ log = (Resolve-Path -LiteralPath $logFile).Path
133
+ generated_at = Get-FwTimestamp
134
+ }
135
+ if ($Json) { Write-FwJson ([pscustomobject]$result) -Compact } else { Write-FwJson ([pscustomobject]$result) }
136
+ exit 0
137
+ }
138
+ default {
139
+ Exit-WithError -Class 'CONFIG_ERROR' -Message "复位后端 '$Backend' 尚未在本机配置。" -Detail '请在 lab/lab.yaml 中配置有效的复位后端。'
140
+ }
141
+ }
@@ -0,0 +1,147 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Dual-Tier MCP setup and registration helper (v0.0.8).
4
+ Inspects installed AI coding agents (Qoder, Claude Code, Antigravity, Cursor)
5
+ and prints/generates exact registration commands and workspace configuration.
6
+
7
+ Usage:
8
+ .\tools\setup-agent-mcp.ps1
9
+ .\tools\setup-agent-mcp.ps1 -Json
10
+ .\tools\setup-agent-mcp.ps1 -WriteWorkspaceMcp
11
+ #>
12
+ [CmdletBinding()]
13
+ param(
14
+ [switch]$Json,
15
+ [switch]$WriteWorkspaceMcp
16
+ )
17
+
18
+ Set-StrictMode -Version Latest
19
+ $ErrorActionPreference = 'Stop'
20
+ Import-Module (Join-Path $PSScriptRoot 'common\fw.psm1') -Force
21
+
22
+ $repoRoot = Get-FwRepoRoot
23
+ $python = Resolve-FwPython -RepoRoot $repoRoot
24
+ $fwMcpServer = Join-Path $repoRoot 'tools\fw_mcp_server.py'
25
+ $ahilExe = Join-Path $repoRoot '.venv\Scripts\agentic-hil.exe'
26
+
27
+ $report = [ordered]@{
28
+ schema = 'firmwareloop-mcp-setup/v1'
29
+ repo_root = $repoRoot
30
+ servers = [ordered]@{
31
+ firmwareloop = [ordered]@{
32
+ command = $python
33
+ args = @($fwMcpServer)
34
+ description = 'Upper-tier firmware engineering workflow MCP'
35
+ }
36
+ agentic_hil = [ordered]@{
37
+ command = $ahilExe
38
+ args = @('mcp-stdio')
39
+ description = 'Lower-tier physical hardware & probe MCP'
40
+ }
41
+ }
42
+ agents = [ordered]@{}
43
+ }
44
+
45
+ # 1. Claude Code CLI
46
+ $claudeCmd = Get-Command claude -ErrorAction SilentlyContinue
47
+ $report.agents.claude_code = [ordered]@{
48
+ detected = ($null -ne $claudeCmd)
49
+ path = if ($claudeCmd) { $claudeCmd.Source } else { $null }
50
+ registration_commands = @(
51
+ "claude mcp add firmwareloop -- `"$python`" `"$fwMcpServer`"",
52
+ "claude mcp add agentic-hil -- `"$ahilExe`" mcp-stdio"
53
+ )
54
+ }
55
+
56
+ # 2. Qoder IDE / CLI
57
+ $qoderCmd = $null
58
+ foreach ($name in @('qoder', 'qodercli')) {
59
+ $c = Get-Command $name -ErrorAction SilentlyContinue
60
+ if ($c) { $qoderCmd = $c; break }
61
+ }
62
+ $report.agents.qoder = [ordered]@{
63
+ detected = ($null -ne $qoderCmd)
64
+ path = if ($qoderCmd) { $qoderCmd.Source } else { $null }
65
+ registration_commands = if ($qoderCmd) {
66
+ @(
67
+ "$($qoderCmd.Name) mcp add firmwareloop -- `"$python`" `"$fwMcpServer`"",
68
+ "$($qoderCmd.Name) mcp add agentic-hil -- `"$ahilExe`" mcp-stdio"
69
+ )
70
+ } else {
71
+ @(
72
+ "qoder mcp add firmwareloop -- `"$python`" `"$fwMcpServer`"",
73
+ "qoder mcp add agentic-hil -- `"$ahilExe`" mcp-stdio"
74
+ )
75
+ }
76
+ }
77
+
78
+ # 3. Antigravity CLI / Gemini Code
79
+ $antigravitySkillsRoot = Join-Path $env:USERPROFILE '.gemini\antigravity-cli\skills'
80
+ $targetGlobalSkillDir = Join-Path $antigravitySkillsRoot 'firmwareloop'
81
+ $sourceSkill = Join-Path $repoRoot 'skills\firmwareloop\SKILL.md'
82
+
83
+ $skillInstalled = $false
84
+ if (Test-Path -LiteralPath (Split-Path $antigravitySkillsRoot)) {
85
+ if (Test-Path -LiteralPath $sourceSkill) {
86
+ New-Item -ItemType Directory -Force -Path $targetGlobalSkillDir | Out-Null
87
+ Copy-Item -LiteralPath $sourceSkill -Destination (Join-Path $targetGlobalSkillDir 'SKILL.md') -Force
88
+ $skillInstalled = $true
89
+ }
90
+ }
91
+
92
+ $report.agents.antigravity = [ordered]@{
93
+ mcp_config_path = (Join-Path $repoRoot '.mcp.json')
94
+ global_skill_installed = $skillInstalled
95
+ global_skill_path = if ($skillInstalled) { Join-Path $targetGlobalSkillDir 'SKILL.md' } else { $null }
96
+ note = "Antigravity automatically discovers project-level .mcp.json and global skills."
97
+ }
98
+
99
+ # Optional: write project-level .mcp.json
100
+ if ($WriteWorkspaceMcp) {
101
+ $mcpJsonPath = Join-Path $repoRoot '.mcp.json'
102
+ $mcpConfig = [ordered]@{
103
+ mcpServers = [ordered]@{
104
+ firmwareloop = [ordered]@{
105
+ type = 'stdio'
106
+ command = $python
107
+ args = @($fwMcpServer)
108
+ timeout = 300000
109
+ }
110
+ "agentic-hil" = [ordered]@{
111
+ type = 'stdio'
112
+ command = $ahilExe
113
+ args = @('mcp-stdio')
114
+ timeout = 120000
115
+ }
116
+ }
117
+ }
118
+ $jsonContent = ConvertTo-Json -InputObject $mcpConfig -Depth 10
119
+ [System.IO.File]::WriteAllText($mcpJsonPath, $jsonContent, [System.Text.Encoding]::UTF8)
120
+ $report.workspace_mcp_written = $mcpJsonPath
121
+ }
122
+
123
+ if ($Json) {
124
+ Write-FwJson ([pscustomobject]$report)
125
+ } else {
126
+ Write-Host "============================================================" -ForegroundColor Cyan
127
+ Write-Host "FirmwareLoop v0.0.8 - Dual-Tier MCP & Skill Setup Helper" -ForegroundColor Green
128
+ Write-Host "============================================================" -ForegroundColor Cyan
129
+ Write-Host ""
130
+ Write-Host "[1] Claude Code CLI Registration:" -ForegroundColor Yellow
131
+ Write-Host " $($report.agents.claude_code.registration_commands[0])"
132
+ Write-Host " $($report.agents.claude_code.registration_commands[1])"
133
+ Write-Host ""
134
+ Write-Host "[2] Qoder IDE (Official GUI & Workspace .mcp.json):" -ForegroundColor Yellow
135
+ Write-Host " - GUI: Press 'Ctrl + Shift + ,' -> MCP -> My Services -> '+ Add', paste the JSON config"
136
+ Write-Host " - Workspace: Qoder automatically detects '.mcp.json' in your workspace root"
137
+ Write-Host " - CLI: $($report.agents.qoder.registration_commands[0])"
138
+ Write-Host " $($report.agents.qoder.registration_commands[1])"
139
+ Write-Host ""
140
+ Write-Host "[3] Antigravity CLI / Cursor / VS Code:" -ForegroundColor Yellow
141
+ Write-Host " - MCP: Automatically reads '.mcp.json' in workspace root"
142
+ if ($skillInstalled) {
143
+ Write-Host " - Global Skill: [OK] Successfully registered to: $targetGlobalSkillDir\SKILL.md" -ForegroundColor Green
144
+ }
145
+ Write-Host " - Generate workspace config: .\tools\setup-agent-mcp.ps1 -WriteWorkspaceMcp"
146
+ Write-Host ""
147
+ }
@@ -0,0 +1,100 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Backend command tests (Spec GAP-002). Verifies:
4
+ - command construction for all 7 backends (dry-run)
5
+ - execution + log capture through fake executables (keil/iar/west/idf.py)
6
+ - artifact detection failure is reported (no fabricated artifacts)
7
+ Runs in CI without Keil/IAR licenses (Spec §27: fake executable).
8
+
9
+ Usage: .\tools\test-backends.ps1 -Json
10
+ #>
11
+ [CmdletBinding()]
12
+ param([switch]$Json)
13
+
14
+ Set-StrictMode -Version Latest
15
+ $ErrorActionPreference = 'Stop'
16
+ Import-Module (Join-Path $PSScriptRoot 'common\fw.psm1') -Force
17
+
18
+ $repoRoot = Get-FwRepoRoot
19
+ $fake = Join-Path $repoRoot 'tests\backend\fake-build-tools'
20
+ $results = [System.Collections.Generic.List[object]]::new()
21
+
22
+ function Assert {
23
+ param([string]$Name, [bool]$Ok, [string]$Detail)
24
+ $results.Add([pscustomobject]@{ name = $Name; ok = $Ok; detail = $Detail })
25
+ if (-not $Ok) { Write-Warning "FAIL: $Name - $Detail" }
26
+ }
27
+
28
+ function Invoke-Build {
29
+ param([string[]]$ToolArgs)
30
+ $argsList = @('-NoProfile', '-NonInteractive', '-File', (Join-Path $PSScriptRoot 'build.ps1')) + $ToolArgs
31
+ return Invoke-FwProcess -FilePath 'pwsh' -Arguments $argsList `
32
+ -WorkingDirectory $repoRoot -TimeoutMs 120000
33
+ }
34
+
35
+ # ---- 1. dry-run command construction (all backends) ---------------------------
36
+ $cases = @(
37
+ @{ name = 'cmake'; args = @('-Backend', 'cmake', '-SourceDir', 'demo-firmware', '-DryRun', '-Json') ; expect = 'cmake -S' },
38
+ @{ name = 'make'; args = @('-Backend', 'make', '-SourceDir', 'demo-make', '-DryRun', '-Json') ; expect = 'make -C' },
39
+ @{ name = 'platformio'; args = @('-Backend', 'platformio', '-SourceDir', 'demo-firmware', '-DryRun', '-Json') ; expect = 'pio run' },
40
+ @{ name = 'keil'; args = @('-Backend', 'keil', '-SourceDir', 'tests/backend/fixtures/keil-proj', '-DryRun', '-Json') ; expect = 'UV4 -b' },
41
+ @{ name = 'iar'; args = @('-Backend', 'iar', '-SourceDir', 'tests/backend/fixtures/iar-proj', '-DryRun', '-Json') ; expect = 'IarBuild' },
42
+ @{ name = 'zephyr'; args = @('-Backend', 'zephyr', '-DryRun', '-Json') ; expect = 'west build' },
43
+ @{ name = 'esp-idf'; args = @('-Backend', 'esp-idf', '-DryRun', '-Json') ; expect = 'idf.py build' }
44
+ )
45
+
46
+ # zephyr needs ZEPHYR_BOARD only for construction
47
+ $env:ZEPHYR_BOARD = 'native_sim'
48
+
49
+ foreach ($c in $cases) {
50
+ $r = Invoke-Build -ToolArgs $c.args
51
+ $jsonLine = ($r.stdout -split "`r?`n" | Where-Object { $_.TrimStart().StartsWith('{') } | Select-Object -Last 1)
52
+ $ok = $false
53
+ $detail = $r.stderr
54
+ if ($jsonLine) {
55
+ try { $j = $jsonLine | ConvertFrom-Json } catch { $j = $null }
56
+ if ($j -and $j.ok -and $j.dry_run -and $j.command -like "*$($c.expect)*") {
57
+ $ok = $true
58
+ $detail = "command: $($j.command)"
59
+ } else {
60
+ $detail = "unexpected: $jsonLine"
61
+ }
62
+ }
63
+ Assert -Name "construct.$($c.name)" -Ok $ok -Detail $detail
64
+ }
65
+
66
+ # ---- 2. fake executable execution (keil / iar / west / esp-idf) ----------------
67
+ $execCases = @(
68
+ @{ name = 'keil'; args = @('-Backend', 'keil', '-SourceDir', 'tests/backend/fixtures/keil-proj', '-BackendTool', (Join-Path $fake 'UV4.cmd'), '-Json') },
69
+ @{ name = 'iar'; args = @('-Backend', 'iar', '-SourceDir', 'tests/backend/fixtures/iar-proj', '-BackendTool', (Join-Path $fake 'IarBuild.cmd'), '-Json') },
70
+ @{ name = 'zephyr'; args = @('-Backend', 'zephyr', '-BackendTool', (Join-Path $fake 'west.cmd'), '-Json') },
71
+ @{ name = 'esp-idf'; args = @('-Backend', 'esp-idf', '-BackendTool', (Join-Path $fake 'idf.py.cmd'), '-Json') }
72
+ )
73
+ foreach ($c in $execCases) {
74
+ $r = Invoke-Build -ToolArgs $c.args
75
+ $log = Get-Content -LiteralPath (Join-Path $repoRoot 'artifacts\logs\build.log') -Raw -ErrorAction SilentlyContinue
76
+ # fake tool ran (log captured) AND artifact absence was detected honestly
77
+ $ran = $log -match 'FAKE_BACKEND_OK'
78
+ $honest = $r.exit_code -eq 2 -and $r.stdout -match 'ARTIFACT_NOT_FOUND'
79
+ Assert -Name "execute.$($c.name)" -Ok ($ran -and $honest) -Detail "ran=$ran honest=$honest exit=$($r.exit_code) log_tail=$($log.Substring(0,[Math]::Min(120,$log.Length)))"
80
+ }
81
+
82
+ # ---- 3. cmake real build still green -------------------------------------------
83
+ $r = Invoke-Build -ToolArgs @('-Configuration', 'Debug', '-Clean', '-Json')
84
+ $jsonLine = ($r.stdout -split "`r?`n" | Where-Object { $_.TrimStart().StartsWith('{') } | Select-Object -Last 1)
85
+ $j = $jsonLine | ConvertFrom-Json
86
+ Assert -Name 'real.cmake' -Ok ($r.exit_code -eq 0 -and $j.ok -and $j.artifacts.primary.path -like '*firmware.elf*') `
87
+ -Detail "exit=$($r.exit_code) artifact=$($j.artifacts.primary.path)"
88
+
89
+ # ---- summary -------------------------------------------------------------------
90
+ $failed = @($results | Where-Object { -not $_.ok }).Count
91
+ $summary = [ordered]@{
92
+ schema = 'backend-test-result/v1'
93
+ ok = ($failed -eq 0)
94
+ total = $results.Count
95
+ failed = $failed
96
+ tests = @($results)
97
+ generated_at = Get-FwTimestamp
98
+ }
99
+ if ($Json) { Write-FwJson ([pscustomobject]$summary) -Compact } else { Write-FwJson ([pscustomobject]$summary) }
100
+ exit $(if ($failed -eq 0) { 0 } else { 1 })
tools/test.ps1 ADDED
@@ -0,0 +1,194 @@
1
+ <#
2
+ .SYNOPSIS
3
+ HIL test runner (Spec §18/19/23). Runs pytest under the project venv,
4
+ produces JUnit XML, a firmware-hil-result/v1 summary and a per-run audit
5
+ directory under artifacts/runs/<run_id>/.
6
+
7
+ Usage:
8
+ .\tools\test.ps1 -Json
9
+ .\tools\test.ps1 -TestPath tests/hil -RunId my-run -Json
10
+
11
+ Exit codes:
12
+ 0 all tests passed (or skipped)
13
+ 1 at least one test failed
14
+ 2 configuration/runner error
15
+ #>
16
+ [CmdletBinding()]
17
+ param(
18
+ [string]$TestPath = 'tests/hil',
19
+ [string]$RunId,
20
+ [switch]$Json,
21
+ [switch]$NoBuild,
22
+ [ValidateSet('simulator', 'real')]
23
+ [string]$Mode = 'simulator'
24
+ )
25
+
26
+ Set-StrictMode -Version Latest
27
+ $ErrorActionPreference = 'Stop'
28
+ Import-Module (Join-Path $PSScriptRoot 'common\fw.psm1') -Force
29
+
30
+ $repoRoot = Get-FwRepoRoot
31
+ $python = Resolve-FwPython -RepoRoot $repoRoot
32
+ if (-not $python) {
33
+ $err = New-FwError -ErrorClass 'CONFIG_ERROR' -Message 'No Python found. Run tools/doctor.ps1.'
34
+ if ($Json) { Write-FwJson $err -Compact } else { Write-FwJson $err; Write-Error $err.error }
35
+ exit 2
36
+ }
37
+
38
+ # --- run identity & audit dir -------------------------------------------------
39
+ $runId = if ($RunId) { $RunId } else { Get-FwRunId }
40
+ $runsDir = Join-Path $repoRoot "artifacts\runs\$runId"
41
+ New-Item -ItemType Directory -Force -Path $runsDir | Out-Null
42
+ $env:FW_RUN_DIR = $runsDir
43
+
44
+ # --- evidence: environment.json + dependencies.json (Spec §25) -----------------
45
+ $doctor = Invoke-FwProcess -FilePath 'pwsh' -Arguments @('-NoProfile', '-NonInteractive', '-File', (Join-Path $PSScriptRoot 'doctor.ps1'), '-Json') -WorkingDirectory $repoRoot -TimeoutMs 120000
46
+ if ($doctor.exit_code -in @(0, 1)) {
47
+ try {
48
+ $d = $doctor.stdout | ConvertFrom-Json
49
+ if ($d.checks) {
50
+ $deps = [ordered]@{}
51
+ foreach ($p in $d.checks.PSObject.Properties) {
52
+ $v = $p.Value
53
+ $verProp = $v.PSObject.Properties['version']
54
+ $deps[$p.Name] = if ($v.status -eq 'ok' -and $verProp) { $v.version } else { $v.status }
55
+ }
56
+ $deps | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $runsDir 'dependencies.json') -Encoding utf8
57
+ }
58
+ if ($d.host) {
59
+ $d.host | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $runsDir 'environment.json') -Encoding utf8
60
+ }
61
+ } catch {
62
+ Write-Warning "evidence block failed: $($_.Exception.Message)"
63
+ }
64
+ }
65
+ # --- firmware identity --------------------------------------------------------
66
+ $artifact = Join-Path $repoRoot 'artifacts\build\firmware.elf'
67
+ $firmwareInfo = [ordered]@{
68
+ artifact = $null
69
+ artifact_sha256 = $null
70
+ git_commit = $null
71
+ }
72
+ if (Test-Path -LiteralPath $artifact) {
73
+ $firmwareInfo.artifact = (Resolve-Path -LiteralPath $artifact).Path
74
+ $firmwareInfo.artifact_sha256 = (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash.ToLowerInvariant()
75
+ }
76
+ $git = Get-FwGitInfo -RepoRoot $repoRoot
77
+ $firmwareInfo.git_commit = $git.commit
78
+
79
+ # repo state at run start for the audit trail (Spec §23 modified files)
80
+ $modifiedFiles = @()
81
+ $statusLines = git -C $repoRoot status --porcelain 2>$null
82
+ foreach ($line in $statusLines) {
83
+ if ($line -match '^..\s+(.+)$') {
84
+ $p = $Matches[1].Trim('"')
85
+ if ($p) { $modifiedFiles += $p }
86
+ }
87
+ }
88
+
89
+ # --- run pytest ---------------------------------------------------------------
90
+ # Pass --junitxml as a SEPARATE token with a relative path: pytest 9.x on this
91
+ # platform mis-parses the "--junitxml=<absolute or relative path>" fused form
92
+ # (the value is wrongly treated as a collect argument -> exit 4, "no match").
93
+ $xmlRel = (Join-Path 'artifacts\runs' "$runId\pytest.xml").Replace('\', '/')
94
+ $xmlNative = Join-Path $repoRoot $xmlRel.Replace('/', '\')
95
+ New-Item -ItemType File -Force -Path $xmlNative | Out-Null
96
+ $pytestArgs = @('-m', 'pytest', $TestPath, '--junitxml', $xmlRel, '--tb=short', '-p', 'no:cacheprovider', '-rA')
97
+ $res = Invoke-FwProcess -FilePath $python -Arguments $pytestArgs -WorkingDirectory $repoRoot -TimeoutMs 900000
98
+ Save-FwLog -Path (Join-Path $runsDir 'pytest.stdout.log') -Content $res.stdout
99
+ Save-FwLog -Path (Join-Path $runsDir 'pytest.stderr.log') -Content $res.stderr
100
+
101
+ if ($res.timed_out) {
102
+ $err = New-FwError -ErrorClass 'INSTRUMENT_TIMEOUT' -Message 'pytest run timed out and was killed.' -Detail $xmlReport
103
+ if ($Json) { Write-FwJson $err -Compact } else { Write-FwJson $err; Write-Error $err.error }
104
+ exit 2
105
+ }
106
+ if ($res.exit_code -eq 4) {
107
+ # usage error: pytest rejected the invocation - configuration problem
108
+ $err = New-FwError -ErrorClass 'CONFIG_ERROR' -Message 'pytest rejected the invocation (usage error, exit 4).' -Detail $res.stdout
109
+ if ($Json) { Write-FwJson $err -Compact } else { Write-FwJson $err; Write-Error $err.error }
110
+ exit 2
111
+ }
112
+
113
+ # --- parse JUnit XML ----------------------------------------------------------
114
+ $tests = @()
115
+ $failures = 0
116
+ $skippedAsFailure = $false
117
+ if (Test-Path -LiteralPath $xmlNative) {
118
+ [xml]$xml = Get-Content -LiteralPath $xmlNative -Raw
119
+ foreach ($tc in $xml.testsuites.testsuite.testcase) {
120
+ $entry = [ordered]@{
121
+ name = $tc.name
122
+ status = 'passed'
123
+ expected = $null
124
+ actual = $null
125
+ evidence = @()
126
+ }
127
+ $failNode = $tc.SelectSingleNode('failure')
128
+ $skipNode = $tc.SelectSingleNode('skipped')
129
+ if ($failNode) {
130
+ $entry.status = 'failed'
131
+ $failures++
132
+ $msg = [string]$failNode.message
133
+ $expected = $null; $actual = $null
134
+ if ($msg -match 'expected (.+?), actual (.+?)(?::|$)') {
135
+ $expected = $Matches[1]; $actual = $Matches[2]
136
+ }
137
+ if (-not $expected -and $msg -match "expected (.+)") { $expected = $Matches[1] }
138
+ $entry.expected = $expected
139
+ $entry.actual = $actual
140
+ $entry.failure = ([string]$failNode.InnerText)
141
+ } elseif ($skipNode) {
142
+ $entry.status = 'skipped'
143
+ $entry.reason = [string]$skipNode.message
144
+ # REAL mode: a skipped required HIL test is a failure, never PASS
145
+ # (release-fix: simulator/skip must not masquerade as real success)
146
+ if ($Mode -eq 'real') {
147
+ $entry.status = 'failed'
148
+ $entry.expected = 'executed on real hardware'
149
+ $entry.actual = "skipped: $($entry.reason)"
150
+ $failures++
151
+ $skippedAsFailure = $true
152
+ }
153
+ }
154
+ # evidence artifacts copied into the run dir by fixtures
155
+ $tests += [pscustomobject]$entry
156
+ }
157
+ }
158
+
159
+ # --- evidence inventory -------------------------------------------------------
160
+ $evidence = @()
161
+ foreach ($f in @('uart.log', 'measurements.json')) {
162
+ $p = Join-Path $runsDir $f
163
+ if (Test-Path -LiteralPath $p) { $evidence += (Resolve-Path -LiteralPath $p).Path }
164
+ }
165
+
166
+ $summary = [ordered]@{
167
+ schema = 'firmware-hil-result/v1'
168
+ run_id = $runId
169
+ execution_mode = $Mode
170
+ simulated = ($Mode -ne 'real')
171
+ hardware_validated = ($Mode -eq 'real' -and $failures -eq 0)
172
+ ok = ($failures -eq 0)
173
+ firmware = $firmwareInfo
174
+ tests = $tests
175
+ evidence = $evidence
176
+ audit = [ordered]@{
177
+ git_commit = $git.commit
178
+ git_dirty = $git.dirty
179
+ modified_files = $modifiedFiles
180
+ generated_at = Get-FwTimestamp
181
+ }
182
+ hardware = [ordered]@{
183
+ target_identity = $null # set when a real probe is configured (M2)
184
+ probe_serial = $null
185
+ com_port = $null
186
+ }
187
+ generated_at = Get-FwTimestamp
188
+ }
189
+ $summaryPath = Join-Path $runsDir 'summary.json'
190
+ $summary | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $summaryPath -Encoding utf8
191
+
192
+ if ($Json) { Write-FwJson ([pscustomobject]$summary) -Compact } else { Write-FwJson ([pscustomobject]$summary) }
193
+
194
+ exit $(if ($failures -eq 0) { 0 } else { 1 })