Добавить протокол Altera Logic и общие клиенты прошивки
This commit is contained in:
1
python/altera_logic/.gitignore
vendored
Normal file
1
python/altera_logic/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
native/
|
||||
4
python/altera_logic/__init__.py
Normal file
4
python/altera_logic/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Portable Altera analyzer client. Wire protocol and sequencing live in C99."""
|
||||
from .native import Capture, NativeAnalyzer
|
||||
|
||||
__all__ = ["Capture", "NativeAnalyzer"]
|
||||
89
python/altera_logic/markers.py
Normal file
89
python/altera_logic/markers.py
Normal 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)
|
||||
131
python/altera_logic/native.py
Normal file
131
python/altera_logic/native.py
Normal 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)
|
||||
27
python/altera_logic/plot_scroll.py
Normal file
27
python/altera_logic/plot_scroll.py
Normal 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()
|
||||
114
python/altera_logic/qt_port.py
Normal file
114
python/altera_logic/qt_port.py
Normal 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)
|
||||
108
python/altera_logic/stream.py
Normal file
108
python/altera_logic/stream.py
Normal 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])
|
||||
103
python/altera_logic/stream_port.py
Normal file
103
python/altera_logic/stream_port.py
Normal 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)
|
||||
@@ -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
123
python/protocan/can_boot.py
Normal 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 завершена"
|
||||
@@ -131,9 +131,11 @@ MSGTYPE_RU = {
|
||||
#: но не присваивает приборам имён.
|
||||
DEVICE_TYPE_NAMES = {
|
||||
0x0: "Верхний уровень",
|
||||
0x6: "Логические анализаторы",
|
||||
}
|
||||
|
||||
DEVICE_ADDRESS_NAMES = {
|
||||
(0x6, 0xE): "Altera Logic",
|
||||
(0x7, 0xD): "configurator",
|
||||
}
|
||||
|
||||
|
||||
239
python/protocan/setp_firmware.py
Normal file
239
python/protocan/setp_firmware.py
Normal 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)))
|
||||
47
python/protocan/stm32_boot.py
Normal file
47
python/protocan/stm32_boot.py
Normal 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,))
|
||||
118
python/protocan/tms_firmware.py
Normal file
118
python/protocan/tms_firmware.py
Normal 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}")
|
||||
268
python/setprotocol/firmware_database.py
Normal file
268
python/setprotocol/firmware_database.py
Normal 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
|
||||
57
python/tests/test_altera_logic.py
Normal file
57
python/tests/test_altera_logic.py
Normal 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()
|
||||
92
python/tests/test_altera_stream.py
Normal file
92
python/tests/test_altera_stream.py
Normal 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()
|
||||
135
python/tests/test_firmware_clients.py
Normal file
135
python/tests/test_firmware_clients.py
Normal 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))
|
||||
192
python/tests/test_firmware_database.py
Normal file
192
python/tests/test_firmware_database.py
Normal 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()
|
||||
Reference in New Issue
Block a user