<# Shared flashing engine for the anchor / monitor / tag firmware sets. Dot-source this file to get the functions; it does nothing on its own. The per-target wrappers (flash_tag.ps1 etc.) call Invoke-EspFlash. Everything that varies per firmware - chip, flash mode/size/freq and the file -> offset map - is read from that target's flasher_args.json, which ESP-IDF generates at build time. Nothing is hard-coded here. #> # Directory of THIS file, captured while it is being dot-sourced, so helpers can # find verify_boot.py regardless of which wrapper called them. $script:CommonRoot = $PSScriptRoot $script:ChipIds = @{ 0 = 'ESP32' 2 = 'ESP32-S2' 5 = 'ESP32-C3' 9 = 'ESP32-S3' 12 = 'ESP32-C2' 13 = 'ESP32-C6' 16 = 'ESP32-H2' 18 = 'ESP32-P4' } $script:UsbVendors = @{ '303A' = 'Espressif USB-Serial/JTAG' '10C4' = 'Silicon Labs CP210x' '1A86' = 'WCH CH340/CH9102' '0403' = 'FTDI' '067B' = 'Prolific PL2303' } $script:PartTypes = @{ 0 = 'app'; 1 = 'data' } function Write-Fail([string]$msg) { Write-Host "ERROR: $msg" -ForegroundColor Red exit 1 } function Write-Info([string]$msg) { Write-Host $msg -ForegroundColor Cyan } function Write-Warn([string]$msg) { Write-Host "WARN: $msg" -ForegroundColor Yellow } function Read-CString([byte[]]$bytes, [int]$off, [int]$len) { $end = $off $limit = [Math]::Min($off + $len, $bytes.Length) while ($end -lt $limit -and $bytes[$end] -ne 0) { $end++ } if ($end -le $off) { return '' } return [System.Text.Encoding]::UTF8.GetString($bytes, $off, $end - $off) } # ---------------------------------------------------------------- esptool ---- function Find-Esptool([string]$Override) { if ($Override) { if (-not (Test-Path $Override)) { Write-Fail "esptool not found at: $Override" } return (Resolve-Path $Override).Path } if ($env:ESPTOOL -and (Test-Path $env:ESPTOOL)) { return $env:ESPTOOL } $cmd = Get-Command 'esptool.exe', 'esptool.py', 'esptool' -ErrorAction SilentlyContinue | Select-Object -First 1 if ($cmd) { return $cmd.Source } $found = @() foreach ($root in @('D:\Espressif', 'C:\Espressif', 'E:\Espressif', "$env:USERPROFILE\.espressif")) { if (-not (Test-Path $root)) { continue } $found += Get-ChildItem -Path (Join-Path $root 'python_env\*\Scripts\esptool.exe') -ErrorAction SilentlyContinue if ($found.Count -eq 0) { $found += Get-ChildItem -Path $root -Filter 'esptool.exe' -Recurse -Depth 5 -ErrorAction SilentlyContinue } } if ($found.Count -eq 0) { Write-Fail "esptool not found. Install ESP-IDF, or 'pip install esptool', or pass -Esptool ." } return ($found | Sort-Object FullName -Descending | Select-Object -First 1).FullName } function Find-Python([string]$esptoolPath) { # python.exe sits next to esptool.exe in a venv Scripts dir; fall back to the # env root and finally to PATH. $toolDir = Split-Path -Parent $esptoolPath foreach ($cand in @((Join-Path $toolDir 'python.exe'), (Join-Path (Split-Path -Parent $toolDir) 'python.exe'))) { if (Test-Path $cand) { return $cand } } $pyCmd = Get-Command python -ErrorAction SilentlyContinue if ($pyCmd) { return $pyCmd.Source } return '' } # ------------------------------------------------------------------- port ---- function Get-CandidatePorts { $all = @(Get-CimInstance Win32_PnPEntity -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '\(COM\d+\)' }) $out = @() foreach ($dev in $all) { $com = '' if ($dev.Name -match '\((COM\d+)\)') { $com = $Matches[1] } $vid = '' if ($dev.DeviceID -match 'VID_([0-9A-Fa-f]{4})') { $vid = $Matches[1].ToUpper() } $usbPid = '' if ($dev.DeviceID -match 'PID_([0-9A-Fa-f]{4})') { $usbPid = $Matches[1].ToUpper() } $kind = 'other' $likely = $false $rank = 9 if ($script:UsbVendors.ContainsKey($vid)) { $kind = $script:UsbVendors[$vid]; $likely = $true; $rank = 2 } if ($vid -eq '1366') { $kind = 'SEGGER J-Link (ignored)'; $likely = $false; $rank = 9 } # Espressif VID port meanings (verified 2026-09-03, IDF v5.5.3): # PID_1001 = ROM download mode / built-in USB-Serial/JTAG -> flash directly. # PID_0009 = running app's ROM-CDC console -> esptool CAN flash through it: # its default_reset (RTS falling edge with DTR high) reboots the # chip into download mode automatically. Use -b 115200 (a baud # change on native USB is flaky and can wedge the device). # PID_4001 = TinyUSB-era app console -> no control-line reset; manual BOOT. if ($vid -eq '303A') { if ($usbPid -eq '1001') { $kind = 'Espressif USB-Serial/JTAG (download mode)' $rank = 0 } elseif ($usbPid -eq '0009') { $kind = 'Espressif app ROM-CDC console (esptool can soft-reset it to download mode)' $rank = 1 } else { $kind = "Espressif USB iface PID_$usbPid (not flashable; needs manual BOOT)" $rank = 5 } } $out += [pscustomobject]@{ Port = $com; Vid = $vid; Pid = $usbPid; Kind = $kind; Likely = $likely; Rank = $rank } } return $out | Sort-Object Rank, { [int]($_.Port -replace '\D', '') } } function Show-Ports($ports) { if ($ports.Count -eq 0) { Write-Host ' (no serial ports found)'; return } foreach ($p in $ports) { $mark = ' ' if ($p.Rank -le 1) { $mark = ' * ' } Write-Host ("{0}{1,-6} VID_{2}:PID_{3} {4}" -f $mark, $p.Port, $p.Vid, $p.Pid, $p.Kind) } } # $AllowNone lets a dry run proceed with no board attached: it returns '' instead # of aborting, and the caller then skips everything that needs the hardware. function Resolve-Port([bool]$AllowNone = $false) { $ports = @(Get-CandidatePorts) # Rank 0/1 = download-mode JTAG interface, or the app's ROM-CDC console # (esptool soft-resets the chip into download mode through it). $best = @($ports | Where-Object { $_.Rank -le 1 }) if ($best.Count -eq 1) { Write-Info ("Port : {0} ({1})" -f $best[0].Port, $best[0].Kind) return $best[0].Port } if ($best.Count -gt 1) { Write-Host 'Multiple Espressif ports - unplug all but the board you want:' Show-Ports $best $ans = Read-Host 'Which port? (e.g. COM8)' if (-not $ans) { Write-Fail 'No port chosen.' } return $ans.Trim().ToUpper() } # No flashable interface. Remaining Espressif PIDs (e.g. TinyUSB-era app # console, PID_4001) have no control-line reset; the board must be put # into download mode by hand once. After flashing this firmware package # (ROM-CDC console), that is never needed again. $espOther = @($ports | Where-Object { $_.Vid -eq '303A' }) if ($espOther.Count -gt 0) { Write-Host 'Serial ports seen:' Show-Ports $ports Write-Warn 'An Espressif device is present, but its USB interface has no esptool soft-reset (old TinyUSB-era firmware?).' Write-Host 'Put the board into download mode ONCE: hold BOOT, tap RESET, release BOOT.' -ForegroundColor Yellow Write-Host 'Then re-run; the port should come back as PID_1001.' -ForegroundColor Yellow if ($AllowNone) { return '' } Write-Fail 'No flashable port. See above.' } if ($AllowNone) { Write-Warn 'No ESP board detected - continuing without it (dry run).' return '' } Write-Host 'Serial ports seen:' Show-Ports $ports Write-Fail 'No USB-serial device that looks like an ESP board. Plug it in, or pass -Port COMx.' } # ------------------------------------------------------------------ image ---- function Read-ImageInfo([string]$path) { $b = [System.IO.File]::ReadAllBytes($path) if ($b.Length -lt 0x100) { Write-Fail "$path is too small to be an ESP image." } if ($b[0] -ne 0xE9) { Write-Fail ("{0} does not start with the ESP image magic 0xE9 (found 0x{1:X2})." -f $path, $b[0]) } $chipId = [int][BitConverter]::ToUInt16($b, 12) $chipName = 'unknown' if ($script:ChipIds.ContainsKey($chipId)) { $chipName = $script:ChipIds[$chipId] } $info = [ordered]@{ Size = $b.Length; ChipId = $chipId; ChipName = $chipName Project = ''; Version = ''; Built = ''; IdfVer = ''; IsApp = $false } # esp_app_desc_t magic 0xABCD5432, little endian. Compared byte-wise because # PowerShell 5.1 parses an 8-digit hex literal as a negative Int32. if ($b[0x20] -eq 0x32 -and $b[0x21] -eq 0x54 -and $b[0x22] -eq 0xCD -and $b[0x23] -eq 0xAB) { $info.IsApp = $true $info.Version = Read-CString $b 0x30 32 $info.Project = Read-CString $b 0x50 32 $t = Read-CString $b 0x70 16 $d = Read-CString $b 0x80 16 $info.Built = ("{0} {1}" -f $d, $t).Trim() $info.IdfVer = Read-CString $b 0x90 32 } return [pscustomobject]$info } # -------------------------------------------------------- partition table ---- function ConvertFrom-PartitionTable([byte[]]$b) { $parts = @() for ($i = 0; ($i + 32) -le $b.Length; $i += 32) { # 0xAA 0x50 = ESP_PARTITION_MAGIC (0x50AA little endian) if ($b[$i] -ne 0xAA -or $b[$i + 1] -ne 0x50) { break } $parts += [pscustomobject]@{ Type = [int]$b[$i + 2] SubType = [int]$b[$i + 3] Offset = [BitConverter]::ToUInt32($b, $i + 4) Size = [BitConverter]::ToUInt32($b, $i + 8) Label = Read-CString $b ($i + 12) 16 } } return $parts } function Get-PartitionTableFromFile([string]$path) { return ConvertFrom-PartitionTable ([System.IO.File]::ReadAllBytes($path)) } function Show-PartitionTable($parts, [string]$indent = ' ') { foreach ($p in $parts) { $tn = 'type ' + $p.Type if ($script:PartTypes.ContainsKey($p.Type)) { $tn = $script:PartTypes[$p.Type] } Write-Host ("{0}{1,-14} {2,-5} sub 0x{3:X2} 0x{4:X6} {5,8} KB" -f ` $indent, $p.Label, $tn, $p.SubType, $p.Offset, [int]($p.Size / 1024)) } } <# Compare the table currently on the board with the one we are about to write and report any DATA partition that would disappear or move. App partitions are rewritten anyway, so only data partitions carry a loss risk. Returns an object with .Identical and .Risks (array of strings). #> function Compare-PartitionTables($current, $incoming) { $risks = @() $identical = $true foreach ($old in @($current | Where-Object { $_.Type -eq 1 })) { $match = @($incoming | Where-Object { $_.Label -eq $old.Label }) if ($match.Count -eq 0) { $risks += ("data partition '{0}' ({1} KB at 0x{2:X}) does not exist in the new table - its contents become unreachable" -f ` $old.Label, [int]($old.Size / 1024), $old.Offset) continue } $new = $match[0] if ($new.Offset -ne $old.Offset) { # phy_init holds RF calibration data, which ESP-IDF regenerates on the # next boot when it is missing or invalid. Say so, so a move of just # this partition is not mistaken for real data loss. $note = '' if ($old.Label -eq 'phy_init') { $note = ' [LOW: RF calibration data, regenerated automatically on next boot]' } $risks += ("data partition '{0}' moves from 0x{1:X} to 0x{2:X} - existing contents would be at the wrong address{3}" -f ` $old.Label, $old.Offset, $new.Offset, $note) } elseif ($new.Size -lt $old.Size) { $risks += ("data partition '{0}' shrinks from {1} KB to {2} KB - contents past the new end are lost" -f ` $old.Label, [int]($old.Size / 1024), [int]($new.Size / 1024)) } } if ($current.Count -ne $incoming.Count) { $identical = $false } else { for ($i = 0; $i -lt $current.Count; $i++) { $a = $current[$i]; $b = $incoming[$i] if ($a.Label -ne $b.Label -or $a.Type -ne $b.Type -or $a.SubType -ne $b.SubType -or $a.Offset -ne $b.Offset -or $a.Size -ne $b.Size) { $identical = $false; break } } } return [pscustomobject]@{ Identical = $identical; Risks = $risks } } <# Check the byte ranges we are about to write against the DATA partitions that exist on the board right now. This is the check that -AppOnly needs: leaving the partition table alone does not help if the app's own offset lands on top of a data partition, which is exactly what happens when a firmware built for a different layout is written to this board (e.g. an app at 0x10000 over a board whose nvs lives there). Returns an array of human-readable risk strings. #> function Test-WriteOverlap($currentPt, $toWrite, $appOffset) { # A bare hex literal at a call site arrives as a string in PowerShell argument # mode, which would break both the -eq below and the 0x{0:X} formatting. if ($appOffset -ne $null) { $appOffset = [int64]$appOffset } $risks = @() foreach ($f in $toWrite) { $start = [int64]$f.Offset $end = $start + (Get-Item $f.Path).Length # exclusive foreach ($p in @($currentPt | Where-Object { $_.Type -eq 1 })) { $ps = [int64]$p.Offset $pe = $ps + [int64]$p.Size if ($start -lt $pe -and $end -gt $ps) { $risks += ("writing {0} at 0x{1:X} (through 0x{2:X}) overwrites data partition '{3}' (0x{4:X}-0x{5:X})" -f ` (Split-Path -Leaf $f.Path), $start, ($end - 1), $p.Label, $ps, ($pe - 1)) } } } # An app must land exactly on an app partition that this board actually has. if ($appOffset -ne $null -and @($toWrite | Where-Object { $_.Offset -eq $appOffset }).Count -gt 0) { $appParts = @($currentPt | Where-Object { $_.Type -eq 0 }) if ($appParts.Count -gt 0 -and @($appParts | Where-Object { $_.Offset -eq $appOffset }).Count -eq 0) { $where = ($appParts | ForEach-Object { '0x{0:X}' -f $_.Offset }) -join ', ' $risks += ("the app would be written to 0x{0:X}, but this board's app partition is at {1} - it would not boot" -f ` $appOffset, $where) } } return $risks } # ------------------------------------------------------------ flasher_args ---- <# Read a target folder's flasher_args.json and resolve it into a plain object. ESP-IDF writes the paths as they sit in a build tree ("bootloader/bootloader.bin"); these folders are flattened, so each file is looked up both ways. #> function Read-FlasherArgs([string]$dir) { $jsonPath = Join-Path $dir 'flasher_args.json' if (-not (Test-Path $jsonPath)) { Write-Fail "No flasher_args.json in $dir" } $j = Get-Content $jsonPath -Raw | ConvertFrom-Json $files = @() foreach ($prop in $j.flash_files.PSObject.Properties) { $offset = [Convert]::ToInt64(($prop.Name -replace '^0[xX]', ''), 16) $rel = $prop.Value $candidates = @( (Join-Path $dir $rel), (Join-Path $dir (Split-Path -Leaf $rel)) ) $resolved = '' foreach ($c in $candidates) { if (Test-Path $c) { $resolved = (Resolve-Path $c).Path; break } } if (-not $resolved) { Write-Fail "flasher_args.json lists '$rel' but it is not in $dir" } $files += [pscustomobject]@{ Offset = $offset; Path = $resolved; Rel = $rel } } $appOffset = $null if ($j.app -and $j.app.offset) { $appOffset = [Convert]::ToInt64(($j.app.offset -replace '^0[xX]', ''), 16) } $ptOffset = $null if ($j.'partition-table' -and $j.'partition-table'.offset) { $ptOffset = [Convert]::ToInt64(($j.'partition-table'.offset -replace '^0[xX]', ''), 16) } return [pscustomobject]@{ Files = ($files | Sort-Object Offset) WriteArgs = @($j.write_flash_args) Chip = $j.extra_esptool_args.chip Before = $j.extra_esptool_args.before After = $j.extra_esptool_args.after AppOffset = $appOffset PtOffset = $ptOffset } } # =============================================================== main flow ==== function Invoke-EspFlash { [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$TargetDir, [string]$Port, [int]$Baud = 115200, # native USB CDC: keep 115200, baud change is flaky [string]$Esptool, [switch]$AppOnly, [switch]$DryRun, [switch]$Monitor, [switch]$NoVerify, [switch]$Force, [switch]$ManualBoot, [switch]$List ) if ($List) { Write-Host 'Serial ports ( * = looks like an ESP board ):' Show-Ports (Get-CandidatePorts) return } if (-not (Test-Path $TargetDir)) { Write-Fail "Target folder not found: $TargetDir" } $TargetDir = (Resolve-Path $TargetDir).Path $name = Split-Path -Leaf $TargetDir Write-Host '' Write-Info ("Target : {0} ({1})" -f $name.ToUpper(), $TargetDir) $fa = Read-FlasherArgs $TargetDir # --- describe what we are about to write ------------------------------ $appImg = $null Write-Host 'Files :' foreach ($f in $fa.Files) { $tag = '' if ($fa.AppOffset -ne $null -and $f.Offset -eq $fa.AppOffset) { $appImg = Read-ImageInfo $f.Path $tag = ' <- app' } Write-Host (" 0x{0,-8:X} {1,-24} {2,8} bytes{3}" -f ` $f.Offset, (Split-Path -Leaf $f.Path), (Get-Item $f.Path).Length, $tag) } if ($appImg) { Write-Host (" project '{0}' version '{1}'" -f $appImg.Project, $appImg.Version) Write-Host (" built {0} with IDF {1}, for {2}" -f $appImg.Built, $appImg.IdfVer, $appImg.ChipName) } Write-Host ("Flash cfg : {0}" -f ($fa.WriteArgs -join ' ')) $tool = Find-Esptool $Esptool Write-Info ("esptool : {0}" -f $tool) $portWasAuto = $false if ($Port) { Write-Info ("Port : {0} (forced)" -f $Port) } else { $Port = Resolve-Port ([bool]$DryRun) $portWasAuto = $true } # --- what is on the board right now? ---------------------------------- $incomingPt = $null $ptFile = $fa.Files | Where-Object { $fa.PtOffset -ne $null -and $_.Offset -eq $fa.PtOffset } if ($ptFile) { $incomingPt = Get-PartitionTableFromFile $ptFile.Path } $currentPt = @() $boardSeen = $false if ($Port) { $tmp = Join-Path $env:TEMP ("ptable_{0}.bin" -f [guid]::NewGuid().ToString('N')) Write-Host '' Write-Info 'Reading the current partition table from the board ...' # A hard reset makes an ESP32-S3's native USB-Serial/JTAG re-enumerate, so # the port can be listed but unopenable for a second or two. For a real # flash, skip the reset entirely and keep the session alive into # write_flash; for a dry run, reset afterwards so the board runs its app. # Always hard_reset after the probe. 'no_reset' leaves the chip sitting in # the ROM bootloader, and on a board whose app exposes its own USB CDC # (PID_0009) that CHANGES the USB identity to PID_1001 - the COM port # disappears while esptool is still closing it, so esptool exits non-zero # even though the read succeeded. hard_reset puts the app back and keeps # the port number stable, which is why dry runs always worked. $afterProbe = 'hard_reset' # -ManualBoot: the board was put into download mode by hand (hold BOOT, # tap RESET, release BOOT). Tell esptool not to attempt its own reset, # which would knock the chip back out of that state. $beforeArg = 'default_reset' if ($ManualBoot) { $beforeArg = 'no_reset' Write-Warn 'Manual boot mode: assuming the board is already in download mode.' } $readOk = $false $out = $null for ($attempt = 1; $attempt -le 3 -and -not $readOk; $attempt++) { if ($attempt -gt 1) { Write-Warn ("Port not answering - retry {0} of 3 (USB-Serial/JTAG re-enumerates after a reset)." -f $attempt) Start-Sleep -Seconds 2 if ($portWasAuto) { $again = Resolve-Port $true if ($again) { $Port = $again } } } $out = & $tool --chip $fa.Chip -p $Port --before $beforeArg --after $afterProbe read_flash 0x8000 0xC00 $tmp # Judge success by the artifact, not only the exit code: esptool can # read the table fine and still fail on the way out (port vanishing # during a USB re-enumeration). If the dump parses, we have what we # came for. if ($LASTEXITCODE -eq 0) { $readOk = $true } elseif ((Test-Path $tmp) -and (ConvertFrom-PartitionTable ([System.IO.File]::ReadAllBytes($tmp))).Count -gt 0) { Write-Warn 'esptool exited with an error after the read, but the partition table came back intact - continuing.' $readOk = $true } } if (-not $readOk) { Remove-Item $tmp -ErrorAction SilentlyContinue if (-not $DryRun) { Write-Fail "esptool could not talk to the board on $Port after 3 tries. Unplug and replug the board, then run again." } Write-Warn "Could not talk to the board on $Port - skipping the on-board checks (dry run)." } else { $boardSeen = $true $joined = ($out | Out-String) $detected = '' # -cmatch (case sensitive) and an explicit ESP32 prefix: esptool's # no_reset warning contains the words "the chip is not in bootloader", # which a loose case-insensitive 'Chip is (\S+)' happily matched, # reporting the chip as "not". if ($joined -cmatch 'Detecting chip type\.\.\.\s*(ESP32[\w-]*)') { $detected = $Matches[1] } elseif ($joined -cmatch 'Chip is (ESP32[\w-]*)') { $detected = $Matches[1] } $flashSize = '' if ($joined -match 'Detected flash size:\s*(\S+)') { $flashSize = $Matches[1] } # read_flash does not report the flash size, so only show it when the # output actually carried one (e.g. when a stub reported it). if ($detected) { $devLine = "Device : $detected" if ($flashSize) { $devLine += " flash $flashSize" } Write-Host $devLine } if ($detected -and $appImg -and $appImg.ChipName -ne 'unknown' -and -not $detected.StartsWith($appImg.ChipName)) { Remove-Item $tmp -ErrorAction SilentlyContinue Write-Fail ("Firmware is built for {0} but the board is {1}." -f $appImg.ChipName, $detected) } if (Test-Path $tmp) { $currentPt = ConvertFrom-PartitionTable ([System.IO.File]::ReadAllBytes($tmp)) } Remove-Item $tmp -ErrorAction SilentlyContinue if ($currentPt.Count -gt 0) { Write-Host 'Currently on the board:' Show-PartitionTable $currentPt } else { Write-Warn 'No valid partition table on the board (blank chip or first flash).' } } } if (-not $boardSeen -and $incomingPt) { Write-Host 'Partition table that would be written:' Show-PartitionTable $incomingPt Write-Warn 'No board was read, so the data-loss check did not run.' } # --- data-loss guard --------------------------------------------------- if ($incomingPt -and $currentPt.Count -gt 0) { $cmp = Compare-PartitionTables $currentPt $incomingPt if ($cmp.Identical) { Write-Host 'New partition table: identical to the board - data partitions keep their contents.' -ForegroundColor Green } else { Write-Host 'About to write this partition table instead:' Show-PartitionTable $incomingPt if ($cmp.Risks.Count -gt 0) { Write-Host '' Write-Host 'DATA LOSS RISK:' -ForegroundColor Red foreach ($r in $cmp.Risks) { Write-Host (" - " + $r) -ForegroundColor Red } Write-Host '' Write-Host 'This usually means the firmware does not belong to this board.' -ForegroundColor Yellow Write-Host 'Back up first, e.g.:' -ForegroundColor Yellow foreach ($old in @($currentPt | Where-Object { $_.Type -eq 1 })) { Write-Host (" & `"{0}`" -p {1} -b {2} read_flash 0x{3:X} 0x{4:X} {5}_backup.bin" -f ` $tool, $Port, $Baud, $old.Offset, $old.Size, $old.Label) } Write-Host '' if (-not $Force) { # Deliberately do NOT suggest -AppOnly here: when the firmware # belongs to a different board its app offset can sit on top of # this board's data partitions, which loses data even though the # partition table is left alone. Test-WriteOverlap catches that. Write-Fail 'Refusing to continue - this firmware does not match the board. Back up first; -Force overrides.' } Write-Warn '-Force given: overwriting the partition table anyway.' } else { Write-Warn 'Partition table differs from the board, but no data partition is lost.' } } } # The probe ends with a hard reset, so the board reboots into its app and the # USB device re-enumerates - which can move the COM number. Let it settle and # re-resolve before writing. if (-not $DryRun -and $boardSeen) { Start-Sleep -Milliseconds 1500 if ($portWasAuto) { $settled = Resolve-Port $true if ($settled -and $settled -ne $Port) { Write-Warn ("Port moved to {0} after the reset - using it." -f $settled) $Port = $settled } } } # --- build the file list ---------------------------------------------- $toWrite = $fa.Files if ($AppOnly) { if ($fa.AppOffset -eq $null) { Write-Fail '-AppOnly given but flasher_args.json has no app entry.' } $toWrite = @($fa.Files | Where-Object { $_.Offset -eq $fa.AppOffset }) Write-Warn 'App-only mode: bootloader and partition table are left untouched.' } # Leaving the partition table alone is not enough - make sure nothing we write # lands on a data partition that exists on the board. if ($currentPt.Count -gt 0) { $overlaps = Test-WriteOverlap $currentPt $toWrite $fa.AppOffset if ($overlaps.Count -gt 0) { Write-Host '' Write-Host 'DATA LOSS RISK (write ranges vs the board layout):' -ForegroundColor Red foreach ($r in $overlaps) { Write-Host (" - " + $r) -ForegroundColor Red } Write-Host '' if (-not $Force) { Write-Fail 'Refusing to continue - these writes would land on this board''s data. -Force overrides.' } Write-Warn '-Force given: writing over those regions anyway.' } } # With no board attached (offline dry run) show a placeholder rather than an # empty -p, so the printed command stays copy-pasteable. $portArg = $Port if (-not $portArg) { $portArg = '' } $espArgs = @('--chip', $fa.Chip, '-p', $portArg, '-b', "$Baud") $writeBefore = $fa.Before if ($ManualBoot) { $writeBefore = 'no_reset' } if ($writeBefore) { $espArgs += @('--before', $writeBefore) } if ($fa.After) { $espArgs += @('--after', $fa.After) } $espArgs += 'write_flash' $espArgs += $fa.WriteArgs foreach ($f in $toWrite) { $espArgs += @(('0x{0:X}' -f $f.Offset), $f.Path) } Write-Host '' Write-Host 'Command:' -ForegroundColor DarkGray $shown = $espArgs | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } } Write-Host (' & "{0}" {1}' -f $tool, ($shown -join ' ')) Write-Host '' if ($DryRun) { Write-Info 'Dry run - nothing was written.' return } & $tool @espArgs if ($LASTEXITCODE -ne 0) { Write-Fail "esptool write_flash failed (exit $LASTEXITCODE)." } Write-Host '' Write-Host 'Flash OK.' -ForegroundColor Green # --- post-flash boot check -------------------------------------------- $py = Find-Python $tool $verifier = Join-Path $script:CommonRoot 'verify_boot.py' if (-not $NoVerify -and $py -and (Test-Path $verifier) -and $appImg) { Write-Host '' Write-Info 'Checking that the board comes up ...' $vArgs = @($verifier, $Port, '--expect-offset', ('0x{0:X}' -f $fa.AppOffset), '--expect-project', $appImg.Project, '--expect-version', $appImg.Version) & $py @vArgs } if ($Monitor) { if (-not $py) { Write-Warn 'No python found for the serial monitor; skipping.'; return } Write-Host '' Write-Info "Monitor on $Port @ 115200 - press Ctrl+] to quit" & $py -m serial.tools.miniterm $Port 115200 } }