Extract reusable byte work panel with configurable terminal addressing

This commit is contained in:
2026-10-09 08:56:16 +03:00
parent fe9b5a246e
commit 86ece3ea90
6 changed files with 493 additions and 69 deletions

View File

@@ -15,7 +15,7 @@ saleae = ["logic2-automation>=1.0.7,<2"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["firmware_image*", "altera_logic*", "logic_analyzer*", "set_devices*", "protocan*", "setprotocol*"] include = ["firmware_image*", "altera_logic*", "logic_analyzer*", "set_devices*", "protocan*", "setprotocol*", "pysetterminal*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
set_devices = ["gas_logger_maps/*.json"] set_devices = ["gas_logger_maps/*.json"]

View File

@@ -3,7 +3,7 @@
Shared PySide6 terminal migrated from PySetTerminal-master/pysetterminal. Shared PySide6 terminal migrated from PySetTerminal-master/pysetterminal.
- Standalone entry point: `python -m pysetterminal` with templates/python on PYTHONPATH. - Standalone entry point: `python -m pysetterminal` with templates/python on PYTHONPATH.
- SETGUI embeds `pysetterminal.panel.build_byte_page` in TMS2812 / Работа с байтом. - SETGUI embeds `pysetterminal.panel.ByteWorkPanel` in TMS2812 / Работа с байтом.
- Serial bus, macro editor, asynchronous macro execution with cancellation, PEEK/POKE and RAW console are included. - Serial bus, macro editor, asynchronous macro execution with cancellation, PEEK/POKE and RAW console are included.
- COM transport uses QtSerialPort. Embedded operations reuse the TMS2812 connection and configured controller address. - 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. - PEEK/POKE packet construction and response validation use the existing protocan native C ABI, with 16-bit TMS2812 values.
@@ -11,3 +11,78 @@ Shared PySide6 terminal migrated from PySetTerminal-master/pysetterminal.
- 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. - 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. `build_and_clean.py` is the migrated original standalone packaging helper.
## Подключение «Работы с байтом» в другую вкладку
Вся панель — самостоятельный `ByteWorkPanel`: чтение и запись слова, HEX/BIN,
операции с шиной, макросы, PEEK/POKE и RAW. Она не требует `Tms2812Tab`.
Данные — 16 бит, адрес слова — 32 бита; протокол по умолчанию — TMS2812.
```python
from pysetterminal.panel import ByteWorkPanel
from pysetterminal.rawprotocol import RawProtocol
# Сохраните протокол в объекте, который владеет соединением.
self.byte_protocol = RawProtocol()
self.byte_page = ByteWorkPanel(
self.byte_protocol,
device_address=lambda: self.controller_address.value(),
)
self.tabs.addTab(self.byte_page, "Работа с байтом")
# Подключение выполняется явным действием пользователя.
# self.byte_protocol.connect("COM3", 57600)
# Перед закрытием соединения:
# self.byte_page.stop()
# self.byte_protocol.disconnect()
```
В существующую компоновку панель добавляется через
`layout.addWidget(self.byte_page)`. Фабрика
`build_byte_page(protocol, **options)` возвращает такой же виджет.
Параметры:
- `protocol` — существующий протокол с методами `request_async`,
`cancel_async`, `peek`, `poke`, `send_raw`, `receive_raw`.
Асинхронный ответ передаётся в `callback(success, result)`: для чтения
`result["value"]`, для записи `result["raw"]`; ошибки — строкой.
Синхронные `peek/poke` возвращают `(success, result)`; чтение дополнительно
содержит `result["raw_response"]["raw"]` для журнала.
- `device_address` — адрес контроллера или функция, возвращающая текущий адрес;
используется всеми блоками панели. Адрес фиксируется на время записи с
проверкой и выполнения макроса.
- `macros_file` — отдельный JSON-файл макросов для конкретной вкладки.
По умолчанию сохраняется существующее хранилище приложения.
- `default_address`, `hint`, `initial_status` — начальный адрес и тексты.
- `start_word(write, address, value)` — необязательный обработчик для вкладок,
у которых уже есть очередь памяти. При чтении `value=None`.
Обработчик отвечает за чтение, проверку записи и остановку своей очереди.
Без `start_word` панель выполняет асинхронный запрос, а запись подтверждает
последующим чтением. Пример подключения к существующей очереди:
```python
self.byte_page = ByteWorkPanel(
self.protocol,
device_address=lambda: self.address.value(),
start_word=lambda write, address, value: self.start_memory(
write=write, address=address, value=value),
macros_file=self.storage_dir / "byte_macros.json",
)
self.tabs.addTab(self.byte_page, "Работа с байтом")
# В обработчике ответа очереди:
self.byte_page.set_read_value(address, value)
self.byte_page.set_status("Чтение завершено")
```
`set_status(text)` показывает внешний статус. Сигнал `status_changed(str)`
передаёт ошибки ввода и результаты операций панели владельцу.
`stop()` отменяет её асинхронный обмен и макрос; внешнюю очередь памяти
останавливает владелец вкладки.
Каждому независимому соединению нужен собственный протокол. Если вкладки
используют одно соединение, владелец транспорта должен запрещать одновременные
запросы и маршрутизировать ответы. В SETGUI это делает существующая очередь TMS2812.

View File

@@ -1,55 +1,183 @@
from PySide6.QtCore import Qt """Reusable byte/word terminal page with an injected protocol or word handler."""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QGroupBox, QGridLayout, QLineEdit, QPushButton, QScrollArea, QSizePolicy from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QLabel, QHBoxLayout, QGroupBox, QGridLayout,
QLineEdit, QPushButton, QScrollArea, QSizePolicy,
)
def build_byte_page(self): from .rawprotocol import RawProtocolWidget
self.byte_page = QScrollArea() from .serialbus import SerialBus, SerialTab
self.byte_page.setWidgetResizable(True)
content = QWidget()
self.byte_page.setWidget(content) class ByteWorkPanel(QScrollArea):
layout = QVBoxLayout(content) """Embed the complete terminal page in any layout or QTabWidget.
hint = QLabel("TMS2812: адрес слова — 32 бита; значение — 16 бит (0000…FFFF). "
"Адрес: десятичный, $HEX или 0xHEX. Запись проверяется чтением.") protocol supplies request_async/cancel_async, peek/poke and RAW methods.
hint.setWordWrap(True) device_address can be an integer or a callable returning the current
layout.addWidget(hint) controller address. An optional start_word(write, address, value) handler
panels = QHBoxLayout() lets an existing tab keep its own memory queue; it must report results with
for write in (True, False): set_read_value() and set_status(). Otherwise this panel performs asynchronous
group = QGroupBox("Запись" if write else "Чтение") reads and verifies writes by reading the same word back.
group.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) """
form = QGridLayout(group)
address = QLineEdit("$1000") status_changed = Signal(str)
value = QLineEdit("0000" if write else "")
binary = QLineEdit("0000 0000 0000 0000" if write else "") def __init__(self, protocol, parent=None, *, device_address=10,
address.setFixedWidth(150) start_word=None, macros_file=None, default_address="$1000",
value.setFixedWidth(100) hint=None, initial_status="Нет данных от контроллера"):
binary.setFixedWidth(240) super().__init__(parent)
value.setReadOnly(not write) self.protocol = protocol
binary.setReadOnly(not write) self.device_address = device_address
for row, (title, widget) in enumerate((("Адрес", address), ("Байт (hex)", value), ("Байт (bin)", binary))): self._start_word = start_word
form.addWidget(QLabel(title), row, 0) self._word_running = False
form.addWidget(widget, row, 1) self._word_generation = 0
button = QPushButton("Записать" if write else "Прочитать") self.setWidgetResizable(True)
form.addWidget(button, 3, 1, Qt.AlignLeft) content = QWidget()
form.setColumnStretch(2, 1) self.setWidget(content)
if write: layout = QVBoxLayout(content)
self.byte_write_address, self.byte_write_value, self.byte_write_binary = address, value, binary self.hint = QLabel(hint if hint is not None else
value.textEdited.connect(lambda text: self._sync_byte_value(text, 16)) "Адрес слова — 32 бита; значение — 16 бит (0000…FFFF). "
binary.textEdited.connect(lambda text: self._sync_byte_value(text, 2)) "Адрес: десятичный, $HEX или 0xHEX. Запись проверяется чтением.")
button.clicked.connect(lambda: self._start_byte(True)) self.hint.setWordWrap(True)
layout.addWidget(self.hint)
panels = QHBoxLayout()
for write in (True, False):
group = QGroupBox("Запись" if write else "Чтение")
group.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
form = QGridLayout(group)
address = QLineEdit(default_address)
value = QLineEdit("0000" if write else "")
binary = QLineEdit("0000 0000 0000 0000" if write else "")
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, 0)
form.addWidget(widget, row, 1)
button = QPushButton("Записать" if write else "Прочитать")
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
self.byte_write_button = button
value.textEdited.connect(lambda text: self.sync_byte_value(text, 16))
binary.textEdited.connect(lambda text: self.sync_byte_value(text, 2))
button.clicked.connect(lambda: self.start_byte(True))
else:
self.byte_read_address, self.byte_read_value, self.byte_read_binary = address, value, binary
self.byte_read_button = button
button.clicked.connect(lambda: self.start_byte(False))
panels.addWidget(group)
panels.addStretch()
layout.addLayout(panels)
self.status = QLabel(initial_status)
self.status.setWordWrap(True)
layout.addWidget(self.status)
self.raw_widget = RawProtocolWidget(protocol, device_address=device_address)
self.bus_widget = SerialTab(SerialBus(protocol), protocol, self.raw_widget,
device_address=device_address, macros_file=macros_file)
layout.addWidget(self.bus_widget)
layout.addWidget(self.raw_widget)
layout.addStretch()
def set_status(self, text):
"""Display external connection/operation status without emitting it again."""
self.status.setText(text)
def _report(self, text):
self.set_status(text)
self.status_changed.emit(text)
@staticmethod
def _binary(value):
bits = f"{value:016b}"
return " ".join(bits[i:i + 4] for i in range(0, 16, 4))
def sync_byte_value(self, text, base):
try:
value = int(text.replace(" ", ""), base)
if not 0 <= value <= 0xFFFF:
return
except ValueError:
return
target = self.byte_write_binary if base == 16 else self.byte_write_value
target.setText(self._binary(value) if base == 16 else f"{value:04X}")
def set_read_value(self, address, value):
self.byte_read_address.setText(f"${address:X}")
self.byte_read_value.setText(f"{value:04X}")
self.byte_read_binary.setText(self._binary(value))
def start_byte(self, write):
if self._word_running:
self._report("Дождитесь завершения текущего обмена")
return
try:
field = self.byte_write_address if write else self.byte_read_address
text = field.text().strip()
address = int(text[1:], 16) if text.startswith("$") else int(
text, 16 if text.lower().startswith("0x") else 10)
value = int(self.byte_write_value.text().replace(" ", ""), 16) if write else None
if not 0 <= address <= 0xFFFFFFFF:
raise ValueError()
if write and (not 0 <= value <= 0xFFFF or
int(self.byte_write_binary.text().replace(" ", ""), 2) != value):
raise ValueError()
except ValueError:
self._report("Проверьте адрес и значение HEX/BIN")
return
if self._start_word is not None:
self._start_word(write, address, value)
return
controller = self.device_address() if callable(self.device_address) else self.device_address
self._word_generation += 1
generation = self._word_generation
self._set_word_running(True)
self._report("Запись и проверка чтением…" if write else "Чтение…")
self.protocol.request_async(address, value, controller,
lambda success, result: self._word_reply(generation, address, value, controller, success, result))
def _word_reply(self, generation, address, expected, controller, success, result):
if generation != self._word_generation or not self._word_running:
return
if not success:
self._set_word_running(False)
self._report(str(result))
return
if expected is not None and "value" not in result:
self.protocol.request_async(address, None, controller,
lambda ok, reply: self._word_reply(generation, address, expected, controller, ok, reply))
return
value = result["value"]
self.set_read_value(address, value)
self._set_word_running(False)
if expected is not None and value != expected:
self._report(f"Запись 0x{address:08X} не подтверждена: прочитано {value:04X}, ожидалось {expected:04X}")
else: else:
self.byte_read_address, self.byte_read_value, self.byte_read_binary = address, value, binary self._report("Запись проверена чтением" if expected is not None else "Чтение завершено")
button.clicked.connect(lambda: self._start_byte(False))
panels.addWidget(group) def _set_word_running(self, running):
panels.addStretch() self._word_running = running
layout.addLayout(panels) self.byte_write_button.setEnabled(not running)
status = QLabel("Нет данных от контроллера") self.byte_read_button.setEnabled(not running)
status.setWordWrap(True)
self.status_changed.connect(status.setText) def stop(self):
layout.addWidget(status) """Cancel this page's scheduled work before closing its connection."""
from pysetterminal.rawprotocol import RawProtocol, RawProtocolWidget self.bus_widget.stop_macro()
from pysetterminal.serialbus import SerialBus, SerialTab if self._word_running:
self.pyset_protocol = RawProtocol(self) self._word_generation += 1
self.pyset_raw_widget = RawProtocolWidget(self.pyset_protocol) self._set_word_running(False)
self.pyset_bus_widget = SerialTab(SerialBus(self.pyset_protocol), self.pyset_protocol, self.pyset_raw_widget) self.protocol.cancel_async()
layout.addWidget(self.pyset_bus_widget) self._report("Обмен прерван; переданные команды могли быть выполнены")
layout.addWidget(self.pyset_raw_widget)
layout.addStretch() def closeEvent(self, event):
self.stop()
super().closeEvent(event)
def build_byte_page(protocol, **options):
"""Create a standalone page; add the returned widget to a tab or layout."""
return ByteWorkPanel(protocol, **options)

