Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt. Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor. Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов. Обновить справочники устройств, проекты CCS и регрессионные проверки.
152 lines
12 KiB
Python
152 lines
12 KiB
Python
"""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
|
|
import tempfile # Резервирует номер сборки через временный файл на том же диске, как общий итератор ветки 166.
|
|
|
|
|
|
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")
|
|
with open(temporary, "w", encoding="utf-8", newline="\n") as stream: # Сохраняет UTF-8 и LF также на Python 3.8, где Path.write_text ещё не принимает newline.
|
|
stream.write(text) # Полностью записывает заголовок или паспорт перед атомарной заменой целевого файла.
|
|
temporary.replace(path)
|
|
|
|
|
|
def next_version(version): # Повторяет общий итератор 166 и переносит переполнение младшего разряда в старший.
|
|
result = list(version) # Не изменяет исходный номер до проверки полного переполнения.
|
|
for index, limit in ((2, 999), (1, 255), (0, 255)): # Контракт firmware-info v1 допускает patch 0..999 и major/minor 0..255.
|
|
if result[index] < limit: # Выбирает младший разряд, который ещё допускает увеличение.
|
|
result[index] += 1 # Занимает следующий номер попытки сборки ровно один раз.
|
|
return result # Возвращает новый номер с сохранёнными старшими и обнулёнными переполненными младшими разрядами.
|
|
result[index] = 0 # Переносит единицу к следующему разряду при достижении предела текущего.
|
|
raise ValueError("Firmware version exhausted at 255.255.999") # Не допускает повторного использования версии после полного переполнения контракта.
|
|
|
|
|
|
def prepare(repository: Path, templates: Path, config: Path, output: Path) -> dict: # Формирует один паспорт текущей попытки после сохранения номера автоматической сборки.
|
|
settings = json.loads(config.read_text(encoding="utf-8-sig")) # Принимает исходный JSON в UTF-8, включая старые файлы Windows с BOM.
|
|
version = settings["version"] # Начальный номер берётся из единственного сохраняемого источника проекта.
|
|
if not isinstance(version, list) or len(version) != 3 or any(type(value) is not int or not 0 <= value <= limit for value, limit in zip(version, (255, 255, 999))): # Проверяет три целых разряда и пределы протокола до изменения JSON.
|
|
raise ValueError("version must be [major 0..255, minor 0..255, patch 0..999]") # Останавливает prebuild при неверном формате счётчика.
|
|
if type(settings.get("increment_version", False)) is not bool: # Старые проекты сохраняют ручной режим; автоматический требует настоящего логического true.
|
|
raise ValueError("increment_version must be true or false") # Не принимает строки и числа вместо явной настройки счётчика.
|
|
for field in ("product", "target"):
|
|
if not isinstance(settings.get(field), str) or not settings[field].strip():
|
|
raise ValueError(f"missing {field}")
|
|
if settings.get("increment_version", False): # Увеличение выполняется только при подготовке основного проекта, а не при упаковке RAM, CAN и Flash.
|
|
version = settings["version"] = next_version(version) # Один вычисленный номер используется исходным JSON, генерируемым заголовком и паспортом.
|
|
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=config.parent, prefix=".firmware-version-", delete=False) as stream: # Временный файл рядом с JSON позволяет атомарную замену счётчика на том же диске.
|
|
temporary = Path(stream.name) # Запоминает только собственный файл для очистки при отказе сохранения.
|
|
try: # Ошибка записи должна сохранить исходный JSON и удалить незавершённый временный файл.
|
|
stream.write(json.dumps(settings, ensure_ascii=False, indent=2) + "\n") # Сохраняет новый номер и все дополнительные настройки проекта в UTF-8.
|
|
except BaseException: # Освобождает собственный файл также при прерывании процесса записи.
|
|
stream.close() # Windows разрешает удалить временный файл после закрытия его дескриптора.
|
|
temporary.unlink() # Удаляет только незавершённый файл этой попытки резервирования номера.
|
|
raise # Возвращает исходную ошибку вместо продолжения сборки с несохранённой версией.
|
|
try: # Номер должен быть надёжно занят до генерации выходов и начала компиляции.
|
|
temporary.replace(config) # Атомарно фиксирует счётчик, чтобы следующая попытка не повторила номер при последующей ошибке сборки.
|
|
finally: # При неудачной замене исходный JSON остаётся прежним, а собственный временный файл очищается.
|
|
temporary.unlink(missing_ok=True) # После успешного переименования файла уже нет; Python 3.8 поддерживает missing_ok.
|
|
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()
|