refactor: organize factory host release layout

This commit is contained in:
chenjintao
2026-09-24 15:07:44 +08:00
parent 95d67962b1
commit c011f5c491
14 changed files with 490 additions and 161 deletions
@@ -0,0 +1,202 @@
"""Validate and package the fixed FT1 audio prompts."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path
EXPECTED_FILES = (
"factory_boot_zh.mp3",
"factory_audio_loopback_zh.mp3",
"factory_nfc_test_zh.mp3",
"factory_gnss_test_zh.mp3",
"factory_shutdown_zh.mp3",
)
IMAGE_SIZE = 262144
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest().upper()
def _skip_id3(data: bytes) -> int:
if not data.startswith(b"ID3") or len(data) < 10:
return 0
size = 0
for value in data[6:10]:
if value & 0x80:
raise ValueError("ID3 使用了无效的 synchsafe size")
size = (size << 7) | value
return 10 + size
def inspect_mp3(path: Path) -> dict:
data = path.read_bytes()
offset = _skip_id3(data)
while offset + 4 <= len(data) and not (data[offset] == 0xFF and data[offset + 1] & 0xE0 == 0xE0):
offset += 1
if offset + 4 > len(data):
raise ValueError(f"{path.name}: 找不到 MPEG 音频帧")
header = int.from_bytes(data[offset : offset + 4], "big")
version_bits = (header >> 19) & 0x3
layer_bits = (header >> 17) & 0x3
bitrate_index = (header >> 12) & 0xF
sample_index = (header >> 10) & 0x3
channel_mode = (header >> 6) & 0x3
if version_bits == 1 or layer_bits != 1 or bitrate_index in (0, 15) or sample_index == 3:
raise ValueError(f"{path.name}: MP3 帧头不受支持")
version = {3: "MPEG1", 2: "MPEG2", 0: "MPEG2.5"}[version_bits]
sample_rates = {
3: (44100, 48000, 32000),
2: (22050, 24000, 16000),
0: (11025, 12000, 8000),
}
mpeg1_bitrates = (0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
mpeg2_bitrates = (0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160)
bitrate = (mpeg1_bitrates if version_bits == 3 else mpeg2_bitrates)[bitrate_index]
sample_rate = sample_rates[version_bits][sample_index]
channels = 1 if channel_mode == 3 else 2
if (sample_rate, channels, bitrate) != (16000, 1, 48):
raise ValueError(
f"{path.name}: 需要 16000Hz/单声道/48kbps,实际为 {sample_rate}Hz/{channels}ch/{bitrate}kbps"
)
duration_ms = round(len(data) * 8 / (bitrate * 1000) * 1000)
return {
"name": path.name,
"bytes": len(data),
"sha256": sha256(path),
"mpeg": version,
"sample_rate_hz": sample_rate,
"channels": channels,
"bitrate_kbps": bitrate,
"estimated_duration_ms": duration_ms,
}
def build_package(root: Path, output: Path) -> tuple[Path, Path, dict]:
source = root / "audio_prompts" / "factory_test" / "customer_fs"
tool = root / "ql-cross-tool" / "win32" / "mklfs-v2.exe"
if not source.is_dir():
raise FileNotFoundError(f"音频目录不存在: {source}")
if not tool.is_file():
raise FileNotFoundError(f"mklfs-v2.exe 不存在: {tool}")
actual = sorted(path.name for path in source.iterdir() if path.is_file())
if actual != sorted(EXPECTED_FILES):
raise ValueError(f"音频目录必须且只能包含 {EXPECTED_FILES},实际为 {actual}")
output.mkdir(parents=True, exist_ok=True)
image = output / "customer_fs.bin"
if image.exists():
image.unlink()
audio = [inspect_mp3(source / name) for name in EXPECTED_FILES]
result = subprocess.run(
[str(tool), "-m", "-c", str(source), "-s", str(IMAGE_SIZE), "-i", str(image)],
cwd=str(root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"mklfs-v2 失败 ({result.returncode}): {result.stdout}\n{result.stderr}")
if not image.is_file() or image.stat().st_size != IMAGE_SIZE:
raise RuntimeError(f"customer_fs.bin 大小错误: {image.stat().st_size if image.exists() else '不存在'}")
verified_files = []
with tempfile.TemporaryDirectory(prefix="verify-", dir=output) as verify_dir:
verify_path = Path(verify_dir)
verify = subprocess.run(
[str(tool), "-o", "-c", str(verify_path), "-s", str(IMAGE_SIZE), "-i", str(image)],
cwd=str(root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if verify.returncode != 0:
raise RuntimeError(f"customer_fs 解包验证失败 ({verify.returncode}): {verify.stdout}\n{verify.stderr}")
extracted = sorted(path.name for path in verify_path.iterdir() if path.is_file())
if extracted != sorted(EXPECTED_FILES):
raise RuntimeError(f"customer_fs 文件清单错误: {extracted}")
for name in EXPECTED_FILES:
source_hash = sha256(source / name)
extracted_hash = sha256(verify_path / name)
if source_hash != extracted_hash:
raise RuntimeError(f"customer_fs 解包内容不一致: {name}")
verified_files.append({"name": name, "sha256": extracted_hash})
try:
revision = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=str(root), capture_output=True, text=True, check=True
).stdout.strip()
except (OSError, subprocess.SubprocessError):
revision = ""
manifest = {
"format": "banban-factory-audio-v1",
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"git_revision": revision,
"filesystem": {
"name": "customer_fs.bin",
"bytes": image.stat().st_size,
"sha256": sha256(image),
"extract_verified": True,
"verified_files": verified_files,
},
"mount_paths": [f"U:/{name}" for name in EXPECTED_FILES],
"audio": audio,
}
manifest_path = output / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8-sig")
readme_path = output / "README.txt"
readme_path.write_text(
"班班 FT1 产测音频包\n\n"
"customer_fs.bin 固定为 262144 字节,用于 EG800AKCN_11LG 的 customer_fs 分区。\n"
"五个 MP3 必须位于 U:/ 根目录;不要将此镜像混入正式业务固件。\n"
"烧录后以 FT1 BOOT_AUDIO 和最终 session_result 作为设备侧验证。\n",
encoding="utf-8-sig",
)
package = output / "EG800AKCN_11LG-FT1-factory-audio.zip"
if package.exists():
package.unlink()
with zipfile.ZipFile(package, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
archive.write(image, "customer_fs.bin")
archive.write(manifest_path, "manifest.json")
archive.write(readme_path, "README.txt")
for name in EXPECTED_FILES:
archive.write(source / name, f"customer_fs/{name}")
manifest["package"] = {"name": package.name, "bytes": package.stat().st_size, "sha256": sha256(package)}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8-sig")
return image, package, manifest
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
parser.add_argument("--output", type=Path, default=Path(__file__).resolve().parents[1] / "build" / "audio")
args = parser.parse_args()
image, package, manifest = build_package(args.root.resolve(), args.output.resolve())
print(f"customer_fs={image}")
print(f"package={package}")
print(f"customer_fs_sha256={manifest['filesystem']['sha256']}")
print(f"package_sha256={manifest['package']['sha256']}")
if __name__ == "__main__":
main()
@@ -0,0 +1,61 @@
param(
[string]$Output = "build\firmware"
)
$ErrorActionPreference = "Stop"
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$factoryHostRoot = Split-Path -Parent $scriptRoot
$projectRoot = Split-Path -Parent $factoryHostRoot
$audioImage = Join-Path $factoryHostRoot "build\audio\customer_fs.bin"
$configuredImage = Join-Path $projectRoot "ql-config\config\EG800AKCN_11LG\customer_fs.bin"
$flashPackage = Join-Path $projectRoot "target\EG800AKCN_11LG\EG800AKCN_11LG.zip"
$outDir = if ([System.IO.Path]::IsPathRooted($Output)) { $Output } else { Join-Path $factoryHostRoot $Output }
$backup = Join-Path $outDir "customer_fs.business-backup.bin"
$export = Join-Path $outDir "EG800AKCN_11LG-FT1-factory-test.zip"
if (-not (Test-Path -LiteralPath $audioImage)) {
throw "Factory audio image is missing. Run build_factory_audio_package.py first."
}
if ((Get-Item -LiteralPath $audioImage).Length -ne 262144) {
throw "Factory customer_fs.bin is not 262144 bytes."
}
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
Copy-Item -LiteralPath $configuredImage -Destination $backup -Force
$originalHash = (Get-FileHash -LiteralPath $configuredImage -Algorithm SHA256).Hash
$audioHash = (Get-FileHash -LiteralPath $audioImage -Algorithm SHA256).Hash
try {
Copy-Item -LiteralPath $audioImage -Destination $configuredImage -Force
if ((Get-FileHash -LiteralPath $configuredImage -Algorithm SHA256).Hash -ne $audioHash) {
throw "Temporary customer_fs.bin hash verification failed."
}
& cmd.exe /d /c "build.bat app"
if ($LASTEXITCODE -ne 0) { throw "build.bat app failed with exit code $LASTEXITCODE" }
& cmd.exe /d /c "build.bat firmware"
if ($LASTEXITCODE -ne 0) { throw "build.bat firmware failed with exit code $LASTEXITCODE" }
if (-not (Test-Path -LiteralPath $flashPackage)) {
throw "Flashable inner package was not generated: $flashPackage"
}
Copy-Item -LiteralPath $flashPackage -Destination $export -Force
$exportHash = (Get-FileHash -LiteralPath $export -Algorithm SHA256).Hash
@{
package = (Split-Path -Leaf $export)
bytes = (Get-Item -LiteralPath $export).Length
sha256 = $exportHash
customer_fs_sha256 = $audioHash
git_revision = (& git -C $projectRoot rev-parse HEAD).Trim()
generated_at = (Get-Date).ToString("o")
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $outDir "firmware_manifest.json") -Encoding UTF8
Write-Host "Factory firmware: $export"
Write-Host "SHA256: $exportHash"
}
finally {
Copy-Item -LiteralPath $backup -Destination $configuredImage -Force
$restoredHash = (Get-FileHash -LiteralPath $configuredImage -Algorithm SHA256).Hash
if ($restoredHash -ne $originalHash) {
throw "Business customer_fs.bin restore verification failed. Backup: $backup"
}
Write-Host "Business customer_fs.bin restored: $restoredHash"
}
+141
View File
@@ -0,0 +1,141 @@
param(
[string]$Output = "dist",
[string]$FactoryFirmware = "",
[string]$OfficialFirmware = ""
)
$ErrorActionPreference = "Stop"
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$factoryHostRoot = Split-Path -Parent $scriptRoot
$projectRoot = Split-Path -Parent $factoryHostRoot
$buildRoot = Join-Path $factoryHostRoot "build"
function Resolve-ProjectInput([string]$Value, [string]$DefaultRelativePath) {
$selected = if ([string]::IsNullOrWhiteSpace($Value)) { $DefaultRelativePath } else { $Value }
if ([System.IO.Path]::IsPathRooted($selected)) {
return [System.IO.Path]::GetFullPath($selected)
}
return [System.IO.Path]::GetFullPath((Join-Path $projectRoot $selected))
}
$outputCandidate = if ([System.IO.Path]::IsPathRooted($Output)) {
$Output
} else {
Join-Path $factoryHostRoot $Output
}
$outDir = [System.IO.Path]::GetFullPath($outputCandidate)
$factoryHostPrefix = [System.IO.Path]::GetFullPath($factoryHostRoot).TrimEnd("\") + "\"
if (-not $outDir.StartsWith($factoryHostPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Output 必须位于 factory_host 目录内: $outDir"
}
$pyinstallerDist = Join-Path $buildRoot "pyinstaller-dist"
$pyinstallerWork = Join-Path $buildRoot "pyinstaller-work"
$pyinstallerSpec = Join-Path $buildRoot "pyinstaller-spec"
$buildExe = Join-Path $pyinstallerDist "EG800FactoryHost.exe"
$releaseExe = Join-Path $outDir "START-EG800-FACTORY-TOOL.exe"
$runtimeDir = Join-Path $outDir ".runtime"
$firmwareDir = Join-Path $outDir "firmware"
$toolSource = Join-Path $factoryHostRoot "vendor\aboot-tools-2024.08.08-win-x64"
$toolDestination = Join-Path $runtimeDir "burn_tool\aboot-tools-2024.08.08-win-x64"
$factoryFirmwareSource = Resolve-ProjectInput $FactoryFirmware "factory_host\firmware\test\EG800AKCN_11LG-FT1-factory-test.zip"
$officialFirmwareSource = Resolve-ProjectInput $OfficialFirmware "factory_host\firmware\official\EG800AKCN_11LG-official-fac.zip"
$factoryFirmwareDestination = Join-Path $firmwareDir "EG800AKCN_11LG-FT1-factory-test.zip"
$officialFirmwareDestination = Join-Path $firmwareDir "EG800AKCN_11LG-official-fac.zip"
$releaseZip = Join-Path $outDir "EG800-Factory-Tool-Full-Package.zip"
python -c "import PyInstaller" 2>$null
if ($LASTEXITCODE -ne 0) {
throw "未找到 PyInstaller。先执行: python -m pip install pyinstaller pyserial"
}
foreach ($required in @($toolSource, $factoryFirmwareSource, $officialFirmwareSource)) {
if (-not (Test-Path -LiteralPath $required)) {
throw "发布所需文件不存在: $required"
}
}
# dist is disposable. Source and intermediate artifacts live elsewhere.
New-Item -ItemType Directory -Force -Path $outDir, $buildRoot | Out-Null
Get-ChildItem -LiteralPath $outDir -Force | Remove-Item -Recurse -Force
if (Test-Path -LiteralPath $pyinstallerDist) {
Remove-Item -LiteralPath $pyinstallerDist -Recurse -Force
}
python -m PyInstaller --noconfirm --clean --onefile --windowed `
--name EG800FactoryHost `
--distpath $pyinstallerDist `
--workpath $pyinstallerWork `
--specpath $pyinstallerSpec `
(Join-Path $factoryHostRoot "src\factory_host.py")
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $buildExe)) {
throw "PyInstaller 构建失败,退出码: $LASTEXITCODE"
}
New-Item -ItemType Directory -Force -Path $runtimeDir, $firmwareDir | Out-Null
Move-Item -LiteralPath $buildExe -Destination $releaseExe -Force
Copy-Item -LiteralPath (Join-Path $factoryHostRoot "config\factory_host_config.json") `
-Destination (Join-Path $runtimeDir "factory_host_config.json") -Force
Copy-Item -LiteralPath $toolSource -Destination $toolDestination -Recurse -Force
Copy-Item -LiteralPath $factoryFirmwareSource -Destination $factoryFirmwareDestination -Force
Copy-Item -LiteralPath $officialFirmwareSource -Destination $officialFirmwareDestination -Force
New-Item -ItemType Directory -Force -Path `
(Join-Path $runtimeDir "docs"), (Join-Path $runtimeDir "logs") | Out-Null
Copy-Item -LiteralPath (Join-Path $factoryHostRoot "docs\USAGE.md") `
-Destination (Join-Path $runtimeDir "docs\USAGE.md") -Force
$revision = ""
try {
$revisionOutput = & git -C $projectRoot rev-parse HEAD 2>$null
if ($LASTEXITCODE -eq 0 -and $revisionOutput) {
$revision = ($revisionOutput | Select-Object -First 1).Trim()
}
} catch {
$revision = ""
}
$manifest = [ordered]@{
format = "eg800-factory-host-release-v1"
generated_at = (Get-Date).ToUniversalTime().ToString("o")
git_revision = $revision
files = [ordered]@{
executable = [ordered]@{
path = "START-EG800-FACTORY-TOOL.exe"
bytes = (Get-Item -LiteralPath $releaseExe).Length
sha256 = (Get-FileHash -LiteralPath $releaseExe -Algorithm SHA256).Hash
}
factory_firmware = [ordered]@{
path = "firmware/EG800AKCN_11LG-FT1-factory-test.zip"
bytes = (Get-Item -LiteralPath $factoryFirmwareDestination).Length
sha256 = (Get-FileHash -LiteralPath $factoryFirmwareDestination -Algorithm SHA256).Hash
}
official_firmware = [ordered]@{
path = "firmware/EG800AKCN_11LG-official-fac.zip"
bytes = (Get-Item -LiteralPath $officialFirmwareDestination).Length
sha256 = (Get-FileHash -LiteralPath $officialFirmwareDestination -Algorithm SHA256).Hash
}
downloader = [ordered]@{
path = ".runtime/burn_tool/aboot-tools-2024.08.08-win-x64/adownload.exe"
bytes = (Get-Item -LiteralPath (Join-Path $toolDestination "adownload.exe")).Length
sha256 = (Get-FileHash -LiteralPath (Join-Path $toolDestination "adownload.exe") -Algorithm SHA256).Hash
}
}
}
$manifest | ConvertTo-Json -Depth 5 | Set-Content `
-LiteralPath (Join-Path $runtimeDir "release_manifest.json") -Encoding UTF8
$runtimeItem = Get-Item -LiteralPath $runtimeDir -Force
$runtimeItem.Attributes = $runtimeItem.Attributes -bor [System.IO.FileAttributes]::Hidden
Push-Location $outDir
try {
& tar.exe -a -c -f $releaseZip "START-EG800-FACTORY-TOOL.exe" "firmware" ".runtime"
if ($LASTEXITCODE -ne 0) {
throw "tar.exe 创建完整交付包失败,退出码: $LASTEXITCODE"
}
} finally {
Pop-Location
}
Write-Host "发布目录: $outDir"
Write-Host "上位机: $releaseExe"
Write-Host "完整交付包: $releaseZip"
Write-Host "SHA256: $((Get-FileHash -LiteralPath $releaseZip -Algorithm SHA256).Hash)"