diff --git a/c/firmware-info/tools/c2000_postbuild.py b/c/firmware-info/tools/c2000_postbuild.py new file mode 100644 index 0000000..4c9987f --- /dev/null +++ b/c/firmware-info/tools/c2000_postbuild.py @@ -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()) diff --git a/c/firmware-info/tools/identity.py b/c/firmware-info/tools/identity.py new file mode 100644 index 0000000..8a4c072 --- /dev/null +++ b/c/firmware-info/tools/identity.py @@ -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() diff --git a/firmware-version-porting.md b/firmware-version-porting.md new file mode 100644 index 0000000..ece253c --- /dev/null +++ b/firmware-version-porting.md @@ -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-файлы сами по себе эту адаптацию не выполняют. diff --git a/tools/firmware-build/README.md b/tools/firmware-build/README.md new file mode 100644 index 0000000..ccb8522 --- /dev/null +++ b/tools/firmware-build/README.md @@ -0,0 +1,90 @@ +# Подключение precompile и postcompile из templates + +## Новый CCS-проект C2000: локальные версия и SCI8 BIN + +Используйте обновлённую папку templates целиком. Режим `--identity` добавлен для нового проекта: Windows, Python 3.8+ в PATH и TI C2000 compiler с hex2000.exe. Git в PATH нужен для хеша коммита. Без Git либо без commit в проекте или templates используется `NOGIT000`; сборка и локальная конвертация разрешены. Это явно видно в manifest. SETGUI и доступ к серверу не требуются. + +### 1. Создайте конфигурацию версии + +В корне проекта CCS (рядом с `.project`) скопируйте `ccs/firmware_identity.example.json` под именем `firmware_identity.json` (UTF-8): + +```json +{ + "product": "MY_CONTROLLER", + "target": "TMS320F28335", + "version": [0, 1, 0] +} +``` + +Задайте своё изделие, настоящий контроллер и нужную версию. Для TMS320F2812 замените target. Пример версии — 0.1.0; это не номер предыдущих прошивок. Этот JSON — единственное вручную редактируемое место версии в данной схеме. + +### 2. Добавьте команды в CCS + +Project Properties → Build → Steps, для нужной конфигурации. Пример предполагает templates в `C:\Projects\templates`. Замените только этот путь своим; макросы CCS оставьте как есть. + +Pre-build steps → Command, одной строкой: + +```bat +cmd /c call "C:\Projects\templates\tools\firmware-build\ccs\precompile.bat" --identity "${PROJECT_LOC}" "${PROJECT_LOC}\firmware_identity.json" "${BuildDirectory}\generated" +``` + +Post-build steps → Command, одной строкой: + +```bat +cmd /c call "C:\Projects\templates\tools\firmware-build\ccs\postcompile.bat" --identity "${CG_TOOL_ROOT}" "${BuildDirectory}" "${BuildArtifactFileBaseName}" "${BuildDirectory}\generated\firmware_identity.json" "${BuildDirectory}\firmware" +``` + +Нужны оба шага. Precompile создаёт версию, UTC-время и Git-метаданные до компиляции. Postcompile преобразует OUT в SCI8 HEX/BIN, создаёт manifest и SHA-256 для точного BIN. Он не повторяет запрос Git после компиляции. В этом режиме `firmware-release.cmd` не нужен. + +`BuildArtifactFileBaseName` должен быть именем без пути; OUT должен находиться в BuildDirectory. Если проект кладёт OUT в отдельный каталог, передайте фактический каталог вторым аргументом postcompile вместо BuildDirectory. + +### 3. Выполните полную сборку + +Apply and Close → Clean → Build Project. Настройте шаги отдельно для Debug/Release; их generated и firmware находятся внутри своих BuildDirectory. + +Результаты: + +```text +Debug/ + generated/ + firmware_identity.h + firmware_identity.json + firmware/ + MyFirmware.hex + MyFirmware_BUILDID.bin + MyFirmware_BUILDID.bin.manifest.json + MyFirmware_BUILDID.bin.sha256 +``` + +Добавьте generated и firmware в игнорируемые Git-каталоги. Обычно вся папка Debug уже игнорируется. Полный Rebuild обязателен для выпуска. BAT сами по себе не подтверждают, что OUT собран именно с этим заголовком. + +### 4. Копирование результата в каталог SETGUI + +Чтобы после успешного postcompile скопировать BIN в `K:\git_project\newProject\SETGUI\..periph_28335\firmware\`, добавьте в конец существующей команды Post-build: + +```bat +&& copy /Y "${BuildDirectory}\firmware\*.bin" "K:\git_project\newProject\SETGUI\..periph_28335\firmware\" +``` + +Для копирования BIN вместе с manifest и SHA-256 используйте полную команду ниже. Она соответствует templates в `K:\git_project\newProject\newTEMPL\templates`; при другом расположении замените путь к postcompile.bat. + +```bat +cmd /c call "K:\git_project\newProject\newTEMPL\templates\tools\firmware-build\ccs\postcompile.bat" --identity "${CG_TOOL_ROOT}" "${BuildDirectory}" "${BuildArtifactFileBaseName}" "${BuildDirectory}\generated\firmware_identity.json" "${BuildDirectory}\firmware" && copy /Y "${BuildDirectory}\firmware\*.bin" "K:\git_project\newProject\SETGUI\..periph_28335\firmware\" && copy /Y "${BuildDirectory}\firmware\*.bin.manifest.json" "K:\git_project\newProject\SETGUI\..periph_28335\firmware\" && copy /Y "${BuildDirectory}\firmware\*.bin.sha256" "K:\git_project\newProject\SETGUI\..periph_28335\firmware\" +``` + +В поле CCS вся команда должна быть одной строкой. Папка назначения должна существовать. `&&` запускает следующую операцию только при успешном завершении предыдущей. Маски копируют все соответствующие файлы из каталога firmware, включая результаты прошлых сборок; `/Y` разрешает замену одноимённых файлов. Это локальное копирование, без публикации на сервер. + +### Версия внутри устройства + +Это подготовка метаданных сборки и файла. Чтобы версия присутствовала в программе и ответах устройства, подключите заголовок `firmware_identity.h` к модулю версии и обработчику запроса. Include path: `${BuildDirectory}/generated`. Для C2000 с 16-битным char нужна адаптация байтового контракта; обычный 8-битный порт нельзя считать готовым. Смотрите [PORTING.md](../../c/firmware-info/PORTING.md). + +## Прежний режим и Keil + +Для совместимости сохранены старые вызовы CCS без `--identity` и исходные Keil BAT: + +```bat +call "templates\tools\firmware-build\keil\precompile.bat" "project-root" "generated\firmware_build_id.h" +call "templates\tools\firmware-build\keil\postcompile.bat" "firmware-release.cmd" +``` + +Этот precompile вызывает make_build_id.ps1, а postcompile — SETGUI preflight готового HEX/BIN. Для этого отдельного режима нужны firmware-release.cmd и настроенный SETGUI по [инструкции публикатора](../firmware-publish/README.md). Его конфигурация выпуска не читается режимом `--identity`. diff --git a/tools/firmware-build/ccs/firmware_identity.example.json b/tools/firmware-build/ccs/firmware_identity.example.json new file mode 100644 index 0000000..acef913 --- /dev/null +++ b/tools/firmware-build/ccs/firmware_identity.example.json @@ -0,0 +1,5 @@ +{ + "product": "MY_CONTROLLER", + "target": "TMS320F28335", + "version": [0, 1, 0] +} diff --git a/tools/firmware-build/ccs/postcompile.bat b/tools/firmware-build/ccs/postcompile.bat new file mode 100644 index 0000000..9bcb94e --- /dev/null +++ b/tools/firmware-build/ccs/postcompile.bat @@ -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 diff --git a/tools/firmware-build/ccs/precompile.bat b/tools/firmware-build/ccs/precompile.bat new file mode 100644 index 0000000..30eba48 --- /dev/null +++ b/tools/firmware-build/ccs/precompile.bat @@ -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 diff --git a/tools/firmware-build/keil/postcompile.bat b/tools/firmware-build/keil/postcompile.bat new file mode 100644 index 0000000..ea6583b --- /dev/null +++ b/tools/firmware-build/keil/postcompile.bat @@ -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 diff --git a/tools/firmware-build/keil/precompile.bat b/tools/firmware-build/keil/precompile.bat new file mode 100644 index 0000000..2efa06a --- /dev/null +++ b/tools/firmware-build/keil/precompile.bat @@ -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