feat(firmware-info): добавь pre/post-build для CCS и Keil
Подключи общий генератор версии и SCI8-конвертацию через templates. Сохрани метаданные и SHA-256 готового BIN, совместимость с Python 3.8 и прежний SETGUI preflight. Опиши настройку CCS и локальное копирование результатов.
This commit is contained in:
72
c/firmware-info/tools/c2000_postbuild.py
Normal file
72
c/firmware-info/tools/c2000_postbuild.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Convert a CCS C2000 OUT to SCI8 HEX/BIN and seal its pre-build identity.
|
||||||
|
|
||||||
|
Uses only the standard library, TI hex2000 and the shared identity module.
|
||||||
|
No network, SETGUI, or project-specific paths are required.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from identity import seal, verify
|
||||||
|
|
||||||
|
|
||||||
|
def postbuild(compiler: Path, build_dir: Path, artifact: str,
|
||||||
|
metadata: Path, output: Path) -> Path:
|
||||||
|
if not artifact or artifact in (".", "..") or any(c in artifact for c in '\\/:*?"<>|'):
|
||||||
|
raise ValueError("artifact must be a file base name, without a path")
|
||||||
|
tool = compiler.resolve() / "bin" / "hex2000.exe"
|
||||||
|
source = build_dir.resolve() / (artifact + ".out")
|
||||||
|
if not tool.is_file():
|
||||||
|
raise FileNotFoundError(f"C2000 Hex Utility not found: {tool}")
|
||||||
|
if not source.is_file():
|
||||||
|
raise FileNotFoundError(f"compiled OUT not found: {source}")
|
||||||
|
metadata = metadata.resolve()
|
||||||
|
settings = json.loads(metadata.read_text(encoding="utf-8"))
|
||||||
|
build_id = settings["build_id"]
|
||||||
|
if not isinstance(build_id, str) or not re.fullmatch(r"[0-9a-fA-F]{8}|[0-9a-fA-F]{7}\+|NOGIT000", build_id):
|
||||||
|
raise ValueError("invalid pre-build identity; run precompile before building")
|
||||||
|
if build_id == "NOGIT000":
|
||||||
|
print("[firmware-build] Git identity unavailable: using NOGIT000")
|
||||||
|
output = output.resolve()
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Keep failed conversions and incomplete manifests out of the final folder.
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".firmware-", dir=output) as temporary:
|
||||||
|
stage = Path(temporary)
|
||||||
|
hex_image = stage / (artifact + ".hex")
|
||||||
|
bin_image = stage / (artifact + "_" + build_id + ".bin")
|
||||||
|
for image, mode in ((hex_image, "-i"), (bin_image, "-b")):
|
||||||
|
subprocess.run([str(tool), str(source), "-boot", "-sci8",
|
||||||
|
"-map", str(image.with_suffix(image.suffix + ".map")),
|
||||||
|
"-o", str(image), mode], check=True)
|
||||||
|
if not image.is_file() or not image.stat().st_size:
|
||||||
|
raise ValueError(f"hex2000 produced no image: {image}")
|
||||||
|
seal(bin_image, metadata)
|
||||||
|
verify(bin_image, bin_image.with_suffix(bin_image.suffix + ".manifest.json"))
|
||||||
|
for item in stage.iterdir():
|
||||||
|
item.replace(output / item.name)
|
||||||
|
result = output / bin_image.name
|
||||||
|
print(f"[firmware-build] BIN: {result}")
|
||||||
|
print(f"[firmware-build] Manifest: {result}.manifest.json")
|
||||||
|
print(f"[firmware-build] SHA-256: {result}.sha256")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
for name in ("compiler", "build-dir", "metadata", "output"):
|
||||||
|
parser.add_argument("--" + name, type=Path, required=True)
|
||||||
|
parser.add_argument("--artifact", required=True)
|
||||||
|
try:
|
||||||
|
postbuild(**vars(parser.parse_args()))
|
||||||
|
except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error:
|
||||||
|
parser.exit(1, f"[firmware-build] ERROR: {error}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
126
c/firmware-info/tools/identity.py
Normal file
126
c/firmware-info/tools/identity.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Build provenance and exact-file SHA-256, shared by CCS, Keil and CLI builds.
|
||||||
|
|
||||||
|
prepare runs BEFORE compiling; seal runs AFTER binary conversion and size checks.
|
||||||
|
The digest is external to the image, avoiding the self-hashing paradox. No MCU
|
||||||
|
protocol encoding lives here: the generated header contains scalar constants.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def git_state(repository: Path) -> dict:
|
||||||
|
"""Record full commits, and flag untracked sources as well as tracked edits.
|
||||||
|
|
||||||
|
Generated outputs must be ignored in the repository. Unlike Climate's older
|
||||||
|
helper, a new, untracked source file must not look like a clean release.
|
||||||
|
"""
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.check_output(
|
||||||
|
["git", "-C", str(repository), *args], stderr=subprocess.DEVNULL,
|
||||||
|
text=True, encoding="utf-8").strip()
|
||||||
|
try:
|
||||||
|
commit = git("rev-parse", "HEAD")
|
||||||
|
dirty = bool(git("status", "--porcelain", "--untracked-files=normal"))
|
||||||
|
return {"commit": commit, "dirty": dirty}
|
||||||
|
except (OSError, subprocess.CalledProcessError):
|
||||||
|
return {"commit": None, "dirty": None}
|
||||||
|
|
||||||
|
|
||||||
|
def write_atomic(path: Path, text: str):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(path.name + ".tmp")
|
||||||
|
# Built-in open supports newline on Python versions before 3.10.
|
||||||
|
with open(temporary, "w", encoding="utf-8", newline="\n") as stream:
|
||||||
|
stream.write(text)
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(repository: Path, templates: Path, config: Path, output: Path) -> dict:
|
||||||
|
settings = json.loads(config.read_text(encoding="utf-8-sig"))
|
||||||
|
version = settings["version"]
|
||||||
|
if (len(version) != 3 or any(type(v) is not int for v in version)
|
||||||
|
or not all(0 <= v <= limit for v, limit in zip(version, (255, 255, 999)))):
|
||||||
|
raise ValueError("version must be [major 0..255, minor 0..255, patch 0..999]")
|
||||||
|
for field in ("product", "target"):
|
||||||
|
if not isinstance(settings.get(field), str) or not settings[field].strip():
|
||||||
|
raise ValueError(f"missing {field}")
|
||||||
|
source, dependency = git_state(repository), git_state(templates)
|
||||||
|
# A clean application built with a modified templates checkout is dirty too.
|
||||||
|
known = source["commit"] is not None and dependency["commit"] is not None
|
||||||
|
dirty = bool(source["dirty"] or dependency["dirty"])
|
||||||
|
build_id = (source["commit"][:7] + "+" if dirty else source["commit"][:8]) if known else "NOGIT000"
|
||||||
|
# UTC has an explicit interpretation across build PCs; SOURCE_DATE_EPOCH is
|
||||||
|
# supported for reproducible release builds, not silently inferred from Git.
|
||||||
|
epoch = os.environ.get("SOURCE_DATE_EPOCH")
|
||||||
|
stamp = datetime.fromtimestamp(int(epoch), timezone.utc) if epoch else datetime.now(timezone.utc)
|
||||||
|
stamp = stamp.replace(microsecond=0)
|
||||||
|
if not 2000 <= stamp.year <= 2199:
|
||||||
|
raise ValueError("build year outside firmware-info v1 range")
|
||||||
|
metadata = {"schema": 1, "product": settings["product"], "target": settings["target"],
|
||||||
|
"version": version, "build_id": build_id, "built_at_utc": stamp.isoformat(),
|
||||||
|
"source": source, "templates": dependency,
|
||||||
|
"identity_contract": 1, "identity_register_count": 12}
|
||||||
|
constants = dict(zip(("FIRMWARE_VERSION_MAJOR", "FIRMWARE_VERSION_MINOR", "FIRMWARE_VERSION_PATCH"), version))
|
||||||
|
constants.update(dict(zip(("FI_YEAR", "FI_MONTH", "FI_DAY", "FI_HOUR", "FI_MINUTE", "FI_SECOND"),
|
||||||
|
(stamp.year, stamp.month, stamp.day, stamp.hour, stamp.minute, stamp.second))))
|
||||||
|
constants.update({f"FI_ID_{i}": ord(c) for i, c in enumerate(build_id)})
|
||||||
|
header = "/* Generated before compilation. Do not edit or commit. UTC stamp. */\n#ifndef FIRMWARE_IDENTITY_H\n#define FIRMWARE_IDENTITY_H\n"
|
||||||
|
header += f'#define FIRMWARE_BUILD_ID "{build_id}"\n'
|
||||||
|
header += "".join(f"#define {key} {value}U\n" for key, value in constants.items())
|
||||||
|
header += "#endif\n"
|
||||||
|
# Rewriting the header forces the identity compilation unit to be rebuilt.
|
||||||
|
# Sharing this directory between concurrent configurations is unsupported.
|
||||||
|
write_atomic(output / "firmware_identity.h", header)
|
||||||
|
write_atomic(output / "firmware_identity.json", json.dumps(metadata, indent=2) + "\n")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def seal(image: Path, metadata_path: Path) -> dict:
|
||||||
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||||
|
data = image.read_bytes()
|
||||||
|
if not data:
|
||||||
|
raise ValueError("empty firmware image")
|
||||||
|
# Seal records the exact file sent to the programmer, including SCI8 headers.
|
||||||
|
# It does not claim that the device has measured RAM/Flash or authenticated it.
|
||||||
|
metadata.update({"file": image.name, "size_bytes": len(data),
|
||||||
|
"sha256": hashlib.sha256(data).hexdigest()})
|
||||||
|
write_atomic(image.with_suffix(image.suffix + ".manifest.json"), json.dumps(metadata, indent=2) + "\n")
|
||||||
|
write_atomic(image.with_suffix(image.suffix + ".sha256"), f'{metadata["sha256"]} {image.name}\n')
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def verify(image: Path, manifest: Path) -> dict:
|
||||||
|
metadata = json.loads(manifest.read_text(encoding="utf-8"))
|
||||||
|
data = image.read_bytes()
|
||||||
|
if len(data) != metadata["size_bytes"] or hashlib.sha256(data).hexdigest() != metadata["sha256"]:
|
||||||
|
raise ValueError("firmware size/SHA-256 does not match manifest")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
p = sub.add_parser("prepare")
|
||||||
|
for name in ("repository", "templates", "config", "output"):
|
||||||
|
p.add_argument("--" + name, type=Path, required=True)
|
||||||
|
p = sub.add_parser("seal")
|
||||||
|
p.add_argument("--image", type=Path, required=True)
|
||||||
|
p.add_argument("--metadata", dest="metadata_path", type=Path, required=True)
|
||||||
|
p = sub.add_parser("verify")
|
||||||
|
p.add_argument("--image", type=Path, required=True)
|
||||||
|
p.add_argument("--manifest", type=Path, required=True)
|
||||||
|
args = vars(parser.parse_args())
|
||||||
|
command = args.pop("command")
|
||||||
|
result = {"prepare": prepare, "seal": seal, "verify": verify}[command](**args)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
13
firmware-version-porting.md
Normal file
13
firmware-version-porting.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Перенос контроля версии прошивки
|
||||||
|
|
||||||
|
В templates используются общий модуль `c/firmware-info` и общий публикатор `tools/firmware-publish`. При переносе подключаются конфигурация контроллера, генерация build ID, обработчик запроса версии и настройки выпуска.
|
||||||
|
|
||||||
|
- [Пошаговое подключение модуля версии](c/firmware-info/PORTING.md).
|
||||||
|
- [BAT-файлы precompile и postcompile для CCS и Keil](tools/firmware-build/README.md).
|
||||||
|
- [Настройка подготовки и публикации образов](tools/firmware-publish/PORTING.md).
|
||||||
|
|
||||||
|
В режиме CCS `--identity` precompile создаёт заголовок версии и Git-метаданные, а postcompile преобразует OUT в SCI8 HEX/BIN и создаёт manifest и SHA-256. В прежнем режиме postcompile запускает SETGUI preflight готового образа. Для каждого проекта задаются явные пути; алгоритмы остаются в templates.
|
||||||
|
|
||||||
|
[Копирование BIN, manifest и SHA-256 в каталог SETGUI после postcompile](tools/firmware-build/README.md#4-копирование-результата-в-каталог-setgui).
|
||||||
|
|
||||||
|
Для C2000 с 16-битным char требуется отдельная адаптация байтового контракта. BAT-файлы сами по себе эту адаптацию не выполняют.
|
||||||
90
tools/firmware-build/README.md
Normal file
90
tools/firmware-build/README.md
Normal file
File diff suppressed because one or more lines are too long
5
tools/firmware-build/ccs/firmware_identity.example.json
Normal file
5
tools/firmware-build/ccs/firmware_identity.example.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"product": "MY_CONTROLLER",
|
||||||
|
"target": "TMS320F28335",
|
||||||
|
"version": [0, 1, 0]
|
||||||
|
}
|
||||||
25
tools/firmware-build/ccs/postcompile.bat
Normal file
25
tools/firmware-build/ccs/postcompile.bat
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
if /i "%~1"=="--identity" goto identity
|
||||||
|
rem Legacy mode: SETGUI release preflight.
|
||||||
|
if "%~1"=="" goto usage
|
||||||
|
if not "%~2"=="" goto usage
|
||||||
|
if not exist "%~1" (
|
||||||
|
echo [firmware-build] Release config not found: "%~1" 1>&2
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
call "%~dp0..\..\firmware-publish\PUBLISH_FIRMWARE.bat" --config "%~f1" --preflight
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:identity
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if "%~3"=="" goto usage
|
||||||
|
if "%~4"=="" goto usage
|
||||||
|
if "%~5"=="" goto usage
|
||||||
|
if "%~6"=="" goto usage
|
||||||
|
if not "%~7"=="" goto usage
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\c2000_postbuild.py" --compiler "%~f2" --build-dir "%~f3" --artifact "%~4" --metadata "%~f5" --output "%~f6"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:usage
|
||||||
|
echo Usage: postcompile.bat --identity "compiler-root" "build-dir" "artifact-name" "generated-metadata.json" "output-dir" 1>&2
|
||||||
|
echo Legacy: postcompile.bat "firmware-release.cmd" 1>&2
|
||||||
|
exit /b 2
|
||||||
24
tools/firmware-build/ccs/precompile.bat
Normal file
24
tools/firmware-build/ccs/precompile.bat
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
if /i "%~1"=="--identity" goto identity
|
||||||
|
rem Legacy mode: build ID header only.
|
||||||
|
if "%~1"=="" goto usage
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if not "%~3"=="" goto usage
|
||||||
|
if not exist "%~1\." (
|
||||||
|
echo [firmware-build] Project directory not found: "%~1" 1>&2
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\..\..\c\firmware-info\tools\make_build_id.ps1" -Repository "%~f1" -Output "%~f2"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:identity
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if "%~3"=="" goto usage
|
||||||
|
if "%~4"=="" goto usage
|
||||||
|
if not "%~5"=="" goto usage
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" prepare --repository "%~f2" --templates "%~dp0..\..\.." --config "%~f3" --output "%~f4"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:usage
|
||||||
|
echo Usage: precompile.bat --identity "project-root" "firmware_identity.json" "generated-dir" 1>&2
|
||||||
|
echo Legacy: precompile.bat "project-root" "output-header" 1>&2
|
||||||
|
exit /b 2
|
||||||
13
tools/firmware-build/keil/postcompile.bat
Normal file
13
tools/firmware-build/keil/postcompile.bat
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal DisableDelayedExpansion
|
||||||
|
if "%~1"=="" goto usage
|
||||||
|
if not "%~2"=="" goto usage
|
||||||
|
if not exist "%~1" (
|
||||||
|
echo [firmware-build] Release config not found: "%~1" 1>&2
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
call "%~dp0..\..\firmware-publish\PUBLISH_FIRMWARE.bat" --config "%~f1" --preflight
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:usage
|
||||||
|
echo Usage: postcompile.bat "firmware-release.cmd" 1>&2
|
||||||
|
exit /b 2
|
||||||
14
tools/firmware-build/keil/precompile.bat
Normal file
14
tools/firmware-build/keil/precompile.bat
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal DisableDelayedExpansion
|
||||||
|
if "%~1"=="" goto usage
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if not "%~3"=="" goto usage
|
||||||
|
if not exist "%~1\." (
|
||||||
|
echo [firmware-build] Project directory not found: "%~1" 1>&2
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\..\..\c\firmware-info\tools\make_build_id.ps1" -Repository "%~f1" -Output "%~f2"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:usage
|
||||||
|
echo Usage: precompile.bat "project-root" "output-header" 1>&2
|
||||||
|
exit /b 2
|
||||||
Reference in New Issue
Block a user