Добавить протокол 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

1
python/altera_logic/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
native/

View File

@@ -0,0 +1,4 @@
"""Portable Altera analyzer client. Wire protocol and sequencing live in C99."""
from .native import Capture, NativeAnalyzer
__all__ = ["Capture", "NativeAnalyzer"]

View File

@@ -0,0 +1,89 @@
"""Presentation state for waveform rulers; no transport or wire processing."""
from dataclasses import dataclass
def drag_zoom_factor(delta_pixels):
"""Visual gesture mapping: 200 pixels doubles the selected axis."""
return 2 ** (max(-1000., min(1000., delta_pixels)) / 200.)
@dataclass
class Marker:
name: str
axis: str
value: float
color: str
visible: bool = False
class WaveformMarkers:
def __init__(self):
self.items = [Marker('X1', 'x', .2, '#ffce63'),
Marker('X2', 'x', .4, '#ffce63'),
Marker('X3', 'x', .6, '#e794ff'),
Marker('X4', 'x', .8, '#e794ff'),
Marker('Y1', 'y', .2, '#72e7ff'),
Marker('Y2', 'y', .4, '#72e7ff'),
Marker('Y3', 'y', .6, '#a9df79'),
Marker('Y4', 'y', .8, '#a9df79')]
self._math = None
self.session = None
self.bounds = None
def window(self, session, start, end):
if session != self.session or self.bounds is None:
for marker, fraction in zip(self.items[:4], (.2, .4, .6, .8)):
marker.value = start + (end-start)*fraction
self.session, self.bounds = session, (start, end)
def reset(self):
if self.bounds:
start, end = self.bounds
for marker, fraction in zip(self.items[:4], (.2, .4, .6, .8)):
marker.value = start + (end-start)*fraction
for marker, fraction in zip(self.items[4:], (.2, .4, .6, .8)):
marker.value = fraction
def measurements(self, period_ns):
"""Use the same native delta operation as Android's plotDelta."""
if self._math is None:
from protocan.plot import PlotMath
from .native import NativeAnalyzer
self._math = PlotMath(NativeAnalyzer().lib)
result = []
for index in range(0, 8, 2):
a, b = self.items[index:index+2]
if not (a.visible and b.visible):
continue
if a.axis == 'x':
if self.bounds is None:
continue
factor = period_ns / 1e9
av, bv = a.value*factor, b.value*factor
delta = self._math.delta(a.value, b.value, factor)
frequency = 1/abs(delta) if delta else None
else:
av, bv = 100*(1-a.value), 100*(1-b.value)
delta = self._math.delta(av, bv)
frequency = None
result.append((a, b, av, bv, delta, frequency))
return result
def place(self, index, fraction):
marker = self.items[index]
fraction = min(1., max(0., fraction))
if marker.axis == 'x':
if self.bounds is None:
return
start, end = self.bounds
marker.value = start + (end-start)*fraction
else:
marker.value = fraction
def fraction(self, marker):
if marker.axis == 'y':
return marker.value
if self.bounds is None:
return 0.
start, end = self.bounds
return (marker.value-start)/max(1, end-start)

View File

