111 lines
5.6 KiB
Python
111 lines
5.6 KiB
Python
"""Firmware-info v1 and the adjacent release passport (no device I/O)."""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from protocan.periph28335 import with_crc
|
||
|
||
MAX_IMAGE_SIZE = 199993
|
||
|
||
REGISTER_ADDRESS = 0x1200
|
||
REGISTER_COUNT = 12
|
||
|
||
|
||
def read_request(controller: int) -> bytes:
|
||
if not 1 <= controller <= 255:
|
||
raise ValueError("Для чтения версии нужен адрес контроллера 1..255")
|
||
return with_crc(bytes((controller, 3)) + REGISTER_ADDRESS.to_bytes(2, 'big')
|
||
+ REGISTER_COUNT.to_bytes(2, 'big'))
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FirmwareIdentity:
|
||
version: tuple[int, int, int]
|
||
built_at: datetime
|
||
build_id: str
|
||
|
||
def __post_init__(self):
|
||
if (len(self.version) != 3 or any(type(v) is not int or not 0 <= v <= limit
|
||
for v, limit in zip(self.version, (255, 255, 999)))
|
||
or not 2000 <= self.built_at.year <= 2199
|
||
or self.built_at.utcoffset() is None
|
||
or self.built_at.microsecond
|
||
or len(self.build_id) != 8
|
||
or any(not 32 <= ord(c) <= 126 for c in self.build_id)):
|
||
raise ValueError("Некорректные данные версии прошивки")
|
||
|
||
def describe(self):
|
||
version = '.'.join(map(str, self.version))
|
||
return f"{version} · {self.built_at:%Y-%m-%d %H:%M:%S} UTC · Build ID {self.build_id}"
|
||
|
||
|
||
def decode_identity(words):
|
||
if len(words) != REGISTER_COUNT or words[0] != 1:
|
||
raise ValueError("Неизвестный формат версии (требуется firmware-info v1)")
|
||
if any(type(w) is not int or not 0 <= w <= 65535 for w in words):
|
||
raise ValueError("Некорректные регистры версии")
|
||
try:
|
||
stamp = datetime(words[4], words[5] >> 8, words[5] & 255,
|
||
words[6] >> 8, words[6] & 255, words[7], tzinfo=timezone.utc)
|
||
build_id = bytes(b for w in words[8:] for b in (w >> 8, w & 255)).decode('ascii')
|
||
return FirmwareIdentity(tuple(words[1:4]), stamp, build_id)
|
||
except (ValueError, UnicodeError) as error:
|
||
raise ValueError("Некорректные данные версии: " + str(error)) from error
|
||
|
||
|
||
def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None,
|
||
expected_product="Balsam-167-PM35", expected_target="TMS320F28335",
|
||
max_image_size=MAX_IMAGE_SIZE):
|
||
"""Return None for legacy images; reject a present but invalid passport.
|
||
|
||
The caller may supply the exact bytes it will upload, avoiding a second
|
||
read between hash verification and transmission.
|
||
"""
|
||
if data is None:
|
||
with path.open('rb') as stream:
|
||
data = stream.read(max_image_size + 1)
|
||
if not 0 < len(data) <= max_image_size:
|
||
raise ValueError(f"Размер бинарного файла должен быть 1..{max_image_size} байт")
|
||
passport = Path(str(path) + '.manifest.json')
|
||
if not passport.exists():
|
||
return None
|
||
try:
|
||
with passport.open('r', encoding='utf-8-sig') as stream:
|
||
raw = stream.read(65537)
|
||
if len(raw) > 65536:
|
||
raise ValueError("Слишком большой паспорт прошивки")
|
||
meta = json.loads(raw)
|
||
if (meta['schema'] != 1 or meta['identity_contract'] != 1
|
||
or meta['identity_register_count'] != REGISTER_COUNT):
|
||
raise ValueError("Неизвестный формат паспорта")
|
||
if meta['product'] != expected_product or meta['target'] != expected_target:
|
||
raise ValueError(f"Паспорт предназначен для другого устройства (ожидается {expected_product} / {expected_target})")
|
||
if (type(meta['size_bytes']) is not int or meta['size_bytes'] != len(data)
|
||
or meta['sha256'] != hashlib.sha256(data).hexdigest()):
|
||
raise ValueError("BIN не соответствует паспорту: размер или SHA-256 не совпадает")
|
||
if expected_eeprom is not None and meta.get('eeprom') is not None:
|
||
if meta['eeprom'] != expected_eeprom:
|
||
raise ValueError(f"BIN для {meta['eeprom']}, а выбрана EEPROM {expected_eeprom}")
|
||
stamp = datetime.fromisoformat(meta['built_at_utc'].replace('Z', '+00:00'))
|
||
if stamp.utcoffset() is None or stamp.utcoffset().total_seconds() != 0:
|
||
raise ValueError("Дата паспорта должна быть в UTC")
|
||
return FirmwareIdentity(tuple(meta['version']), stamp, meta['build_id'])
|
||
except (KeyError, TypeError, ValueError, AttributeError) as error:
|
||
raise ValueError("Ошибка паспорта: " + str(error)) from error
|
||
|
||
|
||
def compare_identity(device, image):
|
||
if device is None or image is None:
|
||
return "Сравнение недоступно: нужны версия с платы и проверенный паспорт BIN."
|
||
if device == image:
|
||
return "Версия, дата сборки и Build ID совпадают. Содержимое памяти платы этим не проверяется."
|
||
if image.version > device.version:
|
||
return "Выбранная версия новее установленной."
|
||
if image.version < device.version:
|
||
return "Выбранная версия старее установленной (понижение версии)."
|
||
return "Номер версии совпадает, но дата сборки или Build ID отличаются."
|