"""Подготавливает версию и выпускает проверенный C2000 SCI8 по настройкам JSON.""" # Один переносимый сценарий обслуживает оба шага CCS через BAT. import argparse # Разбирает команды prepare и build без зависимости от установленной IDE. from datetime import datetime # Восстанавливает регистры даты из времени UTC, зафиксированного до компиляции. import hashlib # Проверяет SHA-256 точных байтов BIN перед переносом в постоянные каталоги. import json # Хранит изделие, счётчик версии, пути и предел EEPROM в одном проектном файле. from pathlib import Path # Разрешает относительные пути от JSON, а не от рабочего каталога CCS. import shutil # Переносит только прошедшие проверку файлы текущей сборки. import struct # Читает загрузочные секции SCI8 как 16-битные слова C28x. import subprocess # Запускает публичные BAT общей библиотеки и передаёт ошибки вызывающей сборке. import tempfile # Изолирует конверсию и обеспечивает атомарную замену файла счётчика. def read_settings(config): # Проверяет настройки до изменения счётчика и запуска общих шагов. settings = json.loads(config.read_text(encoding="utf-8-sig")) # Поддерживает UTF-8 как с BOM, так и без него. version = settings.get("version") # Единственным сохраняемым источником версии является проектный JSON. 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))): # Контракт firmware-info v1 допускает major/minor 0..255 и patch 0..999. raise ValueError("version must be [major 0..255, minor 0..255, patch 0..999]") # Не допускает неверную версию в счётчик или заголовок компилятора. for field in ("product", "target", "templates"): # Требует идентификацию изделия и явно выбранную общую библиотеку. if not isinstance(settings.get(field), str) or not settings[field].strip(): # Не принимает отсутствующее или пустое значение за пригодную настройку. raise ValueError(f"missing {field} in {config}") # Указывает поле, которое нужно заполнить при переносе проекта. if type(settings.get("increment_version", False)) is not bool: # Переключатель итератора допускает только настоящие JSON true/false. raise ValueError("increment_version must be true or false") # Не трактует строку false как включённый счётчик Python. limit = settings.get("max_image_bytes") # Предел образа задаёт плата; отсутствие поля отключает только ограничение размера. if limit is not None and (type(limit) is not int or limit < 24): # Минимальная таблица SCI8 занимает 22 байта заголовка и два байта терминатора. raise ValueError("max_image_bytes must be an integer >= 24") # Не принимает отрицательный либо дробный размер EEPROM. if "copy_to" in settings and (not isinstance(settings["copy_to"], str) or not settings["copy_to"].strip()): # Дополнительный каталог копирования должен быть явно задан непустым путём. raise ValueError("copy_to must be a nonempty path or omitted") # Для отключения копирования достаточно удалить поле из JSON. return settings # Возвращает проверенные настройки без скрытых значений конкретного изделия. def config_path(config, value): # Делает размещение templates и дополнительного выпуска независимым от текущего каталога. return (config.parent / value).resolve() # Абсолютный путь остаётся абсолютным, относительный отсчитывается от JSON. def next_version(version): # Вычисляет следующий номер по пределам общего контракта паспорта. result = list(version) # Не меняет исходные настройки до успешной проверки переполнения. for index, limit in ((2, 999), (1, 255), (0, 255)): # Переносит единицу из patch в minor, затем в major. if result[index] < limit: # Увеличивает первое поле, ещё не достигшее своего предела. result[index] += 1 # Резервирует один следующий номер для очередного запуска prebuild. return result # Старшие поля сохраняются, младшие уже обнулены при переносе. result[index] = 0 # Сбрасывает переполненное поле перед увеличением следующего. raise ValueError("Firmware version exhausted at 255.255.999") # Запрещает повторное использование номеров после полного переполнения. def prepare(config, build_dir): # Увеличивает сохраняемую версию и вызывает штатный precompile templates. settings = read_settings(config) # Сначала проверяет настройки, чтобы ошибочный JSON не занимал номер сборки. script = config_path(config, settings["templates"]) / "tools/firmware-build/ccs/precompile.bat" # Выбирает публичную точку входа общей зависимости из JSON. if not script.is_file(): raise FileNotFoundError(script) # Отсутствие templates останавливает шаг до изменения счётчика. if settings.get("increment_version", False): # Проект включает автоматический итератор явным флагом в JSON. settings["version"] = next_version(settings["version"]) # Получает следующий номер без отдельного C-заголовка. with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=config.parent, prefix=".firmware-version-", delete=False) as stream: # Создаёт UTF-8 файл на том же диске для атомарной замены настроек. temporary = Path(stream.name) # Запоминает только собственный временный файл для очистки при отказе. try: stream.write(json.dumps(settings, ensure_ascii=False, indent=2) + "\n") # Сохраняет все поля проекта вместе с новым номером версии. except BaseException: stream.close(); temporary.unlink(); raise # При ошибке записи сохраняет исходный JSON и удаляет незавершённый файл. try: temporary.replace(config) # Фиксирует занятый номер до запуска генератора и компилятора. finally: temporary.unlink(missing_ok=True) # Не оставляет временный файл при отказе замены конфигурации. output = build_dir / "generated" # Каждая конфигурация CCS получает отдельные заголовок и метаданные. subprocess.run(["cmd.exe", "/d", "/c", "call", str(script), "--identity", str(config.parent), str(config), str(output)], check=True) # Общий precompile создаёт заголовок, UTC и Git-метаданные; ошибка останавливает сборку. return json.loads((output / "firmware_identity.json").read_text(encoding="utf-8")) # Возвращает фактический паспорт без повторного вычисления времени или Build ID. def verify_embedded(image, metadata, max_image_bytes=None): # Проверяет общий паспорт firmware-info v1 независимо от имени платы и проекта. data = image.read_bytes() # Проверяет именно октеты транспортного BIN, предназначенного для загрузки. if data[:2] != b"\xaa\x08" or len(data) < 24: # Ключ загрузчика C2000 SCI8 равен 0x08AA; минимальная таблица содержит терминатор. raise ValueError("Invalid SCI8 header or missing terminator") # Не допускает выпуск произвольного файла под видом загрузочного BIN. if max_image_bytes is not None and len(data) > max_image_bytes: # Сравнивает размер в байтах с пределом из настроек конкретной платы. raise ValueError(f"SCI8 exceeds max_image_bytes={max_image_bytes}") # Сохраняет защиту области параметров EEPROM при переносе между изделиями. stamp = datetime.fromisoformat(metadata["built_at_utc"]) # Берёт время именно того prebuild, который подготовил заголовок. build_id = metadata["build_id"] # Восемь ASCII-символов занимают четыре регистра контракта. words = [1, *metadata["version"], stamp.year, stamp.month * 256 + stamp.day, stamp.hour * 256 + stamp.minute, stamp.second] # Восстанавливает первые восемь регистров; множитель 256 упаковывает два октета в слово. words += [ord(build_id[index]) * 256 + ord(build_id[index + 1]) for index in range(0, 8, 2)] # Упаковывает символы Build ID в том же сетевом порядке, что обработчик устройства. expected = struct.pack("<12H", *words) # SCI8 хранит двенадцать 16-битных регистров младшим октетом вперёд. offset, found = 22, 0 # Пропускает 22 байта ключа, настроек загрузчика и точки входа C2000. while offset + 2 <= len(data): # Обрабатывает конечную таблицу без чтения за пределами BIN. count, = struct.unpack_from(" len(data): raise ValueError("Truncated SCI8 section") # Не принимает усечённую секцию даже при совпавшем паспорте в её начале. payload = data[offset + 4:end] # Проверяет только загружаемые слова, исключая адресные поля. found += sum(payload[index:index + 24] == expected for index in range(0, len(payload) - 23, 2)) # Ищет паспорт из 24 байтов только на границе слова C28x. offset = end # Переходит к следующей непересекающейся секции. if offset != len(data) or count or found != 1: # Требует терминатор в конце файла и ровно один паспорт текущей компиляции. raise ValueError(f"SCI8 does not contain exactly one matching prebuild identity: {found}") # Отклоняет старый OUT, чужую версию, дубликат и лишний хвост. def verify_package(image, metadata): # Проверяет согласованность BIN и контрольных файлов до публикации. manifest = json.loads(image.with_suffix(".bin.manifest.json").read_text(encoding="utf-8")) # Читает manifest, созданный общим postcompile для текущего файла. data = image.read_bytes() # Вычисляет контрольные значения по точным байтам результата конверсии. digest = hashlib.sha256(data).hexdigest() # SHA-256 служит проверкой целостности конкретного транспортного файла. if manifest.get("file") != image.name or manifest.get("size_bytes") != len(data) or manifest.get("sha256") != digest: # Связывает имя, размер и содержимое BIN с manifest. raise ValueError("BIN does not match its manifest") # Не переносит повреждённый выпуск в постоянный каталог. if any(manifest.get(field) != metadata[field] for field in ("product", "target", "version", "build_id", "built_at_utc")): # Проверяет, что manifest относится к тому же паспорту prebuild. raise ValueError("Manifest does not match prebuild identity") # Запрещает контрольные файлы от другой сборки. if image.with_suffix(".bin.sha256").read_text(encoding="utf-8").strip() != f"{digest} {image.name}": # Общий формат SHA-256 содержит хеш и точное имя BIN через два пробела. raise ValueError("Invalid BIN SHA-256 sidecar") # Не допускает рассогласованный комплект файлов для загрузки. def build(compiler, build_dir, artifact, config): # Конвертирует OUT общей библиотекой и публикует только проверенную сборку. settings = read_settings(config) # Берёт ограничения и пути из того же JSON, что использовался перед компиляцией. script = config_path(config, settings["templates"]) / "tools/firmware-build/ccs/postcompile.bat" # Не содержит пути к конкретному репозиторию templates. metadata_path = build_dir / "generated/firmware_identity.json" # Выбирает паспорт текущей конфигурации Debug или Release. metadata = json.loads(metadata_path.read_text(encoding="utf-8")) # Отсутствующий prebuild останавливает выпуск до конверсии. if any(metadata.get(field) != settings[field] for field in ("product", "target", "version")): # Сравнивает изделие, контроллер и сохраняемую версию с текущей конфигурацией. raise ValueError("Configuration changed: run prebuild and rebuild before publishing") # Не выпускает старый OUT после изменения настроек или версии. output = build_dir / "firmware" # Сохраняет стандартную структуру templates внутри BuildDirectory. with tempfile.TemporaryDirectory(prefix=".identity-release-", dir=build_dir) as directory: # Все результаты до проверки остаются во временном каталоге этой сборки. stage = Path(directory) # Изолирует новые результаты от предыдущих архивов и каталога копирования. subprocess.run(["cmd.exe", "/d", "/c", "call", str(script), "--identity", str(compiler), str(build_dir), artifact, str(metadata_path), str(stage)], check=True) # Общий BAT формирует SCI8, manifest, SHA-256 и архив; ненулевой код прерывает выпуск. staged_image = stage / (Path(artifact).name + "_" + metadata["build_id"] + ".bin") # Находит текущий BIN без поиска по времени среди старых файлов. verify_embedded(staged_image, metadata, settings.get("max_image_bytes")) # Проверяет паспорт и проектный предел EEPROM перед постоянной публикацией. verify_package(staged_image, metadata) # Проверяет SHA-256 и соответствие сопроводительных файлов до копирования. output.mkdir(parents=True, exist_ok=True) # Создаёт постоянный каталог только после успешной проверки комплекта. for item in stage.iterdir(): # Переносит результаты только этого вызова общего postcompile. if item.is_dir(): shutil.copytree(item, output / item.name, dirs_exist_ok=True) # Сохраняет полный архив текущей сборки, не удаляя предыдущие. else: shutil.copy2(item, output / item.name) # Сохраняет плоские HEX, BIN, карты и контрольные файлы. image = output / staged_image.name # Возвращает постоянный путь после удаления временного каталога. if "copy_to" in settings: # Копирование в прежний Bin включается только настройкой конкретного проекта. destination = config_path(config, settings["copy_to"]) # Разрешает дополнительный каталог относительно JSON без привязки к имени репозитория. destination.mkdir(parents=True, exist_ok=True) # Позволяет перенести проект в ещё не созданную папку выпусков. for file in (image, image.with_suffix(".bin.manifest.json"), image.with_suffix(".bin.sha256"), output / (Path(artifact).name + ".hex")): # Копирует проверенный комплект, сохраняя прежние имена потребителей BIN и HEX. if file.resolve() != destination / file.name: shutil.copy2(file, destination / file.name) # Допускает совпадение дополнительной папки с firmware без копирования файла в себя. shutil.copy2(output / (Path(artifact).name + ".hex.map"), destination / (Path(artifact).name + ".map")) # Поддерживает старое имя карты конверсии без зашитого имени изделия. verify_package(destination / image.name, metadata) # Подтверждает целостность также скопированного BIN и его контрольных файлов. print(f"[firmware-build] Verified BIN: {image}") # Показывает пользователю точный файл проверенной сборки. return image # Даёт тестам и ручным средствам явный результат без сканирования каталогов. if __name__ == "__main__": # Импорт из тестов не запускает счётчик или конверсию. parser = argparse.ArgumentParser(description=__doc__) # Объясняет назначение переносимого сценария при вызове через BAT. commands = parser.add_subparsers(dest="command", required=True) # Разделяет prebuild и postbuild явными командами. for command in ("prepare", "build"): # Оба шага принимают одинаковые настройки и BuildDirectory. step = commands.add_parser(command) # Создаёт независимый интерфейс выбранного шага сборки. step.add_argument("--config", type=Path, default=Path(__file__).with_name("firmware_identity.json")) # По умолчанию читает JSON рядом со скриптом; другой путь можно передать явно. step.add_argument("--build-dir", type=Path, required=True) # CCS выбирает отдельную папку для каждой конфигурации. if command == "build": # Компилятор и имя OUT требуются только после компиляции. step.add_argument("--compiler", type=Path, required=True) # Получает CG_TOOL_ROOT без поиска конкретной установки CCS. step.add_argument("--artifact", required=True) # Получает BuildArtifactFileBaseName без расширения OUT и без изменения пути. args = parser.parse_args() # Проверяет обязательные параметры до изменения файлов. config, build_dir = args.config.resolve(), args.build_dir.resolve() # Нормализует пути до передачи BAT, независимо от текущего каталога. if args.command == "prepare": print(json.dumps(prepare(config, build_dir), ensure_ascii=False)) # Показывает фактическую версию и метаданные текущего prebuild. else: build(args.compiler.resolve(), build_dir, args.artifact, config) # Запускает конверсию и обязательные проверки готового образа.