@@ -0,0 +1,131 @@
"""ctypes binding only: no Python packet codec or protocol fallback."""
from __future__ import annotations
import csv
import ctypes as C
import os
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Capture:
samples: tuple[int, ...]
trigger_index: int
sample_rate: float
demo: bool = False
def save_csv(self, path):
with open(path, "w", newline="", encoding="utf-8") as stream:
writer = csv.writer(stream)
writer.writerow(["index", "time_s", "sample_hex", "demo"] +
["D%d" % n for n in range(16)])
for i, sample in enumerate(self.samples):
writer.writerow([i, (i-self.trigger_index)/self.sample_rate,
"%04X" % sample, int(self.demo)] +
[(sample >> n) & 1 for n in range(16)])
class NativeAnalyzer:
CONNECTING, READY, CAPTURING, DONE, ERROR = range(5)
ERROR_TEXT = {
1: "Неверный формат ответа FPGA",
2: "Ошибка контрольной суммы ответа",
3: "FPGA отклонила команду",
4: "Тайм-аут UART. При потере синхронизации нужен RESET_N и повторная настройка",
5: "Неподдерживаемая конфигурация FPGA: ожидается 16 каналов, 4096 выборок, версия 1",
6: "Недопустимые или противоречивые настройки триггера",
7: "Ошибка последовательного порта",
}
def __init__(self, library=None):
if library is None:
explicit = os.environ.get("ALTERA_LOGIC_LIBRARY")
name = "setprotocol.dll" if sys.platform == "win32" else "libsetprotocol.so"
bundled = Path(getattr(sys, "_MEIPASS", "")) / "gui_desktop/native" / name
local = Path(__file__).resolve().parent / "native" / name
path = Path(explicit) if explicit else (bundled if bundled.is_file() else local)
try:
library = C.CDLL(str(path))
except OSError as exc:
raise RuntimeError("Не загружено C-ядро Altera Logic. Соберите templates/"
"c/set-protocol/tools/build_host.py; " + str(exc)) from exc
self.lib = library
signatures = {
"la_context_size": ([], C.c_size_t),
"la_init": ([C.c_void_p], None),
"la_start": ([C.c_void_p] + [C.c_uint32]*5, C.c_int),
"la_next": ([C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t),
"la_feed": ([C.c_void_p, C.c_void_p, C.c_size_t], None),
"la_tick": ([C.c_void_p, C.c_uint32], None),
"la_fail": ([C.c_void_p, C.c_uint32], None),
"la_get": ([C.c_void_p, C.c_uint32], C.c_uint32),
"la_samples": ([C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t),
"la_demo_init": ([C.c_void_p], None),
"la_demo_capture": ([C.c_void_p, C.c_uint32], C.c_int),
}
try:
for name, (args, result) in signatures.items():
fn = getattr(self.lib, name)
fn.argtypes, fn.restype = args, result
except AttributeError as exc:
raise RuntimeError("C-ядро устарело: пересоберите templates с Altera Logic") from exc
# Explicitly aligned caller-owned storage; C never allocates memory.
self.ctx = (C.c_uint64 * ((self.lib.la_context_size()+7)//8))()
self.demo = False
self.reset()
def reset(self, demo=False):
self.demo = demo
(self.lib.la_demo_init if demo else self.lib.la_init)(self.ctx)
def get(self, field):
return int(self.lib.la_get(self.ctx, field))
@property
def state(self):
return self.get(0)
@property
def error(self):
return self.ERROR_TEXT.get(self.get(1), "Ошибка обмена")
@property
def progress(self):
return self.get(7)
@property
def flags(self):
return self.get(5)
def start(self, divider, mask=0, value=0, edge_mask=0, edge_value=0):
values = (divider, mask, value, edge_mask, edge_value)
if any(not isinstance(v, int) or not 0 <= v <= 65535 for v in values):
raise ValueError(self.ERROR_TEXT[6])
result = (self.lib.la_demo_capture(self.ctx, divider) if self.demo else
self.lib.la_start(self.ctx, *values))
if result:
raise ValueError(self.ERROR_TEXT.get(result, "Захват уже выполняется"))
def next_request(self):
out = (C.c_ubyte*6)()
size = self.lib.la_next(self.ctx, out, len(out))
return bytes(out[:size])
def feed(self, data):
self.lib.la_feed(self.ctx, data, len(data))
def tick(self, elapsed_ms):
self.lib.la_tick(self.ctx, max(0, min(int(elapsed_ms), 0xffffffff)))
def fail_io(self):
self.lib.la_fail(self.ctx, 7)
def capture(self):
out = (C.c_uint16*4096)()
count = self.lib.la_samples(self.ctx, out, len(out))
if not count:
raise RuntimeError("Запись ещё не завершена")
return Capture(tuple(out[:count]), self.get(6),
self.get(4)/(self.get(8)+1), self.demo)

View File

@@ -0,0 +1,27 @@
"""Qt presentation port: wheel pans X; Ctrl+wheel pans Y, never zooms."""
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QScrollArea
class PlotScrollArea(QScrollArea):
user_scrolled = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._remainder = {'x': 0., 'y': 0.}
def wheelEvent(self, event):
axis = 'y' if event.modifiers() & Qt.KeyboardModifier.ControlModifier else 'x'
bar = self.verticalScrollBar() if axis == 'y' else self.horizontalScrollBar()
pixels, angle = event.pixelDelta(), event.angleDelta()
if not pixels.isNull():
delta = pixels.y() if pixels.y() else pixels.x()
else:
delta = (angle.y() if angle.y() else angle.x()) / 120. * 60.
self._remainder[axis] -= delta
movement = int(self._remainder[axis])
self._remainder[axis] -= movement
if delta:
self.user_scrolled.emit(axis)
bar.setValue(bar.value()+movement)
event.accept()

View File

@@ -0,0 +1,114 @@
"""Qt serial/lifecycle port. Protocol decisions are exclusively in C99."""
from __future__ import annotations
from PySide6.QtCore import QObject, QTimer, QElapsedTimer, Signal
from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo
from .native import NativeAnalyzer
class AnalyzerPort(QObject):
changed = Signal()
completed = Signal(object)
error = Signal(str)
log = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.core = None
self.active = False
self._last_done = False
self.serial = QSerialPort(self)
self.serial.readyRead.connect(self._read)
self.serial.errorOccurred.connect(self._serial_error)
self.timer = QTimer(self)
self.timer.setInterval(10)
self.timer.timeout.connect(self._tick)
self.clock = QElapsedTimer()
@staticmethod
def ports():
return [(p.portName(), p.description()) for p in QSerialPortInfo.availablePorts()]
def open(self, name, demo=False):
self.close()
try:
self.core = NativeAnalyzer()
self.core.reset(demo)
except RuntimeError as exc:
self.error.emit(str(exc))
return
if not demo:
self.serial.setPortName(name)
self.serial.setBaudRate(921600)
self.serial.setDataBits(QSerialPort.DataBits.Data8)
self.serial.setParity(QSerialPort.Parity.NoParity)
self.serial.setStopBits(QSerialPort.StopBits.OneStop)
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self.error.emit(self.serial.errorString())
return
self.active = True
self._last_done = False
self.clock.start()
self.timer.start()
self.log.emit("ДЕМО: синтетические данные, триггер не моделируется" if demo
else "%s · 921600 8N1" % name)
self._pump()
def close(self):
self.active = False
self.timer.stop()
self.serial.close()
self.changed.emit()
def start(self, *settings):
if not self.active or not self.core:
return
try:
self.core.start(*settings)
except ValueError as exc:
self.error.emit(str(exc))
return
self._last_done = False
self._pump()
def _tick(self):
if not self.active:
return
self.core.tick(self.clock.restart())
self._pump()
def _read(self):
data = bytes(self.serial.readAll())
if self.active and data:
self.log.emit("RX " + data.hex(" ").upper())
self.core.feed(data)
self._pump()
def _pump(self):
if not self.active:
return
if self.core.state == NativeAnalyzer.ERROR:
message = self.core.error
self.close()
self.error.emit(message)
return
packet = self.core.next_request()
if packet:
self.log.emit("TX " + packet.hex(" ").upper())
if self.serial.write(packet) != len(packet):
self.core.fail_io()
self._pump()
return
self.clock.restart()
if self.core.state == NativeAnalyzer.DONE and not self._last_done:
self._last_done = True
self.completed.emit(self.core.capture())
self.changed.emit()
def _serial_error(self, code):
if self.active and code != QSerialPort.SerialPortError.NoError:
message = self.serial.errorString()
self.core.fail_io()
self.close()
self.error.emit(message)

View File

@@ -0,0 +1,108 @@
"""SETCAN streaming FFI and immutable display/export models. No wire codec."""
from __future__ import annotations
import csv
import ctypes as C
from dataclasses import dataclass
from .native import NativeAnalyzer
@dataclass(frozen=True)
class StreamSnapshot:
indices: tuple[int, ...]
samples: tuple[int, ...]
breaks: tuple[int, ...]
period_ns: int
session: int
demo: bool = False
traces: tuple = ()
def save_csv(self, path):
with open(path, "w", newline="", encoding="utf-8") as file:
writer = csv.writer(file)
writer.writerow(["session", "sample_index", "time_s", "gap_before", "sample_hex", "demo"] +
["D%d" % n for n in range(16)])
for index, sample, gap in zip(self.indices, self.samples, self.breaks):
writer.writerow([self.session, index, index*self.period_ns/1e9, gap,
"%04X" % sample, int(self.demo)] +
[(sample >> bit)&1 for bit in range(16)])
class NativeStream:
def __init__(self, device_id=None):
self._owner = NativeAnalyzer()
self.lib = self._owner.lib
signatures = {
"las_context_size": ([], C.c_size_t),
"las_init": ([C.c_void_p, C.c_uint32], C.c_int),
"las_can": ([C.c_void_p, C.c_uint32, C.c_void_p, C.c_size_t, C.c_uint32, C.c_uint32], None),
"las_uart": ([C.c_void_p, C.c_void_p, C.c_size_t], None),
"las_get": ([C.c_void_p, C.c_uint32], C.c_uint32),
"las_snapshot": ([C.c_void_p, C.c_void_p, C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t),
"las_trace": ([C.c_void_p, C.c_uint32, C.c_void_p, C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t),
"las_device_type": ([], C.c_uint32),
"las_device_id": ([], C.c_uint32),
"las_device_name": ([], C.c_char_p),
"las_demo_step": ([C.c_void_p, C.c_uint32], None),
"las_metadata": ([C.c_uint32]*4+[C.POINTER(C.c_uint32), C.c_void_p, C.c_size_t], C.c_size_t),
"las_data": ([C.c_uint32]*5+[C.POINTER(C.c_uint32), C.c_void_p, C.c_size_t], C.c_size_t),
}
try:
for name, (args, result) in signatures.items():
fn = getattr(self.lib, name)
fn.argtypes, fn.restype = args, result
except AttributeError as exc:
raise RuntimeError("Обновите DLL templates: отсутствует SETCAN Altera stream") from exc
self.device_type = int(self.lib.las_device_type())
self.device_id = int(self.lib.las_device_id()) if device_id is None else device_id
self.device_name = self.lib.las_device_name().decode("utf-8")
self.ctx = (C.c_uint64*((self.lib.las_context_size()+7)//8))()
if not 0 <= self.device_id <= 15 or not self.lib.las_init(self.ctx, self.device_id):
raise ValueError("DeviceID должен быть 0…15")
@property
def stats(self):
fields = ("count", "period_ns", "session", "received", "missing", "duplicates",
"invalid", "ignored", "crc_errors", "has_meta")
return {name: int(self.lib.las_get(self.ctx, i)) for i, name in enumerate(fields)}
def feed_can(self, identifier, data, extended=True, remote=False):
if not 0 <= identifier <= 0xffffffff:
return
self.lib.las_can(self.ctx, identifier, data, len(data), int(extended), int(remote))
def feed_uart(self, data):
self.lib.las_uart(self.ctx, data, len(data))
def snapshot(self, demo=False):
stats = self.stats
count = stats["count"]
indices, samples, breaks = (C.c_uint64*count)(), (C.c_uint16*count)(), (C.c_ubyte*count)()
n = self.lib.las_snapshot(self.ctx, indices, samples, breaks, count)
traces = []
xs, ys, moves = (C.c_uint64*(2*count))(), (C.c_ubyte*(2*count))(), (C.c_ubyte*(2*count))()
for channel in range(16):
size = self.lib.las_trace(self.ctx, channel, xs, ys, moves, 2*count)
traces.append(tuple(zip(xs[:size], ys[:size], moves[:size])))
return StreamSnapshot(tuple(indices[:n]), tuple(samples[:n]), tuple(breaks[:n]),
stats["period_ns"], stats["session"], demo, tuple(traces))
def demo_step(self, count=50):
self.lib.las_demo_step(self.ctx, count)
def metadata_packet(self, session, period_ns, uart=False):
if not 0 <= session <= 65535 or not 1 <= period_ns <= 0xffffffff:
raise ValueError("Invalid stream metadata")
return self._packet(self.lib.las_metadata, session, period_ns, int(uart))
def data_packet(self, session, index, sample, uart=False):
if not 0 <= index <= 0xffffffff or any(not 0 <= v <= 65535 for v in (session, sample)):
raise ValueError("Invalid stream samples")
return self._packet(self.lib.las_data, session, index, sample, int(uart))
def _packet(self, fn, *args):
identifier = C.c_uint32()
out = (C.c_ubyte*32)()
n = fn(self.device_id, *args, C.byref(identifier), out, len(out))
if not n:
raise ValueError("Invalid SETCAN packet arguments")
return identifier.value, bytes(out[:n])

View File

@@ -0,0 +1,103 @@
"""Qt transport/lifecycle port for the C SETCAN stream receiver.
CAN ingress accepts canonical RX events from an existing bus connection.
UART uses the same SETCAN frames inside the shared AA55/CRC16 transport.
Reception and rendering clocks are separate; no packet-rate repainting.
"""
from __future__ import annotations
from PySide6.QtCore import QObject, QTimer, QElapsedTimer, Signal
from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo
from .stream import NativeStream
class StreamPort(QObject):
updated = Signal(object, object)
changed = Signal(bool)
error = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.core = None
self.active = False
self.mode = "demo"
self.paused = False
self.serial = QSerialPort(self)
self.serial.readyRead.connect(self._read)
self.serial.errorOccurred.connect(self._error)
self.timer = QTimer(self)
self.timer.setInterval(50)
self.timer.timeout.connect(self._tick)
self._previous = None
self._last_data = QElapsedTimer()
self._last_received = None
@staticmethod
def ports():
return [(p.portName(), p.description()) for p in QSerialPortInfo.availablePorts()]
def open(self, mode, port_name="", device_id=None):
self.close()
if mode not in ("demo", "can", "uart", "both"):
self.error.emit("Неизвестный транспорт")
return
try:
self.core = NativeStream(device_id)
except (RuntimeError, ValueError) as exc:
self.error.emit(str(exc))
return
self.mode = mode
if mode in ("uart", "both"):
self.serial.setPortName(port_name)
self.serial.setBaudRate(921600)
self.serial.setDataBits(QSerialPort.DataBits.Data8)
self.serial.setParity(QSerialPort.Parity.NoParity)
self.serial.setStopBits(QSerialPort.StopBits.OneStop)
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self.error.emit(self.serial.errorString())
return
self.active = True
self.paused = False
self._previous = None
self._last_received = None
self._last_data.start()
self.timer.start()
self.changed.emit(True)
def close(self):
self.active = False
self.timer.stop()
self.serial.close()
self.changed.emit(False)
def receive_event(self, event):
if (self.active and self.mode in ("can", "both") and event.get("kind") == "can"
and event.get("direction") == "RX"):
self.core.feed_can(event["identifier"], event["data"],
event.get("extended", False), event.get("remote", False))
def _read(self):
data = bytes(self.serial.readAll())
if self.active and self.mode in ("uart", "both"):
self.core.feed_uart(data)
def _tick(self):
if not self.active:
return
if self.mode == "demo":
self.core.demo_step()
stats = self.core.stats
signature = (stats["received"], stats["session"])
if signature != self._last_received:
self._last_received = signature
self._last_data.restart()
stats["stale"] = self._last_data.elapsed() > max(2000, stats["period_ns"]*3/1e6)
if not self.paused and stats != self._previous:
self._previous = stats
self.updated.emit(self.core.snapshot(self.mode == "demo"), stats)
def _error(self, code):
if self.active and code != QSerialPort.SerialPortError.NoError:
message = self.serial.errorString()
self.close()
self.error.emit(message)

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}")

View File

@@ -0,0 +1,268 @@
"""Standalone firmware database: HTTPS catalog, verified downloads and Gitea publishing.
No Qt, desktop application, credential store or MCU dependencies.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from urllib.error import HTTPError
from urllib.parse import quote, unquote, urljoin, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
from .firmware_catalog import MAX_MANIFEST_BYTES, FirmwareRelease, parse_firmware_catalog
from .firmware_publish import (
MAX_FIRMWARE_BYTES, FirmwarePublication, firmware_release_entry,
firmware_release_tag, sha256_file, update_firmware_manifest,
)
CHUNK = 128 * 1024
def _origin(url: str) -> tuple[str, str, int]:
parsed = urlsplit(url)
if (parsed.scheme != "https" or not parsed.hostname
or parsed.username is not None or parsed.password is not None):
raise ValueError("Expected an HTTPS URL without embedded credentials")
return parsed.scheme, parsed.hostname, parsed.port or 443
@dataclass(frozen=True)
class Credentials:
login: str
password: str = field(repr=False)
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class HttpsClient:
"""Credentials are restricted to one origin, including across redirects."""
def __init__(self, credential_origin: str, credentials: Credentials | None = None):
self.origin = _origin(credential_origin)
self.credentials = credentials
self.opener = build_opener(_NoRedirect())
def open(self, url: str, *, method: str = "GET", data: bytes | None = None,
content_type: str = "application/json"):
for attempt in range(6):
origin = _origin(url)
headers = {"Accept": "application/json, application/octet-stream",
"Content-Type": content_type, "User-Agent": "templates-firmware-db/1"}
if self.credentials is not None and origin == self.origin:
raw = f"{self.credentials.login}:{self.credentials.password}".encode("utf-8")
headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii")
request = Request(url, data=data, headers=headers, method=method)
try:
return self.opener.open(request, timeout=180)
except HTTPError as error:
# Never replay a write or its body at a redirect destination.
if (method != "GET" or error.code not in (301, 302, 303, 307, 308)
or not error.headers.get("Location") or attempt == 5):
raise
destination = urljoin(url, error.headers["Location"])
error.close()
url = destination
raise RuntimeError("Too many redirects")
@dataclass(frozen=True)
class GiteaRepository:
server: str
owner: str
repository: str
branch: str = "main"
manifest_path: str = "update.json"
def __post_init__(self):
_origin(self.server)
parsed = urlsplit(self.server)
if parsed.query or parsed.fragment:
raise ValueError("Server URL must not contain a query or fragment")
if not all((self.owner, self.repository, self.branch, self.manifest_path)):
raise ValueError("Repository settings must not be empty")
@property
def web(self) -> str:
return f"{self.server.rstrip('/')}/{quote(self.owner, safe='')}/{quote(self.repository, safe='')}"
@property
def api(self) -> str:
return (f"{self.server.rstrip('/')}/api/v1/repos/"
f"{quote(self.owner, safe='')}/{quote(self.repository, safe='')}")
@property
def manifest_url(self) -> str:
return (f"{self.web}/raw/branch/{quote(self.branch, safe='')}/"
f"{quote(self.manifest_path, safe='/')}")
class FirmwareDatabase:
"""Blocking service; call from a worker when integrating with a GUI."""
def __init__(self, manifest_url: str, cache_dir: Path, *,
credentials: Credentials | None = None, client=None):
_origin(manifest_url)
self.manifest_url = manifest_url
self.cache_dir = Path(cache_dir)
self.client = client if client is not None else HttpsClient(manifest_url, credentials)
def read_catalog(self, *, product: str | None = None,
transport: str | None = None) -> list[FirmwareRelease]:
with self.client.open(self.manifest_url) as response:
data = response.read(MAX_MANIFEST_BYTES + 1)
releases = parse_firmware_catalog(data, self.manifest_url)
return [r for r in releases
if (product is None or r.product.casefold() == product.casefold())
and (transport is None or r.transport == transport.lower())]
def download(self, release: FirmwareRelease,
progress: Callable[[int], None] | None = None) -> Path:
# Validate even objects constructed directly by callers (including file names).
entry = {"product": release.product, "versionName": release.version,
"versionCode": release.version_code, "imageUrl": release.image_url,
"fileName": release.file_name, "sha256": release.sha256,
"transport": release.transport}
release = parse_firmware_catalog(
json.dumps({"firmware": [entry]}).encode(), self.manifest_url)[0]
directory = self.cache_dir / release.sha256
directory.mkdir(parents=True, exist_ok=True)
target = directory / release.file_name
if target.is_file() and sha256_file(target) == release.sha256:
if progress:
progress(100)
return target
# Unique staging files keep concurrent downloads independent.
with tempfile.NamedTemporaryFile(dir=directory, suffix=".part", delete=False) as output:
temporary = Path(output.name)
try:
digest = hashlib.sha256()
received = 0
with self.client.open(release.image_url) as response, temporary.open("wb") as output:
total = int(response.headers.get("Content-Length", "-1") or -1)
if total > MAX_FIRMWARE_BYTES:
raise ValueError("Firmware exceeds maximum size")
while chunk := response.read(CHUNK):
received += len(chunk)
if received > MAX_FIRMWARE_BYTES:
raise ValueError("Firmware exceeds maximum size")
digest.update(chunk)
output.write(chunk)
if progress and total > 0:
progress(min(99, received * 100 // total))
output.flush()
os.fsync(output.fileno())
if not received or digest.hexdigest() != release.sha256:
raise ValueError("Downloaded firmware is empty or SHA-256 does not match")
temporary.replace(target)
if progress:
progress(100)
return target
finally:
temporary.unlink(missing_ok=True)
class GiteaFirmwarePublisher:
"""Publish an immutable image, verify it, then commit the shared catalog."""
def __init__(self, repository: GiteaRepository, credentials: Credentials | None = None,
*, client=None):
self.repository = repository
self.client = client if client is not None else HttpsClient(repository.server, credentials)
def _call(self, method: str, path: str, payload=None):
binary = isinstance(payload, bytes)
data = payload if binary or payload is None else json.dumps(payload).encode("utf-8")
with self.client.open(
self.repository.api + path, method=method, data=data,
content_type="application/octet-stream" if binary else "application/json",
) as response:
raw = response.read(4 * MAX_MANIFEST_BYTES + 1)
if len(raw) > 4 * MAX_MANIFEST_BYTES:
raise ValueError("Gitea response exceeds maximum size")
return json.loads(raw) if raw else None
def preflight(self, publication: FirmwarePublication) -> dict:
publication.validate()
return self._entry(publication, sha256_file(publication.path))
def _entry(self, publication: FirmwarePublication, digest: str) -> dict:
tag = firmware_release_tag(publication)
# Different bytes never replace an asset used by an already published row.
asset_name = f"{publication.path.stem}-{digest}{publication.path.suffix.lower()}"
url = f"{self.repository.web}/releases/download/{quote(tag, safe='')}/{quote(asset_name, safe='')}"
entry = firmware_release_entry(publication, url, digest)
update_firmware_manifest({}, entry)
return entry
def publish(self, publication: FirmwarePublication) -> dict:
publication.validate()
# Snapshot the bytes once so a concurrent rebuild cannot change the upload.
with publication.path.open("rb") as source:
image = source.read(MAX_FIRMWARE_BYTES + 1)
if not image or len(image) > MAX_FIRMWARE_BYTES:
raise ValueError("Firmware is empty or exceeds maximum size")
entry = self._entry(publication, hashlib.sha256(image).hexdigest())
tag = firmware_release_tag(publication)
tag_path = f"/releases/tags/{quote(tag, safe='')}"
try:
release = self._call("GET", tag_path)
except HTTPError as error:
if error.code != 404:
raise
error.close()
release = self._call("POST", "/releases", {
"tag_name": tag, "target_commitish": self.repository.branch,
"name": f"{publication.product} {publication.version_name}",
"body": publication.notes, "draft": False, "prerelease": False,
})
asset_name = urlsplit(entry["imageUrl"]).path.rsplit("/", 1)[1]
assets_path = f"/releases/{int(release['id'])}/assets"
# Gitea assets are paginated; do not silently miss an existing image.
found = False
page = 1
while True:
assets = self._call("GET", f"{assets_path}?limit=50&page={page}")
if any(asset["name"] == unquote(asset_name) for asset in assets):
found = True
break
if len(assets) < 50:
break
page += 1
if not found:
self._call("POST", f"{assets_path}?name={asset_name}", image)
# Always verify against the server, never against a previous local cache.
with tempfile.TemporaryDirectory(prefix="firmware-verify-") as cache:
reader = FirmwareDatabase(self.repository.manifest_url, Path(cache), client=self.client)
parsed = parse_firmware_catalog(
json.dumps({"firmware": [entry]}).encode(), self.repository.manifest_url)[0]
reader.download(parsed)
path = f"/contents/{quote(self.repository.manifest_path, safe='/')}"
source = self._call("GET", f"{path}?ref={quote(self.repository.branch, safe='')}")
manifest = json.loads(base64.b64decode(source["content"]))
if not isinstance(manifest, dict):
raise ValueError("Manifest must be a JSON object")
updated = update_firmware_manifest(manifest, entry)
if updated != manifest:
self._call("PUT", path, {
"branch": self.repository.branch, "sha": source["sha"],
"message": f"Publish firmware {publication.product} {publication.version_name}",
"content": base64.b64encode(json.dumps(updated, ensure_ascii=False).encode()).decode(),
})
# Use the contents API to avoid a stale raw-file CDN cache on readback.
check = self._call("GET", f"{path}?ref={quote(self.repository.branch, safe='')}")
rows = parse_firmware_catalog(base64.b64decode(check["content"]), self.repository.manifest_url)
if parsed not in rows:
raise RuntimeError("Published catalog readback does not match the release")
return entry

View File

@@ -0,0 +1,57 @@
"""Golden RTL vectors through the Python FFI (no Python production codec)."""
import tempfile
import unittest
from pathlib import Path
from altera_logic import NativeAnalyzer
class AnalyzerTests(unittest.TestCase):
def test_rtl_info_and_first_configuration_vector(self):
core = NativeAnalyzer()
self.assertEqual(core.next_request(), bytes.fromhex("A5 01 00 00 00 A4"))
self.assertEqual(core.next_request(), b"")
for byte in bytes.fromhex("5A 81 00 10 00 10 32 01 E8"):
core.feed(bytes([byte]))
self.assertEqual(core.state, core.READY)
with self.assertRaises(ValueError):
core.start(49, 1, 0, 1, 1)
core.start(49, 0, 0, 1, 1)
self.assertEqual(core.next_request(), bytes.fromhex("A5 02 31 00 00 96"))
core.feed(bytes.fromhex("5A 82 00 D8"))
self.assertEqual(core.next_request(), bytes.fromhex("A5 03 00 00 00 A6"))
def test_error_response_is_short_even_for_info(self):
core = NativeAnalyzer()
core.next_request()
core.feed(bytes.fromhex("5A 81 01 DA"))
self.assertEqual(core.state, core.ERROR)
self.assertEqual(core.get(1), 3)
def test_partial_response_times_out_without_retry(self):
core = NativeAnalyzer()
core.next_request()
core.feed(bytes.fromhex("5A 81 00"))
core.tick(1000)
self.assertEqual(core.get(1), 4)
self.assertEqual(core.next_request(), b"")
def test_demo_export_uses_capture_rate_not_current_ui_settings(self):
core = NativeAnalyzer()
core.reset(demo=True)
core.start(49)
capture = core.capture()
self.assertEqual(len(capture.samples), 4096)
self.assertEqual(capture.sample_rate, 1000000)
self.assertEqual(capture.trigger_index, 2048)
self.assertTrue(capture.demo)
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)/"capture.csv"
capture.save_csv(path)
rows = path.read_text().splitlines()
self.assertEqual(len(rows), 4097)
self.assertIn("time_s", rows[0])
self.assertEqual(rows[2049].split(",")[1], "0.0")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,92 @@
import tempfile
import unittest
from pathlib import Path
from altera_logic.stream import NativeStream
class StreamTests(unittest.TestCase):
def test_can_uart_identical_and_duplicates(self):
source, can, uart = NativeStream(), NativeStream(), NativeStream()
self.assertEqual((source.device_type, source.device_id, source.device_name), (6, 14, "Altera Logic"))
ident, data = source.metadata_packet(7, 1000000)
self.assertEqual(ident, 0x1EE3FE00)
self.assertEqual(data, bytes.fromhex("01 4C 07 00 40 42 0F 00"))
can.feed_can(ident, data)
_, raw = source.metadata_packet(7, 1000000, uart=True)
for byte in raw:
uart.feed_uart(bytes([byte]))
for i in range(100):
ident, data = source.data_packet(7, i, i)
can.feed_can(ident, data)
_, raw = source.data_packet(7, i, i, uart=True)
uart.feed_uart(raw)
can.feed_uart(raw) # Duplicate publication through the second transport.
self.assertEqual(can.snapshot(), uart.snapshot())
self.assertEqual(can.stats["duplicates"], 100)
self.assertEqual(can.stats["received"], 100)
def test_gap_old_session_and_counter_wrap(self):
s = NativeStream()
s.feed_can(*s.metadata_packet(10, 1000000))
s.feed_can(*s.data_packet(10, 0xfffffffe, 1))
s.feed_can(*s.data_packet(10, 1, 2))
snap = s.snapshot()
self.assertEqual(snap.indices, (0xfffffffe, 0x100000001))
self.assertEqual(s.stats["missing"], 2)
self.assertEqual(snap.breaks, (1, 1))
s.feed_can(*s.metadata_packet(11, 2000000))
s.feed_can(*s.data_packet(10, 5, 3))
s.feed_can(*s.metadata_packet(10, 1000000))
self.assertEqual(s.stats["session"], 11)
self.assertEqual(s.snapshot().samples, ())
s.feed_can(*s.data_packet(11, 0, 0x8001))
self.assertEqual(s.snapshot().samples, (0x8001,))
def test_malformed_filter_crc_and_recovery(self):
s = NativeStream()
ident, meta = s.metadata_packet(1, 1000)
s.feed_can(ident, meta, extended=False)
s.feed_can(ident, meta, remote=True)
s.feed_can(ident ^ (1 << 20), meta)
self.assertFalse(s.stats["has_meta"])
raw = bytearray(s.metadata_packet(1, 1000, uart=True)[1])
raw[-1] ^= 1
s.feed_uart(bytes(raw))
self.assertEqual(s.stats["crc_errors"], 1)
s.feed_uart(s.metadata_packet(1, 1000, uart=True)[1])
self.assertTrue(s.stats["has_meta"])
ident, data = s.data_packet(1, 0, 5)
s.feed_can(ident, data[:-1])
self.assertEqual(s.stats["invalid"], 1)
self.assertEqual(s.stats["count"], 0)
def test_bounded_history_and_export(self):
s = NativeStream()
for _ in range(3):
s.demo_step(4096)
snap = s.snapshot(demo=True)
self.assertEqual(len(snap.samples), 8192)
self.assertEqual(snap.indices[0], 4096)
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)/"stream.csv"
snap.save_csv(path)
rows = path.read_text().splitlines()
self.assertEqual(len(rows), 8193)
self.assertIn("gap_before", rows[0])
self.assertEqual(rows[1].split(",")[1], "4096")
def test_sparse_trace_does_not_connect_across_lost_samples(self):
s = NativeStream()
s.feed_can(*s.metadata_packet(1, 1000000))
for index, value in ((0, 0), (1, 1), (4, 0)):
s.feed_can(*s.data_packet(1, index, value))
trace = s.snapshot().traces[0]
self.assertEqual(trace[0], (0, 0, 1))
self.assertIn((1, 0, 0), trace)
self.assertIn((1, 1, 0), trace)
self.assertIn((4, 0, 1), trace)
self.assertNotIn((4, 1, 0), trace)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,135 @@
"""Android wire compatibility and complete host-side flashing transactions."""
from dataclasses import replace
from types import SimpleNamespace
import struct
import zlib
import pytest
from protocan import tms_firmware as tms
from protocan.can_boot import CanBootTarget, CanBootTransfer
from protocan.protocan import ProtoCanId
from protocan.transport import build_frame
def reply(command, payload=b"", tail=2):
return tms.packet(10, command, payload) + bytes(tail)
def test_android_packet_layout_and_crc():
assert tms.crc16_modbus(b"123456789") == 0x4B37
assert tms.init_load(10, 0xA0000, 0x1234)[:10] == bytes.fromhex("0a3a00000a0034120000")
assert tms.load_data(10, b"\1\2\3\4")[:6] == bytes.fromhex("0a3301020304")
assert tms.tflash(10, 0xA0000, 0x110000, 0x123456)[:14] == bytes.fromhex("0a3700000a000000110056341200")
assert tms.extend(10, 0xA0000, 0, 2, 10, 3)[:16] == bytes.fromhex("0a3c00000a0000000000020000000a03")
@pytest.mark.parametrize("command,payload,tail", [(58, b"", 2), (52, b"abcdef", 4)])
def test_only_reserved_tail_may_be_missing(command, payload, tail):
full = reply(command, payload, tail)
for n in range(tail + 1):
assert tms.normalize_reply(full[:len(full)-n], 10, command, len(full)) == full
assert tms.normalize_reply(full[:len(full)-tail-1], 10, command, len(full)) is None
corrupt = bytearray(full)
corrupt[2] ^= 1
assert tms.normalize_reply(bytes(corrupt), 10, command, len(full)) is None
assert tms.normalize_reply(full, 11, command, len(full)) is None
def run_tms(data, target, corrupt=False):
plan = tms.programming_steps(data, target)
response = None
requests = []
while True:
try:
request, size, timeout, title, percent = plan.send(response)
except StopIteration:
return requests
requests.append(request)
command = request[1]
if command == 52:
address, count = struct.unpack_from("<II", request, 2)
offset = (address-target.flash)*2
chunk = data[offset:offset+count].ljust(count, b"\xff")
if corrupt:
chunk = bytes([chunk[0] ^ 1]) + chunk[1:]
response = reply(command, chunk, 4)
assert len(response) == size
elif command == 60:
response = reply(command, struct.pack("<III", (len(data)+1)//2, 0, 0))
else:
response = reply(command)
def test_tms_word_offsets_and_odd_final_byte_readback():
requests = run_tms(bytes(range(256)) + b"x", tms.TmsTarget())
assert [p[1] for p in requests] == [58, 51, 58, 51, 55, 52, 52]
assert struct.unpack_from("<II", requests[2], 2) == (0xA0080, 1)
assert struct.unpack_from("<II", requests[-1], 2) == (0x110080, 2)
with pytest.raises(ValueError, match="не пройдена"):
run_tms(b"test", tms.TmsTarget(), corrupt=True)
@pytest.mark.parametrize("kind,codes", [("spartan2e", [6, 17]), ("spartan6", [10])])
def test_peripheral_flashing_and_verification(kind, codes):
packets = run_tms(b"test", tms.TmsTarget(kind=kind, flash=0, board=3))
assert [p[14] for p in packets if p[1] == 60] == codes
assert all(p[15] == 3 for p in packets if p[1] == 60)
def test_load_only_never_writes_flash():
assert [p[1] for p in run_tms(b"test", tms.TmsTarget(load_only=True))] == [58, 51]
@pytest.mark.parametrize("target,size", [(tms.TmsTarget(flash=0), 4), (tms.TmsTarget(), 0),
(tms.TmsTarget(block_size=257), 4), (tms.TmsTarget(flash=0x17FFFF), 3),
(tms.TmsTarget(ram=0xFFFFFFFF), 4)])
def test_invalid_targets_fail_before_transmission(target, size):
with pytest.raises(ValueError):
target.validate(size)
def image(data):
return SimpleNamespace(data=data, crc32=zlib.crc32(data), version=7)
def status(t, cmd, code=0, slot=1, expected=0):
target = t.target
can_id = ProtoCanId.build(1, 1, target.device_type, target.device, 12, target.session_id << 8 | cmd)
return build_frame(can_id, struct.pack("<BBHI", code, slot, expected, 0), to_can=False)
def enter_data(t):
assert ProtoCanId.parse(t.start()[0].can_id).msg_type == 9
for cmd in (2, 3, 4):
t.handle_status(status(t, cmd))
return t.handle_status(status(t, 5))[0]
def test_legacy_can_full_transaction_and_retransmission():
t = CanBootTransfer(image(bytes(range(130))), CanBootTarget())
frames = enter_data(t)
assert len(frames) == 16
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
frames, _ = t.handle_status(status(t, 0, expected=17))
assert t.stage == "verify"
for cmd in (6, 7, 9):
t.handle_status(status(t, cmd))
assert t.finished and t.percent == 100
def test_can_rejects_unsent_blocks_wrong_slot_and_foreign_session():
t = CanBootTransfer(image(bytes(200)), CanBootTarget())
enter_data(t)
with pytest.raises(RuntimeError, match="непереданный"):
t.handle_status(status(t, 0, expected=20))
foreign = CanBootTransfer(t.image, replace(t.target, session_id=2))
assert not t.accepts(status(foreign, 0))
t = CanBootTransfer(image(bytes(8)), CanBootTarget())
t.handle_status(status(t, 2))
t.handle_status(status(t, 3))
with pytest.raises(RuntimeError, match="слот"):
t.handle_status(status(t, 4, slot=2))

View File

@@ -0,0 +1,192 @@
from __future__ import annotations
import base64
import hashlib
import io
import json
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest.mock import Mock
from urllib.error import HTTPError
from setprotocol.firmware_catalog import FirmwareRelease
from setprotocol.firmware_database import (
Credentials, FirmwareDatabase, GiteaFirmwarePublisher, GiteaRepository, HttpsClient,
)
from setprotocol.firmware_publish import FirmwarePublication
class Response(io.BytesIO):
def __init__(self, data, headers=None):
super().__init__(data)
self.headers = headers or {}
class Server:
"""In-memory HTTP boundary; no real credentials, network or repository writes."""
def __init__(self, *, bad_image=False, conflict=False, missing_release=False):
self.manifest = {"windows": {"versionCode": 42}, "firmware": {"releases": []}}
self.assets = {}
self.calls = []
self.bad_image = bad_image
self.conflict = conflict
self.missing_release = missing_release
def open(self, url, *, method="GET", data=None, **kwargs):
self.calls.append((method, url))
if self.missing_release and "/releases/tags/" in url:
raise HTTPError(url, 404, "Not found", {}, None)
if "/releases/download/" in url:
return Response(b"wrong" if self.bad_image else self.assets[url.rsplit("/", 1)[1]])
if "/raw/branch/" in url:
result = self.manifest
elif "/contents/" in url:
if method == "PUT":
if self.conflict:
raise HTTPError(url, 409, "Conflict", {}, None)
payload = json.loads(data)
assert payload["sha"] == "revision-1"
self.manifest = json.loads(base64.b64decode(payload["content"]))
result = {"sha": "revision-1", "content": base64.b64encode(json.dumps(self.manifest).encode()).decode()}
elif "/assets" in url:
if method == "POST":
self.assets[url.split("?name=", 1)[1]] = data
result = {"id": 2}
else:
result = [{"name": name, "id": index} for index, name in enumerate(self.assets)]
else:
result = {"id": 1}
return Response(json.dumps(result).encode())
class FirmwareDatabaseTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.image = self.root / "image.bin"
self.image.write_bytes(b"test firmware")
self.repository = GiteaRepository("https://git.example", "team", "images")
self.publication = FirmwarePublication(self.image, "DEVICE", "1.2.3", 0x10203, "can")
def test_publish_read_and_download_round_trip_and_idempotency(self):
server = Server()
publisher = GiteaFirmwarePublisher(self.repository, client=server)
entry = publisher.publish(self.publication)
self.assertEqual(server.manifest["windows"], {"versionCode": 42})
self.assertEqual(server.manifest["firmware"]["catalogVersion"], 1)
first_commit = next(i for i, (method, _) in enumerate(server.calls) if method == "PUT")
first_verification = next(i for i, (_, url) in enumerate(server.calls) if "/releases/download/" in url)
self.assertLess(first_verification, first_commit)
publisher.publish(self.publication)
self.assertEqual(sum(method == "PUT" for method, _ in server.calls), 1)
self.assertEqual(len(server.assets), 1)
database = FirmwareDatabase(self.repository.manifest_url, self.root / "cache", client=server)
rows = database.read_catalog(product="device", transport="can")
self.assertEqual(len(rows), 1)
self.assertEqual(database.read_catalog(transport="rs485"), [])
progress = []
downloaded = database.download(rows[0], progress.append)
self.assertEqual(downloaded.read_bytes(), self.image.read_bytes())
self.assertEqual(progress[-1], 100)
count = len(server.calls)
self.assertEqual(database.download(rows[0]), downloaded)
self.assertEqual(len(server.calls), count)
self.assertEqual(entry["fileName"], "image.bin")
def test_failed_image_verification_never_writes_catalog(self):
server = Server(bad_image=True)
with self.assertRaisesRegex(ValueError, "SHA-256"):
GiteaFirmwarePublisher(self.repository, client=server).publish(self.publication)
self.assertFalse(any(method == "PUT" for method, _ in server.calls))
def test_rebuilt_image_keeps_old_asset_and_manifest_on_conflict(self):
server = Server()
publisher = GiteaFirmwarePublisher(self.repository, client=server)
publisher.publish(self.publication)
before = json.dumps(server.manifest)
self.image.write_bytes(b"new firmware")
server.conflict = True
with self.assertRaises(HTTPError) as caught:
publisher.publish(self.publication)
caught.exception.close()
self.assertEqual(json.dumps(server.manifest), before)
self.assertEqual(len(server.assets), 2)
self.assertFalse(any(method == "DELETE" for method, _ in server.calls))
def test_preflight_is_offline(self):
server = Server()
GiteaFirmwarePublisher(self.repository, client=server).preflight(self.publication)
self.assertEqual(server.calls, [])
def test_missing_release_is_created(self):
server = Server(missing_release=True)
GiteaFirmwarePublisher(self.repository, client=server).publish(self.publication)
self.assertTrue(any(method == "POST" and url.endswith("/releases")
for method, url in server.calls))
def test_download_size_limit_and_catalog_readback_failure(self):
from setprotocol.firmware_publish import MAX_FIRMWARE_BYTES
client = Mock()
client.open.return_value = Response(b"", {"Content-Length": str(MAX_FIRMWARE_BYTES + 1)})
database = FirmwareDatabase(self.repository.manifest_url, self.root / "cache", client=client)
release = FirmwareRelease("D", "1", 1, "https://git.example/a.bin", "ab" * 32, "a.bin")
with self.assertRaisesRegex(ValueError, "maximum size"):
database.download(release)
self.assertEqual(list((self.root / "cache").rglob("*.part")), [])
server = Server()
original_open = server.open
def ignore_manifest_write(url, **kwargs):
if kwargs.get("method") == "PUT":
return Response(b"{}")
return original_open(url, **kwargs)
server.open = ignore_manifest_write
with self.assertRaisesRegex(RuntimeError, "readback"):
GiteaFirmwarePublisher(self.repository, client=server).publish(self.publication)
def test_bad_download_cleans_staging_and_rejects_unsafe_filename(self):
release = FirmwareRelease("D", "1", 1, "https://git.example/a.bin",
hashlib.sha256(b"good").hexdigest(), "a.bin")
client = Mock()
client.open.return_value = Response(b"bad")
database = FirmwareDatabase(self.repository.manifest_url, self.root / "cache", client=client)
with self.assertRaisesRegex(ValueError, "SHA-256"):
database.download(release)
self.assertEqual(list((self.root / "cache").rglob("*.part")), [])
with self.assertRaises(ValueError):
database.download(replace(release, file_name="../escaped.bin"))
self.assertEqual(client.open.call_count, 1)
def test_https_credentials_do_not_follow_cross_origin_redirect(self):
client = HttpsClient("https://git.example", Credentials("test", "secret"))
client.opener = Mock()
client.opener.open.side_effect = [
HTTPError("https://git.example/a", 302, "Redirect", {"Location": "https://cdn.example/a"}, None),
Response(b"image"),
]
client.open("https://git.example/a").close()
first, second = [call.args[0] for call in client.opener.open.call_args_list]
self.assertIn("Authorization", first.headers)
self.assertNotIn("Authorization", second.headers)
def test_http_redirect_and_write_redirect_are_rejected(self):
for method, destination in (("GET", "http://git.example/a"), ("POST", "https://cdn.example/a")):
with self.subTest(method=method):
client = HttpsClient("https://git.example")
client.opener = Mock()
client.opener.open.side_effect = HTTPError(
"https://git.example/a", 302, "Redirect", {"Location": destination}, None)
with self.assertRaises((ValueError, HTTPError)) as caught:
client.open("https://git.example/a", method=method)
if isinstance(caught.exception, HTTPError):
caught.exception.close()
self.assertEqual(client.opener.open.call_count, 1)
if __name__ == "__main__":
unittest.main()