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.
@@ -0,0 +1,261 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Logic analyzer capture (Spec §13). Saleae is the primary path (Logic 2 MCP
4
+ in Qoder); this wrapper covers the sigrok fallback for non-Saleae hardware
5
+ and provides a deterministic SIMULATOR backend so the capture->decode->
6
+ assert pipeline can be exercised without any LA hardware.
7
+
8
+ Every capture saves (Spec §13): raw capture file, decoder config snapshot,
9
+ and a metadata JSON with the timestamp - under artifacts/captures/.
10
+
11
+ Usage:
12
+ .\tools\logic_capture.ps1 -Protocol spi -Json # simulator (default)
13
+ .\tools\logic_capture.ps1 -Protocol uart -Backend sigrok -Json
14
+ #>
15
+ [CmdletBinding()]
16
+ param(
17
+ [ValidateSet('spi', 'uart', 'i2c')]
18
+ [string]$Protocol = 'spi',
19
+ [ValidateSet('auto', 'simulator', 'sigrok')]
20
+ [string]$Backend = 'auto',
21
+ [double]$DurationSec = 0.002,
22
+ [int]$SampleRateHz = 1000000,
23
+ [string]$OutDir, # default: artifacts/captures/<stamp>
24
+ [switch]$Json
25
+ )
26
+
27
+ Set-StrictMode -Version Latest
28
+ $ErrorActionPreference = 'Stop'
29
+ Import-Module (Join-Path $PSScriptRoot 'common\fw.psm1') -Force
30
+
31
+ $repoRoot = Get-FwRepoRoot
32
+
33
+ function Exit-WithError {
34
+ param([string]$Class, [string]$Message, [string]$Detail)
35
+ $body = New-FwError -ErrorClass $Class -Message $Message -Detail $Detail
36
+ if ($Json) { Write-FwJson $body -Compact } else { Write-FwJson $body; Write-Error "$Class : $Message" -ErrorAction Continue }
37
+ exit 2
38
+ }
39
+
40
+ # ---- decide backend -----------------------------------------------------------
41
+ if ($Backend -eq 'auto') {
42
+ $g = Get-Command sigrok-cli -ErrorAction SilentlyContinue
43
+ $sigrok = if ($g) { $g.Source } else { $null }
44
+ $Backend = if ($sigrok) { 'sigrok' } else { 'simulator' }
45
+ }
46
+ if ($Backend -eq 'sigrok') {
47
+ $g = Get-Command sigrok-cli -ErrorAction SilentlyContinue
48
+ $sigrok = if ($g) { $g.Source } else { $null }
49
+ if (-not $sigrok) {
50
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message 'sigrok-cli not found; install sigrok or use -Backend simulator' -Detail 'https://sigrok.org/wiki/Downloads'
51
+ }
52
+ }
53
+
54
+ # ---- output layout ------------------------------------------------------------
55
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
56
+ $runId = Get-FwRunId
57
+ if (-not $OutDir) { $OutDir = Join-Path $repoRoot "artifacts\captures\$runId" }
58
+ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
59
+
60
+ # decoder config snapshot (read from lab/protocol-decode.yaml if present)
61
+ $decodeCfg = Join-Path $repoRoot 'lab\protocol-decode.yaml'
62
+ $configOut = Join-Path $OutDir 'decoder-config.yaml'
63
+ if (Test-Path -LiteralPath $decodeCfg) {
64
+ Copy-Item -LiteralPath $decodeCfg -Destination $configOut -Force
65
+ } else {
66
+ Set-Content -LiteralPath $configOut -Value "# decoder config snapshot (protocol-decode.yaml not found at capture time)" -Encoding utf8
67
+ }
68
+
69
+ $meta = [ordered]@{
70
+ schema = 'lab-logic-capture/v1'
71
+ ok = $true
72
+ run_id = $runId
73
+ protocol = $Protocol
74
+ backend = $Backend
75
+ duration_sec = $DurationSec
76
+ sample_rate_hz = $SampleRateHz
77
+ timestamp = Get-FwTimestamp
78
+ files = @()
79
+ }
80
+
81
+ # ---- simulator waveform generation --------------------------------------------
82
+ if ($Backend -eq 'simulator') {
83
+ $rawFile = Join-Path $OutDir 'capture.csv'
84
+ $meta.protocol = $Protocol
85
+ switch ($Protocol) {
86
+ 'spi' { $generator = 'SPI transaction: 0x9F read + 24-bit response 0xEF4018' }
87
+ 'uart' { $generator = 'UART frame: "HIL" at 115200 8N1' }
88
+ 'i2c' { $generator = 'I2C write to 0x50 then read 3 bytes' }
89
+ default { $generator = 'unknown' }
90
+ }
91
+ $rows = New-Object System.Collections.Generic.List[string]
92
+ $rows.Add('time_s,D0,D1,D2,D3')
93
+
94
+ function Add-Sample {
95
+ param([double]$T, [int[]]$Levels)
96
+ $rows.Add(('{0:F8},{1},{2},{3},{4}' -f $T, $Levels[0], $Levels[1], $Levels[2], $Levels[3]))
97
+ }
98
+
99
+ $dt = 1.0 / $SampleRateHz
100
+ $t = 0.0
101
+
102
+ if ($Protocol -eq 'spi') {
103
+ # Channels: D0=SCLK, D1=MOSI, D2=MISO, D3=CS (low active)
104
+ # One bit = 1us at 1 MHz SCLK === 1 sample per half-bit at 1MHz? Use
105
+ # 2 samples per bit: low phase then high (rising edge sampled).
106
+ $bitUs = 2e-6 # 2us per bit -> 500 kHz SCLK
107
+ $bits = @() # command bits MSB first
108
+ foreach ($b in (0x9F)) { for ($i = 7; $i -ge 0; $i--) { $bits += ($b -shr $i) -band 1 } }
109
+ $resp = @(0xEF, 0x40, 0x18)
110
+ $respBits = @()
111
+ foreach ($bb in $resp) { for ($i = 7; $i -ge 0; $i--) { $respBits += ($bb -shr $i) -band 1 } }
112
+
113
+ # idle (CS high)
114
+ $idleSamples = [math]::Max(2, [int](0.00005 / $dt))
115
+ for ($i = 0; $i -lt $idleSamples; $i++) {
116
+ Add-Sample $t @(0, 0, 0, 1)
117
+ $t += $dt
118
+ }
119
+ # CS assert
120
+ $csLevels = @(0, 0, 0, 0)
121
+ for ($i = 0; $i -lt 2; $i++) { Add-Sample $t $csLevels; $t += $dt }
122
+ # command + response bits: first 8 bits drive MOSI, next 24 drive MISO
123
+ $allBits = $bits + $respBits
124
+ for ($idx = 0; $idx -lt $allBits.Count; $idx++) {
125
+ $bit = $allBits[$idx]
126
+ $isCmd = $idx -lt 8
127
+ $mosi = if ($isCmd) { $bit } else { 0 }
128
+ $miso = if ($isCmd) { 0 } else { $bit }
129
+ # low phase
130
+ for ($i = 0; $i -lt [int]($bitUs / 2 / $dt); $i++) {
131
+ if ($mosi -eq 1) { Add-Sample $t @(0, 1, $miso, 0) } else { Add-Sample $t @(0, 0, $miso, 0) }
132
+ $t += $dt
133
+ }
134
+ # high phase (rising edge; MISO valid here)
135
+ for ($i = 0; $i -lt [int]($bitUs / 2 / $dt); $i++) {
136
+ if ($mosi -eq 1) { Add-Sample $t @(1, 1, $miso, 0) } else { Add-Sample $t @(1, 0, $miso, 0) }
137
+ $t += $dt
138
+ }
139
+ }
140
+ # CS deassert
141
+ for ($i = 0; $i -lt 2; $i++) { Add-Sample $t @(0, 0, 0, 1); $t += $dt }
142
+ } elseif ($Protocol -eq 'uart') {
143
+ # Channels: D0=TX, D1=RX, D2=unused, D3=unused
144
+ $bitTime = 1.0 / 115200.0
145
+ function Add-Bit {
146
+ param([double]$TT, [int]$Level, [double]$BitLen)
147
+ $n = [math]::Max(1, [int]($BitLen / $dt))
148
+ for ($i = 0; $i -lt $n; $i++) {
149
+ Add-Sample $TT @($Level, 1, 0, 0)
150
+ $TT += $dt
151
+ }
152
+ return $TT
153
+ }
154
+ $payloadBytes = [System.Text.Encoding]::ASCII.GetBytes('HIL')
155
+ $idleN = [math]::Max(2, [int]((10 * $bitTime) / $dt))
156
+ for ($i = 0; $i -lt $idleN; $i++) { Add-Sample $t @(1, 1, 0, 0); $t += $dt }
157
+ foreach ($byte in $payloadBytes) {
158
+ $t = Add-Bit $t 0 $bitTime # start bit
159
+ for ($i = 0; $i -lt 8; $i++) { # LSB first
160
+ $t = Add-Bit $t (($byte -shr $i) -band 1) $bitTime
161
+ }
162
+ $t = Add-Bit $t 1 $bitTime # stop bit
163
+ # inter-frame gap (like software-driven UART transmit)
164
+ $gapN = [math]::Max(2, [int]((10 * $bitTime) / $dt))
165
+ for ($i = 0; $i -lt $gapN; $i++) { Add-Sample $t @(1, 1, 0, 0); $t += $dt }
166
+ }
167
+ } else { # i2c
168
+ # Channels: D0=SCL, D1=SDA; write transaction: START, addr 0xA0,
169
+ # reg 0x00 with ACKs, STOP.
170
+ $bitTime = 1.0 / 100000.0
171
+ function Add-I2CBit {
172
+ param([double]$TT, [int]$SdaLevel, [double]$BitLen)
173
+ $n = [math]::Max(1, [int]($BitLen / $dt))
174
+ for ($i = 0; $i -lt $n; $i++) {
175
+ Add-Sample $TT @(0, $SdaLevel, 0, 0)
176
+ $TT += $dt
177
+ }
178
+ # clock high
179
+ $nh = [math]::Max(1, [int]($BitLen / $dt))
180
+ for ($i = 0; $i -lt $nh; $i++) {
181
+ Add-Sample $TT @(1, $SdaLevel, 0, 0)
182
+ $TT += $dt
183
+ }
184
+ return $TT
185
+ }
186
+ function Add-I2CHold {
187
+ param([double]$TT, [int]$SdaLevel, [double]$Secs)
188
+ $n = [math]::Max(1, [int]($Secs / $dt))
189
+ for ($i = 0; $i -lt $n; $i++) {
190
+ Add-Sample $TT @(1, $SdaLevel, 0, 0) # SCL stays high
191
+ $TT += $dt
192
+ }
193
+ return $TT
194
+ }
195
+ $hold = $bitTime / 2
196
+
197
+ # idle (bus free: SCL high, SDA high)
198
+ $idleN = [math]::Max(2, [int]((4 * $bitTime) / $dt))
199
+ for ($i = 0; $i -lt $idleN; $i++) { Add-Sample $t @(1, 1, 0, 0); $t += $dt }
200
+ # START: SDA falls while SCL is high
201
+ $t = Add-I2CHold $t 0 $hold
202
+ Add-Sample $t @(1, 0, 0, 0); $t += $dt
203
+ $t = Add-I2CHold $t 0 $hold
204
+ # address + data bytes with ACK after each
205
+ foreach ($byte in @(0xA0, 0x00)) {
206
+ foreach ($i in 7..0) {
207
+ $t = Add-I2CBit $t (($byte -shr $i) -band 1) $bitTime
208
+ }
209
+ $t = Add-I2CBit $t 0 $bitTime # ACK
210
+ }
211
+ # STOP: SDA rises while SCL is high
212
+ $t = Add-I2CHold $t 0 $hold
213
+ Add-Sample $t @(1, 1, 0, 0); $t += $dt
214
+ $t = Add-I2CHold $t 1 $hold
215
+ }
216
+
217
+ Set-Content -LiteralPath $rawFile -Value $rows -Encoding utf8
218
+ $meta.files += (Resolve-Path -LiteralPath $rawFile).Path
219
+ $meta.generator = $generator
220
+ }
221
+
222
+ # ---- sigrok backend ------------------------------------------------------------
223
+ else {
224
+ $rawFile = Join-Path $OutDir 'capture.sr'
225
+ $probeArgs = @(
226
+ "--driver=auto",
227
+ "--config", "samplerate=$SampleRateHz",
228
+ "--channels", "0-7",
229
+ "--output-format", "binary",
230
+ "--output-file", $rawFile,
231
+ "--time", "$([int]([math]::Ceiling($DurationSec * 1000)))ms",
232
+ "$Protocol"
233
+ )
234
+ $res = Invoke-FwProcess -FilePath $sigrok -Arguments $probeArgs -WorkingDirectory $repoRoot -TimeoutMs 120000
235
+ if ($res.timed_out) {
236
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message 'sigrok-cli capture timed out.' -Detail $rawFile
237
+ }
238
+ if ($res.exit_code -ne 0 -or -not (Test-Path -LiteralPath $rawFile)) {
239
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message 'sigrok-cli capture failed.' -Detail ($res.stdout + $res.stderr)
240
+ }
241
+ $meta.files += (Resolve-Path -LiteralPath $rawFile).Path
242
+ }
243
+
244
+ $metaFile = Join-Path $OutDir 'capture.json'
245
+ $meta | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $metaFile -Encoding utf8
246
+ $meta.files += (Resolve-Path -LiteralPath $metaFile).Path
247
+
248
+ $result = [ordered]@{
249
+ schema = 'lab-logic-capture/v1'
250
+ ok = $true
251
+ protocol = $Protocol
252
+ backend = $Backend
253
+ capture = (Resolve-Path -LiteralPath $rawFile).Path
254
+ metadata = (Resolve-Path -LiteralPath $metaFile).Path
255
+ decoder_config = (Resolve-Path -LiteralPath $configOut).Path
256
+ files = @($meta.files)
257
+ run_id = $runId
258
+ timestamp = Get-FwTimestamp
259
+ }
260
+ if ($Json) { Write-FwJson ([pscustomobject]$result) -Compact } else { Write-FwJson ([pscustomobject]$result) }
261
+ exit 0
tools/logic_decode.ps1 ADDED
@@ -0,0 +1,307 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Logic decoder (Spec §13 fallback). Decodes a raw capture (CSV with a
4
+ time_s first column and one column per channel) into JSON frames using the
5
+ channel/protocol layout from lab/protocol-decode.yaml.
6
+
7
+ SPI : D0=SCLK, D1=MOSI, D2=MISO, D3=CS (low active), MSB first
8
+ UART : D0=TX (idle high), 8N1, LSB first, baud from config
9
+ I2C : D0=SCL, D1=SDA (basic byte/ACK extraction)
10
+
11
+ Every decoded result is saved next to the capture and carries the
12
+ timestamp. Optional -Expect/-ExpectKind asserts the result and exits 1
13
+ with TEST_FAILED on mismatch (for HIL assertions).
14
+
15
+ Usage:
16
+ .\tools\logic_decode.ps1 -Capture artifacts\captures\20260817-000000 -Json
17
+ .\tools\logic_decode.ps1 -CaptureFile x.csv -Protocol spi -Expect EF4018 -ExpectKind hex -Json
18
+ #>
19
+ [CmdletBinding()]
20
+ param(
21
+ [string]$Capture, # capture dir containing capture.csv + capture.json
22
+ [string]$CaptureFile, # direct path to the raw CSV
23
+ [ValidateSet('spi', 'uart', 'i2c')]
24
+ [string]$Protocol, # override protocol (else read from metadata)
25
+ [string]$Expect, # expected decoded value (hex string or text)
26
+ [ValidateSet('hex', 'text')]
27
+ [string]$ExpectKind = 'hex',
28
+ [switch]$Json
29
+ )
30
+
31
+ Set-StrictMode -Version Latest
32
+ $ErrorActionPreference = 'Stop'
33
+ Import-Module (Join-Path $PSScriptRoot 'common\fw.psm1') -Force
34
+
35
+ $repoRoot = Get-FwRepoRoot
36
+
37
+ function Exit-WithError {
38
+ param([string]$Class, [string]$Message, [string]$Detail)
39
+ $body = New-FwError -ErrorClass $Class -Message $Message -Detail $Detail
40
+ if ($Json) { Write-FwJson $body -Compact } else { Write-FwJson $body; Write-Error "$Class : $Message" -ErrorAction Continue }
41
+ exit 2
42
+ }
43
+
44
+ # ---- locate raw capture -------------------------------------------------------
45
+ $rawFile = $null
46
+ $meta = $null
47
+ if ($Capture) {
48
+ if (Test-Path -LiteralPath $Capture -PathType Container) {
49
+ $rawFile = Join-Path $Capture 'capture.csv'
50
+ $metaPath = Join-Path $Capture 'capture.json'
51
+ if (Test-Path -LiteralPath $metaPath) {
52
+ $meta = Get-Content -LiteralPath $metaPath -Raw | ConvertFrom-Json
53
+ }
54
+ if (-not (Test-Path -LiteralPath $rawFile)) {
55
+ $srFile = Join-Path $Capture 'capture.sr'
56
+ if (Test-Path -LiteralPath $srFile) {
57
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message 'sigrok binary capture needs sigrok-cli decode; export CSV from PulseView or pass -CaptureFile <csv>.' -Detail $srFile
58
+ }
59
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message 'no capture.csv or capture.sr found in capture dir.' -Detail $Capture
60
+ }
61
+ } elseif (Test-Path -LiteralPath $Capture -PathType Leaf) {
62
+ $rawFile = $Capture # direct file path (capture.csv)
63
+ $metaPath = Join-Path (Split-Path $Capture) 'capture.json'
64
+ if (Test-Path -LiteralPath $metaPath) {
65
+ $meta = Get-Content -LiteralPath $metaPath -Raw | ConvertFrom-Json
66
+ }
67
+ } else {
68
+ Exit-WithError -Class 'CONFIG_ERROR' -Message 'Capture path does not exist.' -Detail $Capture
69
+ }
70
+ } elseif ($CaptureFile) {
71
+ $rawFile = $CaptureFile
72
+ } else {
73
+ Exit-WithError -Class 'CONFIG_ERROR' -Message 'pass -Capture <dir> or -CaptureFile <csv>'
74
+ }
75
+
76
+ if (-not (Test-Path -LiteralPath $rawFile)) {
77
+ Exit-WithError -Class 'ARTIFACT_NOT_FOUND' -Message 'capture file does not exist.' -Detail $rawFile
78
+ }
79
+
80
+ # ---- protocol/config resolution ------------------------------------------------
81
+ $cfg = @{}
82
+ $cfgPath = Join-Path $repoRoot 'lab\protocol-decode.yaml'
83
+ # YAML is not natively parseable in PowerShell; the decoder only needs the
84
+ # baud rate for UART and channel mapping. Fall back to sane defaults.
85
+ if ($meta -and $meta.protocol) { $reqProtocol = [string]$meta.protocol } else { $reqProtocol = $null }
86
+ if ($Protocol) { $reqProtocol = $Protocol }
87
+ if (-not $reqProtocol) {
88
+ Exit-WithError -Class 'CONFIG_ERROR' -Message 'protocol unknown; pass -Protocol or run capture first' -Detail $metaPath
89
+ }
90
+
91
+ $baud = 115200
92
+ if ($reqProtocol -eq 'uart' -and $meta) {
93
+ $bProp = $meta.PSObject.Properties['baud']
94
+ if ($bProp -and $bProp.Value) { $baud = [int]$bProp.Value }
95
+ }
96
+
97
+ # ---- load samples --------------------------------------------------------------
98
+ $rows = Import-Csv -LiteralPath $rawFile
99
+ if ($rows.Count -lt 3) {
100
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message 'capture has too few samples to decode.' -Detail $rawFile
101
+ }
102
+ $cols = $rows[0].PSObject.Properties.Name
103
+ foreach ($c in @('time_s')) { if ($c -notin $cols) { Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message "missing column '$c' in capture" -Detail $rawFile } }
104
+ $ch = @{}
105
+ foreach ($c in $cols) { if ($c -ne 'time_s') { $ch[$c] = $true } }
106
+
107
+ function Get-Ch {
108
+ param([int]$Index)
109
+ $names = @($cols | Where-Object { $_ -ne 'time_s' })
110
+ if ($Index -ge $names.Count) { return $null }
111
+ return $names[$Index]
112
+ }
113
+
114
+ # normalize levels (CSV values may be 0/1, true/false, high/low)
115
+ function Lev([string]$v) {
116
+ $s = $v.Trim().ToLowerInvariant()
117
+ if ($s -in @('1', 'true', 'high', 'h')) { return 1 }
118
+ if ($s -in @('0', 'false', 'low', 'l')) { return 0 }
119
+ return 0
120
+ }
121
+
122
+ # ---- decoders -------------------------------------------------------------------
123
+ $frames = @()
124
+ $bytes = @()
125
+ $text = ''
126
+
127
+ switch ($reqProtocol) {
128
+ 'spi' {
129
+ $cClk = Get-Ch 0; $cMosi = Get-Ch 1; $cMiso = Get-Ch 2; $cCs = Get-Ch 3
130
+ if (-not ($cClk -and $cMosi -and $cMiso -and $cCs)) {
131
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message "SPI needs 4 channels (SCLK,MOSI,MISO,CS); found: $($cols -join ',')"
132
+ }
133
+ $inWindow = $false
134
+ $cmdBits = @(); $respBits = @(); $prevClk = 0
135
+ foreach ($r in $rows) {
136
+ $cs = Lev $r.$cCs
137
+ if (-not $inWindow -and $cs -eq 0) { $inWindow = $true; $cmdBits = @(); $respBits = @() }
138
+ if ($inWindow -and $cs -eq 1) { break } # window closed
139
+ if ($inWindow) {
140
+ $clk = Lev $r.$cClk
141
+ if ($clk -eq 1 -and $prevClk -eq 0) { # rising edge
142
+ $mosi = Lev $r.$cMosi
143
+ $miso = Lev $r.$cMiso
144
+ if ($cmdBits.Count + $respBits.Count -lt 8) {
145
+ $cmdBits += $mosi
146
+ } else {
147
+ $respBits += $miso
148
+ }
149
+ }
150
+ $prevClk = $clk
151
+ }
152
+ }
153
+ if ($cmdBits.Count + $respBits.Count -ne 32) {
154
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message "expected 32 SPI edges (8 cmd + 24 resp), got $($cmdBits.Count + $respBits.Count)" -Detail $rawFile
155
+ }
156
+ $cmdByte = 0; foreach ($b in $cmdBits) { $cmdByte = ($cmdByte -shl 1) -bor $b }
157
+ $respBytes = @()
158
+ for ($k = 0; $k -lt 24; $k += 8) {
159
+ $v = 0
160
+ for ($j = 0; $j -lt 8; $j++) { $v = ($v -shl 1) -bor $respBits[$k + $j] }
161
+ $respBytes += $v
162
+ }
163
+ $bytes = @($cmdByte) + $respBytes
164
+ $frames = @([ordered]@{
165
+ type = 'command'; bytes_hex = ('0x{0:X2}' -f $cmdByte)
166
+ }, [ordered]@{
167
+ type = 'response'; bytes_hex = (($respBytes | ForEach-Object { '0x{0:X2}' -f $_ }) -join ' ')
168
+ })
169
+ }
170
+
171
+ 'uart' {
172
+ $cTx = Get-Ch 0
173
+ if (-not $cTx) { Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message "UART needs a TX channel (D0); found: $($cols -join ',')" }
174
+ $samples = @($rows | ForEach-Object { Lev $_.$cTx })
175
+ # Estimate bit width from the MODE of adjacent edge spacings - robust
176
+ # against data bits that are low for more than one bit (e.g. 0x48).
177
+ $edges = @()
178
+ for ($k = 0; $k -lt $samples.Count - 1; $k++) {
179
+ if ($samples[$k] -ne $samples[$k + 1]) { $edges += $k + 1 }
180
+ }
181
+ $spacings = @()
182
+ for ($k = 1; $k -lt $edges.Count; $k++) {
183
+ $spacings += ($edges[$k] - $edges[$k - 1])
184
+ }
185
+ if ($spacings.Count -eq 0) {
186
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message 'no edges found on UART TX channel; is the capture from a real UART?'
187
+ }
188
+ $bitSamples = ($spacings | Group-Object | Sort-Object Count -Descending | Select-Object -First 1).Name
189
+ $bitSamples = [math]::Max(2, [int]$bitSamples)
190
+
191
+ $i = 0
192
+ while ($i -lt $samples.Count - 1) {
193
+ # Frame boundary: falling edge preceded by >= 3 bits of idle high.
194
+ # A real start bit has stop + inter-frame idle before it; mid-data
195
+ # runs of 1s are at most 2 bits before a data falling edge.
196
+ $preHigh = $true
197
+ for ($c = 0; $c -lt 3 * $bitSamples; $c++) {
198
+ $pi = $i - $c
199
+ if ($pi -lt 0) { $preHigh = $false; break }
200
+ if ($samples[$pi] -ne 1) { $preHigh = $false; break }
201
+ }
202
+ if ($samples[$i] -eq 1 -and $samples[$i + 1] -eq 0 -and $preHigh) {
203
+ $collected = @()
204
+ for ($b = 0; $b -lt 8; $b++) {
205
+ $idx = $i + 1 + [int]((1.5 + $b) * $bitSamples)
206
+ $bitVal = if ($idx -lt $samples.Count) { $samples[$idx] } else { 1 }
207
+ $collected += $bitVal # LSB first
208
+ }
209
+ # stop bit must be high (idle level) for a valid UART frame
210
+ $stopIdx = $i + 1 + [int](10.0 * $bitSamples)
211
+ $stopOk = ($stopIdx -lt $samples.Count) -and ($samples[$stopIdx] -eq 1)
212
+ if (-not $stopOk) {
213
+ $i++ # not a real frame boundary (mid-data flip); keep scanning
214
+ continue
215
+ }
216
+ $val = 0
217
+ for ($b = 7; $b -ge 0; $b--) { $val = ($val -shl 1) -bor $collected[$b] }
218
+ $bytes += $val
219
+ $frames += [ordered]@{ type = 'frame'; bytes_hex = '0x{0:X2}' -f $val }
220
+ $i += $bitSamples # advance into the frame interior; the scan
221
+ # re-hits the next boundary via the idle-high precondition
222
+ continue
223
+ }
224
+ $i++
225
+ }
226
+ $text = -join ($bytes | ForEach-Object { [char]$_ })
227
+ }
228
+
229
+ 'i2c' {
230
+ $cScl = Get-Ch 0; $cSda = Get-Ch 1
231
+ if (-not ($cScl -and $cSda)) {
232
+ Exit-WithError -Class 'LOGIC_CAPTURE_ERROR' -Message "I2C needs SCL (D0) and SDA (D1); found: $($cols -join ',')"
233
+ }
234
+ # Collect the SDA value at every SCL rising edge; one byte = 8 data
235
+ # bits + 1 ACK. START (SDA falls while SCL high) opens the stream.
236
+ $prevScl = 0
237
+ $bits = [System.Collections.Generic.List[int]]::new()
238
+ $inStream = $false
239
+ foreach ($r in $rows) {
240
+ $scl = Lev $r.$cScl
241
+ $sda = Lev $r.$cSda
242
+ if ($scl -eq 1 -and $prevScl -eq 0) { # rising edge
243
+ if ($inStream) { $bits.Add($sda) }
244
+ } elseif ($scl -eq 1 -and $prevScl -eq 1) {
245
+ # SCL high window: SDA falling => START, rising => STOP
246
+ if ($lastSclHighSda -eq 1 -and $sda -eq 0) { $inStream = $true; $bits.Clear() }
247
+ elseif ($lastSclHighSda -eq 0 -and $sda -eq 1) { $inStream = $false }
248
+ $lastSclHighSda = $sda
249
+ }
250
+ $prevScl = $scl
251
+ if ($scl -eq 1) { $lastSclHighSda = $sda }
252
+ }
253
+ # group 8-bit chunks (skip the ACK bit every 9th sample)
254
+ $payload = [System.Collections.Generic.List[int]]::new()
255
+ $idx = 0
256
+ while ($idx + 8 -le $bits.Count) {
257
+ $v = 0
258
+ for ($b = 0; $b -lt 8; $b++) { $v = ($v -shl 1) -bor $bits[$idx + $b] }
259
+ $payload.Add($v)
260
+ $idx += 9 # skip ACK
261
+ }
262
+ foreach ($v in $payload) {
263
+ $bytes += $v
264
+ $frames += [ordered]@{ type = 'byte'; bytes_hex = '0x{0:X2}' -f $v }
265
+ }
266
+ }
267
+ }
268
+
269
+ $hexStr = (($bytes | ForEach-Object { '{0:X2}' -f $_ }) -join '')
270
+ $outDir = Split-Path -Parent (Resolve-Path -LiteralPath $rawFile).Path
271
+ $outJson = Join-Path $outDir 'decoded.json'
272
+
273
+ $decoded = [ordered]@{
274
+ schema = 'lab-logic-decode/v1'
275
+ ok = $true
276
+ protocol = $reqProtocol
277
+ source = (Resolve-Path -LiteralPath $rawFile).Path
278
+ bytes_hex = $hexStr
279
+ text = $text
280
+ frames = $frames
281
+ timestamp = Get-FwTimestamp
282
+ }
283
+ $decoded | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outJson -Encoding utf8
284
+ $decoded.out_file = (Resolve-Path -LiteralPath $outJson).Path
285
+
286
+ # ---- assertion ----------------------------------------------------------------
287
+ if ($Expect) {
288
+ $actual = if ($ExpectKind -eq 'hex') { $hexStr } else { $text }
289
+ if ($actual -ne $Expect) {
290
+ $body = [ordered]@{
291
+ schema = 'lab-logic-decode/v1'
292
+ ok = $false
293
+ error_class = 'TEST_FAILED'
294
+ error = 'decoded value did not match expectation'
295
+ protocol = $reqProtocol
296
+ expected = $Expect
297
+ actual = $actual
298
+ decoded_file = $outJson
299
+ timestamp = Get-FwTimestamp
300
+ }
301
+ if ($Json) { Write-FwJson ([pscustomobject]$body) -Compact } else { Write-FwJson ([pscustomobject]$body) }
302
+ exit 1
303
+ }
304
+ }
305
+
306
+ if ($Json) { Write-FwJson ([pscustomobject]$decoded) -Compact } else { Write-FwJson ([pscustomobject]$decoded) }
307
+ exit 0