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/common/fw.psm1 ADDED
@@ -0,0 +1,311 @@
1
+ # fw.psm1 - FirmwareLoop: shared helpers for PowerShell tools.
2
+ # Provides: unified JSON result envelope, Error Class registry (Spec §22),
3
+ # python/venv resolution, log saving, compiler diagnostics parsing.
4
+
5
+ Set-StrictMode -Version Latest
6
+ $ErrorActionPreference = 'Stop'
7
+
8
+ # --- Error Classes (Spec §22 + v0.0.2 §24 additions) --------------------------
9
+ $script:FW_ERROR_CLASSES = @(
10
+ 'BUILD_ERROR', 'ARTIFACT_NOT_FOUND', 'PROBE_NOT_FOUND', 'TARGET_MISMATCH',
11
+ 'FLASH_ERROR', 'FLASH_VERIFY_ERROR', 'RESET_ERROR', 'UART_TIMEOUT',
12
+ 'UART_BUSY', 'CAN_ERROR', 'DEBUGGER_ERROR', 'LOGIC_CAPTURE_ERROR',
13
+ 'INSTRUMENT_NOT_FOUND', 'INSTRUMENT_TIMEOUT', 'MEASUREMENT_OUT_OF_RANGE',
14
+ 'TEST_FAILED', 'PERMISSION_DENIED', 'SAFETY_LIMIT', 'CONFIG_ERROR',
15
+ 'UNKNOWN_ERROR',
16
+ # v0.0.2 additions (Gap spec §24)
17
+ 'CAPABILITY_NOT_SUPPORTED', 'DEPENDENCY_DISCOVERY_REQUIRED',
18
+ 'HARDWARE_GATE_BYPASSED', 'REAL_HARDWARE_REQUIRED', 'HARDWARE_VALIDATION_FAILED'
19
+ )
20
+
21
+ # --- JSON output --------------------------------------------------------------
22
+ function Write-FwJson {
23
+ <#
24
+ .SYNOPSIS
25
+ Emit a structured result as JSON. Always UTF-8; deep serialization.
26
+ #>
27
+ param(
28
+ [Parameter(Mandatory = $true)] $Object,
29
+ [switch]$Compact
30
+ )
31
+ $depth = 20
32
+ if ($Compact) {
33
+ $json = $Object | ConvertTo-Json -Depth $depth -Compress
34
+ } else {
35
+ $json = $Object | ConvertTo-Json -Depth $depth
36
+ }
37
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
38
+ Write-Output $json
39
+ }
40
+
41
+ function Get-FwErrorClass {
42
+ <#
43
+ .SYNOPSIS
44
+ Validate that a string is a legal Error Class (Spec §22).
45
+ #>
46
+ param([Parameter(Mandatory = $true)][string]$Name)
47
+ if ($Name -notin $script:FW_ERROR_CLASSES) {
48
+ throw "Illegal error class '$Name'. Allowed: $($script:FW_ERROR_CLASSES -join ', ')"
49
+ }
50
+ return $Name
51
+ }
52
+
53
+ function New-FwError {
54
+ <#
55
+ .SYNOPSIS
56
+ Deterministic failure envelope. Every external-process failure must
57
+ return one of these (Spec Rule: every operation returns structured errors).
58
+ #>
59
+ param(
60
+ [Parameter(Mandatory = $true)][string]$ErrorClass,
61
+ [Parameter(Mandatory = $true)][string]$Message,
62
+ [string]$Detail = $null,
63
+ [int]$ExitCode = 1
64
+ )
65
+ Get-FwErrorClass $ErrorClass | Out-Null
66
+ $body = [ordered]@{
67
+ ok = $false
68
+ error_class = $ErrorClass
69
+ error = $Message
70
+ }
71
+ if ($Detail) { $body.detail = $Detail }
72
+ [pscustomobject]$body
73
+ }
74
+
75
+ # --- Python / venv resolution -------------------------------------------------
76
+ function Resolve-FwPython {
77
+ <#
78
+ .SYNOPSIS
79
+ Locate a project Python: prefer .venv\Scripts\python.exe relative to
80
+ repo root, then PATH. Never hardcoded absolute paths (Spec §25).
81
+ #>
82
+ param([string]$RepoRoot)
83
+ $candidates = @()
84
+ if ($RepoRoot) {
85
+ $candidates += (Join-Path $RepoRoot '.venv\Scripts\python.exe')
86
+ $candidates += (Join-Path $RepoRoot 'venv\Scripts\python.exe')
87
+ }
88
+ foreach ($path in $candidates) {
89
+ if (Test-Path -LiteralPath $path) { return (Resolve-Path -LiteralPath $path).Path }
90
+ }
91
+ $fromPath = Get-Command python -ErrorAction SilentlyContinue
92
+ if ($fromPath) { return $fromPath.Source }
93
+ return $null
94
+ }
95
+
96
+ function Get-FwRepoRoot {
97
+ <#
98
+ .SYNOPSIS
99
+ Root of the firmware project = directory containing tools/ and lab/.
100
+ #>
101
+ $cur = Get-Location
102
+ while ($cur) {
103
+ if ((Test-Path (Join-Path $cur 'tools')) -and (Test-Path (Join-Path $cur 'lab'))) {
104
+ return $cur.Path
105
+ }
106
+ $cur = $cur.Parent
107
+ }
108
+ return (Get-Location).Path
109
+ }
110
+
111
+ function Save-FwLog {
112
+ <#
113
+ .SYNOPSIS
114
+ Append text to a log file under artifacts/logs/ (creates dirs).
115
+ #>
116
+ param(
117
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path,
118
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content
119
+ )
120
+ $dir = Split-Path -Parent $Path
121
+ if ($dir -and -not (Test-Path -LiteralPath $dir)) {
122
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
123
+ }
124
+ if ($Content -eq '') { $Content = "`n" }
125
+ Add-Content -LiteralPath $Path -Value $Content -Encoding utf8
126
+ }
127
+
128
+ function Get-FwTimestamp {
129
+ return (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz')
130
+ }
131
+
132
+ function Get-FwRunId {
133
+ <#
134
+ .SYNOPSIS
135
+ run_id like "20260817-a81f" (Spec §19).
136
+ #>
137
+ $stamp = Get-Date -Format 'yyyyMMddHHmmss'
138
+ $rand = -join ((97..122) | Get-Random -Count 4 | ForEach-Object { [char]$_ })
139
+ return "$stamp-$rand"
140
+ }
141
+
142
+ function Get-FwGitInfo {
143
+ <#
144
+ .SYNOPSIS
145
+ {commit, dirty} for audit purposes. Never fails the caller when
146
+ the directory is not a git repo.
147
+ #>
148
+ param([string]$RepoRoot)
149
+ $info = [ordered]@{ commit = $null; dirty = $true }
150
+ if (-not $RepoRoot) { return [pscustomobject]$info }
151
+ $commit = git -C $RepoRoot rev-parse --short HEAD 2>$null
152
+ if ($LASTEXITCODE -eq 0 -and $commit) {
153
+ $info.commit = $commit
154
+ $dirtyLines = git -C $RepoRoot status --porcelain 2>$null
155
+ $info.dirty = [bool]($dirtyLines | Where-Object { $_ })
156
+ }
157
+ return [pscustomobject]$info
158
+ }
159
+
160
+ # --- Compiler diagnostics (GCC/Clang "file:line:col: severity: msg") ----------
161
+ function Get-FwDiagnostics {
162
+ <#
163
+ .SYNOPSIS
164
+ Parse a compiler log into specified diagnostics entries. Handles both
165
+ "file:line:col: error: message" and bare "error:"/"warning:" lines
166
+ (attributed to the last seen file when possible).
167
+ #>
168
+ param(
169
+ [Parameter(Mandatory = $true)][string[]]$Lines,
170
+ [string]$BaseDir = $null
171
+ )
172
+ $diags = [System.Collections.Generic.List[object]]::new()
173
+ $lastFile = $null
174
+ foreach ($raw in $Lines) {
175
+ $line = $raw.TrimEnd()
176
+ if (-not $line) { continue }
177
+ $m = [regex]::Match($line, '^(?<file>.+?):(?<line>\d+):(?<col>\d+):\s*(?<sev>fatal error|error|warning):\s*(?<msg>.*)$')
178
+ if ($m.Success) {
179
+ $file = $m.Groups['file'].Value.Trim('"')
180
+ if ($BaseDir) {
181
+ # Normalize separators so windows/posix mixups don't block the trim.
182
+ $fileNorm = $file.Replace('/', '\')
183
+ $baseNorm = $BaseDir.Replace('/', '\')
184
+ if ($fileNorm.StartsWith($baseNorm, [System.StringComparison]::OrdinalIgnoreCase)) {
185
+ $file = $fileNorm.Substring($baseNorm.Length).TrimStart('\')
186
+ } else {
187
+ $file = $fileNorm
188
+ }
189
+ }
190
+ $sev = $m.Groups['sev'].Value
191
+ if ($sev -eq 'fatal error') { $sev = 'error' }
192
+ $diags.Add([pscustomobject]@{
193
+ file = $file
194
+ line = [int]$m.Groups['line'].Value
195
+ col = [int]$m.Groups['col'].Value
196
+ severity = $sev
197
+ message = $m.Groups['msg'].Value
198
+ })
199
+ $lastFile = $file
200
+ continue
201
+ }
202
+ $m2 = [regex]::Match($line, '^\s*(?<sev>fatal error|error|warning):\s*(?<msg>.*)$')
203
+ if ($m2.Success) {
204
+ $sev = $m2.Groups['sev'].Value
205
+ if ($sev -eq 'fatal error') { $sev = 'error' }
206
+ $diags.Add([pscustomobject]@{
207
+ file = if ($lastFile) { $lastFile } else { $null }
208
+ line = $null
209
+ col = $null
210
+ severity = $sev
211
+ message = $m2.Groups['msg'].Value
212
+ })
213
+ }
214
+ }
215
+ return ,$diags
216
+ }
217
+
218
+ function Invoke-FwProcess {
219
+ <#
220
+ .SYNOPSIS
221
+ Run an external process with a hard timeout. Every external process
222
+ in this toolchain MUST go through here (Spec Rule 6).
223
+ Returns { exit_code, timed_out, stdout, stderr }.
224
+ #>
225
+ param(
226
+ [Parameter(Mandatory = $true)][string]$FilePath,
227
+ [string[]]$Arguments = @(),
228
+ [string]$WorkingDirectory = $null,
229
+ [int]$TimeoutMs = 300000,
230
+ [string]$StdoutFile = $null,
231
+ [string]$StderrFile = $null
232
+ )
233
+ $psi = [System.Diagnostics.ProcessStartInfo]::new()
234
+ $psi.FileName = $FilePath
235
+ foreach ($a in $Arguments) { $psi.ArgumentList.Add($a) }
236
+ $psi.UseShellExecute = $false
237
+ $psi.CreateNoWindow = $true
238
+ $psi.RedirectStandardOutput = $true
239
+ $psi.RedirectStandardError = $true
240
+ if ($WorkingDirectory) { $psi.WorkingDirectory = $WorkingDirectory }
241
+
242
+ try {
243
+ $proc = [System.Diagnostics.Process]::new()
244
+ $proc.StartInfo = $psi
245
+ if (-not $proc.Start()) {
246
+ return [pscustomobject]@{ exit_code = -1; timed_out = $false; stdout = ''; stderr = 'failed to start' }
247
+ }
248
+ $stdoutTask = $proc.StandardOutput.ReadToEndAsync()
249
+ $stderrTask = $proc.StandardError.ReadToEndAsync()
250
+
251
+ $timedOut = -not $proc.WaitForExit($TimeoutMs)
252
+ if ($timedOut) {
253
+ try { $proc.Kill($true) } catch { }
254
+ $proc.WaitForExit()
255
+ }
256
+ $stdout = $stdoutTask.GetAwaiter().GetResult()
257
+ $stderr = $stderrTask.GetAwaiter().GetResult()
258
+
259
+ if ($StdoutFile) {
260
+ Save-FwLog -Path $StdoutFile -Content $stdout
261
+ }
262
+ if ($StderrFile) {
263
+ Save-FwLog -Path $StderrFile -Content $stderr
264
+ }
265
+
266
+ return [pscustomobject]@{
267
+ exit_code = $proc.ExitCode
268
+ timed_out = $timedOut
269
+ stdout = $stdout
270
+ stderr = $stderr
271
+ }
272
+ }
273
+ catch {
274
+ return [pscustomobject]@{
275
+ exit_code = -1
276
+ timed_out = $false
277
+ stdout = ''
278
+ stderr = "failed to launch '$FilePath': $($_.Exception.Message)"
279
+ }
280
+ }
281
+ }
282
+
283
+ function Get-FwLabConfig {
284
+ <#
285
+ .SYNOPSIS
286
+ Parse lab/lab.yaml (or the example fallback) into a PowerShell object.
287
+ YAML is not natively parseable in PowerShell; the project venv python
288
+ + PyYAML does the parsing (release-fix #5: no ConvertFrom-Json on YAML).
289
+ #>
290
+ param([string]$RepoRoot)
291
+ if (-not $RepoRoot) { return $null }
292
+ $python = Resolve-FwPython -RepoRoot $RepoRoot
293
+ if (-not $python) { return $null }
294
+ $candidates = @(
295
+ (Join-Path $RepoRoot 'lab\lab.yaml'),
296
+ (Join-Path $RepoRoot 'lab\lab.example.yaml')
297
+ )
298
+ foreach ($path in $candidates) {
299
+ if (-not (Test-Path -LiteralPath $path)) { continue }
300
+ $script = "import json, sys; sys.stdout.reconfigure(encoding='utf-8', errors='replace');`nimport yaml`nprint(json.dumps(yaml.safe_load(open(sys.argv[1], encoding='utf-8')) or {}))"
301
+ $res = Invoke-FwProcess -FilePath $python -Arguments @('-c', $script, $path) -TimeoutMs 30000
302
+ if ($res.exit_code -eq 0) {
303
+ try { return ($res.stdout | ConvertFrom-Json) } catch { }
304
+ }
305
+ }
306
+ return $null
307
+ }
308
+
309
+ Export-ModuleMember -Function Write-FwJson, Get-FwErrorClass, New-FwError, `
310
+ Resolve-FwPython, Get-FwRepoRoot, Save-FwLog, Get-FwTimestamp, `
311
+ Get-FwRunId, Get-FwGitInfo, Get-FwDiagnostics, Invoke-FwProcess, Get-FwLabConfig
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+ """uart_probe.py - minimal DUT UART probe over a subprocess (simulator DUT).
3
+
4
+ Spawns the compiled firmware artifact, waits for the boot banner, sends one
5
+ CLI command and prints the observed reply line as JSON:
6
+
7
+ {"ok": true, "banner": [...], "command": "jedec", "reply": "JEDEC EF 40 18"}
8
+
9
+ Used by tools/acceptance-scenario.ps1 for the offline (simulator) leg of the
10
+ final acceptance scenario. Real-serial probing goes through Agentic HIL.
11
+ """
12
+
13
+ import json
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+ REPLY_TOKENS = ("JEDEC", "PWM", "ERR")
19
+
20
+
21
+ def main() -> int:
22
+ exe = sys.argv[1]
23
+ command = sys.argv[2] if len(sys.argv) > 2 else "jedec"
24
+ timeout = float(sys.argv[3]) if len(sys.argv) > 3 else 8.0
25
+
26
+ try:
27
+ proc = subprocess.Popen(
28
+ [exe], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
29
+ stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1,
30
+ )
31
+ except OSError as exc:
32
+ print(json.dumps({"ok": False, "error_class": "PROBE_NOT_FOUND",
33
+ "error": f"cannot spawn artifact: {exc}"}))
34
+ return 2
35
+
36
+ banner: list[str] = []
37
+ deadline = time.monotonic() + timeout
38
+ reply = None
39
+
40
+ # read banner until it becomes quiet or the prompt command lands
41
+ while time.monotonic() < deadline:
42
+ try:
43
+ line = proc.stdout.readline()
44
+ except Exception: # noqa: BLE001
45
+ break
46
+ if not line:
47
+ break
48
+ line = line.rstrip("\r\n")
49
+ if not line:
50
+ continue
51
+ banner.append(line)
52
+ if line.startswith("Application started"):
53
+ break
54
+
55
+ try:
56
+ proc.stdin.write(command + "\n")
57
+ proc.stdin.flush()
58
+ except Exception as exc: # noqa: BLE001
59
+ print(json.dumps({"ok": False, "error_class": "UART_TIMEOUT",
60
+ "error": f"cannot write command: {exc}", "banner": banner}))
61
+ proc.kill()
62
+ return 1
63
+
64
+ deadline = time.monotonic() + timeout
65
+ collected: list[str] = []
66
+ while time.monotonic() < deadline:
67
+ try:
68
+ line = proc.stdout.readline()
69
+ except Exception: # noqa: BLE001
70
+ break
71
+ if not line:
72
+ break
73
+ line = line.rstrip("\r\n")
74
+ if not line:
75
+ continue
76
+ if line.startswith(("Bootloader ", "Application ")):
77
+ continue
78
+ collected.append(line)
79
+ if line.startswith(REPLY_TOKENS):
80
+ reply = line
81
+ break
82
+
83
+ proc.kill()
84
+ try:
85
+ proc.wait(timeout=3)
86
+ except Exception: # noqa: BLE001
87
+ pass
88
+
89
+ if reply is None:
90
+ print(json.dumps({"ok": False, "error_class": "UART_TIMEOUT", "error": "no reply",
91
+ "banner": banner, "collected": collected}))
92
+ return 1
93
+
94
+ print(json.dumps({"ok": True, "banner": banner, "command": command,
95
+ "reply": reply, "collected": collected}, ensure_ascii=False))
96
+ return 0
97
+
98
+
99
+ if __name__ == "__main__":
100
+ sys.exit(main())
tools/doctor.ps1 ADDED
@@ -0,0 +1,148 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Environment doctor (Spec M0 / §26). Checks the pieces of the baseline
4
+ toolchain and returns one JSON document:
5
+
6
+ .\tools\doctor.ps1 -Json
7
+
8
+ The doctor NEVER fails the whole run because hardware is absent: hardware
9
+ categories report status "missing"/"not_configured" and delivery is
10
+ deferred to the project. Only a broken core toolchain (no python, no git,
11
+ no build tool) is reported as not-ok.
12
+ #>
13
+ [CmdletBinding()]
14
+ param([switch]$Json)
15
+
16
+ Set-StrictMode -Version Latest
17
+ $ErrorActionPreference = 'Stop'
18
+ Import-Module (Join-Path $PSScriptRoot 'common\fw.psm1') -Force
19
+
20
+ $repoRoot = Get-FwRepoRoot
21
+ $python = Resolve-FwPython -RepoRoot $repoRoot
22
+ $venvBin = if ($python -and $python -match '.venv[\\/]Scripts[\\/]python.exe$') { Split-Path $python } else { $null }
23
+
24
+ function Test-Tool {
25
+ param([string]$Name)
26
+ # prefer the project venv bin dir (tools installed without polluting PATH)
27
+ $candidate = if ($venvBin) { Join-Path $venvBin ($Name + '.exe') } else { $null }
28
+ $cmd = if ($candidate -and (Test-Path -LiteralPath $candidate)) {
29
+ Get-Item -LiteralPath $candidate
30
+ } else {
31
+ Get-Command $Name -ErrorAction SilentlyContinue
32
+ }
33
+ if ($cmd) {
34
+ $v = $null
35
+ $src = if ($cmd.GetType().Name -eq 'ApplicationInfo') { $cmd.Source } else { $cmd.FullName }
36
+ try { $v = (& $src --version 2>&1 | Select-Object -First 1) } catch { }
37
+ return [pscustomobject]@{ status = 'ok'; path = $src; version = [string]$v }
38
+ }
39
+ return [pscustomobject]@{ status = 'missing'; path = $null; version = $null }
40
+ }
41
+
42
+ function Test-PyModule {
43
+ param([string]$Module, [string]$Python)
44
+ if (-not $Python) { return [pscustomobject]@{ status = 'missing'; detail = 'no python' } }
45
+ $out = & $Python -c "import $Module; print(getattr($Module, '__version__', 'present'))" 2>&1
46
+ if ($LASTEXITCODE -eq 0) { return [pscustomobject]@{ status = 'ok'; detail = ([string]$out).Trim() } }
47
+ return [pscustomobject]@{ status = 'missing'; detail = ([string]$out).Trim() }
48
+ }
49
+
50
+ $checks = [ordered]@{}
51
+
52
+ # --- core toolchain -----------------------------------------------------------
53
+ $checks.python = if ($python) {
54
+ $pyv = & $python --version 2>&1
55
+ [pscustomobject]@{ status = 'ok'; path = $python; version = ([string]$pyv).Trim() }
56
+ } else {
57
+ [pscustomobject]@{ status = 'missing'; path = $null; version = $null }
58
+ }
59
+
60
+ $checks.git = if ((Get-Command git -ErrorAction SilentlyContinue)) {
61
+ $gv = git --version 2>&1
62
+ $gitStatus = [pscustomobject]@{ status = 'ok'; path = (Get-Command git).Source; version = ([string]$gv).Trim() }
63
+ $gitStatus | Add-Member -NotePropertyName repo -NotePropertyValue (git -C $repoRoot rev-parse --is-inside-work-tree 2>$null)
64
+ $gitStatus
65
+ } else {
66
+ [pscustomobject]@{ status = 'missing'; path = $null; version = $null }
67
+ }
68
+
69
+ # --- build backends -----------------------------------------------------------
70
+ foreach ($tool in @('cmake', 'ninja', 'make', 'gcc', 'clang')) {
71
+ $checks[$tool] = Test-Tool $tool
72
+ }
73
+ $checks.keil = Test-Tool 'UV4'
74
+ $checks.iar = Test-Tool 'IarBuild'
75
+ $checks.idf = Test-Tool 'idf.py'
76
+ $checks.west = Test-Tool 'west'
77
+ $checks.platformio = Test-Tool 'pio'
78
+
79
+ # --- hardware / lab layer ------------------------------------------------------
80
+ $checks.agentic_hil = Test-Tool 'agentic-hil'
81
+ $checks.openocd = Test-Tool 'openocd'
82
+ $checks.sigrok_cli = Test-Tool 'sigrok-cli'
83
+ $checks.pyocd = if ($python) {
84
+ Test-PyModule 'pyocd' $python
85
+ } else { [pscustomobject]@{ status = 'missing'; detail = 'no python' } }
86
+
87
+ $checks.pytest = if ($python) { Test-PyModule 'pytest' $python } else { [pscustomobject]@{ status = 'missing'; detail = 'no python' } }
88
+ $checks.pyserial = if ($python) { Test-PyModule 'serial' $python } else { [pscustomobject]@{ status = 'missing'; detail = 'no python' } }
89
+ $checks.pyvisa = if ($python) { Test-PyModule 'pyvisa' $python } else { [pscustomobject]@{ status = 'missing'; detail = 'no python' } }
90
+
91
+ # COM ports present (UART candidates) - informational, never hardcoded.
92
+ # Prefer Agentic HIL's enumeration (hardware-aware) when installed, else WMI.
93
+ $comPorts = @()
94
+ $ahil = if ($venvBin) { Join-Path $venvBin 'agentic-hil.exe' } else { $null }
95
+ if ($ahil -and (Test-Path -LiteralPath $ahil)) {
96
+ try {
97
+ $raw = & $ahil com-ports 2>$null | Out-String
98
+ $parsed = $raw | ConvertFrom-Json -ErrorAction SilentlyContinue
99
+ if ($parsed -and $parsed.ports) {
100
+ $comPorts = @($parsed.ports | ForEach-Object { $_.device } | Where-Object { $_ })
101
+ }
102
+ } catch { }
103
+ }
104
+ if ($comPorts.Count -eq 0) {
105
+ try {
106
+ $comPorts = @((Get-CimInstance Win32_SerialPort -ErrorAction SilentlyContinue | Select-Object -ExpandProperty DeviceID))
107
+ } catch { }
108
+ }
109
+ $checks.com_ports = [pscustomobject]@{
110
+ status = if ($comPorts.Count -gt 0) { 'ok' } else { 'none' }
111
+ source = if ($ahil -and (Test-Path -LiteralPath $ahil)) { 'agentic-hil' } else { 'wmi' }
112
+ ports = $comPorts
113
+ }
114
+
115
+ # Saleae Logic 2 MCP endpoint (127.0.0.1:10530) is optional; check socket
116
+ $logicOk = $false
117
+ try {
118
+ $tcp = [System.Net.Sockets.TcpClient]::new()
119
+ $tcp.Connect('127.0.0.1', 10530)
120
+ $logicOk = $tcp.Connected
121
+ $tcp.Close()
122
+ } catch { }
123
+ $checks.logic2_mcp = [pscustomobject]@{ status = if ($logicOk) { 'ok' } else { 'not_configured' }; endpoint = 'http://127.0.0.1:10530' }
124
+
125
+ # --- composition ----------------------------------------------------------------
126
+ $coreNames = @('python', 'git', 'cmake')
127
+ $coreOk = $true
128
+ $missingCore = @()
129
+ foreach ($n in $coreNames) {
130
+ if ($checks[$n].status -ne 'ok') { $coreOk = $false; $missingCore += $n }
131
+ }
132
+
133
+ $result = [ordered]@{
134
+ schema = 'firmware-doctor/v1'
135
+ ok = $coreOk
136
+ generated_at = Get-FwTimestamp
137
+ host = [ordered]@{
138
+ os = [System.Environment]::OSVersion.VersionString
139
+ pwsh = $PSVersionTable.PSVersion.ToString()
140
+ machine = $env:COMPUTERNAME
141
+ }
142
+ missing_core = $missingCore
143
+ checks = $checks
144
+ }
145
+
146
+ if ($Json) { Write-FwJson ([pscustomobject]$result) -Compact } else { Write-FwJson ([pscustomobject]$result) }
147
+
148
+ exit $(if ($coreOk) { 0 } else { 1 })