Checkpoint EEPROM, firmware analysis and instrument protocol updates
This commit is contained in:
@@ -20,20 +20,42 @@ def _api(name, args, result):
|
||||
return function
|
||||
|
||||
|
||||
def capacity():
|
||||
return int(_api("capacity", [], ct.c_uint32)())
|
||||
def capacity(m95m02=False):
|
||||
return int(_api("capacity_profile", [ct.c_uint], ct.c_uint32)(int(m95m02)))
|
||||
|
||||
|
||||
def write_request(controller, destination, source, length, *, m95m02=False):
|
||||
values = (controller, destination, source, length)
|
||||
if any(type(v) is not int or not 0 <= v <= 0xffffffff for v in values):
|
||||
raise ValueError("Адрес или длина вне диапазона uint32")
|
||||
output = ct.create_string_buffer(18)
|
||||
size = _api("write_profile", [ct.c_uint32]*4 + [ct.c_uint, ct.c_void_p, ct.c_size_t], ct.c_size_t)(
|
||||
*values, int(m95m02), output, len(output))
|
||||
if not size:
|
||||
raise ValueError("LengthLoad или адрес выходит за область прошивки выбранной EEPROM либо пересекает область параметров. "
|
||||
"Предел от адреса 0: AT25256B — 32508 байт, M95M02 — 261884 байт.")
|
||||
return output.raw[:size]
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, controller, start, length):
|
||||
def __init__(self, controller, start, length, *, block=False, m95m02=False):
|
||||
self.m95m02 = bool(m95m02)
|
||||
if m95m02 and not block:
|
||||
raise ValueError("Для M95M02 необходимо блочное чтение")
|
||||
self.block = block
|
||||
for value in (controller, start, length):
|
||||
if type(value) is not int or not 0 <= value <= 0xffffffff:
|
||||
raise ValueError("Адрес и длина должны быть неотрицательными целыми числами")
|
||||
self._state = _State()
|
||||
result = _api("begin", [ct.c_void_p, ct.c_uint32, ct.c_uint32, ct.c_uint32], ct.c_int)(
|
||||
ct.byref(self._state), controller, start, length)
|
||||
result = _api("begin_profile", [ct.c_void_p, ct.c_uint32, ct.c_uint32, ct.c_uint32, ct.c_uint], ct.c_int)(
|
||||
ct.byref(self._state), controller, start, length, int(self.m95m02))
|
||||
if result:
|
||||
raise ValueError("Диапазон вне EEPROM AT25256B: 0…0x7FFF байт; длина должна быть больше нуля")
|
||||
raise ValueError(f"Диапазон вне выбранной EEPROM: 0…0x{capacity(self.m95m02)-1:X}; длина должна быть больше нуля")
|
||||
if block:
|
||||
# Detect an old DLL before the GUI acquires ownership of the port.
|
||||
_api("block_request_profile", [ct.c_void_p, ct.c_void_p, ct.c_size_t, ct.c_uint], ct.c_size_t)
|
||||
_api("block_accept_profile", [ct.c_void_p, ct.c_void_p, ct.c_size_t, ct.c_void_p, ct.c_size_t, ct.c_uint], ct.c_int)
|
||||
self.expected
|
||||
|
||||
@property
|
||||
def received(self): return int(self._state.received)
|
||||
@@ -42,17 +64,26 @@ class Reader:
|
||||
@property
|
||||
def start(self): return int(self._state.start)
|
||||
|
||||
@property
|
||||
def expected(self):
|
||||
if not self.block: return 8
|
||||
return int(_api("block_expected_profile", [ct.c_void_p, ct.c_uint], ct.c_size_t)(ct.byref(self._state), int(self.m95m02)))
|
||||
|
||||
def request(self):
|
||||
output = ct.create_string_buffer(8)
|
||||
size = _api("request", [ct.c_void_p, ct.c_void_p, ct.c_size_t], ct.c_size_t)(
|
||||
ct.byref(self._state), output, len(output))
|
||||
args = [ct.c_void_p, ct.c_void_p, ct.c_size_t] + ([ct.c_uint] if self.block else [])
|
||||
extra = [int(self.m95m02)] if self.block else []
|
||||
size = _api("block_request_profile" if self.block else "request", args, ct.c_size_t)(
|
||||
ct.byref(self._state), output, len(output), *extra)
|
||||
return output.raw[:size]
|
||||
|
||||
def accept(self, reply):
|
||||
output = ct.create_string_buffer(2)
|
||||
output = ct.create_string_buffer(240 if self.block else 2)
|
||||
reply = bytes(reply)
|
||||
size = _api("accept", [ct.c_void_p, ct.c_void_p, ct.c_size_t, ct.c_void_p, ct.c_size_t], ct.c_int)(
|
||||
ct.byref(self._state), reply, len(reply), output, len(output))
|
||||
args = [ct.c_void_p, ct.c_void_p, ct.c_size_t, ct.c_void_p, ct.c_size_t] + ([ct.c_uint] if self.block else [])
|
||||
extra = [int(self.m95m02)] if self.block else []
|
||||
size = _api("block_accept_profile" if self.block else "accept", args, ct.c_int)(
|
||||
ct.byref(self._state), reply, len(reply), output, len(output), *extra)
|
||||
if size < 0:
|
||||
errors = {-3: "Неверная длина ответа EEPROM", -4: "Ошибка CRC ответа EEPROM",
|
||||
-5: "Неверный адрес контроллера или команда ответа EEPROM"}
|
||||
|
||||
@@ -8,6 +8,6 @@ Shared PySide6 terminal migrated from PySetTerminal-master/pysetterminal.
|
||||
- COM transport uses QtSerialPort. Embedded operations reuse the TMS2812 connection and configured controller address.
|
||||
- PEEK/POKE packet construction and response validation use the existing protocan native C ABI, with 16-bit TMS2812 values.
|
||||
- Macro files are stored under the application data directory, outside the source checkout.
|
||||
- PySide2 and pyserial are no longer required. A native set-protocol runtime remains required.
|
||||
- The standalone launcher uses PySide6; SETGUI Windows 7 uses its existing PySide2/Qt5 compatibility layer. Frozen SETGUI builds bundle Python and Qt. Pyserial is not required. A native set-protocol runtime remains required.
|
||||
|
||||
`build_and_clean.py` is the migrated original standalone packaging helper.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QGroupBox, QGridLayout, QLineEdit, QPushButton, QScrollArea
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QGroupBox, QGridLayout, QLineEdit, QPushButton, QScrollArea, QSizePolicy
|
||||
|
||||
def build_byte_page(self):
|
||||
self.byte_page = QScrollArea()
|
||||
@@ -13,18 +14,22 @@ def build_byte_page(self):
|
||||
panels = QHBoxLayout()
|
||||
for write in (True, False):
|
||||
group = QGroupBox("Запись" if write else "Чтение")
|
||||
group.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
|
||||
form = QGridLayout(group)
|
||||
address = QLineEdit("$1000")
|
||||
value = QLineEdit("0000" if write else "")
|
||||
binary = QLineEdit("0000 0000 0000 0000" if write else "")
|
||||
binary.setMinimumWidth(220)
|
||||
address.setFixedWidth(150)
|
||||
value.setFixedWidth(100)
|
||||
binary.setFixedWidth(240)
|
||||
value.setReadOnly(not write)
|
||||
binary.setReadOnly(not write)
|
||||
for row, (title, widget) in enumerate((("Адрес", address), ("Байт (hex)", value), ("Байт (bin)", binary))):
|
||||
form.addWidget(QLabel(title), row * 2, 0)
|
||||
form.addWidget(widget, row * 2 + 1, 0)
|
||||
form.addWidget(QLabel(title), row, 0)
|
||||
form.addWidget(widget, row, 1)
|
||||
button = QPushButton("Записать" if write else "Прочитать")
|
||||
form.addWidget(button, 6, 0)
|
||||
form.addWidget(button, 3, 1, Qt.AlignLeft)
|
||||
form.setColumnStretch(2, 1)
|
||||
if write:
|
||||
self.byte_write_address, self.byte_write_value, self.byte_write_binary = address, value, binary
|
||||
value.textEdited.connect(lambda text: self._sync_byte_value(text, 16))
|
||||
@@ -34,6 +39,7 @@ def build_byte_page(self):
|
||||
self.byte_read_address, self.byte_read_value, self.byte_read_binary = address, value, binary
|
||||
button.clicked.connect(lambda: self._start_byte(False))
|
||||
panels.addWidget(group)
|
||||
panels.addStretch()
|
||||
layout.addLayout(panels)
|
||||
status = QLabel("Нет данных от контроллера")
|
||||
status.setWordWrap(True)
|
||||
|
||||
@@ -229,6 +229,7 @@ class RawProtocolWidget(QWidget):
|
||||
self.mem_result_label = QLabel("---")
|
||||
mem_layout.addWidget(self.mem_result_label, 1, 3)
|
||||
|
||||
mem_layout.setColumnStretch(4, 1)
|
||||
mem_group.setLayout(mem_layout)
|
||||
layout.addWidget(mem_group)
|
||||
|
||||
@@ -238,6 +239,7 @@ class RawProtocolWidget(QWidget):
|
||||
|
||||
self.raw_send_edit = QLineEdit()
|
||||
self.raw_send_edit.setPlaceholderText("Введите hex данные для отправки")
|
||||
self.raw_send_edit.setMaximumWidth(640)
|
||||
raw_layout.addWidget(self.raw_send_edit)
|
||||
|
||||
hbox = QHBoxLayout()
|
||||
@@ -250,6 +252,7 @@ class RawProtocolWidget(QWidget):
|
||||
hbox.addWidget(self.raw_read_btn)
|
||||
|
||||
|
||||
hbox.addStretch()
|
||||
raw_layout.addLayout(hbox)
|
||||
|
||||
self.raw_output = QTextEdit()
|
||||
@@ -260,7 +263,7 @@ class RawProtocolWidget(QWidget):
|
||||
|
||||
self.raw_clear_btn = QPushButton("Очистить логи")
|
||||
self.raw_clear_btn.clicked.connect(self.clear_logs)
|
||||
raw_layout.addWidget(self.raw_clear_btn)
|
||||
raw_layout.addWidget(self.raw_clear_btn, 0, Qt.AlignLeft)
|
||||
|
||||
raw_group.setLayout(raw_layout)
|
||||
layout.addWidget(raw_group)
|
||||
|
||||
@@ -1,11 +1,56 @@
|
||||
import json
|
||||
import os
|
||||
import weakref
|
||||
from functools import partial
|
||||
from copy import deepcopy
|
||||
from PySide6.QtWidgets import *
|
||||
from PySide6.QtCore import *
|
||||
from PySide6.QtGui import *
|
||||
|
||||
|
||||
class _MacroActionIcon(QIconEngine):
|
||||
"""Scalable action icons, painted with the button's current theme color."""
|
||||
def __init__(self, action, button):
|
||||
super().__init__()
|
||||
self.action = action
|
||||
self.button = weakref.ref(button)
|
||||
|
||||
def clone(self):
|
||||
return _MacroActionIcon(self.action, self.button())
|
||||
|
||||
def paint(self, painter, rect, mode, state):
|
||||
button = self.button()
|
||||
palette = button.palette() if button is not None else QApplication.palette()
|
||||
group = QPalette.Disabled if mode == QIcon.Disabled else QPalette.Active
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
side = min(rect.width(), rect.height())
|
||||
painter.translate(rect.x() + (rect.width() - side) / 2, rect.y() + (rect.height() - side) / 2)
|
||||
painter.scale(side / 24, side / 24)
|
||||
painter.setPen(QPen(palette.color(group, QPalette.ButtonText), 1.8, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
if self.action == "edit":
|
||||
path = QPainterPath(QPointF(4, 16))
|
||||
for x, y in ((15, 5), (19, 9), (8, 20), (3, 21), (4, 16)):
|
||||
path.lineTo(x, y)
|
||||
painter.drawPath(path)
|
||||
painter.drawLine(QPointF(13, 7), QPointF(17, 11))
|
||||
else:
|
||||
painter.drawLine(QPointF(4, 6), QPointF(20, 6))
|
||||
painter.drawRoundedRect(QRectF(6, 6, 12, 15), 1.5, 1.5)
|
||||
painter.drawPolyline(QPolygonF([QPointF(9, 6), QPointF(9, 3), QPointF(15, 3), QPointF(15, 6)]))
|
||||
painter.drawLine(QPointF(10, 10), QPointF(10, 17))
|
||||
painter.drawLine(QPointF(14, 10), QPointF(14, 17))
|
||||
painter.restore()
|
||||
|
||||
def pixmap(self, size, mode, state):
|
||||
pixmap = QPixmap(size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
self.paint(painter, QRect(QPoint(), size), mode, state)
|
||||
painter.end()
|
||||
return pixmap
|
||||
|
||||
class SerialBus:
|
||||
"""ВЫСОКОУРОВНЕВЫЙ протокол для работы с серийной шиной (только через регистры)"""
|
||||
|
||||
@@ -320,12 +365,17 @@ class SerialTab(QWidget):
|
||||
|
||||
self.bus_write_btn = QPushButton("Записать в шину")
|
||||
self.bus_write_btn.clicked.connect(self.write_to_bus)
|
||||
bus_layout.addWidget(self.bus_write_btn, 1, 0, 1, 3)
|
||||
|
||||
self.bus_read_btn = QPushButton("Прочитать из шину")
|
||||
self.bus_read_btn.clicked.connect(self.read_from_bus)
|
||||
bus_layout.addWidget(self.bus_read_btn, 1, 3, 1, 3)
|
||||
bus_buttons = QHBoxLayout()
|
||||
bus_buttons.setSpacing(6)
|
||||
bus_buttons.addWidget(self.bus_write_btn)
|
||||
bus_buttons.addWidget(self.bus_read_btn)
|
||||
bus_buttons.addStretch(1)
|
||||
bus_layout.addLayout(bus_buttons, 1, 0, 1, 6)
|
||||
|
||||
bus_layout.setColumnStretch(6, 1)
|
||||
bus_group.setLayout(bus_layout)
|
||||
layout.addWidget(bus_group)
|
||||
|
||||
@@ -336,23 +386,34 @@ class SerialTab(QWidget):
|
||||
# Кнопка создания нового макроса
|
||||
self.create_macro_btn = QPushButton("Создать новый макрос")
|
||||
self.create_macro_btn.clicked.connect(self.create_macro)
|
||||
macros_layout.addWidget(self.create_macro_btn)
|
||||
self.stop_macro_btn = QPushButton("Остановить макрос")
|
||||
self.stop_macro_btn.setEnabled(False)
|
||||
self.stop_macro_btn.clicked.connect(self.stop_macro)
|
||||
macros_layout.addWidget(self.stop_macro_btn)
|
||||
macro_actions = QHBoxLayout()
|
||||
macro_actions.setSpacing(6)
|
||||
macro_actions.addWidget(self.create_macro_btn)
|
||||
macro_actions.addWidget(self.stop_macro_btn)
|
||||
macro_actions.addStretch(1)
|
||||
macros_layout.addLayout(macro_actions)
|
||||
for button in (self.bus_write_btn, self.bus_read_btn, self.create_macro_btn, self.stop_macro_btn):
|
||||
button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
|
||||
button.setFixedHeight(30)
|
||||
button.setStyleSheet("QPushButton { padding: 3px 10px; }")
|
||||
|
||||
# Прокручиваемая область для кнопок макросов
|
||||
self.macros_scroll_area = QScrollArea()
|
||||
self.macros_scroll_area.setWidgetResizable(True)
|
||||
# Устанавливаем фиксированную высоту, чтобы вместить 3-4 строки макросов
|
||||
self.macros_scroll_area.setMinimumHeight(150) # Высота для 3-4 строк
|
||||
self.macros_scroll_area.setMinimumHeight(120) # Высота для 3-4 строк
|
||||
|
||||
self.macros_scroll_content = QWidget()
|
||||
self.macros_scroll_layout = QGridLayout()
|
||||
self.macros_scroll_content.setLayout(self.macros_scroll_layout)
|
||||
self.macros_scroll_area.setWidget(self.macros_scroll_content)
|
||||
self.macros_scroll_layout.setAlignment(Qt.AlignTop)
|
||||
self.macros_scroll_layout.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||
self.macros_scroll_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.macros_scroll_layout.setHorizontalSpacing(8)
|
||||
self.macros_scroll_layout.setVerticalSpacing(6)
|
||||
macros_layout.addWidget(self.macros_scroll_area)
|
||||
|
||||
macros_group.setLayout(macros_layout)
|
||||
@@ -375,6 +436,9 @@ class SerialTab(QWidget):
|
||||
results_layout.addWidget(self.bus_result_label, 1, 1)
|
||||
|
||||
# Создаем фрейм для результатов
|
||||
results_layout.setColumnStretch(2, 1)
|
||||
self.bus_read_value_label.setMinimumWidth(180)
|
||||
self.bus_result_label.setMaximumWidth(640)
|
||||
results_frame = QFrame()
|
||||
results_frame.setFrameShape(QFrame.StyledPanel)
|
||||
results_frame.setLayout(results_layout)
|
||||
@@ -470,8 +534,7 @@ class SerialTab(QWidget):
|
||||
|
||||
def add_macro_button_with_index(self, macro, index):
|
||||
"""Добавить кнопку макроса в интерфейс с указанным индексом"""
|
||||
# Изменяем расчет строк и колонок для 3 колонок
|
||||
items_per_row = 3 # Теперь 3 кнопки в строке
|
||||
items_per_row = 2
|
||||
row = len(self.macro_buttons) // items_per_row
|
||||
col = len(self.macro_buttons) % items_per_row
|
||||
|
||||
@@ -479,18 +542,20 @@ class SerialTab(QWidget):
|
||||
container = QWidget()
|
||||
container_layout = QHBoxLayout()
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.setSpacing(4)
|
||||
container.setMaximumWidth(360)
|
||||
container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
|
||||
|
||||
# Основная кнопка макроса
|
||||
macro_btn = QPushButton(macro['name'])
|
||||
macro_btn.setMinimumHeight(40)
|
||||
macro_btn.setFixedHeight(30)
|
||||
macro_btn.setMinimumWidth(120)
|
||||
macro_btn.setToolTip(macro["name"])
|
||||
macro_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #e0e0e0;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
""")
|
||||
|
||||
@@ -498,14 +563,22 @@ class SerialTab(QWidget):
|
||||
macro_btn.clicked.connect(partial(self.execute_macro_by_index, index))
|
||||
|
||||
# Кнопка редактирования
|
||||
edit_btn = QPushButton("✎")
|
||||
edit_btn = QPushButton()
|
||||
edit_btn.setIcon(QIcon(_MacroActionIcon("edit", edit_btn)))
|
||||
edit_btn.setIconSize(QSize(16, 16))
|
||||
edit_btn.setFixedSize(30, 30)
|
||||
edit_btn.setStyleSheet("QPushButton { padding: 0; }")
|
||||
edit_btn.setAccessibleName("Редактировать макрос")
|
||||
edit_btn.setToolTip("Редактировать макрос")
|
||||
edit_btn.clicked.connect(partial(self.edit_macro_by_index, index))
|
||||
|
||||
# Кнопка удаления
|
||||
delete_btn = QPushButton("✕")
|
||||
delete_btn = QPushButton()
|
||||
delete_btn.setIcon(QIcon(_MacroActionIcon("delete", delete_btn)))
|
||||
delete_btn.setIconSize(QSize(16, 16))
|
||||
delete_btn.setFixedSize(30, 30)
|
||||
delete_btn.setStyleSheet("QPushButton { padding: 0; }")
|
||||
delete_btn.setAccessibleName("Удалить макрос")
|
||||
delete_btn.setToolTip("Удалить макрос")
|
||||
delete_btn.clicked.connect(partial(self.delete_macro, index))
|
||||
|
||||
|
||||
64
python/set_devices/bin_compare.py
Normal file
64
python/set_devices/bin_compare.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Renderer-independent comparison of arbitrary binary files."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
|
||||
|
||||
def compare_sha256(image: bytes, readback: bytes):
|
||||
"""Compare a complete physical readback against an immutable BIN snapshot."""
|
||||
if not image or len(image) != len(readback):
|
||||
raise ValueError('Для SHA-256 нужен полный прочитанный диапазон размером с BIN.')
|
||||
expected = hashlib.sha256(image).hexdigest()
|
||||
actual = hashlib.sha256(readback).hexdigest()
|
||||
return expected == actual, expected, actual
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Comparison:
|
||||
left: bytes
|
||||
right: bytes
|
||||
offsets: tuple[int, ...]
|
||||
changed_bits: int
|
||||
|
||||
@property
|
||||
def changed_bytes(self) -> int:
|
||||
return len(self.offsets)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return max(len(self.left), len(self.right))
|
||||
|
||||
|
||||
def compare(left: bytes, right: bytes) -> Comparison:
|
||||
"""Count bit differences in overlap; unmatched bytes count as changed bytes."""
|
||||
common = min(len(left), len(right))
|
||||
offsets = tuple(i for i in range(common) if left[i] != right[i])
|
||||
bits = sum(bin(left[i] ^ right[i]).count('1') for i in offsets)
|
||||
return Comparison(left, right, offsets + tuple(range(common, max(len(left), len(right)))), bits)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MultiComparison:
|
||||
images: tuple[bytes, ...]
|
||||
offsets: tuple[int, ...]
|
||||
pairs: tuple
|
||||
|
||||
@property
|
||||
def size(self): return max(map(len, self.images))
|
||||
|
||||
@property
|
||||
def changed_bytes(self): return len(self.offsets)
|
||||
|
||||
|
||||
def compare_many(images):
|
||||
"""Highlight offsets where any pair differs, including missing bytes."""
|
||||
images = tuple(images)
|
||||
if len(images) not in (1, 2, 3):
|
||||
raise ValueError('Выберите от 1 до 3 BIN-файлов.')
|
||||
if len(images) == 2:
|
||||
return compare(*images)
|
||||
pairs = tuple((a, b, compare(images[a], images[b]))
|
||||
for a in range(len(images)) for b in range(a + 1, len(images)))
|
||||
offsets = tuple(sorted({offset for _, _, result in pairs for offset in result.offsets}))
|
||||
return MultiComparison(images, offsets, pairs)
|
||||
107
python/set_devices/disassembly.py
Normal file
107
python/set_devices/disassembly.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""TI C28x tool adapter. Decode instructions using TI's own assembler tools."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def align_disassembly(*texts):
|
||||
"""Align TI listings by word offset even when instruction sizes differ."""
|
||||
listings = []
|
||||
for text in texts:
|
||||
if text.startswith('Ошибка:'):
|
||||
return texts
|
||||
listing = {}
|
||||
previous = None
|
||||
for line in text.splitlines():
|
||||
match = re.match(r'^([0-9A-Fa-f]{8})\s+', line)
|
||||
if match:
|
||||
previous = int(match.group(1), 16)
|
||||
listing[previous] = line
|
||||
elif previous is not None and line.strip():
|
||||
listing[previous] += ' ' + line.strip()
|
||||
if not listing:
|
||||
return texts
|
||||
listings.append(listing)
|
||||
addresses = sorted({address for listing in listings for address in listing})
|
||||
return tuple('\n'.join(listing.get(address, f'{address:08X} —') for address in addresses)
|
||||
for listing in listings)
|
||||
|
||||
|
||||
def find_ghidra(root):
|
||||
configured = os.environ.get('GHIDRA_INSTALL_DIR', '')
|
||||
if configured and (Path(configured) / 'support/analyzeHeadless.bat').is_file():
|
||||
return configured
|
||||
return next((str(path) for path in sorted(Path(root).glob('ghidra_*'), reverse=True)
|
||||
if (path / 'support/analyzeHeadless.bat').is_file()), '')
|
||||
|
||||
|
||||
def decompile_c28x(data: bytes, tools: str, base: int = 0) -> str:
|
||||
"""Run the configured Ghidra C28x processor and decompiler locally."""
|
||||
root = Path(tools)
|
||||
headless = root / 'support/analyzeHeadless.bat'
|
||||
if not tools or not headless.is_file():
|
||||
raise ValueError('Укажите папку Ghidra с установленным процессором TMS320C28x.')
|
||||
if not data or len(data) % 2 or len(data) > 262144:
|
||||
raise ValueError('Нужен непустой чётный диапазон до 256 КиБ.')
|
||||
if not 0 <= base <= 0x3fffff:
|
||||
raise ValueError('Адрес C28x должен быть в диапазоне 0…0x3FFFFF (в словах).')
|
||||
scripts = Path(__file__).parent / 'disassembly_scripts'
|
||||
env = dict(os.environ)
|
||||
jdks = sorted(path for path in root.parent.glob('jdk*')
|
||||
if path.is_dir() and (path / 'bin/java.exe').is_file())
|
||||
if jdks:
|
||||
env['JAVA_HOME'] = str(jdks[-1])
|
||||
with tempfile.TemporaryDirectory(prefix='setgui-decompile-') as folder:
|
||||
work = Path(folder)
|
||||
binary, output = work / 'range.bin', work / 'result.c'
|
||||
binary.write_bytes(data)
|
||||
arguments = [str(headless), str(work), 'analysis', '-import', str(binary),
|
||||
'-processor', 'TMS320C28x:LE:32:default', '-loader', 'BinaryLoader',
|
||||
'-loader-baseAddr', f'{base:x}', '-noanalysis', '-scriptPath', str(scripts),
|
||||
'-postScript', 'SetguiExportC.java', str(output), '-deleteProject']
|
||||
result = subprocess.run(arguments, env=env, capture_output=True, text=True,
|
||||
errors='replace', timeout=180, stdin=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
||||
if result.returncode or not output.is_file():
|
||||
raise RuntimeError((result.stderr + '\n' + result.stdout)[-6000:])
|
||||
text = output.read_text(encoding='utf-8')
|
||||
if not text.rstrip().endswith('/* SETGUI_DECOMPILE_SUCCESS */'):
|
||||
raise RuntimeError('Декомпиляция не завершена. ' + (result.stderr + '\n' + result.stdout)[-6000:])
|
||||
return text.removesuffix('/* SETGUI_DECOMPILE_SUCCESS */\n')
|
||||
|
||||
|
||||
def find_c28x_tools():
|
||||
candidates = sorted(Path('C:/ti').glob('ti-cgt-c2000_*/bin'), reverse=True)
|
||||
candidates += sorted(Path('C:/ti').glob('ccs*/ccs/tools/compiler/ti-cgt-c2000_*/bin'), reverse=True)
|
||||
return next((str(path) for path in candidates
|
||||
if (path / 'cl2000.exe').is_file() and (path / 'dis2000.exe').is_file()), '')
|
||||
|
||||
|
||||
def disassemble_c28x(data: bytes, tools: str) -> str:
|
||||
"""Wrap raw little-endian words in a temporary text section for dis2000."""
|
||||
if not data or len(data) % 2:
|
||||
raise ValueError('Для C28x нужен непустой диапазон с чётным числом байтов.')
|
||||
if len(data) > 262144:
|
||||
raise ValueError('Выберите диапазон не более 256 КиБ.')
|
||||
directory = Path(tools)
|
||||
for executable in ('cl2000.exe', 'dis2000.exe'):
|
||||
if not (directory / executable).is_file():
|
||||
raise ValueError(f'Не найден {executable} в {directory}')
|
||||
with tempfile.TemporaryDirectory(prefix='setgui-disasm-') as folder:
|
||||
root = Path(folder)
|
||||
source = root / 'dump.asm'
|
||||
source.write_text(' .text\n' + '\n'.join(
|
||||
f' .word 0x{int.from_bytes(data[i:i + 2], "little"):04X}'
|
||||
for i in range(0, len(data), 2)) + '\n', encoding='ascii')
|
||||
def run(arguments):
|
||||
result = subprocess.run(arguments, cwd=root, capture_output=True,
|
||||
text=True, errors='replace', timeout=30,
|
||||
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
||||
if result.returncode:
|
||||
raise RuntimeError(result.stderr or result.stdout or 'Ошибка TI tools')
|
||||
return result.stdout
|
||||
run([str(directory / 'cl2000.exe'), '--silicon_version=28', '--compile_only',
|
||||
'--output_file=dump.obj', str(source)])
|
||||
return run([str(directory / 'dis2000.exe'), '--data_as_text', 'dump.obj'])
|
||||
38
python/set_devices/disassembly_scripts/SetguiExportC.java
Normal file
38
python/set_devices/disassembly_scripts/SetguiExportC.java
Normal file
@@ -0,0 +1,38 @@
|
||||
// Local C pseudocode export for a raw, user-selected C28x code range.
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.app.decompiler.DecompInterface;
|
||||
import ghidra.app.decompiler.DecompileResults;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.FunctionIterator;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class SetguiExportC extends GhidraScript {
|
||||
public void run() throws Exception {
|
||||
Address entry = currentProgram.getMinAddress();
|
||||
disassemble(entry);
|
||||
createFunction(entry, null);
|
||||
analyzeAll(currentProgram);
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
try (PrintWriter out = new PrintWriter(getScriptArgs()[0], StandardCharsets.UTF_8.name())) {
|
||||
out.println("/* Ghidra C pseudocode. Recovered types/names may be incomplete. */");
|
||||
if (!decompiler.openProgram(currentProgram))
|
||||
throw new Exception(decompiler.getLastMessage());
|
||||
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
|
||||
int count = 0;
|
||||
while (functions.hasNext() && !monitor.isCancelled()) {
|
||||
Function function = functions.next();
|
||||
if (function.isExternal()) continue;
|
||||
DecompileResults result = decompiler.decompileFunction(function, 10, monitor);
|
||||
out.println("/* Address: " + function.getEntryPoint() + " */");
|
||||
if (result.decompileCompleted() && result.getDecompiledFunction() != null) {
|
||||
out.println(result.getDecompiledFunction().getC());
|
||||
count++;
|
||||
} else out.println("/* Decompilation failed: " + result.getErrorMessage() + " */");
|
||||
}
|
||||
if (count == 0) throw new Exception("No functions decompiled. Select a range beginning at a function entry.");
|
||||
out.println("/* SETGUI_DECOMPILE_SUCCESS */");
|
||||
} finally { decompiler.dispose(); }
|
||||
}
|
||||
}
|
||||
40
python/set_devices/firmware_check.py
Normal file
40
python/set_devices/firmware_check.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Portable, read-only TMS firmware verification against an immutable BIN."""
|
||||
from protocan.tms2812 import build_upload_request, decode_upload_reply
|
||||
from .bin_compare import compare_sha256
|
||||
|
||||
|
||||
class TmsFirmwareCheck:
|
||||
def __init__(self, controller, word_address, image, block_size=256):
|
||||
self.image = bytes(image)
|
||||
if not 1 <= controller <= 255:
|
||||
raise ValueError("Для проверки нужен адрес контроллера 1..255")
|
||||
if not self.image or len(self.image) > 4 * 1024 * 1024:
|
||||
raise ValueError("Размер BIN должен быть 1..4194304 байт")
|
||||
if not 0 <= word_address <= 0xFFFFFFFF or word_address + (len(self.image) + 1) // 2 > 0x100000000:
|
||||
raise ValueError("Диапазон памяти вне 32-битного адресного пространства")
|
||||
if not 2 <= block_size <= 65534 or block_size % 2:
|
||||
raise ValueError("Размер блока должен быть чётным: 2..65534 байт")
|
||||
self.controller, self.word_address = controller, word_address
|
||||
self.block_size = block_size
|
||||
self.readback = bytearray()
|
||||
|
||||
@property
|
||||
def done(self):
|
||||
return len(self.readback) == len(self.image)
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
return min(self.block_size, len(self.image) - len(self.readback))
|
||||
|
||||
def request(self):
|
||||
if self.done:
|
||||
raise ValueError("Проверка уже завершена")
|
||||
return build_upload_request(self.controller, self.word_address + len(self.readback) // 2, self.count)
|
||||
|
||||
def accept(self, reply):
|
||||
if self.done:
|
||||
raise ValueError("Проверка уже завершена")
|
||||
self.readback.extend(decode_upload_reply(reply, self.controller, self.count))
|
||||
|
||||
def result(self):
|
||||
return compare_sha256(self.image, bytes(self.readback))
|
||||
110
python/set_devices/firmware_identity.py
Normal file
110
python/set_devices/firmware_identity.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Firmware-info v1 and the adjacent release passport (no device I/O)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from protocan.periph28335 import with_crc
|
||||
|
||||
MAX_IMAGE_SIZE = 199993
|
||||
|
||||
REGISTER_ADDRESS = 0x1200
|
||||
REGISTER_COUNT = 12
|
||||
|
||||
|
||||
def read_request(controller: int) -> bytes:
|
||||
if not 1 <= controller <= 255:
|
||||
raise ValueError("Для чтения версии нужен адрес контроллера 1..255")
|
||||
return with_crc(bytes((controller, 3)) + REGISTER_ADDRESS.to_bytes(2, 'big')
|
||||
+ REGISTER_COUNT.to_bytes(2, 'big'))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwareIdentity:
|
||||
version: tuple[int, int, int]
|
||||
built_at: datetime
|
||||
build_id: str
|
||||
|
||||
def __post_init__(self):
|
||||
if (len(self.version) != 3 or any(type(v) is not int or not 0 <= v <= limit
|
||||
for v, limit in zip(self.version, (255, 255, 999)))
|
||||
or not 2000 <= self.built_at.year <= 2199
|
||||
or self.built_at.utcoffset() is None
|
||||
or self.built_at.microsecond
|
||||
or len(self.build_id) != 8
|
||||
or any(not 32 <= ord(c) <= 126 for c in self.build_id)):
|
||||
raise ValueError("Некорректные данные версии прошивки")
|
||||
|
||||
def describe(self):
|
||||
version = '.'.join(map(str, self.version))
|
||||
return f"{version} · {self.built_at:%Y-%m-%d %H:%M:%S} UTC · Build ID {self.build_id}"
|
||||
|
||||
|
||||
def decode_identity(words):
|
||||
if len(words) != REGISTER_COUNT or words[0] != 1:
|
||||
raise ValueError("Неизвестный формат версии (требуется firmware-info v1)")
|
||||
if any(type(w) is not int or not 0 <= w <= 65535 for w in words):
|
||||
raise ValueError("Некорректные регистры версии")
|
||||
try:
|
||||
stamp = datetime(words[4], words[5] >> 8, words[5] & 255,
|
||||
words[6] >> 8, words[6] & 255, words[7], tzinfo=timezone.utc)
|
||||
build_id = bytes(b for w in words[8:] for b in (w >> 8, w & 255)).decode('ascii')
|
||||
return FirmwareIdentity(tuple(words[1:4]), stamp, build_id)
|
||||
except (ValueError, UnicodeError) as error:
|
||||
raise ValueError("Некорректные данные версии: " + str(error)) from error
|
||||
|
||||
|
||||
def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None,
|
||||
expected_product="Balsam-167-PM35", expected_target="TMS320F28335",
|
||||
max_image_size=MAX_IMAGE_SIZE):
|
||||
"""Return None for legacy images; reject a present but invalid passport.
|
||||
|
||||
The caller may supply the exact bytes it will upload, avoiding a second
|
||||
read between hash verification and transmission.
|
||||
"""
|
||||
if data is None:
|
||||
with path.open('rb') as stream:
|
||||
data = stream.read(max_image_size + 1)
|
||||
if not 0 < len(data) <= max_image_size:
|
||||
raise ValueError(f"Размер бинарного файла должен быть 1..{max_image_size} байт")
|
||||
passport = Path(str(path) + '.manifest.json')
|
||||
if not passport.exists():
|
||||
return None
|
||||
try:
|
||||
with passport.open('r', encoding='utf-8-sig') as stream:
|
||||
raw = stream.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError("Слишком большой паспорт прошивки")
|
||||
meta = json.loads(raw)
|
||||
if (meta['schema'] != 1 or meta['identity_contract'] != 1
|
||||
or meta['identity_register_count'] != REGISTER_COUNT):
|
||||
raise ValueError("Неизвестный формат паспорта")
|
||||
if meta['product'] != expected_product or meta['target'] != expected_target:
|
||||
raise ValueError(f"Паспорт предназначен для другого устройства (ожидается {expected_product} / {expected_target})")
|
||||
if (type(meta['size_bytes']) is not int or meta['size_bytes'] != len(data)
|
||||
or meta['sha256'] != hashlib.sha256(data).hexdigest()):
|
||||
raise ValueError("BIN не соответствует паспорту: размер или SHA-256 не совпадает")
|
||||
if expected_eeprom is not None and meta.get('eeprom') is not None:
|
||||
if meta['eeprom'] != expected_eeprom:
|
||||
raise ValueError(f"BIN для {meta['eeprom']}, а выбрана EEPROM {expected_eeprom}")
|
||||
stamp = datetime.fromisoformat(meta['built_at_utc'].replace('Z', '+00:00'))
|
||||
if stamp.utcoffset() is None or stamp.utcoffset().total_seconds() != 0:
|
||||
raise ValueError("Дата паспорта должна быть в UTC")
|
||||
return FirmwareIdentity(tuple(meta['version']), stamp, meta['build_id'])
|
||||
except (KeyError, TypeError, ValueError, AttributeError) as error:
|
||||
raise ValueError("Ошибка паспорта: " + str(error)) from error
|
||||
|
||||
|
||||
def compare_identity(device, image):
|
||||
if device is None or image is None:
|
||||
return "Сравнение недоступно: нужны версия с платы и проверенный паспорт BIN."
|
||||
if device == image:
|
||||
return "Версия, дата сборки и Build ID совпадают. Содержимое памяти платы этим не проверяется."
|
||||
if image.version > device.version:
|
||||
return "Выбранная версия новее установленной."
|
||||
if image.version < device.version:
|
||||
return "Выбранная версия старее установленной (понижение версии)."
|
||||
return "Номер версии совпадает, но дата сборки или Build ID отличаются."
|
||||
@@ -8,6 +8,8 @@ See docs/tektronix-dpo4034.md for wiring, commands and scaling.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
def resource_name(interface, address, board=0):
|
||||
@@ -52,18 +54,31 @@ class DPO4034:
|
||||
try:
|
||||
import pyvisa
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Установите PyVISA и NI-VISA или TekVISA") from exc
|
||||
if getattr(sys, "frozen", False):
|
||||
message = "В сборке SETGUI недоступна PyVISA. Используйте исправленную сборку EXE."
|
||||
else:
|
||||
message = "В Python-окружении недоступна PyVISA. Установите PyVISA (для Python 3.8: версия 1.14.1)."
|
||||
raise RuntimeError(message + " Причина: " + str(exc)) from exc
|
||||
factory = pyvisa.ResourceManager
|
||||
try:
|
||||
self.manager = factory()
|
||||
try:
|
||||
self.manager = factory()
|
||||
except (OSError, ValueError) as exc:
|
||||
if self._factory is not None:
|
||||
raise
|
||||
bits = struct.calcsize("P") * 8
|
||||
raise RuntimeError(
|
||||
f"Не удалось загрузить VISA-драйвер. Установите NI-VISA или TekVISA {bits}-bit "
|
||||
"с поддержкой вашей Windows (для Win7 нужна совместимая версия). Причина: " + str(exc)
|
||||
) from exc
|
||||
self.instrument = self.manager.open_resource(resource, open_timeout=timeout_ms)
|
||||
self.instrument.timeout = timeout_ms
|
||||
self.instrument.write_termination = "\n"
|
||||
self.instrument.read_termination = "\n"
|
||||
identity = self.instrument.query("*IDN?").strip()
|
||||
fields = identity.upper().split(",")
|
||||
if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() not in ("DPO4034", "MDO4034C"):
|
||||
raise ValueError(f"Ожидался Tektronix DPO4034 или MDO4034C, получено: {identity}")
|
||||
if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() not in ("DPO4034", "DPO4034B", "MDO4034C"):
|
||||
raise ValueError(f"Ожидался Tektronix DPO4034, DPO4034B или MDO4034C, получено: {identity}")
|
||||
self.instrument.write("HEADER OFF")
|
||||
return identity
|
||||
except Exception:
|
||||
|
||||
@@ -54,6 +54,8 @@ class ScopeSession:
|
||||
value = CaptureFrame(value, prepare_preview(value, args[3]))
|
||||
if automatic:
|
||||
state.consumed(status)
|
||||
elif action == "sync_settings":
|
||||
value = client.settings(*args)
|
||||
elif action == "export":
|
||||
filename, waves = args
|
||||
with open(filename, "w", newline="", encoding="utf-8-sig") as stream:
|
||||
|
||||
@@ -14,6 +14,7 @@ from dataclasses import dataclass
|
||||
from protocan import periph28335 as protocol
|
||||
|
||||
BASE = 0x1000
|
||||
PROTECTION = 0x1300 # Balsam 167: versioned voltage protection/test block.
|
||||
DATA = 0x1010
|
||||
LIVE = 0x1100
|
||||
EMU_CONTROL = 0x1200
|
||||
@@ -79,7 +80,7 @@ def request(controller: int, function: int, address: int, value: int, *, record_
|
||||
raise ValueError('Неверный размер записи логгера')
|
||||
capacity = {40: 800, 42: 760}[record_words]
|
||||
if function == 3:
|
||||
valid = ((address == BASE and value == 16) or
|
||||
valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
|
||||
(address == DATA and 1 <= value <= 120 and (value % WORDS == 0 or value in (42, 84))) or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4)
|
||||
or (address == FAST_CAPABILITY and value == 4)
|
||||
or (FAST_DATA <= address < FAST_DATA + capacity * record_words
|
||||
@@ -87,7 +88,9 @@ def request(controller: int, function: int, address: int, value: int, *, record_
|
||||
and 1 <= value <= 120 and value % record_words == 0
|
||||
and address + value <= FAST_DATA + capacity * record_words))
|
||||
else:
|
||||
valid = function == 6 and (address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
|
||||
valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) or
|
||||
(address == PROTECTION + 3 and 1 <= value <= 60000) or
|
||||
address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
|
||||
(address == EMU_CONTROL + 2 and value in (0, 1)))
|
||||
if not valid:
|
||||
raise ValueError('Недопустимый запрос логгера УМП')
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
import struct
|
||||
from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL
|
||||
from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL, PROTECTION
|
||||
|
||||
MARKER = 0x1F00
|
||||
MAX_WORDS = 124
|
||||
@@ -26,10 +26,12 @@ def request_ids(mode):
|
||||
def build_request(mode, token, function, address, value):
|
||||
if not 1 <= token <= 255:
|
||||
raise ValueError('Неверный номер CAN-запроса')
|
||||
valid = ((function == 3 and ((address == BASE and value == 16) or
|
||||
valid = ((function == 3 and ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
|
||||
(address == DATA and value in (40, 80, 120, 42, 84)) or
|
||||
(address == LIVE and value == MAX_WORDS) or (address == EMU_CONTROL and value == 4))) or
|
||||
(function == 6 and ((BASE <= address <= BASE+3 and 0 <= value <= 65535) or
|
||||
(address == PROTECTION+2 and value in (0, 1, 2, 3)) or
|
||||
(address == PROTECTION+3 and 1 <= value <= 60000) or
|
||||
(address == EMU_CONTROL+2 and value in (0, 1)))))
|
||||
if not valid:
|
||||
raise ValueError('Недопустимый запрос CAN-логгера')
|
||||
|
||||
@@ -24,7 +24,8 @@ def sample(time_ms, period_ms=20, *, repeat=True):
|
||||
else:
|
||||
current = 4
|
||||
|
||||
words = [0] * codec.WORDS
|
||||
# Extended logger record includes both DC-link voltage measurements.
|
||||
words = [0] * 42
|
||||
words[0], words[1] = time_ms & 65535, (time_ms >> 16) & 65535
|
||||
words[2] = 1 | (0x100 if 5000 <= phase < 5000 + period_ms else 0)
|
||||
words[3] = (int(started) << 7) | (int(reset) << 6)
|
||||
@@ -42,6 +43,14 @@ def sample(time_ms, period_ms=20, *, repeat=True):
|
||||
words[18:24] = [0xE07F, words[3], 6, 16, (time_ms // 100) & 65535, 0]
|
||||
words[24:31] = [6, 16, 500, 3500, 2000, 500, 3500]
|
||||
words[31:36] = [ramp if started else 0, 2500, 60, 160, round(ramp * 1000 / 2500)]
|
||||
if started:
|
||||
charge = min(1.0, (phase - 1000) / 1500)
|
||||
elif phase >= 5000:
|
||||
charge = max(0.0, 1 - (phase - 5000) / 800)
|
||||
else:
|
||||
charge = 0.0
|
||||
words[40] = round(charge * (600 + 3 * math.sin(phase / 80)))
|
||||
words[41] = round(charge * (590 + 4 * math.sin(phase / 95 + .7)))
|
||||
return codec.sample(words)
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,17 @@ import os
|
||||
import unittest
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtCore import QEventLoop, QTimer
|
||||
from pysetterminal.rawprotocol import RawProtocolWidget
|
||||
from pysetterminal.serialbus import SerialBus, SerialTab
|
||||
|
||||
|
||||
def process_events(milliseconds):
|
||||
loop = QEventLoop()
|
||||
QTimer.singleShot(milliseconds, loop.quit)
|
||||
loop.exec()
|
||||
|
||||
|
||||
class PendingProtocol:
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
@@ -39,11 +45,11 @@ class MacroTests(unittest.TestCase):
|
||||
def test_scheduled_execution_returns_and_can_be_cancelled(self):
|
||||
self.tab.execute_macro_commands([self.command])
|
||||
self.assertEqual(self.protocol.requests, [])
|
||||
QTest.qWait(20)
|
||||
process_events(20)
|
||||
self.assertEqual(self.protocol.requests, [(0x200B, 0x35)])
|
||||
self.assertTrue(self.tab.stop_macro_btn.isEnabled())
|
||||
self.tab.stop_macro()
|
||||
QTest.qWait(20)
|
||||
process_events(20)
|
||||
self.assertFalse(self.tab._macro_running)
|
||||
self.assertTrue(self.tab.bus_read_btn.isEnabled())
|
||||
self.assertEqual(len(self.protocol.requests), 1)
|
||||
@@ -52,20 +58,20 @@ class MacroTests(unittest.TestCase):
|
||||
self.tab.execute_macro_commands([self.command])
|
||||
self.tab.stop_macro()
|
||||
self.tab.execute_macro_commands([self.command])
|
||||
QTest.qWait(20)
|
||||
process_events(20)
|
||||
self.assertEqual(len(self.protocol.requests), 1)
|
||||
self.protocol.callback(False, "CRC error")
|
||||
QTest.qWait(20)
|
||||
process_events(20)
|
||||
self.assertFalse(self.tab._macro_running)
|
||||
self.assertEqual(len(self.protocol.requests), 1)
|
||||
|
||||
def test_completion_runs_next_step_and_updates_value(self):
|
||||
self.tab.execute_macro_commands([self.command])
|
||||
QTest.qWait(20)
|
||||
process_events(20)
|
||||
self.protocol.callback(True, {'raw': b"ack"})
|
||||
QTest.qWait(20)
|
||||
process_events(20)
|
||||
self.assertEqual(self.protocol.requests[-1], (0x200F, None))
|
||||
self.protocol.callback(True, {'value': 0x1234, 'raw_response': {'raw': b"reply"}})
|
||||
QTest.qWait(20)
|
||||
process_events(20)
|
||||
self.assertFalse(self.tab._macro_running)
|
||||
self.assertEqual(self.tab.bus_read_value_label.text(), "0x1234 (4660)")
|
||||
|
||||
Reference in New Issue
Block a user