Add shared waveform processing, generation and firmware image support
This commit is contained in:
183
python/set_devices/qt_ports/f407_usb_bootloader.py
Normal file
183
python/set_devices/qt_ports/f407_usb_bootloader.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Asynchronous SET v2 USB updater for the STM32F407VE single-slot port."""
|
||||
from __future__ import annotations
|
||||
import struct
|
||||
from .qt_compat import QObject, QTimer, Signal, QSerialPort
|
||||
from setprotocol.core import (Frame, FrameFlag, FrameParser, build_frame,
|
||||
decode_response, DeviceInfo, FirmwareBegin, FirmwareFlag, encode_firmware_data)
|
||||
|
||||
APP_BASE = 0x08010000
|
||||
APP_SIZE = 0x70000
|
||||
BOOT_MODEL = "STM32F407-EMU-USB-BOOT"
|
||||
|
||||
|
||||
def validate_image(image):
|
||||
if image.base_address != APP_BASE or not 8 <= len(image.data) <= APP_SIZE:
|
||||
raise ValueError("Нужен образ приложения для 0x08010000, размером до 448 КБ")
|
||||
sp, pc = struct.unpack_from("<II", image.data)
|
||||
if sp & 7 or not (0x20000000 < sp <= 0x20020000 or 0x10000000 < sp <= 0x10010000):
|
||||
raise ValueError("Неверный начальный указатель стека")
|
||||
if not pc & 1 or not APP_BASE <= (pc & ~1) < APP_BASE + len(image.data):
|
||||
raise ValueError("Образ собран не для USB-загрузчика (нужен адрес 0x08010000)")
|
||||
|
||||
|
||||
class F407UsbBootloader(QObject):
|
||||
progress = Signal(int, str)
|
||||
finished = Signal(bool, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._serial.readyRead.connect(self._read)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._timeout)
|
||||
self._reconnect = QTimer(self)
|
||||
self._reconnect.setSingleShot(True)
|
||||
self._reconnect.timeout.connect(self._open_boot)
|
||||
self._active = False
|
||||
self._sequence = 0
|
||||
self._pending = None
|
||||
self._parser = FrameParser()
|
||||
self._attempts = 0
|
||||
|
||||
def start(self, image, port_name, baud_rate=115200):
|
||||
if self._active:
|
||||
return
|
||||
try:
|
||||
validate_image(image)
|
||||
if not port_name:
|
||||
raise ValueError("Выберите COM-порт USB платы")
|
||||
except ValueError as error:
|
||||
self.finished.emit(False, str(error))
|
||||
return
|
||||
self._active = True
|
||||
self._image = image
|
||||
self._port_name = port_name
|
||||
self._offset = 0
|
||||
self._attempts = 0
|
||||
self._parser = FrameParser()
|
||||
if not self._open():
|
||||
self._finish(False, "Не удалось открыть USB COM-порт: " + self._serial.errorString())
|
||||
return
|
||||
self.progress.emit(0, "Определение USB-загрузчика")
|
||||
self._request(2, b"", "identify", 2500)
|
||||
|
||||
def _open(self):
|
||||
self._serial.close()
|
||||
self._serial.setPortName(self._port_name)
|
||||
self._serial.setBaudRate(115200)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
self._parser = FrameParser()
|
||||
return self._serial.open(QSerialPort.OpenModeFlag.ReadWrite)
|
||||
|
||||
def _request(self, kind, payload, phase, timeout=20000):
|
||||
self._sequence = (self._sequence + 1) & 0xffff
|
||||
self._pending = (kind, self._sequence, phase)
|
||||
packet = build_frame(Frame(kind, self._sequence, payload, FrameFlag.ACK_REQUIRED, 42, 0))
|
||||
if self._serial.write(packet) != len(packet):
|
||||
self._finish(False, "Ошибка передачи USB")
|
||||
return
|
||||
self._timer.start(timeout)
|
||||
|
||||
def _read(self):
|
||||
for frame in self._parser.feed(bytes(self._serial.readAll())):
|
||||
if not self._active or self._pending is None:
|
||||
continue
|
||||
kind, sequence, phase = self._pending
|
||||
if (frame.message_type != kind or frame.sequence != sequence or
|
||||
frame.source != 16 or frame.destination != 42 or
|
||||
not frame.flags & FrameFlag.RESPONSE):
|
||||
continue
|
||||
self._timer.stop()
|
||||
self._pending = None
|
||||
try:
|
||||
status, body = decode_response(frame)
|
||||
except ValueError as error:
|
||||
self._finish(False, "Неверный ответ загрузчика: " + str(error))
|
||||
return
|
||||
if status:
|
||||
self._finish(False, f"Загрузчик отклонил {phase}: код {status}. Повторите загрузку целиком.")
|
||||
return
|
||||
try:
|
||||
self._advance(phase, body)
|
||||
except (ValueError, struct.error) as error:
|
||||
self._finish(False, "Неверный ответ загрузчика: " + str(error))
|
||||
return
|
||||
|
||||
def _advance(self, phase, body):
|
||||
if phase in ("identify", "reconnect"):
|
||||
model = DeviceInfo.decode(body).model
|
||||
if model == BOOT_MODEL:
|
||||
self._attempts = 0
|
||||
image = self._image
|
||||
begin = FirmwareBegin(len(image.data), image.crc32, image.version,
|
||||
APP_BASE, 0, 256, bytes(32), FirmwareFlag.ERASE_SLOT)
|
||||
self.progress.emit(1, "Стирание области приложения")
|
||||
self._request(0x100, begin.encode(), "begin")
|
||||
elif model == "STM32F407-EMU-EXPERIMENT" and phase == "identify":
|
||||
self._request(0x1003, b"", "enter")
|
||||
elif phase == "reconnect" and model == "STM32F407-EMU-EXPERIMENT":
|
||||
self._retry_open()
|
||||
else:
|
||||
self._finish(False, "Выбрано другое устройство: " + model)
|
||||
elif phase == "enter":
|
||||
self.progress.emit(0, "Ожидание переподключения USB")
|
||||
self._serial.close()
|
||||
self._attempts = 40
|
||||
self._reconnect.start(1000)
|
||||
elif phase in ("begin", "data"):
|
||||
if phase == "data":
|
||||
self._offset += self._sent
|
||||
if self._offset == len(self._image.data):
|
||||
self.progress.emit(98, "Проверка CRC содержимого Flash")
|
||||
self._request(0x102, struct.pack("<II32s", len(self._image.data),
|
||||
self._image.crc32, bytes(32)), "end")
|
||||
else:
|
||||
block = self._image.data[self._offset:self._offset+256]
|
||||
self._sent = len(block)
|
||||
self.progress.emit(2 + 95*self._offset//len(self._image.data),
|
||||
f"Запись {self._offset}/{len(self._image.data)} байт")
|
||||
self._request(0x101, encode_firmware_data(self._offset, block), "data")
|
||||
elif phase == "end":
|
||||
self._request(0x105, b"", "activate")
|
||||
elif phase == "activate":
|
||||
self.progress.emit(100, "CRC проверена, команда запуска подтверждена")
|
||||
self._finish(True, "Прошивка записана. Плата перезапускается; подключитесь к ней снова.")
|
||||
|
||||
def _retry_open(self):
|
||||
self._serial.close()
|
||||
if self._attempts <= 0:
|
||||
self._finish(False, "USB не появился. Выберите COM-порт загрузчика и повторите загрузку.")
|
||||
else:
|
||||
self._reconnect.start(500)
|
||||
|
||||
def _open_boot(self):
|
||||
if not self._active:
|
||||
return
|
||||
self._attempts -= 1
|
||||
if self._open():
|
||||
self._request(2, b"", "reconnect", 500)
|
||||
else:
|
||||
self._retry_open()
|
||||
|
||||
def _timeout(self):
|
||||
if self._pending and self._pending[2] == "reconnect":
|
||||
self._pending = None
|
||||
self._retry_open()
|
||||
else:
|
||||
self._finish(False, "Нет ответа USB. Проверьте порт и повторите полную загрузку.")
|
||||
|
||||
def cancel(self):
|
||||
if self._active:
|
||||
self._finish(False, "Загрузка отменена. Незавершённый образ не запустится; повторите загрузку.")
|
||||
|
||||
def _finish(self, ok, text):
|
||||
self._active = False
|
||||
self._pending = None
|
||||
self._timer.stop()
|
||||
self._reconnect.stop()
|
||||
self._serial.close()
|
||||
self.finished.emit(ok, text)
|
||||
293
python/set_devices/qt_ports/plot_processing.py
Normal file
293
python/set_devices/qt_ports/plot_processing.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Reusable Qt processing panel and plot attachment; no SETGUI dependency."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import replace
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import QObject, QPointF, QRunnable, QThreadPool, QSignalBlocker, QTimer, Qt, Signal, Slot
|
||||
from PySide6.QtGui import QColor, QPainterPath, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSpinBox,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QObject, QPointF, QRunnable, QThreadPool, QSignalBlocker, QTimer, Qt, Signal, Slot
|
||||
from PySide2.QtGui import QColor, QPainterPath, QPen
|
||||
from PySide2.QtWidgets import (
|
||||
QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSpinBox,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from set_devices.signal_reconstruction import METHODS
|
||||
from set_devices.plot_processing import Snapshot, prepare, process, write_csv
|
||||
|
||||
|
||||
class _ResultSignals(QObject):
|
||||
done = Signal(object, object, str)
|
||||
|
||||
|
||||
class _Calculation(QRunnable):
|
||||
def __init__(self, signature):
|
||||
super().__init__()
|
||||
self.signature = signature
|
||||
self.signals = _ResultSignals()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
result = process(self.signature)
|
||||
except Exception as error:
|
||||
self.signals.done.emit(self.signature, None, str(error))
|
||||
else:
|
||||
self.signals.done.emit(self.signature, result, "")
|
||||
|
||||
|
||||
class SignalProcessingPanel(QWidget):
|
||||
changed = Signal()
|
||||
RESULT_KEY = "__calculated_trend__"
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._snapshot = Snapshot()
|
||||
self._result = None
|
||||
self._result_signature = None
|
||||
self._job = None
|
||||
self._revision = 0
|
||||
self._job_revision = 0
|
||||
self.preserve_on_view_change = False
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(QLabel("Обработка"))
|
||||
self.channel = QComboBox()
|
||||
self.channel.setMinimumWidth(140)
|
||||
row.addWidget(self.channel)
|
||||
self.method = QComboBox()
|
||||
for key, title in METHODS.items():
|
||||
self.method.addItem(title, key)
|
||||
self.method.setCurrentIndex(2)
|
||||
row.addWidget(self.method)
|
||||
row.addStretch(1)
|
||||
layout.addLayout(row)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(QLabel("Степень"))
|
||||
self.degree = QSpinBox()
|
||||
self.degree.setRange(1, 5)
|
||||
self.degree.setValue(2)
|
||||
row.addWidget(self.degree)
|
||||
row.addWidget(QLabel("Точек результата"))
|
||||
self.count = QSpinBox()
|
||||
self.count.setRange(2, 10000)
|
||||
self.count.setValue(1000)
|
||||
row.addWidget(self.count)
|
||||
self.apply_button = QPushButton("Рассчитать")
|
||||
self.apply_button.clicked.connect(self.calculate)
|
||||
row.addWidget(self.apply_button)
|
||||
self.clear_button = QPushButton("Убрать")
|
||||
self.clear_button.clicked.connect(self.clear)
|
||||
row.addWidget(self.clear_button)
|
||||
self.export_button = QPushButton("Результат CSV…")
|
||||
self.export_button.clicked.connect(self.export_csv)
|
||||
row.addWidget(self.export_button)
|
||||
row.addStretch(1)
|
||||
layout.addLayout(row)
|
||||
self.status = QLabel("Выберите аналоговый канал и метод. Расчёт по переданным точкам, без экстраполяции.")
|
||||
self.status.setWordWrap(True)
|
||||
layout.addWidget(self.status)
|
||||
self.method.setToolTip(
|
||||
"Полином МНК — сглаживание шумных данных. Линейная — отрезки между точками.\n"
|
||||
"PCHIP — сохранение формы без выбросов. Сплайн — гладкая кривая, возможны выбросы.\n"
|
||||
"Восстановление — оценка между измерениями; утраченные детали не определяются однозначно.")
|
||||
for widget in (self.channel, self.method):
|
||||
widget.currentIndexChanged.connect(self.clear)
|
||||
for widget in (self.degree, self.count):
|
||||
widget.valueChanged.connect(self.clear)
|
||||
self._update_actions()
|
||||
|
||||
def _signature(self):
|
||||
return prepare(self._snapshot, self.channel.currentData(), self.method.currentData(),
|
||||
self.count.value(), self.degree.value())
|
||||
|
||||
def _current_request_for(self, signature):
|
||||
if not self.preserve_on_view_change or signature is None:
|
||||
return self._signature()
|
||||
# Validate the original calculation domain, independently of navigation.
|
||||
return prepare(replace(self._snapshot, x_range=signature.x_range),
|
||||
self.channel.currentData(), self.method.currentData(),
|
||||
self.count.value(), self.degree.value())
|
||||
|
||||
def set_snapshot(self, snapshot):
|
||||
previous = ((self._result_signature or (self._job.signature if self._job is not None else None))
|
||||
if self.preserve_on_view_change else None) or self._signature()
|
||||
was_blocked = bool(self._snapshot.blocked_reason)
|
||||
choices = [(signal.key, signal.label) for signal in snapshot.analogs]
|
||||
current = [(self.channel.itemData(i), self.channel.itemText(i)) for i in range(self.channel.count())]
|
||||
if choices != current:
|
||||
selected = self.channel.currentData()
|
||||
blocker = QSignalBlocker(self.channel)
|
||||
self.channel.clear()
|
||||
for key, title in choices:
|
||||
self.channel.addItem(title, key)
|
||||
index = self.channel.findData(selected)
|
||||
if index >= 0:
|
||||
self.channel.setCurrentIndex(index)
|
||||
del blocker
|
||||
self._snapshot = snapshot
|
||||
if previous != self._current_request_for(previous):
|
||||
self._revision += 1
|
||||
if self._result is not None:
|
||||
self._result = self._result_signature = None
|
||||
self.status.setText("Данные или окно изменились — нажмите «Рассчитать» повторно.")
|
||||
if snapshot.blocked_reason:
|
||||
self.status.setText(snapshot.blocked_reason)
|
||||
elif not choices:
|
||||
self.status.setText("Нет видимых аналоговых каналов для обработки.")
|
||||
elif was_blocked:
|
||||
self.status.setText("Выберите канал и метод, затем нажмите «Рассчитать».")
|
||||
self._update_actions()
|
||||
|
||||
def _update_actions(self):
|
||||
self.degree.setEnabled(self.method.currentData() == "polynomial")
|
||||
request = self._signature()
|
||||
self.apply_button.setEnabled(self._job is None and request is not None and
|
||||
len(request.series.points) >= 2)
|
||||
self.export_button.setEnabled(self._result is not None and not self._snapshot.blocked_reason)
|
||||
self.clear_button.setEnabled(self._result is not None or self._job is not None)
|
||||
self.apply_button.setToolTip("Обработка выбранной кривой в её единицах X/Y. Для живого потока остановите сбор.")
|
||||
|
||||
def clear(self, *_):
|
||||
self._revision += 1
|
||||
self._result = self._result_signature = None
|
||||
self.status.setText("Расчёт по текущему окну. Исходные измерения сохраняются; кривая — оценка между точками.")
|
||||
self._update_actions()
|
||||
self.changed.emit()
|
||||
|
||||
def calculate(self):
|
||||
if self._job is not None or self._snapshot.blocked_reason:
|
||||
return
|
||||
signature = self._signature()
|
||||
if signature is None or len(signature.series.points) < 2:
|
||||
self.status.setText("Нужны минимум две точки аналогового канала.")
|
||||
return
|
||||
self._job = _Calculation(signature)
|
||||
self._job_revision = self._revision
|
||||
self._job.signals.done.connect(self._finished)
|
||||
self.status.setText("Расчёт…")
|
||||
self._update_actions()
|
||||
QThreadPool.globalInstance().start(self._job)
|
||||
|
||||
@Slot(object, object, str)
|
||||
def _finished(self, signature, result, error):
|
||||
self._job = None
|
||||
if signature != self._current_request_for(signature) or self._job_revision != self._revision:
|
||||
self.status.setText("Данные изменились во время расчёта. Остановите сбор и повторите расчёт.")
|
||||
elif error:
|
||||
self.status.setText("Расчёт не выполнен: " + error)
|
||||
else:
|
||||
self._result, self._result_signature = result, signature
|
||||
duplicates = result.input_count - result.unique_count
|
||||
self.status.setText(
|
||||
f"{self.channel.currentText()}: {result.unique_count} исходных → {len(result.points)} расчётных точек; "
|
||||
f"СКО на измерениях: {result.rmse:.6g}. "
|
||||
+ (f"Повторы X усреднены: {duplicates}. " if duplicates else "")
|
||||
+ "Розовая линия — расчётная оценка; исходные данные сохранены.")
|
||||
self._update_actions()
|
||||
self.changed.emit()
|
||||
|
||||
def overlay(self):
|
||||
if self._result is None or self._snapshot.blocked_reason:
|
||||
return [], {}
|
||||
return [(self.RESULT_KEY, self._result.label, False, "#FF70D0", True)], {self.RESULT_KEY: self._result.points}
|
||||
|
||||
@property
|
||||
def curve(self):
|
||||
return self._result
|
||||
|
||||
def export_csv(self):
|
||||
if self._result is None:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Сохранить расчётную кривую", "calculated-signal.csv", "CSV (*.csv)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, "w", newline="", encoding="utf-8-sig") as stream:
|
||||
write_csv(self._result, stream)
|
||||
except (OSError, ValueError, OverflowError) as error:
|
||||
self.status.setText("Не удалось сохранить CSV: " + str(error))
|
||||
|
||||
|
||||
class PlotProcessingAttachment(QObject):
|
||||
"""Attach to any Qt plot via snapshot/project/repaint callbacks.
|
||||
|
||||
Call source_changed when raw data, units or visible channel selection change.
|
||||
Paint after raw analog curves with the same rect/projection. No channel or
|
||||
history mutation, no dependency on the caller's renderer or acquisition API.
|
||||
"""
|
||||
def __init__(self, parent, snapshot, repaint):
|
||||
super().__init__(parent)
|
||||
self.snapshot = snapshot
|
||||
self.repaint = repaint
|
||||
self.dialog = None
|
||||
self.panel = None
|
||||
self._external_curve = None
|
||||
self._external_offset = 0.0
|
||||
self.button = QPushButton("Обработка…", parent)
|
||||
self.button.setToolTip("Аппроксимация, интерполяция и восстановление аналоговой кривой")
|
||||
self.button.clicked.connect(self.open)
|
||||
self._refresh_timer = QTimer(self)
|
||||
self._refresh_timer.setSingleShot(True)
|
||||
self._refresh_timer.timeout.connect(self.refresh)
|
||||
self._dirty = False
|
||||
|
||||
@property
|
||||
def curve(self):
|
||||
# Hide stale geometry immediately, before coalesced refresh runs.
|
||||
if self._external_curve is not None:
|
||||
return self._external_curve
|
||||
return self.panel.curve if self.panel is not None and not self._dirty else None
|
||||
|
||||
def set_external_curve(self, curve, x_offset=0.0):
|
||||
"""Embedded panels own snapshot invalidation; display rebasing is explicit."""
|
||||
self._external_curve = curve
|
||||
self._external_offset = x_offset
|
||||
self.repaint()
|
||||
|
||||
def source_changed(self):
|
||||
if self.panel is not None:
|
||||
self._dirty = True
|
||||
self._refresh_timer.start(0)
|
||||
|
||||
def refresh(self):
|
||||
if self.panel is not None:
|
||||
self.panel.set_snapshot(self.snapshot())
|
||||
self._dirty = False
|
||||
self.repaint()
|
||||
|
||||
def open(self):
|
||||
if self.dialog is None:
|
||||
self.dialog = QDialog(self.parent())
|
||||
self.dialog.setWindowTitle("Обработка графика")
|
||||
layout = QVBoxLayout(self.dialog)
|
||||
self.panel = SignalProcessingPanel(self.dialog)
|
||||
self.panel.changed.connect(self.repaint)
|
||||
layout.addWidget(self.panel)
|
||||
self.dialog.resize(760, 240)
|
||||
self.refresh()
|
||||
self.dialog.show()
|
||||
self.dialog.raise_()
|
||||
self.dialog.activateWindow()
|
||||
|
||||
def paint(self, painter, rect, project):
|
||||
curve = self.curve
|
||||
if curve is None:
|
||||
return
|
||||
painter.save()
|
||||
painter.setClipRect(rect)
|
||||
path = QPainterPath()
|
||||
for index, (x, y) in enumerate(curve.points):
|
||||
point = project(x - (self._external_offset if self._external_curve is not None else 0), y, rect)
|
||||
path.moveTo(point) if index == 0 else path.lineTo(point)
|
||||
painter.setPen(QPen(QColor("#FF70D0"), 2, Qt.DashLine))
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.drawPath(path)
|
||||
painter.setPen(QColor("#FF70D0"))
|
||||
painter.drawText(QPointF(rect.left() + 6, rect.bottom() - 8), curve.label)
|
||||
painter.restore()
|
||||
@@ -17,10 +17,10 @@ def decode_config(raw):
|
||||
if len(raw) != 25:
|
||||
raise ValueError("Нужна прошивка STM с выбором режима эмуляции")
|
||||
words = struct.unpack(">10H", raw[3:-2])
|
||||
if words[:2] != (0x5343, 2):
|
||||
if words[0] != 0x5343 or words[1] not in (2, 3):
|
||||
raise ValueError("Прошивка STM не поддерживает настройку связи (нужна новая версия)")
|
||||
address, tms, rate = words[2:5]
|
||||
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] > 1 or words[9] > 1:
|
||||
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] > (3 if words[1] == 3 else 1) or words[9] > (3 if words[1] == 3 else 1):
|
||||
raise ValueError("STM вернула неверные параметры связи")
|
||||
return words
|
||||
|
||||
@@ -28,9 +28,9 @@ def decode_config(raw):
|
||||
class StmSettingsClient(QObject):
|
||||
finished = Signal(bool, str, object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, parent=None, serial_factory=None):
|
||||
super().__init__(parent)
|
||||
self.serial = QSerialPort(self)
|
||||
self.serial = (serial_factory or QSerialPort)(self)
|
||||
self.serial.readyRead.connect(self._receive)
|
||||
self.serial.errorOccurred.connect(self._error)
|
||||
self.timeout = QTimer(self)
|
||||
@@ -48,7 +48,7 @@ class StmSettingsClient(QObject):
|
||||
return
|
||||
if desired is not None:
|
||||
addr, tms, rate, mode = desired
|
||||
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in (0, 1):
|
||||
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in (0, 1, 2, 3):
|
||||
self.finished.emit(False, "Адреса УМП и 2812 должны различаться; проверьте диапазоны и скорость", None)
|
||||
return
|
||||
self.unit, self.baud, self.timeout_ms = unit, baud, timeout
|
||||
@@ -69,7 +69,8 @@ class StmSettingsClient(QObject):
|
||||
def _send(self, function, register, value):
|
||||
self.rx.clear()
|
||||
self.packet = request(self.unit, function, register, value)
|
||||
self.timeout.start(self.timeout_ms)
|
||||
budget = getattr(self.serial, 'response_timeout', lambda ms: ms)(self.timeout_ms)
|
||||
self.timeout.start(budget)
|
||||
if self.stage == "commit":
|
||||
# If ACK is lost, the board may nevertheless switch settings.
|
||||
self.committed = True
|
||||
@@ -114,6 +115,8 @@ class StmSettingsClient(QObject):
|
||||
self._finish(True, "Настройки STM прочитаны", (words[2], words[3], BAUDRATES[words[4]], words[8]))
|
||||
return
|
||||
addr, tms, baud, mode = self.desired
|
||||
if mode >= 2 and words[1] < 3:
|
||||
raise ValueError("Обновите прошивку STM: для отдельных режимов IGBT и ЦАП нужна версия настроек 3")
|
||||
self.staged = (addr, tms, BAUDRATES.index(baud), mode)
|
||||
self.steps = [(6, 0x1218, 0), (6, 0x1215, addr), (6, 0x1216, tms),
|
||||
(6, 0x1217, self.staged[2]), (6, 0x1219, mode), (3, 0x1210, 10)]
|
||||
@@ -166,5 +169,5 @@ class StmSettingsClient(QObject):
|
||||
if self.serial.isOpen():
|
||||
self.serial.close()
|
||||
if not ok and self.committed:
|
||||
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812). Повторите чтение по ним или перезагрузите плату." % self.desired
|
||||
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812, 2=IGBT, 3=ЦАП). Повторите чтение по ним или перезагрузите плату." % self.desired
|
||||
self.finished.emit(ok, message, values)
|
||||
|
||||
106
python/set_devices/qt_ports/wavegen.py
Normal file
106
python/set_devices/qt_ports/wavegen.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""USB CDC/COM worker for WG v1/v2/v3, with SET v2 block uploads. All waits run in a worker, never the GUI thread."""
|
||||
import time
|
||||
from PySide6.QtCore import QObject, QRunnable, Signal, QIODevice
|
||||
from PySide6.QtSerialPort import QSerialPort
|
||||
from set_devices.wavegen_protocol import WaveProtocol
|
||||
|
||||
class _Signals(QObject):
|
||||
finished = Signal(object, str)
|
||||
progress = Signal(int)
|
||||
|
||||
class WaveTransfer(QRunnable):
|
||||
def __init__(self, port_name, address, action, waveform=None, port_factory=None):
|
||||
super().__init__()
|
||||
self.port_name, self.address, self.action, self.waveform = port_name, address, action, waveform
|
||||
self.signals = _Signals()
|
||||
self.port_factory = port_factory or QSerialPort
|
||||
|
||||
def run(self):
|
||||
port = self.port_factory()
|
||||
try:
|
||||
protocol = WaveProtocol(self.address)
|
||||
port.setPortName(self.port_name)
|
||||
port.setBaudRate(115200)
|
||||
port.setDataBits(QSerialPort.Data8)
|
||||
port.setParity(QSerialPort.NoParity)
|
||||
port.setStopBits(QSerialPort.OneStop)
|
||||
port.setFlowControl(QSerialPort.NoFlowControl)
|
||||
if not port.open(QIODevice.ReadWrite):
|
||||
raise OSError(port.errorString())
|
||||
port.clear()
|
||||
|
||||
def transfer(request, decode):
|
||||
if port.write(request) != len(request):
|
||||
raise OSError(port.errorString())
|
||||
if port.bytesToWrite() and not port.waitForBytesWritten(1000):
|
||||
raise OSError("Таймаут передачи USB")
|
||||
reply = bytearray()
|
||||
budget = getattr(port, 'response_timeout', lambda ms: ms)(1500)
|
||||
deadline = time.monotonic() + budget / 1000
|
||||
while time.monotonic() < deadline:
|
||||
if port.bytesAvailable() or port.waitForReadyRead(50):
|
||||
reply.extend(bytes(port.readAll()))
|
||||
response = decode(request, reply)
|
||||
if response is not None:
|
||||
return response
|
||||
raise OSError("Нет подтверждения USB. Состояние выхода неизвестно; проверьте связь и нажмите Стоп.")
|
||||
|
||||
def exchange(operation, index=0, value=0):
|
||||
return transfer(protocol.request(operation, index, value), protocol.response)
|
||||
|
||||
def read_status():
|
||||
words = exchange(0)
|
||||
result = protocol.status(words)
|
||||
if result['version'] >= 2:
|
||||
result = protocol.status(words, exchange(8)[0])
|
||||
return result
|
||||
|
||||
status = read_status() # Positive device identification before any write.
|
||||
if self.action == "upload":
|
||||
wave = self.waveform
|
||||
if wave is None or len(wave.codes) > status["capacity"]:
|
||||
raise ValueError("Таблица не помещается в память устройства")
|
||||
if status['version'] == 1 and wave.sample_rate > 50000:
|
||||
raise ValueError("Для частоты выше 50 000 отсчётов/с обновите прошивку МК: нужна карта генератора версии 2")
|
||||
exchange(1)
|
||||
if status['version'] >= 2:
|
||||
exchange(9, value=wave.sample_rate >> 16)
|
||||
exchange(2, value=wave.sample_rate & 0xffff)
|
||||
exchange(3, value=len(wave.codes))
|
||||
blocks = status['version'] >= 3 and getattr(port, 'supports_wave_blocks', False)
|
||||
step = 120 if blocks else 1
|
||||
for index in range(0, len(wave.codes), step):
|
||||
values = wave.codes[index:index + step]
|
||||
if blocks:
|
||||
transfer(protocol.block_request(index, values), protocol.block_response)
|
||||
else:
|
||||
exchange(4, index, values[0])
|
||||
self.signals.progress.emit(int((index + len(values)) * 50 / len(wave.codes)))
|
||||
for index in range(0, len(wave.codes), step):
|
||||
values = wave.codes[index:index + step]
|
||||
actual = (transfer(protocol.block_request(index, count=len(values)), protocol.block_response)
|
||||
if blocks else exchange(7, index))
|
||||
if tuple(actual) != tuple(values):
|
||||
raise ValueError(f"Проверка таблицы не пройдена: отсчёты с {index}")
|
||||
self.signals.progress.emit(50 + int((index + len(values)) * 50 / len(wave.codes)))
|
||||
exchange(5)
|
||||
elif self.action == "start":
|
||||
if not status["ready"]:
|
||||
raise ValueError("Сначала загрузите и проверьте таблицу")
|
||||
exchange(6)
|
||||
elif self.action == "stop":
|
||||
exchange(1)
|
||||
elif self.action != "status":
|
||||
raise ValueError("Неизвестное действие")
|
||||
result = read_status()
|
||||
if self.action == "upload" and (not result["ready"] or result["running"] or
|
||||
result["count"] != len(self.waveform.codes) or result["rate"] != self.waveform.sample_rate):
|
||||
raise ValueError("Устройство не подтвердило загруженную таблицу")
|
||||
if self.action in ("start", "stop") and result["running"] != (self.action == "start"):
|
||||
raise ValueError("Устройство не подтвердило состояние выхода")
|
||||
self.signals.progress.emit(100)
|
||||
self.signals.finished.emit(result, "")
|
||||
except Exception as error:
|
||||
self.signals.finished.emit(None, str(error))
|
||||
finally:
|
||||
port.close()
|
||||
Reference in New Issue
Block a user