Добавить протокол Altera Logic и общие клиенты прошивки

This commit is contained in:
2026-09-19 07:12:09 +03:00
parent def3eb08f3
commit 80ba17d77d
38 changed files with 3271 additions and 10 deletions

View File

@@ -1,5 +1,10 @@
"""Переносимые модули и тонкая Python-обёртка SETProtocol."""
# Consumers may supply additional platform ports from their pinned templates
# checkout. The selected primary checkout wins; missing modules can coexist.
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
from .native import (
NativeCore, NativeCoreUnavailable, NativeFrame, NativeGuiFrame,
NativeGuiParser, NativeParser, NativeProtocol, NativeProtocolUnavailable,

123
python/protocan/can_boot.py Normal file
View File

@@ -0,0 +1,123 @@
"""Legacy ProtoCAN Boot client ported from Gui_Android CanFirmwareProtocol.kt."""
from __future__ import annotations
from dataclasses import dataclass
import struct
from .protocan import ProtoCanId
from .transport import build_frame
@dataclass(frozen=True)
class CanBootTarget:
device_type: int = 0
device: int = 13
product_type: int = 0x166
hardware_min: int = 0
hardware_max: int = 255
session_id: int = 1
def __post_init__(self):
if not 0 <= self.device_type <= 7 or not 0 <= self.device <= 15:
raise ValueError("Адрес ProtoCAN: Device Type 0…7, Device 0…15")
if not 0 <= self.product_type <= 65535:
raise ValueError("Product Type вне диапазона u16")
if not 0 <= self.hardware_min <= self.hardware_max <= 255:
raise ValueError("Неверный диапазон аппаратных ревизий")
if not 1 <= self.session_id <= 255:
raise ValueError("Session ID должен быть 1…255")
class CanBootTransfer:
"""Windowed 8-byte blocks; control 0x9, slots 0xA/0xB, status 0xC."""
def __init__(self, image, target: CanBootTarget):
if not 0 < len(image.data) <= 512 * 1024:
raise ValueError("ProtoCAN Boot: размер образа 1…512 КиБ")
if not 0 <= image.version <= 0xFFFFFFFF:
raise ValueError("Версия образа вне диапазона u32")
self.image, self.target = image, target
self.stage = "enter"
self.next_block = 0
self.slot = None
self.finished = False
self._window_end = 0
@property
def percent(self):
return min(100, self.next_block * 8 * 100 // len(self.image.data))
def _frame(self, kind, body, data=b""):
t = self.target
return build_frame(ProtoCanId.build(1, 0, t.device_type, t.device, kind, body), data, to_can=True)
def _control(self, command, data=b""):
return [self._frame(9, self.target.session_id << 8 | command, data)]
def start(self):
return self._control(2)
def abort(self):
return self._control(10)
def accepts(self, frame):
p = ProtoCanId.parse(frame.can_id)
t = self.target
return (not frame.to_can and frame.ide and not frame.rtr and not frame.is_error
and p.msg_type == 12 and p.pm == 1 and p.device_type == t.device_type
and p.device == t.device and p.body >> 8 == t.session_id and len(frame.data) == 8)
def _window(self):
total = (len(self.image.data) + 7) // 8
self._window_end = min(total, self.next_block + 16)
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)]
def handle_status(self, frame):
if not self.accepts(frame):
return [], ""
command = ProtoCanId.parse(frame.can_id).body & 255
expected_command = {"enter": 2, "image": 3, "compat": 4, "erase": 5,
"data": 0, "verify": 6, "commit": 7, "reboot": 9}.get(self.stage)
if command != expected_command:
return [], ""
status, slot, expected = struct.unpack_from("<BBH", frame.data)
if status == 1:
return [], "Загрузчик занят"
if status not in (0, 8) or (status == 8 and self.stage != "data"):
raise RuntimeError(f"ProtoCAN Boot: ошибка 0x{status:02X}")
if self.stage == "enter":
self.stage = "image"
return self._control(3, struct.pack("<II", len(self.image.data), self.image.crc32)), "Передача метаданных"
if self.stage == "image":
self.stage = "compat"
t = self.target
return self._control(4, struct.pack("<HBBI", t.product_type, t.hardware_min, t.hardware_max, self.image.version)), "Проверка совместимости"
if self.stage == "compat":
if slot not in (0, 1):
raise RuntimeError("ProtoCAN Boot: неверный слот")
self.slot, self.stage = slot, "erase"
return self._control(5), "Стирание неактивного слота"
if self.stage in ("erase", "data"):
total = (len(self.image.data) + 7) // 8
# The final index of a 512 KiB image wraps the u16 status field.
if self.stage == "data" and expected == 0 and self._window_end == 65536 and status == 0:
expected = 65536
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 [], ""
self.next_block = expected
if expected == total:
self.stage = "verify"
return self._control(6), "Проверка CRC32"
self.stage = "data"
return self._window(), "Передача блоков ProtoCAN Boot"
if self.stage == "verify":
self.stage = "commit"
return self._control(7), "Активация образа"
if self.stage == "commit":
self.stage = "reboot"
return self._control(9), "Перезапуск прибора"
self.stage, self.finished = "finished", True
return [], "Прошивка ProtoCAN Boot завершена"

