Files
Balsam_167/tests/firmware_identity/test_release.py
T
Andrey bbd82b3a4e Перенести улучшения 166 и добавить сборку для M95M02 и Flash
Восстановить телеметрию CAN, обработку TESTLAMP и сохранение команд при загрузке параметров.

Выбрать M95M02 основным профилем, добавить самостоятельную Flash-сборку и проверять образы до публикации.

Проверки: хостовые тесты, реальные сборки TI C2000, контроль COFF/SCI8 и защита записи при активной нагрузке.
2026-10-19 16:33:28 +03:00

182 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Проверяет выпуск 167: старый OUT, EEPROM, обрезанный SCI8 и отсутствие публикации при отказе.""" # Использует производственные проверки и настоящий TI-конвертор при доступном toolchain.
from datetime import datetime # Независимо восстанавливает регистры даты для контрольного образа.
import hashlib # Сравнивает постоянные каталоги до и после отклонённого выпуска.
import importlib.util # Загружает существующий общий seal/archive без изменения templates.
import json # Создаёт минимальный проект и проверяет поля конечного manifest.
import os # Позволяет указать установленный TI toolchain переменной TI_CGT_ROOT.
from pathlib import Path # Изолирует все временные файлы от настоящих generated и Bin проекта.
import struct # Формирует независимые контрольные SCI8-блоки согласно формату загрузчика TI.
import subprocess # Компилирует минимальный настоящий COFF для проверки интеграции с hex2000.
import sys # Подключает локальные prebuild/postbuild из каталога CCS-проекта.
import tempfile # Удаляет только временные каталоги этой проверки после завершения тестов.
import unittest # Группирует успешные и отказные сценарии с отдельными результатами.
from unittest.mock import patch # Подменяет только конверсию для испытания повреждённых результатов инструментов.
ROOT = Path(__file__).resolve().parents[2] # Находит исходники проверяемой 167 независимо от рабочего каталога запуска.
sys.path.insert(0, str(ROOT / "UKSSTMS320F28335")) # Импортирует производственные функции без запуска их CLI.
import prepare_identity as prebuild # Проверяет фактическую генерацию ручной версии и физического профиля.
import publish_identity as release # Вызывает именно обязательный защищённый postbuild проекта.
VENDOR = ROOT.parent / "newProject/SETGUI/third_party/templates" # Совпадает с include paths обеих конфигураций CCS.
TOOLS = ROOT.parent / "newProject/templates" # Полная рабочая библиотека содержит общий BAT-конвертор и архивирование.
COMPILER = Path(os.environ.get("TI_CGT_ROOT", "C:/ti/ccs1230/ccs/tools/compiler/ti-cgt-c2000_22.6.0.LTS")) # Проверяет установленный TI без загрузки нового toolchain.
SPEC = importlib.util.spec_from_file_location("test_common_identity", TOOLS / "c/firmware-info/tools/identity.py") # Подключает тот же seal/archive, который вызывает postcompile.
IDENTITY = importlib.util.module_from_spec(SPEC) # Отделяет общие инструменты тестов от производственного prebuild.
SPEC.loader.exec_module(IDENTITY) # Неполная зависимость явно останавливает тест вместо пропуска проверок выпуска.
def identity_words(metadata): # Независимый контрольный вектор проверяет общий 12-словный контракт, а не копирует поиск production-кода.
stamp = datetime.fromisoformat(metadata["built_at_utc"]) # Использует фиксированное время того же тестового prebuild.
result = [1, *metadata["version"], stamp.year, stamp.month * 256 + stamp.day, stamp.hour * 256 + stamp.minute, stamp.second] # Первые восемь слов содержат формат, ручную версию и UTC.
result += [ord(metadata["build_id"][index]) * 256 + ord(metadata["build_id"][index + 1]) for index in range(0, 8, 2)] # Последние четыре слова хранят ASCII Build ID в сетевом порядке.
return result # Возвращает числа для независимой SCI8-таблицы и настоящего COFF-фикстура.
def block(address, words): # Строит минимальный загрузочный блок с адресом в словах C28x.
return struct.pack("<HHH", len(words), address >> 16, address & 65535) + struct.pack("<" + "H" * len(words), *words) # Поля длины, старшего/младшего адресов и данных соответствуют документации SCI8.
def image_for(metadata): # Формирует два непересекающихся блока RAM с загруженной точкой входа и неизменяемым паспортом.
return b"\xaa\x08" + bytes(16) + struct.pack("<HH", 0, 0x8000) + block(0x8000, [0x761F, 0x8000]) + block(0x8100, identity_words(metadata)) + bytes(2) # Заголовок занимает 22 октета, нулевая длина завершает таблицу.
def snapshot(path): # Доказывает сохранность прежних выпусков и каталога GUI при любом отказе валидации.
return {str(file.relative_to(path)): hashlib.sha256(file.read_bytes()).hexdigest() for file in path.rglob("*") if file.is_file()} if path.exists() else {} # Сравнивает имена и точные байты всех постоянных файлов.
class ReleaseTests(unittest.TestCase): # Испытывает реальную последовательность prebuild, проверок и публикации.
def setUp(self): # Каждый сценарий получает собственные project/generated/output/export.
self.temporary = tempfile.TemporaryDirectory(prefix="balsam-release-test-") # Временные результаты не попадают в настоящее дерево Bin.
self.directory = Path(self.temporary.name) # Задаёт корень только текущего тестового проекта.
self.project = self.directory / "Project" # Сохраняет отношение project.parent/Source, используемое настоящим prebuild.
self.project.mkdir() # Создаёт отдельный каталог настроек CCS.
self.build = self.project / "Debug" # Использует штатное соглашение выбранной конфигурации.
self.build.mkdir() # Позволяет postbuild изолировать конверсию внутри этой конфигурации.
self.config = self.directory / "Source/Internal/Include/eeprom_config.h" # Отделяет выбор физической EEPROM от настоящей платы пользователя.
self.config.parent.mkdir(parents=True) # Создаёт только тестовую иерархию Source.
self.config.write_text("#define EEPROM_M95M02 0 /* Тестовая плата с AT25256B. */\n", encoding="utf-8") # Начальный профиль соответствует совместимой малой EEPROM 167.
self.version_text = "#define FIRMWARE_VERSION_MAJOR 1U /* Основной выпуск. */\n#define FIRMWARE_VERSION_MINOR 1U /* Дополнительный выпуск. */\n#define FIRMWARE_VERSION_PATCH 2U /* Ручной номер исправления. */\n" # Весь тест сохраняет пользовательский выпуск 1.1.2.
(self.project / "firmware_version.h").write_text(self.version_text, encoding="utf-8") # Источником версии остаётся C-заголовок.
self.settings = {"product": "Balsam-167-PM35", "target": "TMS320F28335", "increment_version": True} # Старый автоинкремент не должен преодолеть новый ручной контракт.
(self.project / "firmware_identity.json").write_text(json.dumps(self.settings), encoding="utf-8") # Проверяет совместимость со случайно сохранённым старым полем JSON.
self.output, self.export = self.directory / "Published", self.directory / "Gui" # Выбирает отдельные постоянные назначения для проверки порядка публикации.
self.metadata = prebuild.prepare(self.project, VENDOR, self.build / "generated") # Выполняет настоящий prebuild с фактической компилируемой библиотекой.
self.map_path = self.build / "application.map" # Каждая попытка обязана представить карту активного линкера.
self.write_map(self.metadata) # Начальные абсолютные символы согласованы с текущей конфигурацией платы.
def tearDown(self): # Завершение теста не затрагивает настоящее состояние Git или прошивки.
self.temporary.cleanup() # Удаляет только созданный TemporaryDirectory после закрытия всех файлов.
def write_map(self, metadata): # Формирует формат TI MAP для испытания аппаратной согласованности до конверсии.
self.map_path.write_text(f"abs {metadata['eeprom_bytes']:08x} _EEPROM_TOTAL_BYTES\nabs {metadata['max_image_bytes']:08x} _EEPROM_FIRMWARE_LIMIT_BYTES\nabs {metadata['max_image_bytes'] // 2:08x} _EEPROM_PARAMS_START_WORD\n", encoding="utf-8") # Повторяет реальные абсолютные символы F28335.cmd с единицами октеты/слова.
def invoke(self, **arguments): # Передаёт стандартные пути в реальный postbuild, сохраняя возможность выбора EEPROM.
return release.publish(self.project, VENDOR, TOOLS, COMPILER, self.build, "application", self.map_path, self.output, self.export, **arguments) # Обязательные проверки и копирование выполняет production-код.
def converter(self, payload=None, wrong_eeprom=False): # Имитирует только результат инструмента, оставляя seal/archive и все проверки настоящими.
def produce(compiler, build_dir, artifact, metadata_path, stage, tools_templates): # Получает временное назначение так же, как общий postcompile.
image = stage / (artifact + "_" + self.metadata["build_id"] + ".bin") # Создаёт имя только текущей попытки выпуска.
image.write_bytes(payload if payload is not None else image_for(self.metadata)) # Позволяет испытать обрезанный, старый и слишком большой BIN после успешной конверсии.
IDENTITY.seal(image, metadata_path) # Создаёт настоящий SHA/manifest, чтобы проверки содержимого не подменялись проверкой хеша.
IDENTITY.archive_build(image, metadata_path, stage) # Формирует настоящий архив только во временном каталоге без export.
if wrong_eeprom: # Повреждённый аппаратный паспорт должен отклоняться даже при правильном хеше файла.
manifest_path = image.with_suffix(".bin.manifest.json") # Изменяет лишь паспорт текущего staged-BIN.
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) # Сохраняет остальные корректные поля, включая SHA и версию.
manifest["eeprom"] = "M95M02" if manifest["eeprom"] == "AT25256B" else "AT25256B" # Переключает ровно один аппаратный признак для адресной проверки.
manifest_path.write_text(json.dumps(manifest), encoding="utf-8") # Оставляет целый JSON с намеренно неверным профилем.
return produce # Подмена ограничивается внешним инструментом конверсии; проверки выпуска остаются производственными.
def assert_rejected_without_publication(self, converter, **arguments): # Проверяет не только отказ, но и сохранность уже выпущенных файлов.
self.output.mkdir(exist_ok=True) # Моделирует существующий успешный выпуск до неудачной новой попытки.
self.export.mkdir(exist_ok=True) # Моделирует каталог GUI с ранее проверенными файлами.
(self.output / "previous.bin").write_bytes(b"previous verified release") # Контрольный файл должен остаться побайтно прежним.
(self.export / "previous.bin").write_bytes(b"previous GUI release") # Ошибка не должна обновить внешнее приложение.
before = snapshot(self.output), snapshot(self.export) # Фиксирует полный состав обоих постоянных каталогов.
with patch.object(release, "convert", converter), self.assertRaises((ValueError, OSError)): # Подмена внешнего инструмента не ослабляет настоящие проверки SCI8 и паспорта.
self.invoke(**arguments) # Ошибочная попытка обязана завершиться до копирования staged-файлов.
self.assertEqual(before, (snapshot(self.output), snapshot(self.export))) # Новые архивы и BIN не появляются ни локально, ни в GUI.
def test_manual_version_and_compiled_templates(self): # Проверяет исходный дефект происхождения зависимости и ручной номер выпуска.
self.assertEqual(self.metadata["version"], [1, 1, 2]) # Старый increment_version=true не увеличивает версию из заголовка.
self.assertEqual((self.project / "firmware_version.h").read_text(encoding="utf-8"), self.version_text) # Prebuild не редактирует исходный C-заголовок.
self.assertEqual(json.loads((self.project / "firmware_identity.json").read_text()), self.settings) # Старый JSON также остаётся пользовательской настройкой без скрытой записи.
self.assertEqual(self.metadata["templates_root"], str(VENDOR.resolve())) # Паспорт привязан к компилируемой submodule-копии, а не другой checkout инструментов.
self.assertEqual(self.metadata["templates"], prebuild.git_state(VENDOR)) # Full SHA и dirty соответствуют именно фактической зависимости C.
def test_success_for_both_eeprom_profiles(self): # Проверяет полноценные publish/archive/export для обеих физических ёмкостей.
for eeprom, limit in (("AT25256B", 32508), ("M95M02", 261884)): # Пределы включают исторический резерв 129 параметров и последнее свободное слово.
with self.subTest(eeprom=eeprom): # Различает ошибки аппаратных вариантов в отчёте тестов.
self.metadata = prebuild.prepare(self.project, VENDOR, self.build / "generated", eeprom) # CLI-профиль также попадёт в паспорт до компиляции.
self.write_map(self.metadata) # Моделирует согласованный define выбранного линкера.
with patch.object(release, "convert", self.converter()): # Имитирует только внешний инструмент и использует настоящие проверки staged-комплекта.
image = self.invoke(eeprom=eeprom) # Успешный выпуск обязан завершить локальное и GUI-копирование.
manifest = json.loads(image.with_suffix(".bin.manifest.json").read_text(encoding="utf-8")) # Проверяет опубликованный, а не промежуточный паспорт.
self.assertEqual((manifest["eeprom"], manifest["max_image_bytes"], manifest["version"]), (eeprom, limit, [1, 1, 2])) # Связывает аппаратную ёмкость, границу и неизменённый номер версии.
self.assertTrue(any(self.export.rglob(image.name))) # Каталог GUI получает только проверенный архив текущей сборки.
def test_stale_embedded_identity(self): # Воспроизводит опасную подпись старого OUT новым manifest при правильном SHA.
stale = dict(self.metadata, version=[1, 1, 1]) # Единственным дефектом образа остаётся предыдущий номер встроенного паспорта.
self.assert_rejected_without_publication(self.converter(image_for(stale))) # Проверка содержимого обязана остановить публикацию до export.
def test_oversize_image(self): # Воспроизводит выход нового кода за начало сохраняемых параметров малой EEPROM.
self.assert_rejected_without_publication(self.converter(image_for(self.metadata) + bytes(32508))) # Даже корректно подписанный SHA большого файла не разрешает выпуск.
def test_truncated_image(self): # Воспроизводит неполную загрузочную секцию после сбоя конверсии или копирования.
self.assert_rejected_without_publication(self.converter(image_for(self.metadata)[:-5])) # Обрезает и часть данных, и терминатор, сохраняя заново вычисленный manifest.
def test_wrong_manifest_eeprom(self): # Проверяет требуемое различение аппаратных вариантов по полю manifest.eeprom.
self.assert_rejected_without_publication(self.converter(wrong_eeprom=True)) # Правильные BIN, версия и SHA не маскируют аппаратное несоответствие паспорта.
def test_flash_requires_flash_blocks_and_boot_entry(self): # Проверяет разделение EEPROM/RAM SCI8 и отдельного образа программирования внутренней Flash.
header = b"\xaa\x08" + bytes(16) + struct.pack("<HH", 0x30, 0) # Задаёт реальную точку входа 0x300000 вместо RAM-адреса обычной прошивки.
body = block(0x300000, [0x761F, 0x8000]) + block(0x308000, identity_words(self.metadata)) # Контрольные Flash-блоки содержат entry и ровно один паспорт текущего prebuild.
boot = block(0x33FFF6, [0x0070, 0x0000]) # Единственный разрешённый двухсловный переход перед областью CSM.
image = self.build / "flash.bin" # Изолирует проверки структуры от постоянных каталогов выпуска и GUI.
image.write_bytes(header + body + boot + bytes(2)) # Минимальная полноценная Flash SCI8-таблица должна проходить самостоятельную проверку.
entry, loaded = release.verify_embedded(image, self.metadata, flash=True) # Вызывает production-парсер с отдельными ограничениями Flash и RS-буфера.
self.assertEqual((entry, loaded[0x33FFF6]), (0x300000, 0x0070)) # Проверяет конкретный аппаратный вход и фактически загружаемые слова.
failures = [image_for(self.metadata), header + body + bytes(2), header + body + boot + boot + bytes(2)] # RAM-таблица, отсутствующий и дублированный boot не разрешают Flash-выпуск.
failures += [header + body + block(address, [1, 2]) + boot + bytes(2) for address in (0x2FFFFF, 0x33FF80, 0x33FFF8, 0x380400)] # Запрещает нижнюю границу, CSM, пароли и OTP до первого аппаратного стирания.
for payload in failures: # Все негативные сценарии проверяются настоящими границами production-парсера.
image.write_bytes(payload) # Корректный ключ/паспорт не должны маскировать запрещённые адреса либо аппаратный вход.
with self.assertRaises(ValueError): # Отказ является обязательным, даже если файл заново подписать корректным SHA.
release.verify_embedded(image, self.metadata, flash=True) # Проверяет конкретные Flash-условия независимо от общих RAM-тестов.
def test_wrong_linker_eeprom(self): # Проверяет случай define EEPROM_M95M02, переданного не всем этапам сборки.
self.write_map(dict(self.metadata, eeprom_bytes=262144, max_image_bytes=261884)) # MAP описывает большую EEPROM при AT25256B в prebuild.
self.assert_rejected_without_publication(self.converter()) # Отказ обязан наступить до конверсии и публикации.
def test_changed_profile_after_prebuild(self): # Проверяет изменение заголовка аппаратной конфигурации между prebuild и выпуском.
self.config.write_text("#define EEPROM_M95M02 1 /* После prebuild установили другой аппаратный профиль. */\n", encoding="utf-8") # Сохраняет прежние metadata и карту для обнаружения устаревшего комплекта.
self.assert_rejected_without_publication(self.converter()) # Требует повторной подготовки паспорта и пересборки вместо тихой смены аппаратного типа.
def test_required_flash_variant_cannot_be_omitted(self): # Проверяет, что неполная попытка Flash-build не публикует даже корректный новый RAM BIN.
(self.project / "build_flash.py").write_text("# Тестовый признак обязательного отдельного Flash-варианта проекта.\n", encoding="utf-8") # Повторяет соглашение автоматического обнаружения общего postcompile.
self.assert_rejected_without_publication(self.converter()) # Отсутствующие Flash OUT/SCI8 должны остановить выпуск до output и GUI.
@unittest.skipUnless((COMPILER / "bin/cl2000.exe").is_file(), "TI C2000 compiler is unavailable") # Настоящий препроцессор TI проверяет global compiler override без изменения прошивки.
def test_compiler_only_eeprom_override(self): # Закрывает случай M95M02-компилятора при паспорте и линкере AT25256B.
self.config.write_text("#ifndef EEPROM_M95M02 /* Разрешает штатное переопределение через define компилятора. */\n#define EEPROM_M95M02 0 /* Аппаратный профиль по умолчанию совпадает с prebuild. */\n#endif /* Завершает конфигурацию тестовой платы. */\n", encoding="utf-8") # Повторяет реальную семантику eeprom_config.h, при которой ошибка раньше оставалась незамеченной.
source = self.build / "profile_guard.c" # Контрольный C-модуль включает тот же generated-заголовок, что shared_ump.c.
source.write_text('#include "firmware_identity.h" /* Заставляет компилятор проверить аппаратный профиль паспорта. */\n', encoding="utf-8") # Не добавляет исполняемые данные в проверяемый BIN.
result = subprocess.run([str(COMPILER / "bin/cl2000.exe"), "-v28", "--preproc_only", "--define=EEPROM_M95M02=1", "--include_path=" + str(self.config.parent), "--include_path=" + str(self.build / "generated"), str(source)], cwd=self.build, text=True, capture_output=True) # Намеренно передаёт M95M02 только компилятору, оставляя паспорт AT25256B.
self.assertNotEqual(result.returncode, 0) # Несогласованный define обязан останавливать компиляцию до появления OUT.
self.assertIn("EEPROM_profile_differs_from_prebuild", result.stdout + result.stderr) # Отказ вызван профильной защитой, а не посторонней ошибкой установленного TI.
@unittest.skipUnless((COMPILER / "bin/cl2000.exe").is_file(), "TI C2000 compiler is unavailable") # Реальный конвертор проверяется на установленном toolchain; host-проверки обязательны в любом случае.
def test_real_ti_postcompile(self): # Проверяет общий BAT, TI COFF, hex2000, staged-архив и защищённую публикацию без полной прошивки платы.
assembly = self.build / "fixture.asm" # Минимальная таблица содержит реальную исполняемую секцию и настоящий встроенный паспорт.
assembly.write_text(' .sect ".text" ; Тестовая секция исполняется из внутренней RAM.\n .global _fixture_entry ; Экспортирует точку входа контрольного COFF.\n_fixture_entry: ; Начало двухсловного перехода в загруженном RAM-блоке.\n LB _fixture_entry ; Не завершает тестовый код при случайном запуске под отладчиком.\n .sect ".econst" ; Неизменяемые слова паспорта загружаются отдельным блоком.\n .word ' + ','.join(str(value) for value in identity_words(self.metadata)) + ' ; Точные 12 слов текущего prebuild.\n .end ; Завершает минимальный модуль C28x.\n', encoding="utf-8") # Создаёт настоящий исходник TI с русскими комментариями каждой строки.
linker = self.build / "fixture.cmd" # Карта фикстура соответствует тем RAM-диапазонам, которые разрешает производственный проверяющий код.
linker.write_text('MEMORY { PAGE 0: CODE: origin=0x8000,length=0x100 DATA: origin=0x8100,length=0x100 } /* Отдельные RAM-области тестового кода и паспорта. */\nSECTIONS { .text: > CODE PAGE=0 .econst: > DATA PAGE=0 } /* Создаёт два полных непересекающихся блока SCI8. */\n' + f'_EEPROM_TOTAL_BYTES={self.metadata["eeprom_bytes"]}; /* Физическая ёмкость выбранной EEPROM в октетах. */\n_EEPROM_FIRMWARE_LIMIT_BYTES={self.metadata["max_image_bytes"]}; /* Конец области прошивки до параметров, в октетах. */\n_EEPROM_PARAMS_START_WORD={self.metadata["max_image_bytes"] // 2}; /* Адрес первого слова сохранённых параметров. */\n', encoding="utf-8") # Абсолютные символы проходят ту же проверку, что F28335.cmd настоящего проекта.
subprocess.run([str(COMPILER / "bin/cl2000.exe"), "-v28", "--abi=coffabi", "-c", "--output_file=" + str(self.build / "fixture.obj"), str(assembly)], check=True) # Получает настоящий объект TI без аппаратного программирования.
subprocess.run([str(COMPILER / "bin/cl2000.exe"), "-v28", "--abi=coffabi", "-z", "--entry_point=_fixture_entry", "-m" + str(self.map_path), "-o", str(self.build / "application.out"), str(self.build / "fixture.obj"), str(linker)], check=True) # Линкует настоящий COFF и абсолютную карту выбранного EEPROM.
with self.assertRaises(ValueError): # Настоящий RAM COFF нельзя выдать за пригодный JTAG Flash OUT только из-за одинакового расширения.
release.verify_flash_coff(self.build / "application.out", COMPILER) # Проверяет отказ на фактических секциях TI вместо поддельной тестовой MAP.
image = self.invoke() # Запускает реальный общий BAT и hex2000, сохраняя обязательные производственные проверки и публикацию.
release.verify_embedded(image, self.metadata) # Дополнительно проверяет фактические байты, опубликованные после конверсии TI.
release.verify_package(image, self.metadata) # Подтверждает совпадение аппаратного manifest и контрольной суммы конечного файла.
if __name__ == "__main__": # Позволяет запускать всю проверку обычной командой Python из корня проекта.
unittest.main(verbosity=2) # Каждый отказный сценарий явно виден в отчёте без зависимости от pytest.