Files
MeshCore/tools/hil/firmware_bench_flash.ps1
T

398 lines
23 KiB
PowerShell

# Narrow bench entry to firmware.cmd's actual ESP32 install/update/reset helpers.
# Import definitions only: never execute its menus, dependency installers,
# background upgrades, backup prompts, or top-level cleanup.
[CmdletBinding()]
param(
[ValidateSet('Inventory', 'Probe', 'Install', 'AppUpdate')][string]$Mode = 'Inventory',
[Parameter(Mandatory)][ValidatePattern('^COM[0-9]+$')][string]$ComPort,
[Parameter(Mandatory)][string]$ExpectedUsbInstanceId,
[string]$ExpectedLocationPath = '',
[string]$ExpectedMac = '',
[string]$Image = '',
[string]$ExpectedSha256 = '',
[switch]$Erase,
[string]$Flasher = 'C:\git\meshfirmware\firmware.cmd',
[string]$PythonCommand = ''
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
throw 'This bench wrapper requires Windows.'
}
$ComPort = $ComPort.ToUpperInvariant()
if ($ExpectedUsbInstanceId -notmatch '^USB\\VID_10C4&PID_EA60\\[^\\&]+$') {
throw 'This narrow UART bench wrapper requires an exact CP210x USB instance ID with a serial.'
}
if ($Mode -eq 'Install') {
if (-not $Erase -or -not [IO.Path]::IsPathRooted($Image) -or
$ExpectedSha256 -notmatch '^[0-9a-fA-F]{64}$' -or
($ExpectedMac -replace '[:\-]', '') -notmatch '^[0-9a-fA-F]{12}$') {
throw 'Install requires -Erase, an absolute merged -Image, -ExpectedSha256 and the prior probe -ExpectedMac.'
}
} elseif ($Mode -eq 'AppUpdate') {
if ($Erase -or $Image -notmatch '^[A-Za-z]:[\\/]' -or
$ExpectedSha256 -notmatch '^[0-9a-fA-F]{64}$' -or
($ExpectedMac -replace '[:\-]', '') -notmatch '^[0-9a-fA-F]{12}$' -or
[string]::IsNullOrWhiteSpace($ExpectedLocationPath)) {
throw 'AppUpdate requires an absolute plain app -Image, -ExpectedSha256, prior probe -ExpectedMac and -ExpectedLocationPath; -Erase is forbidden.'
}
} elseif ($Erase -or $Image -or $ExpectedSha256) {
throw 'Inventory/Probe cannot erase, install, or select an image.'
}
$tokens = $null; $parseErrors = $null
$flasherPath = (Resolve-Path -LiteralPath $Flasher -ErrorAction Stop).ProviderPath
$ast = [Management.Automation.Language.Parser]::ParseFile(
$flasherPath, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count) { throw "firmware.cmd parse failed: $parseErrors" }
$required = @(
'Get-AvailableComPorts', 'Test-IsWindowsHost', 'Resolve-UsbParentInstanceId',
'Get-UsbInterfaceNumberFromInstanceId', 'Get-UsbIdentityInterfaceNumber',
'Get-UsbPnpProperties', 'Get-UsbComPortIdentity', 'Test-UsbComPortIdentityMatch',
'Get-UsbIdentityDisplayText', 'Resolve-UsbComPortForIdentity', 'Resolve-LiveUsbComPort',
'Resolve-EspUsbComPort', 'Assert-UsbComPortIdentityFor1200Touch',
'Get-EspUsbBootloaderStrategy', 'Get-Esp32RomBeforeMode', 'Get-Esp32RomSessionStamp',
'Get-Esp32WriteAfterMode', 'Assert-Esp32FinishCapability', 'Get-EspRomMac',
'Set-Esp32VerifiedChipMac', 'Assert-Esp32RomChipIdentity', 'Enter-Esp32Bootloader',
'New-EspUsbTouchSerialPort', 'Invoke-EspTinyUsbTouch1200',
'Split-CommandLine', 'run_cmd', 'get_esptool_cmd',
'Wait-FirmwareToolUpdates', 'Enter-FirmwareToolUse', 'Exit-FirmwareToolUse',
'Get-EspRuntimeStorageLayout', 'Complete-Esp32FlashSession',
'Get-EspFileNameMode', 'Get-EspBinClassification', 'Get-EspFlashStrategy',
'Install-SimpleMergedEspImage'
)
if ($Mode -eq 'AppUpdate') {
$required += @('Get-SelectedUsbIdentityForFlash', 'Read-EspPartitionSnapshot',
'Get-EspOtadataSnapshot', 'Get-EspNvsSnapshot', 'updateFlashViaEspTool')
}
foreach ($name in $required) {
$definitions = @($ast.EndBlock.Statements | Where-Object {
$_ -is [Management.Automation.Language.FunctionDefinitionAst] -and $_.Name -eq $name
})
if ($definitions.Count -ne 1) { throw "Expected exactly one firmware.cmd function: $name" }
Invoke-Expression $definitions[0].Extent.Text
}
# This is the sole non-function initializer needed by run_cmd. Select the
# exact existing interop block, not arbitrary executable top-level statements.
$interop = @($ast.EndBlock.Statements | Where-Object {
$_ -is [Management.Automation.Language.IfStatementAst] -and
$_.Extent.Text -match 'public static extern IntPtr CommandLineToArgvW' -and
$_.Extent.Text -match 'public static class NativeMethods'
})
if ($interop.Count -ne 1) { throw 'Missing/ambiguous firmware.cmd native command-line interop.' }
Invoke-Expression $interop[0].Extent.Text
function Assert-BenchAppImage {
param([string]$Path, [string]$Sha256)
$resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath
$item = Get-Item -LiteralPath $resolved -ErrorAction Stop
if ($resolved -match '[\s"]' -or $item.PSIsContainer -or
$item.Extension -ine '.bin' -or
($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw 'AppUpdate requires a regular plain .bin file without whitespace, quotes or reparse points.'
}
$strategy = Get-EspFlashStrategy -Path $resolved
if ($strategy.Classification -ne 'LIKELY_SINGLE' -or $strategy.ClassifiedMode -ne 'update') {
throw 'AppUpdate accepts only a content-classified single application, never a merged/data/unknown image.'
}
if ((Get-FileHash -LiteralPath $resolved -Algorithm SHA256).Hash -ne $Sha256) {
throw 'The selected application does not match the explicitly expected SHA-256.'
}
return $resolved
}
function Get-BenchPartitionFailureReason {
param([object]$PlannerError, [bool]$MigrationNeeded)
# Only fixed reviewed messages may cross the private-snapshot boundary.
# Never echo arbitrary subprocess output, exception text, paths or NVS data.
$known = @(
'A required partition expansion input is missing or too large.',
'Cannot read a required partition expansion input.',
'ESP32 flash identification output is not valid text.',
'Physical ESP32 chip and flash capacity were not unambiguously detected.',
'This ESP32 chip family has no reviewed partition expansion bridge.',
'Partition expansion requires a valid ESP32 application-only image.',
'ESP32 image declares an unsupported flash capacity.',
'Selected application does not match the connected ESP32 chip/flash capacity.',
'Partition table must contain exactly 4096 bytes.',
'Partition table has malformed trailing data.',
'Partition table MD5 verification failed.',
'Partition table has entries after its checksum.',
'Partition table has an invalid entry.',
'Partition table has an invalid label.',
'Partition table contains invalid, duplicate or out-of-flash geometry.',
'Partition table is empty.',
'Partition table contains overlapping partitions.',
'OTA metadata read must contain exactly 8192 bytes.',
'OTA table does not have unique contiguous OTA slot identities.',
'OTA metadata has no valid active sequence and is not erased initial data.',
'OTA active sequence ordering is ambiguous.',
'ESP32 table has ambiguous NVS partitions for migration-state verification.',
'An actual NVS snapshot is required to check for unfinished partition migration.',
'NVS snapshot size does not match its actual partition geometry.',
'Migration NVS page verification failed or requires recovery.',
'Migration NVS has missing or ambiguous page ordering.',
'Migration NVS has multiple active pages and requires recovery.',
'Migration NVS sequence ordering is ambiguous.',
'Migration NVS contains an invalid entry state.',
'Migration NVS entry CRC verification failed.',
'Migration NVS contains an invalid key.',
'Migration NVS entry format is invalid or unsupported.',
'Migration NVS primitive entry geometry is invalid.',
'Migration NVS variable-length entry is incomplete.',
'Migration NVS data CRC verification failed.',
'No verified partition migration NVS namespace exists.',
'Partition migration NVS namespace identity is ambiguous.',
'Partition migration NVS completion flag has an invalid type/value.',
'Live NVS reports unfinished partition migration; restore the original bridge handoff before final flashing.',
'Device cache contains invalid partition expansion state.',
'Device has ambiguous pending partition expansion state.',
'A partition expansion is pending; select its original exact firmware before updating another image.',
'Pending partition expansion belongs to another physical device.',
'Pending partition expansion layout changed unexpectedly; recovery is required.',
'Bench app-only validation is offline; partition migration is forbidden.',
'Partition expansion preparation or verification failed.'
)
if ($PlannerError -is [string] -and $known -ccontains $PlannerError) { return $PlannerError }
if ($MigrationNeeded) { return 'Partition migration is required or pending.' }
return 'Local partition validation failed; unrecognized details were withheld.'
}
function Get-BenchAppUpdatePlan {
param([psobject]$Hardware, [string]$EspToolCommand, [string]$FlasherPath,
[string]$ExpectedImageSha256)
# Use the exact local flasher validator, never its resolver/downloader or
# migration driver. The precheck below makes its expansion branch unreachable.
$tool = Join-Path (Split-Path $FlasherPath -Parent) 'tools\meshcore_partition_expansion.py'
$toolSha256 = 'fd1cc2c2198d31878587a2a7aa48f7ec49055b29af3cb5f2a9630ec2d8787df8'
if (-not (Test-Path -LiteralPath $tool -PathType Leaf) -or
(Get-FileHash -LiteralPath $tool -Algorithm SHA256).Hash -ine $toolSha256) {
throw 'The required local partition validator is missing or not the reviewed version; no download or update is allowed.'
}
$temporaryPath = Join-Path ([IO.Path]::GetTempPath()) ('meshcore-bench-app-' + [guid]::NewGuid().ToString('N'))
$tempDir = (New-Item -ItemType Directory -Path $temporaryPath -ErrorAction Stop).FullName
$identity = $Hardware.UsbIdentity
try {
$port = Enter-Esp32Bootloader -ComPort $Hardware.ComPort -UsbIdentity $identity -EspToolCommand $EspToolCommand
$Hardware.ComPort = $port
$tablePath = Join-Path $tempDir 'partitions.bin'
$flashPath = Join-Path $tempDir 'flash-id.txt'
$otaPath = Join-Path $tempDir 'otadata.bin'
$nvsPath = Join-Path $tempDir 'nvs.bin'
Read-EspPartitionSnapshot -ComPort $port -UsbIdentity $identity -EspToolCommand $EspToolCommand `
-Offset '0x8000' -Length 4096 -Path $tablePath
$table = [IO.File]::ReadAllBytes($tablePath)
if ($table.Length -ne 4096) { throw 'AppUpdate requires a complete physical partition table.' }
$imageBytes = (Get-Item -LiteralPath $Hardware.FirmwareFile).Length
$physicalAppOffsets = @()
for ($offset = 0; $offset -lt 4096; $offset += 32) {
if ($table[$offset] -eq 0xff -and $table[$offset + 1] -eq 0xff) { break }
if ($table[$offset] -eq 0xeb -and $table[$offset + 1] -eq 0xeb) { break }
if ($table[$offset] -ne 0xaa -or $table[$offset + 1] -ne 0x50) {
throw 'The physical partition table is malformed; no application update is allowed.'
}
if ($table[$offset + 2] -eq 0) {
$physicalAppOffsets += [int64][BitConverter]::ToUInt32($table, $offset + 4)
if ($imageBytes -gt [BitConverter]::ToUInt32($table, $offset + 8)) {
throw 'The application does not fit every physical app slot; this mode never migrates partitions.'
}
}
}
if (-not $physicalAppOffsets.Count) { throw 'No physical application partitions were found.' }
$port = Assert-Esp32RomChipIdentity -ComPort $port -UsbIdentity $identity `
-EspToolCommand $EspToolCommand -Purpose 'App-only physical flash capacity'
$before = Get-Esp32RomBeforeMode -UsbIdentity $identity
$flashId = run_cmd "$EspToolCommand --baud 115200 --port $port --before $before --after $script:ESPTOOL_NO_RESET $script:ESPTOOL_FLASH_ID"
[IO.File]::WriteAllText($flashPath, [string]$flashId)
$ota = Get-EspOtadataSnapshot -PartitionTable $tablePath -ComPort $port -UsbIdentity $identity `
-EspToolCommand $EspToolCommand -Path $otaPath
$nvs = Get-EspNvsSnapshot -PartitionTable $tablePath -ComPort $port -UsbIdentity $identity `
-EspToolCommand $EspToolCommand -Path $nvsPath
# Reuse the flasher's per-MAC cache so interrupted migrations cannot be
# hidden by choosing a new bench cache. Live NVS is independently checked.
$cache = Join-Path (Split-Path $FlasherPath -Parent) ('.tmp\partition-expansion\' + $identity.Esp32ChipMac)
# Keep the pinned planner offline even if an input changes between the
# size precheck and its own read. Never execute its serial/bridge commands.
$offlineChecker = @'
import importlib.util, sys
spec = importlib.util.spec_from_file_location("bench_partition_validator", sys.argv[1])
validator = importlib.util.module_from_spec(spec)
spec.loader.exec_module(validator)
def refuse_download(*args, **kwargs):
raise validator.ExpansionError("Bench app-only validation is offline; partition migration is forbidden.")
validator.download = refuse_download
raise SystemExit(validator.main(sys.argv[2:]))
'@
$arguments = @('-c', $offlineChecker, $tool, 'plan', '--firmware', $Hardware.FirmwareFile,
'--partition-table', $tablePath, '--flash-id-output', $flashPath,
'--output-dir', $cache, '--device-mac', $identity.Esp32ChipMac)
if ($ota) { $arguments += @('--otadata', $ota) }
if ($nvs) { $arguments += @('--nvs', $nvs) }
$previousPreference = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
$output = (& $script:pythonCommand @arguments 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
} finally { $ErrorActionPreference = $previousPreference }
try { $plan = $output | ConvertFrom-Json -ErrorAction Stop }
catch { throw 'The local partition validator returned no valid app-update proof.' }
if ($exitCode -ne 0 -or $plan.ok -ne $true -or $plan.needed -ne $false -or $plan.resume) {
$reason = Get-BenchPartitionFailureReason -PlannerError $plan.error `
-MigrationNeeded ($plan.needed -eq $true -or $plan.resume -eq $true)
throw "Physical partition/NVS/OTA proof failed; no application was written. Reason: $reason"
}
if ($plan.firmware_sha256 -cne $ExpectedImageSha256.ToLowerInvariant() -or
-not @($plan.app_offsets).Count -or @($plan.app_offsets).Count -ne $physicalAppOffsets.Count) {
throw 'The partition proof does not match the exact application and all physical app slots.'
}
$offsets = @($plan.app_offsets | ForEach-Object {
$value = [int64]$_
if ($value -lt 0x10000 -or $value % 0x10000 -ne 0 -or $value -notin $physicalAppOffsets) {
throw 'Invalid verified application offset.'
}
'0x{0:x}' -f $value
})
if (@($offsets | Select-Object -Unique).Count -ne $offsets.Count) { throw 'Duplicate application offsets in proof.' }
$Hardware | Add-Member -NotePropertyName EspUpdateFirmwareSha256 -NotePropertyValue $plan.firmware_sha256 -Force
$Hardware | Add-Member -NotePropertyName EspUpdateOffsets -NotePropertyValue ([string[]]$offsets) -Force
$Hardware | Add-Member -NotePropertyName EspUpdateNvsProof -NotePropertyValue ([pscustomobject]@{
namespace_exists = ($plan.nvs_migration.namespace_exists -eq $true)
namespace_empty = ($plan.nvs_migration.namespace_empty -eq $true)
restored = ($plan.nvs_migration.'cfg-restored' -eq $true)
config_pending = ($plan.nvs_migration.'cfg-pending' -eq $true)
identity_pending = ($plan.nvs_migration.'id-pending' -eq $true)
}) -Force
return $offsets
} finally {
# NVS may contain keys/bonds. Delete only our exact snapshots, never log
# their bytes and never retain them as ordinary bench evidence.
foreach ($name in @('partitions.bin', 'flash-id.txt', 'otadata.bin', 'nvs.bin')) {
$path = Join-Path $tempDir $name
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force }
}
Remove-Item -LiteralPath $tempDir -Force
}
}
function Assert-BenchUsb {
$device = @(Get-CimInstance Win32_PnPEntity | Where-Object {
$_.PNPDeviceID -eq $ExpectedUsbInstanceId -and $_.Present -ne $false -and
[int]$_.ConfigManagerErrorCode -eq 0 -and $_.Name -match "\($([regex]::Escape($ComPort))\)$"
})
if ($device.Count -ne 1) {
throw 'The exact selected CP210x instance is not healthy on the requested COM port.'
}
$identity = Get-UsbComPortIdentity -ComPort $ComPort -PnpDevice $device[0]
if ($null -eq $identity -or $identity.ParentInstanceId -ne $ExpectedUsbInstanceId -or
[string]::IsNullOrWhiteSpace([string]$identity.LocationPath)) {
throw 'The selected USB parent/location could not be captured; refusing a reset or write.'
}
if ($ExpectedLocationPath -and $identity.LocationPath -ne $ExpectedLocationPath) {
throw 'The selected USB physical location changed; refusing a reset or write.'
}
if ((Get-EspUsbBootloaderStrategy -UsbIdentity $identity) -ne 'uart') {
throw 'The selected CP210x is not recognized as a UART transport by firmware.cmd.'
}
return $identity
}
$identity = Assert-BenchUsb
if ($Mode -eq 'Inventory') {
[pscustomobject]@{
Mode = $Mode; ComPort = $ComPort; UsbInstanceId = $identity.ParentInstanceId
LocationPath = $identity.LocationPath; UsbSerial = $identity.SerialNumber
Description = $identity.BusReportedDescription; HardwareOpened = $false
} | ConvertTo-Json
return
}
$ExpectedLocationPath = [string]$identity.LocationPath
if ($ExpectedMac) {
$identity | Add-Member -NotePropertyName Esp32ChipMac `
-NotePropertyValue ($ExpectedMac -replace '[:\-]', '').ToUpperInvariant() -Force
}
if ($Mode -eq 'Install') {
$imagePath = (Resolve-Path -LiteralPath $Image -ErrorAction Stop).ProviderPath
# firmware.cmd's native streaming adapter currently reparses its arguments.
# Avoid its whitespace-path ambiguity rather than risking a different file.
if ($imagePath -match '[\s"]' -or (Get-Item -LiteralPath $imagePath).PSIsContainer) {
throw 'Use an unambiguous file path without whitespace or double quotes for this bench install.'
}
$strategy = Get-EspFlashStrategy -Path $imagePath
if ($strategy.Classification -ne 'LIKELY_MERGED' -or $strategy.ClassifiedMode -ne 'install') {
throw 'Only a content-classified merged ESP32 image can be installed by this wrapper.'
}
if ((Get-FileHash -LiteralPath $imagePath -Algorithm SHA256).Hash -ne $ExpectedSha256) {
throw 'The selected merged image does not match the explicitly expected SHA-256.'
}
} elseif ($Mode -eq 'AppUpdate') {
$imagePath = Assert-BenchAppImage -Path $Image -Sha256 $ExpectedSha256
}
# Match firmware.cmd's per-user tool lock, without starting an updater or
# installing dependencies. Its helpers observe this already-held mutex.
$script:FirmwareToolUpdateJob = $null
$script:FirmwareToolUseLocked = $false
$account = [Security.Principal.WindowsIdentity]::GetCurrent()
try { $mutexName = "Local\MeshFirmwareTools-$($account.User.Value)" }
finally { $account.Dispose() }
$script:FirmwareToolUseMutex = New-Object Threading.Mutex($false, $mutexName)
try {
try { $script:FirmwareToolUseLocked = $script:FirmwareToolUseMutex.WaitOne(15000) }
catch [Threading.AbandonedMutexException] { $script:FirmwareToolUseLocked = $true }
if (-not $script:FirmwareToolUseLocked) { throw 'Firmware tools are busy in another process.' }
if (-not $PythonCommand) { $PythonCommand = (Get-Command python.exe -ErrorAction Stop).Source }
$script:pythonCommand = $PythonCommand
$esptool = get_esptool_cmd
$fresh = Assert-BenchUsb
if ($fresh.LocationPath -ne $identity.LocationPath) { throw 'USB location changed before handoff.' }
if ($Mode -eq 'Probe') {
$livePort = Enter-Esp32Bootloader -ComPort $ComPort -UsbIdentity $identity -EspToolCommand $esptool
$null = Assert-BenchUsb
$before = Get-Esp32RomBeforeMode -UsbIdentity $identity
$flashInfo = run_cmd "$esptool --baud 115200 --port $livePort --before $before --after $script:ESPTOOL_NO_RESET $script:ESPTOOL_FLASH_ID"
$livePort = Complete-Esp32FlashSession -ComPort $livePort -UsbIdentity $identity -SkipApplicationVerification
[pscustomobject]@{
Mode = $Mode; ComPort = $livePort; UsbInstanceId = $identity.ParentInstanceId
LocationPath = $identity.LocationPath; ChipMac = $identity.Esp32ChipMac
FlashInfo = $flashInfo; FlashWritten = $false; ApplicationVerified = $false
} | ConvertTo-Json
} elseif ($Mode -eq 'AppUpdate') {
$hardware = [pscustomobject]@{ ComPort = $ComPort; FirmwareFile = $imagePath; UsbIdentity = $identity }
$offsets = @(Get-BenchAppUpdatePlan -Hardware $hardware -EspToolCommand $esptool `
-FlasherPath $flasherPath -ExpectedImageSha256 $ExpectedSha256)
$null = Assert-BenchUsb
$null = Assert-BenchAppImage -Path $imagePath -Sha256 $ExpectedSha256
if (-not (updateFlashViaEspTool -hw $hardware)) {
throw 'firmware.cmd did not confirm the checked application-only write.'
}
$livePort = Complete-Esp32FlashSession -ComPort $hardware.ComPort -UsbIdentity $identity -SkipApplicationVerification
[pscustomobject]@{
Mode = $Mode; ComPort = $livePort; UsbInstanceId = $identity.ParentInstanceId
LocationPath = $identity.LocationPath; ChipMac = $identity.Esp32ChipMac
Image = $imagePath; Sha256 = $ExpectedSha256.ToLowerInvariant(); AppOffsets = $offsets
NvsMigration = $hardware.EspUpdateNvsProof
FlashWritten = $true; Erased = $false; PartitionTableWritten = $false
ApplicationVerified = $false
} | ConvertTo-Json
} else {
# No image substitution, backup prompt or update/partition migration:
# call the real flasher's single merged-install path, including its
# MAC rechecks before erase/write and qualified final hardware reset.
$null = Assert-BenchUsb
if ((Get-FileHash -LiteralPath $imagePath -Algorithm SHA256).Hash -ne $ExpectedSha256) {
throw 'The selected image changed before the verified full install.'
}
if (-not (Install-SimpleMergedEspImage -ImagePath $imagePath -ComPort $ComPort -UsbIdentity $identity)) {
throw 'firmware.cmd did not confirm the merged-image write.'
}
$null = Complete-Esp32FlashSession -ComPort $ComPort -UsbIdentity $identity -SkipApplicationVerification
[pscustomobject]@{
Mode = $Mode; ComPort = $ComPort; ChipMac = $identity.Esp32ChipMac
Image = $imagePath; Sha256 = $ExpectedSha256.ToLowerInvariant()
FlashWritten = $true; ApplicationVerified = $false
} | ConvertTo-Json
}
} finally {
Exit-FirmwareToolUse
if ($script:FirmwareToolUseMutex) { $script:FirmwareToolUseMutex.Dispose() }
}