View File

@@ -131,9 +131,11 @@ MSGTYPE_RU = {
#: но не присваивает приборам имён.
DEVICE_TYPE_NAMES = {
0x0: "Верхний уровень",
0x6: "Логические анализаторы",
}
DEVICE_ADDRESS_NAMES = {
(0x6, 0xE): "Altera Logic",
(0x7, 0xD): "configurator",
}

View File

@@ -0,0 +1,239 @@
"""SETProtocol v2 firmware client over segmented classic CAN."""
from __future__ import annotations
import struct
from dataclasses import dataclass
from time import perf_counter
from setprotocol.can import CanAddress, CanFrame, CanReassembler, segment
from setprotocol.core import (
Capabilities,
DeviceInfo,
Feature,
FirmwareBegin,
FirmwareFlag,
Frame as SetFrame,
FrameFlag,
MessageType,
SetProtocolError,
Status,
build_frame,
decode_datagram,
decode_response,
encode_firmware_data,
)
from . import transport as tr
from typing import Any
FirmwareImage = Any
@dataclass(frozen=True)
class CanFirmwareTarget:
"""Address and compatibility settings for a SETProtocol v2 target."""
node_id: int
device_class: int
hardware_min: int = 0
hardware_max: int = 0xFF
def __post_init__(self) -> None:
if not 0 <= self.node_id <= 0xFF:
raise ValueError("SETP node ID вне диапазона 0..255")
if not 0 <= self.device_class <= 0xFFFF:
raise ValueError("Device class вне диапазона u16")
if not 0 <= self.hardware_min <= self.hardware_max <= 0xFFFFFFFF:
raise ValueError("Диапазон hardware version задан неверно")
class CanFirmwareTransfer:
"""Stop-and-wait SETP v2 firmware transaction for a classic CAN link."""
HOST_NODE = 0
CHANNEL = 1
DEFAULT_BLOCK_SIZE = 64
def __init__(self, image: FirmwareImage, target: CanFirmwareTarget) -> None:
if not image.data:
raise ValueError("Образ прошивки пуст")
if len(image.data) > 512 * 1024:
raise ValueError("BALZAM поддерживает образ не более 512 КиБ")
self.image = image
self.target = target
self.offset = 0
self.block_size = self.DEFAULT_BLOCK_SIZE
self.stage = "ping"
self.finished = False
self._sequence = 0
self._expected_sequence = 0
self._expected_type = 0
self._response: SetFrame | None = None
self._reassembler = CanReassembler()
def _request(self, message_type: int, payload: bytes = b"") -> list[tr.Frame]:
self._sequence = (self._sequence + 1) & 0xFFFF
if self._sequence == 0:
self._sequence = 1
self._expected_sequence = self._sequence
self._expected_type = int(message_type)
packet = build_frame(
SetFrame(
message_type=message_type,
sequence=self._sequence,
payload=payload,
flags=FrameFlag.ACK_REQUIRED | FrameFlag.PRIORITY,
source=self.HOST_NODE,
destination=self.target.node_id,
)
)
address = CanAddress(
destination=self.target.node_id,
source=self.HOST_NODE,
priority=1,
channel=self.CHANNEL,
)
return [tr.build_frame(item.can_id, item.data, to_can=True)
for item in segment(packet, address)]
def start(self) -> list[tr.Frame]:
return self._request(MessageType.PING)
def abort(self) -> list[tr.Frame]:
return self._request(MessageType.FW_ABORT)
def accepts(self, frame: tr.Frame) -> bool:
if frame.to_can or not frame.ide:
return False
try:
address = CanAddress.unpack(frame.can_id)
except SetProtocolError:
return False
if address.source != self.target.node_id or address.destination != self.HOST_NODE:
return False
try:
packet = self._reassembler.feed(
CanFrame(frame.can_id, frame.data), int(perf_counter() * 1000)
)
if packet is None:
return False
response = decode_datagram(packet)
except SetProtocolError:
return False
if (
not response.flags & FrameFlag.RESPONSE
or response.source != self.target.node_id
or response.destination != self.HOST_NODE
or response.sequence != self._expected_sequence
or response.message_type != self._expected_type
):
return False
self._response = response
return True
def _begin(self) -> list[tr.Frame]:
begin = FirmwareBegin(
image_size=len(self.image.data),
image_crc32=self.image.crc32,
image_version=self.image.version,
base_address=self.image.base_address,
slot=0,
block_size=self.block_size,
sha256=bytes.fromhex(self.image.sha256),
flags=FirmwareFlag.RESUME | FirmwareFlag.ERASE_SLOT,
)
self.stage = "begin"
return self._request(MessageType.FW_BEGIN, begin.encode())
def _next_data_or_end(self) -> tuple[list[tr.Frame], str]:
if self.offset >= len(self.image.data):
self.stage = "end"
payload = struct.pack(
"<II32s", len(self.image.data), self.image.crc32,
bytes.fromhex(self.image.sha256),
)
return self._request(MessageType.FW_END, payload), "Проверка CRC32 и SHA-256"
self.stage = "data"
data = self.image.data[self.offset : self.offset + self.block_size]
return (
self._request(MessageType.FW_DATA, encode_firmware_data(self.offset, data)),
"Передача блоков SETProtocol v2",
)
def handle_status(self, _frame: tr.Frame) -> tuple[list[tr.Frame], str]:
response, self._response = self._response, None
if response is None:
return [], ""
status, body = decode_response(response)
if status != Status.OK:
try:
name = Status(status).name
except ValueError:
name = "0x%04X" % status
raise RuntimeError(name)
if self.stage == "ping":
if len(body) != 4:
raise RuntimeError("PING: неверная длина ответа")
self.stage = "device_info"
return self._request(MessageType.DEVICE_INFO), "Чтение информации об устройстве"
if self.stage == "device_info":
info = DeviceInfo.decode(body)
if self.target.device_class and info.device_class != self.target.device_class:
raise RuntimeError(
"device class 0x%04X вместо 0x%04X"
% (info.device_class, self.target.device_class)
)
if not self.target.hardware_min <= info.hardware_version <= self.target.hardware_max:
raise RuntimeError("hardware version устройства вне разрешённого диапазона")
self.stage = "capabilities"
return self._request(MessageType.CAPABILITIES), "Проверка возможностей устройства"
if self.stage == "capabilities":
capabilities = Capabilities.decode(body)
if not capabilities.features & Feature.FIRMWARE:
raise RuntimeError("устройство не объявило поддержку firmware update")
self.block_size = min(
self.DEFAULT_BLOCK_SIZE,
capabilities.max_payload - 12,
)
if self.block_size <= 0:
raise RuntimeError("устройство объявило слишком маленький CAN firmware MTU")
return self._begin(), "Начало SETProtocol v2 firmware session"
if self.stage == "begin":
if len(body) != 4:
raise RuntimeError("FW_BEGIN: неверный next_offset")
self.offset = int.from_bytes(body, "little")
if self.offset > len(self.image.data):
raise RuntimeError("FW_BEGIN: next_offset за пределами образа")
return self._next_data_or_end()
if self.stage == "data":
if len(body) != 4:
raise RuntimeError("FW_DATA: неверный next_offset")
next_offset = int.from_bytes(body, "little")
expected = min(self.offset + self.block_size, len(self.image.data))
if next_offset != expected:
raise RuntimeError(
"FW_DATA: подтверждён offset %d вместо %d" % (next_offset, expected)
)
self.offset = next_offset
return self._next_data_or_end()
if self.stage == "end":
if len(body) != 4 or int.from_bytes(body, "little") != len(self.image.data):
raise RuntimeError("FW_END: устройство не подтвердило полный образ")
self.stage = "activate"
return self._request(MessageType.FW_ACTIVATE), "Активация образа"
if self.stage == "activate":
self.finished = True
return [], "Прошивка по CAN (SETProtocol v2) завершена"
return [], ""
@property
def percent(self) -> int:
return min(100, int(self.offset * 100 / len(self.image.data)))

View File

@@ -0,0 +1,47 @@
"""Packets and constants for the STM32 system-memory UART bootloader (AN3155)."""
from __future__ import annotations
ACK = 0x79
NACK = 0x1F
SYNC = b"\x7f"
def command(code: int) -> bytes:
"""Encode an AN3155 command and its one's-complement checksum."""
if not 0 <= code <= 0xFF:
raise ValueError("Код команды вне диапазона байта")
return bytes((code, code ^ 0xFF))
def address(value: int) -> bytes:
"""Encode a big-endian 32-bit address followed by XOR checksum."""
if not 0 <= value <= 0xFFFFFFFF:
raise ValueError("Адрес вне диапазона u32")
raw = value.to_bytes(4, "big")
return raw + bytes((raw[0] ^ raw[1] ^ raw[2] ^ raw[3],))
def write_payload(data: bytes) -> bytes:
"""Encode one Write Memory payload (1..256 bytes)."""
if not 1 <= len(data) <= 256:
raise ValueError("Блок STM32 должен содержать от 1 до 256 байт")
count = len(data) - 1
checksum = count
for value in data:
checksum ^= value
return bytes((count,)) + data + bytes((checksum,))
MASS_ERASE = b"\xff\x00"
def erase_pages_payload(pages: list[int]) -> bytes:
"""Encode the standard Erase Memory page list used by STM32F1."""
if not pages or len(pages) > 256 or any(not 0 <= page <= 0xFF for page in pages):
raise ValueError("Список страниц STM32 должен содержать 1..256 номеров")
body = bytes((len(pages) - 1, *pages))
checksum = 0
for value in body:
checksum ^= value
return body + bytes((checksum,))

View File

@@ -0,0 +1,118 @@
"""BALZAM/PM67 firmware protocol, ported from Gui_Android Tms2812Protocol.
Addresses count 16-bit words; transfer lengths count bytes.
"""
from __future__ import annotations
from dataclasses import dataclass
import struct
from protocan.tms2812 import crc16_modbus, build_upload_request, decode_upload_reply
CMD_LOAD = 51
CMD_UPLOAD = 52
CMD_TFLASH = 55
CMD_INITLOAD = 58
CMD_EXTEND = 60
def packet(controller: int, command: int, payload: bytes) -> bytes:
raw = bytes((controller, command)) + payload
return raw + struct.pack("<H", crc16_modbus(raw))
def init_load(controller: int, ram: int, size: int) -> bytes:
return packet(controller, CMD_INITLOAD, struct.pack("<II", ram, size))
def load_data(controller: int, data: bytes) -> bytes:
return packet(controller, CMD_LOAD, data)
def tflash(controller: int, ram: int, flash: int, size: int) -> bytes:
return packet(controller, CMD_TFLASH, struct.pack("<III", ram, flash, size))
def extend(controller: int, first: int, second: int, size: int, code: int, board: int) -> bytes:
return packet(controller, CMD_EXTEND, struct.pack("<IIIBB", first, second, size, code, board))
def extend_result(raw: bytes) -> tuple[int, int, int]:
return struct.unpack_from("<III", raw, 2)
def normalize_reply(raw: bytes, controller: int, command: int, size: int) -> bytes | None:
"""Accept omitted reserved tail bytes only after the receive settle interval."""
tail = 4 if command == CMD_UPLOAD and size > 6 else 2
if not size - tail <= len(raw) <= size or raw[:2] != bytes((controller, command)):
return None
full = raw.ljust(size, b"\0")
if tail == 4:
try:
decode_upload_reply(full, controller, size - 8)
except ValueError:
return None
elif crc16_modbus(full[:-4]) != int.from_bytes(full[-4:-2], "little"):
return None
return full
@dataclass(frozen=True)
class TmsTarget:
controller: int = 10
ram: int = 0x000A0000
flash: int = 0x00110000
block_size: int = 256
kind: str = "tms"
board: int = 0
load_only: bool = False
def validate(self, size: int) -> None:
if not 0 <= self.controller <= 255:
raise ValueError("Адрес контроллера должен быть 0…255")
if not 256 <= self.block_size <= 65534 or self.block_size % 2:
raise ValueError("Блок LOAD должен быть чётным, 256…65534 байт")
if size <= 0:
raise ValueError("Образ пуст")
words = (size + 1) // 2
if not 0 <= self.ram <= 0xFFFFFFFF or self.ram + words > 0x100000000:
raise ValueError("Диапазон RAM выходит за пределы uint32")
if self.kind not in ("tms", "spartan2e", "spartan6"):
raise ValueError("Неизвестный протокол прошивки TMS")
if not 0 <= self.board <= 255 or not 0 <= self.flash <= 0xFFFFFFFF:
raise ValueError("Неверный адрес платы или памяти")
if not self.load_only and self.kind == "tms" and (not 0x00100000 <= self.flash < 0x00180000 or self.flash + words > 0x00180000):
raise ValueError("Flash должна находиться в диапазоне слов 0x00100000…0x00180000")
def programming_steps(data: bytes, target: TmsTarget):
"""Yield requests; send each validated response back into the generator."""
t = target
t.validate(len(data))
for offset in range(0, len(data), t.block_size):
block = data[offset:offset + t.block_size]
ram = t.ram + offset // 2
yield init_load(t.controller, ram, len(block)), 6, 1500, "CMD_INITLOAD", int(offset * 85 / len(data))
yield load_data(t.controller, block), 6, 3000, "CMD_LOAD", int(offset * 85 / len(data))
if t.load_only:
return
if t.kind != "tms":
code = 6 if t.kind == "spartan2e" else 10
response = yield extend(t.controller, t.ram, t.flash, len(data), code, t.board), 18, 600000, "CMD_EXTEND: запись платы", 85
words, error, repeats = extend_result(response)
if error or words != (len(data) + 1) // 2:
raise ValueError(f"Ошибка записи платы: код {error}, обработано {words} слов, повторов {repeats}")
if t.kind == "spartan2e":
response = yield extend(t.controller, t.ram, t.flash, len(data), 17, t.board), 18, 600000, "CMD_EXTEND: проверка EEPROM", 95
words, error, repeats = extend_result(response)
if error or words != (len(data) + 1) // 2:
raise ValueError(f"Проверка EEPROM не пройдена: код {error}, обработано {words} слов")
return
yield tflash(t.controller, t.ram, t.flash, len(data)), 6, 180000, "CMD_TFLASH: запись Flash", 85
for offset in range(0, len(data), 256):
block = data[offset:offset + 256]
count = (len(block) + 1) & ~1
response = yield build_upload_request(t.controller, t.flash + offset // 2, count), count + 8, 30000, "CMD_UPLOAD: проверка Flash", 90 + int(offset * 10 / len(data))
actual = decode_upload_reply(response, t.controller, count)[:len(block)]
if actual != block:
raise ValueError(f"Проверка Flash не пройдена: блок по адресу слова 0x{t.flash + offset // 2:08X}")