"""Переносимое ядро проверки образа прошивки STM32. Модуль не знает ни про HTTP, ни про Modbus, ни про файловую систему, ни про конкретный программатор: на вход подаются уже прочитанные байты, на выходе получается проверенный план записи. Благодаря этому те же правила проверяются host-тестом без платы и без bridge. Основные границы: * :func:`parse_intel_hex` — полный разбор Intel HEX с проверкой checksum каждой записи, поддержкой расширенной адресации и поиском пересечений; * :func:`build_raw_plan` / :func:`build_hex_plan` — план записи с проверкой выравнивания, границ приложения и защищённых секторов; * :func:`image_digest` — CRC32 и SHA-256 фактически записываемых байтов. Геометрия Flash задаётся объектом :class:`FlashLayout`, поэтому ядро не привязано к STM32F407: значения по умолчанию описывают текущую плату и берутся из ``MDK-ARM/flash_layout.sct`` и ``EEPROM_Emul/lib/flash_ring.h``. """ from __future__ import annotations import hashlib import re import zlib from dataclasses import dataclass, field from typing import Iterable, Optional, Sequence __all__ = [ "FirmwareImageError", "FlashLayout", "FlashRegion", "FlashSector", "HexSegment", "ImagePlan", "STM32F407VE_LAYOUT", "RAW_EXTENSIONS", "HEX_EXTENSIONS", "build_hex_plan", "build_raw_plan", "detect_format", "format_address", "image_digest", "parse_flash_address", "parse_intel_hex", ] class FirmwareImageError(ValueError): """Единственный тип ошибки ядра: сообщение пригодно для показа оператору.""" # --------------------------------------------------------------------------- # Геометрия Flash # --------------------------------------------------------------------------- @dataclass(frozen=True) class FlashSector: index: int start: int size: int @property def end(self) -> int: """Последний байт сектора включительно.""" return self.start + self.size - 1 @dataclass(frozen=True) class FlashRegion: name: str start: int end: int def overlaps(self, start: int, end: int) -> bool: return start <= self.end and end >= self.start @dataclass(frozen=True) class FlashLayout: """Полное описание внутренней Flash целевого МК.""" name: str sectors: Sequence[FlashSector] application: FlashRegion reserved: Sequence[FlashRegion] = field(default_factory=tuple) write_alignment: int = 4 min_image_bytes: int = 8 @property def max_image_bytes(self) -> int: return self.application.end - self.application.start + 1 def sector_at(self, address: int) -> Optional[FlashSector]: for sector in self.sectors: if sector.start <= address <= sector.end: return sector return None def sectors_for_range(self, start: int, end: int) -> list[FlashSector]: return [sector for sector in self.sectors if start <= sector.end and end >= sector.start] def reserved_hit(self, start: int, end: int) -> Optional[FlashRegion]: for region in self.reserved: if region.overlaps(start, end): return region return None # Секторы STM32F407VE: 4x16 КиБ, 64 КиБ, 3x128 КиБ. STM32F407VE_LAYOUT = FlashLayout( name="STM32F407VET6", sectors=( FlashSector(0, 0x08000000, 16 * 1024), FlashSector(1, 0x08004000, 16 * 1024), FlashSector(2, 0x08008000, 16 * 1024), FlashSector(3, 0x0800C000, 16 * 1024), FlashSector(4, 0x08010000, 64 * 1024), FlashSector(5, 0x08020000, 128 * 1024), FlashSector(6, 0x08040000, 128 * 1024), FlashSector(7, 0x08060000, 128 * 1024), ), application=FlashRegion("Приложение", 0x08000000, 0x0803FFFF), # Секторы 6-7 отданы кольцу EEPROM: их стирание уничтожило бы настройки. reserved=(FlashRegion("Кольцо EEPROM (секторы 6-7)", 0x08040000, 0x0807FFFF),), ) RAW_EXTENSIONS = (".bin", ".fw") HEX_EXTENSIONS = (".hex",) def detect_format(name: str) -> str: """Формат образа по расширению; исключение для всего остального.""" lower = str(name or "").lower() if lower.endswith(RAW_EXTENSIONS): return "bin" if lower.endswith(HEX_EXTENSIONS): return "hex" raise FirmwareImageError("Поддерживаются только .bin/.hex/.fw") def format_address(address: int) -> str: return f"0x{int(address):08X}" def parse_flash_address(value: object) -> int: """Адрес принимается только как явный decimal или HEX с префиксом 0x.""" text = str(value if value is not None else "").strip().replace(" ", "") if not text: raise FirmwareImageError("Базовый адрес Flash не задан") if not re.fullmatch(r"0[xX][0-9a-fA-F]{1,8}|\d+", text): raise FirmwareImageError("Адрес должен быть decimal или HEX с префиксом 0x") address = int(text, 16 if text.lower().startswith("0x") else 10) if not 0 <= address <= 0xFFFFFFFF: raise FirmwareImageError("Адрес выходит за диапазон uint32") return address # --------------------------------------------------------------------------- # Intel HEX # --------------------------------------------------------------------------- @dataclass(frozen=True) class HexSegment: """Непрерывный участок данных Intel HEX.""" start: int data: bytes @property def end(self) -> int: return self.start + len(self.data) - 1 RECORD_DATA = 0x00 RECORD_EOF = 0x01 RECORD_EXTENDED_SEGMENT = 0x02 RECORD_START_SEGMENT = 0x03 RECORD_EXTENDED_LINEAR = 0x04 RECORD_START_LINEAR = 0x05 _KNOWN_RECORDS = (RECORD_DATA, RECORD_EOF, RECORD_EXTENDED_SEGMENT, RECORD_START_SEGMENT, RECORD_EXTENDED_LINEAR, RECORD_START_LINEAR) # Лимит защищает bridge от файла, который «раздувается» адресными записями. MAX_HEX_RECORDS = 1_000_000 def parse_intel_hex(text: object, *, max_records: int = MAX_HEX_RECORDS) -> list[HexSegment]: """Validate HEX with the shared C99 parser and return contiguous segments.""" from .native import parse_segments return parse_segments(text, max_records, FirmwareImageError, HexSegment) def contiguous_image(text, maximum_size=2 * 1024 * 1024): """Convert validated segments to a bounded image, filling gaps with FF.""" segments = parse_intel_hex(text) base, end = segments[0].start, segments[-1].end + 1 if end - base > maximum_size: raise FirmwareImageError('Размер образа превышает %d байт' % maximum_size) image = bytearray(b'\xff' * (end - base)) for segment in segments: offset = segment.start - base image[offset:offset + len(segment.data)] = segment.data return bytes(image), base @dataclass(frozen=True) class ImagePlan: """Проверенный план: что именно и куда будет записано.""" image_format: str start: int end: int payload_bytes: int sectors: tuple[int, ...] erased_bytes: int segments: tuple[HexSegment, ...] gaps: tuple[tuple[int, int], ...] crc32: str sha256: str @property def text(self) -> str: return f"{format_address(self.start)}..{format_address(self.end)}" def as_dict(self) -> dict: """Представление для JSON API и GUI.""" return { "format": self.image_format, "start": format_address(self.start), "end": format_address(self.end), "targetRange": self.text, "payloadBytes": self.payload_bytes, "spanBytes": self.end - self.start + 1, "sectors": list(self.sectors), "erasedBytes": self.erased_bytes, "segments": [ {"start": format_address(item.start), "end": format_address(item.end), "size": len(item.data)} for item in self.segments ], "gaps": [ {"start": format_address(low), "end": format_address(high), "size": high - low + 1} for low, high in self.gaps ], "crc32": self.crc32, "sha256": self.sha256, } def image_digest(data: bytes) -> tuple[str, str]: """CRC32 и SHA-256 фактически записываемых байтов.""" return ( f"{zlib.crc32(bytes(data)) & 0xFFFFFFFF:08X}", hashlib.sha256(bytes(data)).hexdigest().upper(), ) def _check_range(layout: FlashLayout, start: int, end: int) -> None: reserved = layout.reserved_hit(start, end) if reserved is not None: raise FirmwareImageError(f"Диапазон пересекает защищённую область: {reserved.name}") if start < layout.application.start or end > layout.application.end: raise FirmwareImageError( "Диапазон должен помещаться в " f"{format_address(layout.application.start)}.." f"{format_address(layout.application.end)}" ) def _sector_summary(layout: FlashLayout, start: int, end: int) -> tuple[tuple[int, ...], int]: sectors = layout.sectors_for_range(start, end) return tuple(item.index for item in sectors), sum(item.size for item in sectors) def build_raw_plan(size: object, base_address: object, *, layout: FlashLayout = STM32F407VE_LAYOUT, data: Optional[bytes] = None) -> ImagePlan: """План для сырого образа. Базовый адрес обязан совпадать с началом сектора: стирание STM32F4 идёт только целыми секторами, поэтому запись с середины сектора уничтожила бы соседние данные. ``data`` необязателен: список файлов SD знает лишь размер, и контрольные суммы считаются только тогда, когда содержимое действительно прочитано. """ try: length = int(size) except (TypeError, ValueError) as exc: raise FirmwareImageError("Размер образа должен быть целым числом") from exc if data is not None and len(data) != length: raise FirmwareImageError("Размер образа не совпадает с числом прочитанных байт") if length < layout.min_image_bytes: raise FirmwareImageError(f"Размер образа меньше {layout.min_image_bytes} байт") if length > layout.max_image_bytes: raise FirmwareImageError( f"Образ больше области приложения ({layout.max_image_bytes // 1024} КиБ)" ) start = parse_flash_address(base_address) if start % layout.write_alignment: raise FirmwareImageError( f"Базовый адрес должен быть выровнен на {layout.write_alignment} байта" ) sector = layout.sector_at(start) if sector is None: raise FirmwareImageError(f"Базовый адрес вне внутренней Flash {layout.name}") if sector.start != start: raise FirmwareImageError( f"Базовый адрес должен совпадать с началом сектора ({format_address(sector.start)})" ) end = start + length - 1 _check_range(layout, start, end) sectors, erased = _sector_summary(layout, start, end) crc32, sha256 = image_digest(data) if data is not None else ("", "") segments = (HexSegment(start, bytes(data)),) if data is not None else () return ImagePlan("bin", start, end, length, sectors, erased, segments, (), crc32, sha256) def build_hex_plan(segments: Iterable[HexSegment], *, layout: FlashLayout = STM32F407VE_LAYOUT) -> ImagePlan: """План для Intel HEX: адреса берутся из файла, оператор их не задаёт. Проверяется каждый сегмент отдельно, поэтому файл с одним «правильным» и одним посторонним диапазоном отклоняется целиком. Разрывы между сегментами сохраняются в плане: при записи они заполняются 0xFF, и оператор должен видеть, какая часть Flash будет стёрта без данных из файла. """ ordered = sorted(segments, key=lambda item: item.start) if not ordered: raise FirmwareImageError("Файл Intel HEX не содержит данных") for segment in ordered: if not segment.data: raise FirmwareImageError("Сегмент Intel HEX пуст") _check_range(layout, segment.start, segment.end) start = ordered[0].start end = ordered[-1].end if start % layout.write_alignment: raise FirmwareImageError( f"Начальный адрес Intel HEX не выровнен на {layout.write_alignment} байта" ) sector = layout.sector_at(start) if sector is None or sector.start != start: raise FirmwareImageError( "Первый адрес Intel HEX должен совпадать с началом сектора " f"({format_address(sector.start) if sector else 'вне Flash'})" ) # Пустоты заполняются 0xFF — стёртым состоянием NOR Flash. image = bytearray(b"\xFF" * (end - start + 1)) gaps: list[tuple[int, int]] = [] previous_end = None payload = 0 for segment in ordered: if previous_end is not None and segment.start > previous_end + 1: gaps.append((previous_end + 1, segment.start - 1)) image[segment.start - start:segment.end - start + 1] = segment.data payload += len(segment.data) previous_end = segment.end sectors, erased = _sector_summary(layout, start, end) crc32, sha256 = image_digest(bytes(image)) return ImagePlan("hex", start, end, payload, sectors, erased, tuple(ordered), tuple(gaps), crc32, sha256) def build_plan(image_format: str, *, data: Optional[bytes] = None, size: Optional[int] = None, base_address: object = None, layout: FlashLayout = STM32F407VE_LAYOUT) -> ImagePlan: """Единая точка входа: выбрать ветку разбора по формату образа.""" if image_format == "hex": if data is None: raise FirmwareImageError("Для Intel HEX требуется содержимое файла") return build_hex_plan(parse_intel_hex(data), layout=layout) if image_format == "bin": length = len(data) if data is not None else size return build_raw_plan(length, base_address, layout=layout, data=data) raise FirmwareImageError("Неизвестный формат образа")