View File

@@ -66,7 +66,8 @@ class RawProtocol:
if self._loop is not None or self._callback is not None or (self.host and self.host._pending_address is not None): if self._loop is not None or self._callback is not None or (self.host and self.host._pending_address is not None):
callback(False, "Дождитесь завершения текущего обмена") callback(False, "Дождитесь завершения текущего обмена")
return return
self._controller = self.host.address.value() if self.host else device_address self._controller = (device_address if device_address is not None else
self.host.address.value() if self.host else 1)
self._command = memory.PEEK if value is None else memory.POKE self._command = memory.PEEK if value is None else memory.POKE
try: try:
request = memory.request(self._controller, address, value) request = memory.request(self._controller, address, value)
@@ -119,7 +120,8 @@ class RawProtocol:
return False, "Подключите COM-порт" return False, "Подключите COM-порт"
if self._loop is not None or self._callback is not None or (self.host and self.host._pending_address is not None): if self._loop is not None or self._callback is not None or (self.host and self.host._pending_address is not None):
return False, "Дождитесь завершения текущего обмена" return False, "Дождитесь завершения текущего обмена"
self._controller = self.host.address.value() if self.host else device_address self._controller = (device_address if device_address is not None else
self.host.address.value() if self.host else 1)
self._command = memory.PEEK if value is None else memory.POKE self._command = memory.PEEK if value is None else memory.POKE
try: try:
request = memory.request(self._controller, address, value) request = memory.request(self._controller, address, value)
@@ -162,10 +164,10 @@ class RawProtocol:
self.host._pending_address = self.host._pending_kind = None self.host._pending_address = self.host._pending_kind = None
self.host.byte_page.setEnabled(True) self.host.byte_page.setEnabled(True)
def poke(self, mem_address, data_value, device_address=1): def poke(self, mem_address, data_value, device_address=None):
return self._exchange(mem_address, data_value, device_address) return self._exchange(mem_address, data_value, device_address)
def peek(self, mem_address, device_address=1): def peek(self, mem_address, device_address=None):
return self._exchange(mem_address, None, device_address) return self._exchange(mem_address, None, device_address)
def send_raw(self, hex_data): def send_raw(self, hex_data):
@@ -196,9 +198,10 @@ class RawProtocol:
class RawProtocolWidget(QWidget): class RawProtocolWidget(QWidget):
"""Виджет для отладки RAW протокола (всегда виден внизу окна)""" """Виджет для отладки RAW протокола (всегда виден внизу окна)"""
def __init__(self, raw_protocol): def __init__(self, raw_protocol, parent=None, *, device_address=10):
super().__init__() super().__init__(parent)
self.raw_protocol = raw_protocol self.raw_protocol = raw_protocol
self.device_address = device_address
self.init_ui() self.init_ui()
def init_ui(self): def init_ui(self):
@@ -272,7 +275,7 @@ class RawProtocolWidget(QWidget):
def memory_write(self): def memory_write(self):
"""Запись в память (POKE)""" """Запись в память (POKE)"""
try: try:
device_addr = 10 device_addr = self.device_address() if callable(self.device_address) else self.device_address
mem_addr = int(self.mem_addr_edit.text(), 16) mem_addr = int(self.mem_addr_edit.text(), 16)
data = int(self.mem_data_edit.text(), 16) data = int(self.mem_data_edit.text(), 16)
@@ -290,7 +293,7 @@ class RawProtocolWidget(QWidget):
def memory_read(self): def memory_read(self):
"""Чтение из памяти (PEEK)""" """Чтение из памяти (PEEK)"""
try: try:
device_addr = 10 device_addr = self.device_address() if callable(self.device_address) else self.device_address
mem_addr = int(self.mem_addr_edit.text(), 16) mem_addr = int(self.mem_addr_edit.text(), 16)
success, result = self.raw_protocol.peek(mem_addr, device_addr) success, result = self.raw_protocol.peek(mem_addr, device_addr)

