feat(protocan-boot): Добавь порты MCU и сервисы загрузки

Обеспечь единый процесс обновления для STM32F103/F407/G474 и TMS320F2812/F28335: discovery, служебные ответы, BUSY, тайм-аут сессии и подтверждение приложения. Добавь самостоятельные проекты CCS, аппаратные хранилища и Python-клиент.

Совместимость: структуры конфигурации и порта расширены, поэтому потребители должны пересобраться и обнулить новые поля. Для C28x callbacks используют pcan_boot_byte_t; периодический pcan_boot_poll обслуживает discovery и настроенный тайм-аут.
This commit is contained in:
2026-10-23 14:16:19 +03:00
parent 876b58517c
commit c6390ae1c9
147 changed files with 42760 additions and 104 deletions
+101 -3
View File
@@ -37,6 +37,8 @@ class CanBootTransfer:
def __init__(self, image, target: CanBootTarget):
if not 0 < len(image.data) <= 512 * 1024:
raise ValueError("ProtoCAN Boot: размер образа 1…512 КиБ")
if target.product_type == 0x2833 and len(image.data) > 0x1FE00:
raise ValueError("F28335 M95M02 A/B: образ не должен превышать 130560 байт")
if not 0 <= image.version <= 0xFFFFFFFF:
raise ValueError("Версия образа вне диапазона u32")
self.image, self.target = image, target
@@ -46,6 +48,10 @@ class CanBootTransfer:
self.finished = False
self._window_end = 0
@property
def response_timeout_ms(self):
return 15000 if self.stage in ("enter", "verify", "commit") else 1500
@property
def percent(self):
return min(100, self.next_block * 8 * 100 // len(self.image.data))
@@ -74,7 +80,7 @@ class CanBootTransfer:
# но метаданные образа сохраняют исходную длину без этого дополнения.
def _window(self):
total = (len(self.image.data) + 7) // 8
self._window_end = min(total, self.next_block + 16)
self._window_end = min(total, (self.next_block // 16 + 1) * 16) # Ядро подтверждает абсолютные границы по 16 блоков; после потери повторяется только хвост текущего окна, чтобы его ACK не застал в очереди кадры следующего окна.
return [self._frame(10 + self.slot, i, self.image.data[i * 8:(i + 1) * 8].ljust(8, b"\xff"))
for i in range(self.next_block, self._window_end)]
@@ -113,8 +119,11 @@ class CanBootTransfer:
limit = total if self.stage == "erase" else self._window_end
if expected > limit:
raise RuntimeError("ProtoCAN Boot: подтверждён непереданный блок")
if self.stage == "data" and status == 0 and expected < self.next_block:
return [], ""
# Один и тот же ACK или SEQUENCE_ERROR может прийти от нескольких оставшихся кадров старого окна.
# Повторная отправка на каждый такой ответ переполняет очередь MCU; при отсутствии продвижения
# последнее окно повторяет транспорт по тайм-ауту, а подтверждённые ранее блоки не отправляются заново.
if self.stage == "data" and expected <= self.next_block: # Сравнивает next_block для обоих статусов: OK и SEQUENCE_ERROR не должны повторно открывать одно окно.
return [], "" # Оставляет текущие кадры и срок ожидания транспорта без изменений до нового прогресса или тайм-аута.
self.next_block = expected
if expected == total:
self.stage = "verify"
@@ -128,4 +137,93 @@ class CanBootTransfer:
self.stage = "reboot"
return self._control(9), "Перезапуск прибора"
self.stage, self.finished = "finished", True
if self.target.product_type == 0x2833:
return [], "Образ принят; после перезапуска приложение подтвердит пробный запуск"
return [], "Прошивка ProtoCAN Boot завершена"
class CanBootServices:
"""Read-only passport queries and explicit session service commands.
Replies use message D, body=(page<<8)|command. IDENTIFY/discovery reply body=1.
All multibyte values are little endian; version is major:8/minor:8/patch:16.
"""
def __init__(self, target: CanBootTarget):
self.target = target
def request(self, command: int, page: int = 0):
if command not in (1, 8, 11, 12, 13, 14, 15):
raise ValueError("Unsupported service command")
if not 0 <= page <= 255 or (page and command not in (14, 15)):
raise ValueError("Invalid service page")
t = self.target
payload = bytes([page]) if command in (14, 15) else b""
return build_frame(ProtoCanId.build(1, 0, t.device_type, t.device, 9,
t.session_id << 8 | command), payload, to_can=True)
@staticmethod
def discover():
return build_frame(ProtoCanId.build(1, 0, 7, 15, 13, 0), b"", to_can=True)
def decode(self, frame, *, discovery=False):
p = ProtoCanId.parse(frame.can_id)
if (frame.to_can or not frame.ide or frame.rtr or frame.is_error or p.pm != 1
or p.msg_type not in (12, 13) or len(frame.data) != 8):
return None
if discovery:
if p.msg_type != 13 or p.body != 1:
return None
elif (p.device_type, p.device) != (self.target.device_type, self.target.device):
return None
if p.msg_type == 12:
if p.body >> 8 != self.target.session_id:
return None
status, slot, next_block, crc32 = struct.unpack("<BBHI", frame.data)
return {"kind": "status", "device_type": p.device_type, "device": p.device,
"command": p.body & 255, "status": status, "target_slot": slot,
"next_block": next_block, "crc32": crc32}
command, page = p.body & 255, p.body >> 8
d = frame.data
result = {"kind": "passport", "device_type": p.device_type, "device": p.device, "command": command, "page": page}
if command == 1 and page == 0:
product, hardware, protocol, version = struct.unpack("<HBBI", d)
result.update(product_type=product, hardware_revision=hardware, protocol_version=protocol,
firmware_version=version, version=(version >> 24, (version >> 16) & 255, version & 65535))
elif command == 12 and page == 0:
result.update(protocol_version=d[0], state=d[1], active_slot=d[2], target_slot=d[3],
session_id=d[4], slot_count=d[5], last_status=d[6])
elif command == 13 and page == 0:
flags, capacity = struct.unpack("<II", d)
result.update(capabilities=flags, slot_capacity=capacity)
elif command == 14:
result["name_fragment"] = d.split(b"\0", 1)[0].decode("ascii", errors="replace")
elif command == 15:
if page == 0:
size, page_size, mode, running = struct.unpack("<IHBB", d)
result.update(memory_bytes=size, page_bytes=page_size, bootloader=bool(mode), running_slot=running)
elif page == 1:
result["slot_offsets"] = struct.unpack("<II", d)
elif page == 2:
active, pending, attempted, healthy, timeout = struct.unpack("<BBBBI", d)
result.update(active_slot=active, pending_slot=pending, trial_attempted=bool(attempted),
storage_healthy=bool(healthy), session_timeout_ms=timeout)
elif page == 3:
result["parameters_offset"], result["parameters_bytes"] = struct.unpack("<II", d)
else:
return None
else:
return None
return result
class CanBootPingExchange:
"""PING adapter with the same request/feed shape as the SETProtocol client."""
def __init__(self, target: CanBootTarget):
self.services = CanBootServices(target)
def request(self):
return [self.services.request(12)]
def feed(self, frame):
reply = self.services.decode(frame)
return reply if reply and reply["kind"] == "passport" and reply["command"] == 12 else None
+99
View File
@@ -0,0 +1,99 @@
import base64 # Декодирует загружаемые секции из официального XML TI OFD, сохраняя адреса LOAD и RUN.
from pathlib import Path # Сохраняет изменения только в явно переданном результате текущей сборки.
import struct # Упаковывает адресуемые 16-битные слова C28x младшей октетой вперёд.
import subprocess # Вызывает локальный инспектор объектных файлов без доступа к JTAG.
import xml.etree.ElementTree as ET # Разбирает фактические секции COFF независимо от соседней карты линкера.
import zlib # CRC32 IEEE совпадает с проверкой постоянного загрузчика и учитывает незаписанные слова 0xFFFF.
APP_START = 0x300000 # Сектор H начинает область приложения; аппаратный boot остаётся в секторе A.
HEADER_WORDS = 16 # Заголовок версии 1 занимает 16 слов C28x, то есть 32 физических октета.
APP_DATA = APP_START + HEADER_WORDS # Исполняемые секции следуют после служебного заголовка приложения.
APP_STOP = 0x338000 # Исключённая верхняя граница совпадает с началом защищённого сектора A.
def descriptor(entry, loaded): # Создаёт проверяемое описание Flash-приложения без сектора постоянного загрузчика.
if not loaded or any(not APP_START <= address < APP_STOP for address in loaded): # Любое слово в секторе A, CSM, OTP или RAM делает образ непригодным для обновления приложения.
raise ValueError('Flash-приложение пересекает защищённый загрузчик или выходит за область 0x300000–0x337FFF') # Отказ происходит до стирания физической памяти.
end = max(loaded) + 1 # Верхняя граница включает все Flash LOAD-секции, в том числе копируемый в RAM код API.
if not APP_DATA <= entry < end or entry not in loaded: # Точка запуска обязана указывать на действительно присутствующее слово кода после заголовка.
raise ValueError('Точка входа Flash-приложения отсутствует в загружаемом коде') # Исключает пустой заголовок и устаревшую RAM-сборку.
payload = struct.pack('<' + 'H' * (end - APP_DATA), *(loaded.get(address, 0xFFFF) for address in range(APP_DATA, end))) # Пробелы между секциями должны оставаться стёртыми; порядок октетов соответствует C28x.
crc = zlib.crc32(payload) # Контрольная сумма защищает весь непрерывный диапазон приложения, включая промежутки.
values = (0x4146, 0x3142, 1, HEADER_WORDS, entry & 65535, entry >> 16, APP_DATA & 65535, APP_DATA >> 16, end & 65535, end >> 16, crc & 65535, crc >> 16, (~entry) & 65535, ((~entry) >> 16) & 65535, 0x5AA5, 0xA55A) # Первые четыре физических октета образуют ASCII FAB1; далее следуют версия, размер, адреса, CRC, инверсия entry и маркеры завершения.
return struct.pack('<16H', *values) # Передаёт ровно 32 октета без платформенного выравнивания структуры.
def validate_words(entry, loaded): # Одинаково проверяет содержимое JTAG COFF и транспортной таблицы RS Flash.
if any(APP_START + index not in loaded for index in range(HEADER_WORDS)): # Все слова заголовка должны присутствовать явно, а не получаться заполнением 0xFFFF.
raise ValueError('Нет полного заголовка Flash-приложения FAB1; требуется новая сборка под постоянный загрузчик') # Старый образ с codestart по 0x33FFF6 не допускается.
actual = struct.pack('<16H', *(loaded[APP_START + index] for index in range(HEADER_WORDS))) # Получает описание именно из передаваемых данных.
if actual != descriptor(entry, loaded): # Проверяет CRC, границы, обе половины entry и маркеры одной независимой реконструкцией.
raise ValueError('Заголовок или CRC32 Flash-приложения FAB1 не соответствует данным') # Повреждение нельзя скрыть корректным именем файла или внешним паспортом.
def parse_sci8(data): # Разбирает только приложение ниже сектора A, не разрешая аппаратный переход ROM.
if len(data) < 24 or len(data) % 2 or data[:2] != b'\xaa\x08': # SCI8 имеет ключ 0x08AA, 22-октетный заголовок и чётный терминатор.
raise ValueError('Неверный заголовок или размер Flash SCI8') # Обычный бинарный дамп и COFF не являются таблицей адресных блоков TI.
def address(offset): # Адрес TI состоит из старшего 16-битного слова, затем младшего; каждое слово little endian.
high, low = struct.unpack_from('<HH', data, offset) # Читает две половины без зависимости от архитектуры Python.
return (high << 16) | low # Возвращает адрес в словах C28x, а не байтовое смещение.
entry, offset, loaded, blocks = address(18), 22, {}, [] # Хранит точку запуска, позицию таблицы и неперекрывающиеся диапазоны.
while offset + 2 <= len(data): # Длина блока должна присутствовать полностью даже для завершающего нуля.
count, = struct.unpack_from('<H', data, offset) # Размер полезных данных выражен 16-битными словами.
offset += 2 # Переходит к адресу блока либо к концу таблицы.
if not count: # Нулевой размер завершает адресную таблицу без последующего поля адреса.
if offset != len(data): # Дополнительные байты после терминатора недопустимы даже при правильной CRC полезной части.
raise ValueError('Хвост после терминатора Flash SCI8') # Не принимает склеенные или частично заменённые файлы.
validate_words(entry, loaded) # До передачи проверяет CRC и отсутствие слов постоянного загрузчика.
return entry, loaded, blocks # Вызывающий использует проверенные блоки для записи либо сравнения с COFF.
if offset + 4 + 2 * count > len(data): # Адрес и все слова должны помещаться в текущем файле.
raise ValueError('Обрезанный блок Flash SCI8') # Не читает случайные байты за границей изображения.
start = address(offset) # Получает абсолютный адрес назначения очередного блока.
offset += 4 # Пропускает две половины абсолютного адреса.
if not APP_START <= start < start + count <= APP_STOP: # Сектор A и весь аппаратный boot исключены независимо от имени секции.
raise ValueError(f'Запрещённый Flash-диапазон 0x{start:06X}, {count} слов: загрузчик защищён') # Показывает точное пересечение до начала обновления.
values = struct.unpack_from('<' + 'H' * count, data, offset) # Читает только полностью проверенный блок.
if any(start + index in loaded for index in range(count)): # Дублирование адреса могло бы скрыть подмену заголовка поздним блоком.
raise ValueError('Перекрывающиеся блоки Flash SCI8') # Запрещает неоднозначный порядок программирования.
loaded.update((start + index, value) for index, value in enumerate(values)) # Сохраняет каждое слово по его физическому LOAD-адресу.
blocks.append((start, data[offset:offset + count * 2])) # Сохраняет исходные октеты без изменения порядка секций.
offset += count * 2 # Переходит к размеру следующего блока.
raise ValueError('Нет терминатора Flash SCI8') # Обрезанный файл не считается полным приложением.
def coff_sections(document): # Извлекает адреса и точные октеты из результата TI ofd2000 -x.
coff = document.find('.//ti_coff') # Рабочая прошивка F28335 использует COFF ABI.
if coff is None or coff.findtext('file_header/target_id') != '0x9d' or coff.findtext('file_header/exec') != 'true': # Проверяет процессор и признак связанного исполняемого файла.
raise ValueError('Требуется исполняемый COFF C2000') # Объектные модули и файлы другой архитектуры не программируются.
entry = int(coff.findtext('optional_file_header/entry_point', '0'), 0) # Entry задаёт запуск приложения через общий Flash boot.
sections, loaded = {}, {} # Отдельно хранит XML полей секций и адресное пространство программируемых слов.
for section in coff.findall('section'): # Рассматривает все секции, включая LOAD для функций, исполняемых из RAM.
sections[section.findtext('name')] = section # Служебный заголовок находится по стабильному имени .flash_app.
raw, start = section.findtext('raw_data'), int(section.findtext('virtual_addr', '0'), 0) # OFD называет virtual адресом LOAD, physical — RUN.
if raw is None or section.findtext('copy') == 'true': # Только явно незагружаемые COPY/отладочные секции и секции без данных исключаются из программирования.
continue # Их наличие не расширяет область разрешённого программирования.
payload = base64.b64decode(raw) # Получает точные октеты вместо доверия к соседней MAP.
if len(payload) != int(section.findtext('raw_data_size', '0'), 0) or len(payload) % 2 or not APP_START <= start < start + len(payload) // 2 <= APP_STOP: # Любая загружаемая секция обязана целиком находиться ниже A; RAM, OTP, CSM и несогласованный размер запрещены.
raise ValueError('COFF содержит запрещённую Flash-секцию: ' + str(section.findtext('name'))) # Старый самостоятельный codestart будет отклонён.
for index, value in enumerate(struct.unpack('<' + 'H' * (len(payload) // 2), payload)): # Восстанавливает 16-битное представление каждого слова C28x.
if start + index in loaded: # Две секции не должны программировать одно физическое слово.
raise ValueError('Перекрывающиеся LOAD-секции COFF') # Сохраняет единственность заголовка и однозначную контрольную сумму.
loaded[start + index] = value # Собирает ожидаемый Flash-образ для проверки CRC и конверсии SCI8.
return entry, loaded, sections # Метаданные нужны как подписанию, так и независимой проверке готового OUT.
def seal_coff(image, inspector): # Заполняет только заранее выделенный linker заголовок после успешной линковки.
image = Path(image) # Принимает путь текущей сборки без зависимости от рабочего каталога.
document = ET.fromstring(subprocess.check_output([str(inspector), '-x', str(image)])) # Доверяет размещению секций, подтверждённому официальным TI-инспектором.
entry, loaded, sections = coff_sections(document) # Отсекает любой LOAD в сектор A до изменения двоичного файла.
section = sections.get('.flash_app') # Секция размером 16 слов резервируется linker и не меняет адресов остальной программы.
if section is None or int(section.findtext('virtual_addr', '0'), 0) != APP_START or int(section.findtext('raw_data_size', '0'), 0) != HEADER_WORDS * 2: # Нельзя добавлять заголовок поверх машинного кода.
raise ValueError('Linker должен выделить .flash_app из 16 слов по адресу 0x300000') # Ошибка требует пересборки вместо небезопасного смещения программы.
offset = int(section.findtext('file_offsets/raw_data_ptr', '0'), 0) # OFD указывает точное файловое смещение содержимого секции COFF.
raw, signed = bytearray(image.read_bytes()), descriptor(entry, loaded) # Считает CRC уже окончательно связанных Flash LOAD-секций.
if offset <= 0 or offset + len(signed) > len(raw): # Проверяет размер файла до адресной записи заголовка.
raise ValueError('Неверное смещение заголовка COFF') # Повреждённые метаданные не могут изменить постороннюю часть OUT.
raw[offset:offset + len(signed)] = signed # Меняет только зарезервированные 32 октета, не трогая символы, адреса и отладочную информацию.
image.write_bytes(raw) # Сохраняет подписанный результат до конверсии в SCI8 и создания внешнего SHA-256.
loaded.update((APP_START + index, value) for index, value in enumerate(struct.unpack('<16H', signed))) # Приводит адресную модель к записанному заголовку.
validate_words(entry, loaded) # Независимо проверяет только что сформированный результат.
+86
View File
@@ -0,0 +1,86 @@
from types import SimpleNamespace
import struct
import pytest
from protocan.can_boot import CanBootServices, CanBootTarget, CanBootTransfer, CanBootPingExchange
from protocan.protocan import ProtoCanId
from protocan.transport import build_frame
def response(command, data, device=7):
return build_frame(ProtoCanId.build(1, 1, 3, device, 13, command), data, to_can=False)
def test_passport_and_discovery_address_filters():
service = CanBootServices(CanBootTarget(device_type=3, device=7, product_type=0x2833))
identify = response(1, struct.pack('<HBBI', 0x2833, 1, 1, 0x01010002))
assert service.decode(identify)['version'] == (1, 1, 2)
foreign = response(1, identify.data, device=8)
assert service.decode(foreign) is None
assert service.decode(foreign, discovery=True)['device'] == 8
capabilities = service.decode(response(13, struct.pack('<II', 0xFFF, 130560)))
assert capabilities['slot_capacity'] == 130560
assert capabilities['capabilities'] & (1 << 8)
assert service.decode(response(14, b'F28335-M'))['name_fragment'] == 'F28335-M'
assert service.decode(response(0x10E, b'95M02\0\0\0'))['name_fragment'] == '95M02'
memory = service.decode(response(15, struct.pack('<IHBB', 262144, 256, 1, 255)))
assert memory['bootloader'] and memory['memory_bytes'] == 262144
assert service.decode(response(0x10F, struct.pack('<II', 512, 131072)))['slot_offsets'] == (512, 131072)
pending = service.decode(response(0x20F, struct.pack('<BBBBI', 0, 1, 1, 1, 30000)))
assert pending['trial_attempted'] and pending['pending_slot'] == 1
assert service.decode(response(0x30F, struct.pack('<II', 0x3FEFC, 258)))['parameters_bytes'] == 258
assert service.decode(response(0x40F, bytes(8))) is None
discover = ProtoCanId.parse(service.discover().can_id)
assert (discover.device_type, discover.device, discover.msg_type, discover.body) == (7, 15, 13, 0)
def test_service_requests_and_transfer_limits():
target = CanBootTarget(device_type=3, device=7, product_type=0x2833, session_id=15)
service = CanBootServices(target)
request = service.request(15, 2)
assert request.data == b'\x02' and ProtoCanId.parse(request.can_id).body == 0xF0F
assert ProtoCanId.parse(service.request(8).can_id).body == 0xF08
with pytest.raises(ValueError):
service.request(5)
with pytest.raises(ValueError):
service.request(1, 3)
with pytest.raises(ValueError):
CanBootTransfer(SimpleNamespace(data=bytes(130561), version=1), target)
transfer = CanBootTransfer(SimpleNamespace(data=bytes(40), version=1), target)
assert transfer.response_timeout_ms == 15000
transfer.stage = 'data'
assert transfer.response_timeout_ms == 1500
def test_ping_exchange_only_accepts_addressed_ping_reply():
ping = CanBootPingExchange(CanBootTarget(device_type=3, device=7))
assert ProtoCanId.parse(ping.request()[0].can_id).body & 255 == 12
assert ping.feed(response(1, bytes(8))) is None
assert ping.feed(response(12, bytes([1, 0, 0, 255, 0, 2, 0, 0]), device=8)) is None
assert ping.feed(response(12, bytes([1, 0, 0, 255, 0, 2, 0, 0])))['slot_count'] == 2
def test_confirm_and_progress_status_correlate_session():
service = CanBootServices(CanBootTarget(device_type=3, device=7, session_id=5))
frame = build_frame(ProtoCanId.build(1, 1, 3, 7, 12, 0x508),
struct.pack('<BBHI', 0, 1, 123, 0x12345678), to_can=False)
reply = service.decode(frame)
assert reply['status'] == 0 and reply['command'] == 8 and reply['next_block'] == 123
assert service.decode(frame, discovery=True) is None
assert CanBootServices(CanBootTarget(device_type=3, device=7, session_id=6)).decode(frame) is None
@pytest.mark.parametrize("code,expected", [(0, 0), (0, 8), (0, 16), (8, 0), (8, 8), (8, 16)]) # Проверяет повторные и запоздалые OK/SEQUENCE_ERROR после подтверждённого первого окна.
def test_duplicate_data_status_does_not_resend_or_rewind_window(code, expected): # Один поздний ответ не должен порождать дополнительные 16 кадров в очереди MCU.
target = CanBootTarget(device_type=1, device=8, product_type=0x2833) # Использует адрес задатчика, на котором наблюдалось зависание передачи.
client = CanBootTransfer(SimpleNamespace(data=bytes(512), version=1, crc32=0), target) # Несколько окон позволяют проверить ответ до завершения передачи.
def status_frame(command, status_code=0, next_block=0): # Формирует тестовый STATUS в той же сессии, что запрос клиента.
return build_frame(ProtoCanId.build(1, 1, 1, 8, 12, 0x100 | command), struct.pack("<BBHI", status_code, 0, next_block, 0), to_can=False) # Session 1 и слот A отделяют проверку повторов от адресной фильтрации.
client.start() # Начинает обычную последовательность ENTER_BOOT и метаданных.
for command in (2, 3, 4, 5): # Переводит production-клиент к первому окну через штатные подтверждения этапов.
client.handle_status(status_frame(command)) # Каждый управляющий ответ разрешает только следующий этап протокола.
outgoing, _ = client.handle_status(status_frame(0, next_block=16)) # Подтверждает первое окно и ставит в отправку блоки 16–31.
assert len(outgoing) == 16 and client.next_block == 16 # Контрольное состояние должно содержать новое неподтверждённое окно.
for _ in range(16): # Остаток прежнего окна способен вернуть до 16 одинаковых ошибок последовательности.
assert client.handle_status(status_frame(0, code, expected)) == ([], "") # Дубликаты не пересылают текущее окно и не отодвигают транспортный тайм-аут.
assert client.next_block == 16 and client.stage == "data" # Запоздалый SEQUENCE_ERROR не откатывает уже подтверждённые блоки.
+4 -1
View File
@@ -126,7 +126,10 @@ def test_legacy_can_full_transaction_and_retransmission():
assert all(ProtoCanId.parse(f.can_id).msg_type == 11 for f in frames)
frames, _ = t.handle_status(status(t, 0, code=8, expected=8))
assert ProtoCanId.parse(frames[0].can_id).body == 8
assert frames[-1].data == bytes([128,129]) + b"\xff" * 6
assert [ProtoCanId.parse(frame.can_id).body for frame in frames] == list(range(8, 16)) # Восстановление повторяет хвост до абсолютной границы ACK 16 и не посылает преждевременно блок следующего окна.
frames, _ = t.handle_status(status(t, 0, expected=16)) # Настоящее ядро подтверждает блок 16 до передачи последнего неполного блока 16.
assert len(frames) == 1 and ProtoCanId.parse(frames[0].can_id).body == 16 # После ACK предыдущего окна остаётся ровно один блок образа из 130 байт.
assert frames[0].data == bytes([128,129]) + b"\xff" * 6 # Дополнение последнего блока до восьми байт сохраняет прежний wire-формат.
frames, _ = t.handle_status(status(t, 0, expected=17))
assert t.stage == "verify"
for cmd in (6, 7, 9):