View File

@@ -322,8 +322,10 @@ class SerialTab(QWidget):
MACROS_FILE = "macros.json" # Файл для сохранения макросов MACROS_FILE = "macros.json" # Файл для сохранения макросов
def __init__(self, serial_bus, raw_protocol, raw_widget): def __init__(self, serial_bus, raw_protocol, raw_widget, parent=None, *,
super().__init__() device_address=10, macros_file=None):
super().__init__(parent)
self.device_address = device_address
self.serial_bus = serial_bus self.serial_bus = serial_bus
self.raw_protocol = raw_protocol self.raw_protocol = raw_protocol
self.raw_widget = raw_widget self.raw_widget = raw_widget
@@ -333,7 +335,8 @@ class SerialTab(QWidget):
self._macro_generation = 0 self._macro_generation = 0
from pathlib import Path from pathlib import Path
self.MACROS_FILE = str(Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)) / "pysetterminal" / "macros.json") self.MACROS_FILE = str(macros_file if macros_file is not None else
Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)) / "pysetterminal" / "macros.json")
Path(self.MACROS_FILE).parent.mkdir(parents=True, exist_ok=True) Path(self.MACROS_FILE).parent.mkdir(parents=True, exist_ok=True)
# Загружаем сохраненные макросы # Загружаем сохраненные макросы
self.load_macros() self.load_macros()
@@ -455,7 +458,7 @@ class SerialTab(QWidget):
def write_to_bus(self): def write_to_bus(self):
"""Запись в шину через регистры""" """Запись в шину через регистры"""
try: try:
device_addr = 10 device_addr = self.device_address() if callable(self.device_address) else self.device_address
data = int(self.bus_data_edit.text(), 16) data = int(self.bus_data_edit.text(), 16)
tetrad = int(self.bus_tetrad_edit.text()) tetrad = int(self.bus_tetrad_edit.text())
register_num = int(self.bus_register_edit.text()) register_num = int(self.bus_register_edit.text())
@@ -477,7 +480,7 @@ class SerialTab(QWidget):
def read_from_bus(self): def read_from_bus(self):
"""Чтение из шины через регистры""" """Чтение из шины через регистры"""
try: try:
device_addr = 10 device_addr = self.device_address() if callable(self.device_address) else self.device_address
tetrad = int(self.bus_tetrad_edit.text()) tetrad = int(self.bus_tetrad_edit.text())
register_num = int(self.bus_register_edit.text()) register_num = int(self.bus_register_edit.text())
@@ -652,6 +655,7 @@ class SerialTab(QWidget):
return return
from protocan.tms_terminal import bus_steps from protocan.tms_terminal import bus_steps
try: try:
controller = self.device_address() if callable(self.device_address) else self.device_address
jobs = [] jobs = []
for command in commands: for command in commands:
if command['type'] not in ('write', 'read'): if command['type'] not in ('write', 'read'):
@@ -664,6 +668,7 @@ class SerialTab(QWidget):
if not jobs: if not jobs:
self.raw_widget.append_text("Макрос пуст") self.raw_widget.append_text("Макрос пуст")
return return
self._macro_controller = controller
self._macro_jobs = jobs self._macro_jobs = jobs
self._macro_index = 0 self._macro_index = 0
self._macro_running = True self._macro_running = True
@@ -686,7 +691,7 @@ class SerialTab(QWidget):
return return
generation = self._macro_generation generation = self._macro_generation
address, value = self._macro_jobs[self._macro_index] address, value = self._macro_jobs[self._macro_index]
self.raw_protocol.request_async(address, value, 10, self.raw_protocol.request_async(address, value, self._macro_controller,
lambda success, result: self._macro_reply(generation, success, result)) lambda success, result: self._macro_reply(generation, success, result))
def _macro_reply(self, generation, success, result): def _macro_reply(self, generation, success, result):

View File

@@ -0,0 +1,213 @@
"""The reusable terminal must work without a Tms2812Tab or its private state."""
import os
from pathlib import Path
import subprocess
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QTabWidget
from pysetterminal.panel import ByteWorkPanel, build_byte_page
class Protocol:
def __init__(self):
self.requests = []
self.callback = None
self.cancelled = 0
def request_async(self, address, value, controller, callback):
self.requests.append((address, value, controller))
self.callback = callback
def reply(self, success, result):
callback, self.callback = self.callback, None
callback(success, result)
def cancel_async(self):
self.cancelled += 1
if self.callback is not None:
self.reply(False, "cancelled")
def poke(self, address, value, controller):
self.requests.append((address, value, controller))
return True, {"raw": b"ack"}
def peek(self, address, controller):
self.requests.append((address, None, controller))
return True, {"value": 0x1234, "raw_response": {"raw": b"reply"}}
def send_raw(self, text):
return True, text
def receive_raw(self):
return False, "Нет данных"
@pytest.fixture(scope="module")
def app():
return QApplication.instance() or QApplication([])
@pytest.fixture
def page(app, tmp_path):
protocol = Protocol()
widget = ByteWorkPanel(protocol, device_address=7, macros_file=tmp_path / "macros.json")
yield widget, protocol
widget.close()
app.processEvents()
def test_shared_panel_imports_and_constructs_without_desktop_application(tmp_path):
templates = Path(__file__).resolve().parents[1]
code = """
import importlib.abc, sys
class BlockDesktop(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname.split('.')[0] == 'gui_desktop':
raise ImportError('Unexpected desktop dependency')
sys.meta_path.insert(0, BlockDesktop())
from PySide6.QtWidgets import QApplication, QTabWidget
from pysetterminal.panel import build_byte_page
from pysetterminal.rawprotocol import RawProtocol
app = QApplication([])
tabs = QTabWidget()
page = build_byte_page(RawProtocol(), macros_file='macros.json')
tabs.addTab(page, 'Работа с байтом')
assert tabs.widget(0) is page
assert 'gui_desktop' not in sys.modules
assert page.bus_widget and page.raw_widget
page.close()
"""
result = subprocess.run([sys.executable, "-c", code], cwd=tmp_path,
env=dict(os.environ, PYTHONPATH=str(templates)),
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
def test_multiple_tabs_keep_controls_status_and_macros_separate(app, tmp_path):
calls = []
tabs = QTabWidget()
first = build_byte_page(Protocol(), start_word=lambda *args: calls.append(args),
macros_file=tmp_path / "first.json")
second = ByteWorkPanel(Protocol(), macros_file=tmp_path / "second.json")
tabs.addTab(first, "Первый")
tabs.addTab(second, "Второй")
first.byte_write_value.setText("0301")
first.byte_write_value.textEdited.emit("0301")
first.byte_write_address.setText("0x1234")
first.byte_write_button.click()
first.set_read_value(0x1234, 0x0301)
first.set_status("Готово")
first.bus_widget.macros = [{"name": "Первый", "commands": []}]
first.bus_widget.save_macros()
assert calls == [(True, 0x1234, 0x0301)]
assert first.byte_write_binary.text() == "0000 0011 0000 0001"
assert second.byte_write_value.text() == "0000"
assert second.byte_read_value.text() == ""
assert second.status.text() == "Нет данных от контроллера"
second.bus_widget.load_macros()
assert second.bus_widget.macros == []
first.close()
second.close()
tabs.close()
@pytest.mark.parametrize("address", ["4096", "$1000", "0x1000"])
def test_read_accepts_address_formats_and_displays_both_values(page, address):
widget, protocol = page
widget.byte_read_address.setText(address)
widget.byte_read_button.click()
assert protocol.requests == [(0x1000, None, 7)]
assert not widget.byte_read_button.isEnabled()
protocol.reply(True, {"value": 0xABCD})
assert widget.byte_read_address.text() == "$1000"
assert widget.byte_read_value.text() == "ABCD"
assert widget.byte_read_binary.text() == "1010 1011 1100 1101"
assert widget.byte_read_button.isEnabled()
@pytest.mark.parametrize("address,value,binary", [
("-1", "0000", "0"),
("$100000000", "0000", "0"),
("invalid", "0000", "0"),
("$1000", "10000", "10000000000000000"),
("$1000", "0001", "0"),
("$1000", "GGGG", "0"),
("$1000", "0000", "invalid"),
])
def test_invalid_inputs_do_not_send(page, address, value, binary):
widget, protocol = page
widget.byte_write_address.setText(address)
widget.byte_write_value.setText(value)
widget.byte_write_binary.setText(binary)
widget.byte_write_button.click()
assert protocol.requests == []
assert "Проверьте" in widget.status.text()
@pytest.mark.parametrize("readback,confirmed", [(0x0301, True), (0x0302, False)])
def test_write_is_verified_and_controller_is_captured_for_whole_operation(page, readback, confirmed):
widget, protocol = page
controller = [13]
widget.device_address = lambda: controller[0]
widget.byte_write_value.setText("0301")
widget.sync_byte_value("0301", 16)
widget.byte_write_button.click()
assert protocol.requests == [(0x1000, 0x0301, 13)]
controller[0] = 21
protocol.reply(True, {"raw": b"ack"})
assert protocol.requests[-1] == (0x1000, None, 13)
protocol.reply(True, {"value": readback})
assert widget.byte_read_value.text() == f"{readback:04X}"
assert ("Запись проверена" in widget.status.text()) == confirmed
if not confirmed:
assert "не подтверждена" in widget.status.text()
assert widget.byte_write_button.isEnabled()
def test_failure_and_cancellation_restore_controls_and_ignore_late_reply(page):
widget, protocol = page
widget.byte_read_button.click()
protocol.reply(False, "CRC ответа не совпадает")
assert widget.status.text() == "CRC ответа не совпадает"
assert widget.byte_read_button.isEnabled()
widget.byte_read_button.click()
late_reply = protocol.callback
widget.stop()
assert protocol.cancelled == 1
widget.byte_read_button.click()
late_reply(True, {"value": 0x9999})
assert widget.byte_read_value.text() == ""
assert not widget.byte_read_button.isEnabled()
protocol.reply(True, {"value": 0x1234})
assert widget.byte_read_value.text() == "1234"
assert widget.byte_read_button.isEnabled()
def test_controller_provider_is_used_by_bus_raw_memory_and_macros(app, tmp_path):
protocol = Protocol()
controller = [21]
widget = ByteWorkPanel(protocol, device_address=lambda: controller[0],
macros_file=tmp_path / "address.json")
try:
widget.raw_widget.memory_write()
widget.raw_widget.memory_read()
assert protocol.requests == [(0x2000, 0, 21), (0x2000, None, 21)]
widget.bus_widget.read_from_bus()
assert protocol.requests[-2:] == [(0x200B, 0, 21), (0x200F, None, 21)]
widget.bus_widget.execute_macro_commands([{"type": "read", "tetrad": 3, "register": 5}])
controller[0] = 22
QTest.qWait(20)
assert protocol.requests[-1] == (0x200B, 0x35, 21)
protocol.reply(True, {"raw": b"ack"})
QTest.qWait(20)
assert protocol.requests[-1] == (0x200F, None, 21)
protocol.reply(True, {"value": 0x4321, "raw_response": {"raw": b"reply"}})
QTest.qWait(20)
assert not widget.bus_widget._macro_running
assert widget.bus_widget.bus_read_value_label.text() == "0x4321 (17185)"
finally:
widget.close()