Добавить SCI и J-Link и обновить общие порты устройств

Подключить профили прошивки из JSON и подготовку Python для CCS/Keil.
Добавить SCI-программатор F28335 и поддержку STM32 через J-Link.
Обновить Linux-консоль, CAN485 и Modbus и проверки общих портов.
This commit is contained in:
2026-11-13 17:07:20 +03:00
parent 07929c880a
commit 4d101302be
32 changed files with 1400 additions and 44 deletions
File diff suppressed because one or more lines are too long
+7
View File
@@ -44,3 +44,10 @@ CANDLE_G474_BOOT = { # Профиль воспроизводит CAN_BOOTLOADER
"device_type": 7, "device": 15, "product": 0, "hardware": 0, # Идентичность ProtoCAN BOOT: Type 7, Device 15, Product 0 и аппаратная ревизия 0.
"can_bitrate": 500_000, "uart_baud": 512_000, # Classic CAN работает на 500 кбит/с, USART1 PA9/PA10 — на 512000 бод, 8N1.
} # Адреса и скорости относятся только к CANdle G474VET6 и не подменяют профиль USB BOOT G474RB.
UKSVEP_F103RC_BOOT = { # Профиль отдельного boot-проекта UKSVEP_ICE_22220.6 согласован с ports/stm32f103rc-uksvep/boot_profile.h.
"app_base": 0x08008000, "app_size": 94 * 1024, # Начальные 32 КиБ заняты BOOT; приложение заканчивается перед существующей EEPROM-страницей 0x0801F800.
"stack_base": 0x20000000, "stack_end": 0x2000C000, # DS5792 определяет для STM32F103RC 48 КиБ SRAM и восьмибайтовое выравнивание начального MSP.
"device_type": 5, "product": 0x5606, "hardware": 1, # DeviceID задают активные нулём перемычки PA6/PA7/PC4/PC5; Product отличает ВЭП от других STM32.
"can_bitrate": 250_000, "uart_baud": 115_200, # Загрузчик UKSVEP использует CAN PA11/PA12 и USART1 PA9 TX/PA10 RX на 115200 бод.
} # Single-slot обновление сохраняет параметры; для запуска требуется постоянный manifest и CRC образа.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,135 @@
"""Встроенная прошивка F28335 через штатный SCI Boot без внешнего C2Prog.""" # Qt отвечает за COM и потоки; протокол реализован общим C99-ядром templates.
import hashlib # Проверяет точные байты RAM-ядра, сохранённого в комплекте postbuild.
from pathlib import Path # Разрешает имя RAM-ядра относительно выбранного BIN.
import threading # Передаёт отмену из GUI в рабочий поток без принудительного завершения потока.
import time # Системные часы используются только транспортом для конечных тайм-аутов ожидания.
try: # Поддерживает основные сборки Qt 6 и Windows 7/Qt 5.
from PySide6.QtCore import QThread, Signal, QIODevice # Рабочий поток не блокирует GUI ожиданием UART или Flash API.
from PySide6.QtWidgets import QWidget, QFormLayout, QLineEdit, QPushButton, QCheckBox, QSpinBox, QFileDialog, QLabel # Панель использует обычные контролы SETGUI.
from PySide6.QtSerialPort import QSerialPort # Транспорт создаёт отдельный системный COM-сеанс на время программирования.
except ImportError: # Для Python 3.8/Windows 7 использует те же операции Qt 5.
from PySide2.QtCore import QThread, Signal, QIODevice # Сигналы безопасно возвращают результаты в главный поток.
from PySide2.QtWidgets import QWidget, QFormLayout, QLineEdit, QPushButton, QCheckBox, QSpinBox, QFileDialog, QLabel # Интерфейс не требует компонентов, доступных только Qt 6.
from PySide2.QtSerialPort import QSerialPort # COM-порт остаётся локальной зависимостью адаптера.
from protocan.c2000_sci import Session, decode, validate, ulitco_enter # Сборка, разбор, checksum и автомат обмена не повторяются в порту.
class SciWorker(QThread): # Один сеанс владеет COM-портом и неизменяемыми буферами до завершения.
progress = Signal(int, str) # Штатная полоса GUI получает только подтверждённый прогресс C-автомата.
result = Signal(bool, str) # Ошибка транспорта или checksum не выдаётся за успешную запись.
def __init__(self, kernel, image, port, baud, enter=False, timeout_ms=3000, parent=None): # Все параметры снимаются с GUI до запуска потока.
super().__init__(parent) # Время жизни потока связано с существующей панелью прошивки.
self.kernel, self.image, self.port, self.baud = kernel, image, port, baud # Буферы не перечитываются с диска после аппаратного старта.
self.enter, self.timeout_ms, self.stop = enter, timeout_ms, threading.Event() # Отмена проверяется во всех конечных ожиданиях.
def cancel(self): # GUI не вызывает методы QSerialPort чужого потока.
self.stop.set() # Рабочий поток сам завершит чтение и закроет COM-порт.
def _cancelled(self): # Единая проверка отмены используется в задержках, чтении и записи.
if self.stop.is_set(): raise ValueError("Прошивка остановлена оператором") # Отмена не запускает повторную передачу или попытку erase.
def _pause(self, milliseconds): # Обязательные паузы TI остаются прерываемыми оператором.
end = time.monotonic() + milliseconds / 1000 # Миллисекунды C-автомата преобразуются только в системное ожидание порта.
while time.monotonic() < end: self._cancelled(); self.msleep(min(50, max(1, int((end - time.monotonic()) * 1000)))) # Проверяет отмену не реже одного раза за 50 мс.
def _write(self, serial, data): # Передаёт готовые байты и дожидается фактической отправки UART.
self._cancelled() # Не добавляет новые данные после запроса остановки.
if not data: return # Отдельное подтверждение пустого остатка TI не требует передачи.
if serial.write(data) != len(data): raise OSError("Не удалось поставить полный пакет SCI в очередь COM") # Частичная запись не должна сдвинуть автомат протокола.
end = time.monotonic() + max(5, len(data) * 12 / self.baud + 2) # Бюджет включает время передачи октетов UART и запас драйвера.
while serial.bytesToWrite(): # Очередь обязана опустеть до начала ожидания ответа.
self._cancelled() # Отмена сохраняет конечность длительной отправки на низкой скорости.
serial.waitForBytesWritten(100) # Короткое ожидание позволяет проверять отмену и ошибку драйвера.
if time.monotonic() > end: raise TimeoutError("Истёк тайм-аут передачи SCI") # Не ожидает бесконечно отключённый преобразователь.
def _read(self, serial, count, timeout): # Собирает ровно требуемое число октетов, не разбирая их поля.
result, end = bytearray(), time.monotonic() + timeout / 1000 # Единственный дедлайн сохраняется при частичных UART-порциях.
while len(result) < count: # Фрагментация USB-UART не меняет смысл ответа C-автомата.
self._cancelled() # Оператор может прекратить ожидание недоступного MCU.
if serial.bytesAvailable(): result.extend(bytes(serial.read(count - len(result)))) # Следующий ответ не поглощается текущим чтением.
else: serial.waitForReadyRead(100) # Ожидание остаётся внутри рабочего потока, без зависания GUI.
if time.monotonic() > end: raise TimeoutError("Загрузчик F28335 не подтвердил обмен SCI") # Отсутствие эха или успешного Flash API не трактуется как продолжение.
return bytes(result) # Проверку эха и checksum выполнит C-ядро.
def run(self): # Qt вызывает метод в отдельном потоке, где создаётся и закрывается COM-порт.
serial = None # Ошибка проверки образа не должна даже открывать аппаратный интерфейс.
try: # Все исключения превращаются в конечный результат GUI.
session = Session(self.kernel, self.image) # Проверяет RAM-ядро и Flash-таблицу до любой управляющей команды.
serial = QSerialPort(); serial.setPortName(self.port); serial.setBaudRate(468750 if self.enter else self.baud) # Штатный Monitor ИБП работает на 468750 бит/с (MONITOR_BRR=9, LSPCLK=37,5 МГц); при ручном входе сразу используется скорость загрузчика из manifest.
serial.setDataBits(QSerialPort.DataBits.Data8); serial.setParity(QSerialPort.Parity.NoParity); serial.setStopBits(QSerialPort.StopBits.OneStop); serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # TI SCI Boot и первичный загрузчик UlitCo используют 8N1 без аппаратного управления потоком.
if not serial.open(QIODevice.OpenModeFlag.ReadWrite): raise OSError(serial.errorString()) # Занятый терминалом порт не открывается вторым владельцем.
serial.clear() # Старые данные терминала не должны стать ложным эхо autobaud.
if self.enter: # Приложение UlitCo принимает переход в загрузчик лишь в разрешённом остановленном состоянии.
self.progress.emit(0, "Переход UlitCo в первичный загрузчик…") # Показывает отдельный этап до передачи RAM-ядра.
self._write(serial, ulitco_enter()); self._pause(500) # Отправляет проверенную команду Monitor №93 на скорости приложения и даёт штатному SCI-приёмнику 500 мс на подготовку autobaud.
if not serial.setBaudRate(self.baud): raise OSError("Не удалось переключить COM на скорость загрузчика SCI") # Только после выхода из приложения включает скорость RAM-ядра из manifest; отказ запрещает дальнейшую передачу.
serial.clear() # Остатки ответов Monitor не должны стать ложным эхом нового autobaud.
while True: # Порядок обменов полностью определяется общим C-автоматом.
self._cancelled(); state, tx, expected, delay = session.next() # Порт не вычисляет пакетные поля или checksum.
if state == 0: break # Подтверждённые порции и отправленный терминатор завершили поток.
if state == 2: self._pause(delay); serial.clear(); continue # TI требует паузу после RAM-ядра и его autobaud.
self._write(serial, tx) # Отправляет готовый октет ROM либо порцию Flash Kernel A.
if expected: session.accept(self._read(serial, expected, 60000 if expected == 2 else self.timeout_ms)) # Erase/program получают минуту; эхо ROM использует точный тайм-аут из профиля JSON в миллисекундах.
self.progress.emit(session.progress, "Ядро TI / запись Flash…") # Продвижение следует только успешно проверенным ответам.
self.result.emit(True, "Передача завершена; ядро TI подтвердило порции Flash. Проверка чтением памяти отдельно не выполнялась.") # Не заявляет недоступную в Kernel A полную readback-проверку.
except (OSError, ValueError, RuntimeError) as error: self.result.emit(False, str(error)) # Ошибка остановит GUI и сохранится в журнале текущей прошивки.
finally: # Владение портом освобождается также после отмены или тайм-аута.
if serial is not None: serial.close() # Закрывает только созданный этим потоком COM-сеанс.
class C2000SciPanel(QWidget): # Общая панель используется SETGUI без зависимости от отдельного приложения C2Prog.
progress = Signal(int, str) # Согласует рабочий поток со штатной полосой прошивки.
finished = Signal(bool, str) # Завершение освобождает форму одинаково после успеха, отказа или отмены.
def __init__(self, parent=None): # Создаёт только параметры маршрута, оставляя COM и скорость основной форме.
super().__init__(parent); self.worker = None; self.metadata = {}; self.path = None # Состояние соответствует одному выбранному Flash BIN.
form = QFormLayout(self) # Контролы не требуют дополнительного окна приложения.
self.kernel = QLineEdit(); form.addRow("RAM-ядро TI", self.kernel) # Готовый комплект заполняет путь автоматически.
button = QPushButton("Выбрать ядро…"); button.clicked.connect(self._choose); form.addRow(button) # Старые образы допускают явный выбор соответствующего ядра.
self.enter_monitor = QCheckBox("Перевести остановленный UlitCo в загрузчик через Monitor №93"); form.addRow(self.enter_monitor) # Управляющая команда отправляется только при явном выбранном режиме.
self.oscillator_hz = QSpinBox(); self.oscillator_hz.setRange(20000000, 30000000); self.oscillator_hz.setSingleStep(5000000); self.oscillator_hz.setValue(30000000); self.oscillator_hz.setReadOnly(True); form.addRow("Кварц ядра, Гц", self.oscillator_hz) # Частота зафиксирована при сборке ядра и не меняется настройкой COM.
self.timeout_ms = QSpinBox(); self.timeout_ms.setRange(50, 10000); self.timeout_ms.setValue(3000); form.addRow("Ожидание связи, мс", self.timeout_ms) # Отдельный erase/program получает обязательный бюджет до 60 секунд.
label = QLabel("Штатный SCI Boot F28335: приложение UlitCo → RAM-ядро TI → Flash. Выберите порт SCI-A; при ручном входе предварительно включите загрузчик."); label.setWordWrap(True); form.addRow(label) # Оператор видит физический интерфейс и последовательность до начала записи.
def _choose(self): # Выбор RAM-ядра сам по себе ничего не отправляет в устройство.
path, _ = QFileDialog.getOpenFileName(self, "RAM-ядро F28335 SCI", self.kernel.text(), "SCI8 (*.bin *.txt)") # Принимает штатные бинарное и ASCII-представления TI.
if path: self.kernel.setText(path) # Отмена сохраняет текущий комплект.
def set_manifest(self, image, metadata): # Подтягивает ядро и параметры из выбранного постсборочного комплекта.
self.path, self.metadata = Path(image), metadata # Сохраняет принадлежность ядра конкретному Flash BIN.
info = metadata.get("flash_kernel", {}); info = info if isinstance(info, dict) else {} # Не относящийся к SCI manifest не должен нарушать визуальную загрузку других режимов.
name = info.get("file", "") # Manifest хранит только исходное имя соседнего RAM-бинарника.
if isinstance(name, str) and name and Path(name).name == name: self.kernel.setText(str(self.path.parent / name)) # Не разрешает metadata-путь за пределы папки образа.
else: self.kernel.clear() # Новый образ не должен наследовать RAM-ядро предыдущей прошивки.
frequency = info.get("oscillator_hz", 30000000); self.oscillator_hz.setValue(frequency if type(frequency) is int and frequency in (20000000, 25000000, 30000000) else 30000000) # Показывает допустимую частоту без исключения setter на чужих metadata.
def prepare(self, image): # Фиксирует и проверяет все байты до подтверждения аппаратной операции.
with Path(image).open("rb") as stream: data = stream.read(1024 * 1024 + 1) # Ограничивает чтение Flash BIN до одного МиБ и одного контрольного октета.
with Path(self.kernel.text()).open("rb") as stream: kernel_raw = stream.read(1024 * 1024 + 1) # Worker получает ограниченный неизменяемый снимок RAM-ядра.
if len(data) > 1024 * 1024 or len(kernel_raw) > 1024 * 1024: raise ValueError("Слишком большой файл SCI8") # Ограничивает отдельные Flash и RAM-образы одним МиБ.
if self.metadata and (len(data) != self.metadata.get("size_bytes") or hashlib.sha256(data).hexdigest() != self.metadata.get("sha256")): raise ValueError("Flash BIN изменён после выбора или не соответствует manifest") # Новый корректный SCI8 не должен незаметно заменить ранее подтверждённый образ.
info = self.metadata.get("flash_kernel", {}) # При наличии паспорта зависимость должна совпадать побайтно.
if not isinstance(info, dict): raise ValueError("В manifest требуется объект flash_kernel") # Неверный тип метаданных возвращается обычной ошибкой комплекта до доступа к COM.
if info and (len(kernel_raw) != info.get("size_bytes") or hashlib.sha256(kernel_raw).hexdigest() != info.get("sha256")): raise ValueError("RAM-ядро TI не соответствует manifest Flash BIN") # Чужая частота или маска erase не должны незаметно попасть в сеанс.
kernel, flash = decode(kernel_raw), decode(data) # Оба представления разбираются только общим C-декодером.
validate(kernel, False); _, sectors = validate(flash, True) # Не открывает COM для повреждённых таблиц или адресов CSM; маску вычисляет единственный C-декодер.
if info and (info.get("sector_mask") != sectors or info.get("oscillator_hz") != self.oscillator_hz.value()): raise ValueError("RAM-ядро собрано для другой частоты или набора секторов Flash") # Комплект обязан соответствовать фактическим адресам приложения и выбранному при сборке кварцу.
return kernel, flash # Возвращает готовые байты для неизменяемой аппаратной операции.
def start(self, job, port, baud): # Основная форма вызывает метод после штатного подтверждения пользователя.
if self.worker is not None: raise ValueError("Прошивка SCI уже выполняется") # Один виджет не может владеть двумя одновременными COM-сеансами.
self.worker = SciWorker(*job, port, baud, self.enter_monitor.isChecked(), self.timeout_ms.value(), self) # Worker получает только снимок параметров главного потока.
self._result = None; self.worker.progress.connect(self.progress.emit); self.worker.result.connect(self._capture_result); self.worker.finished.connect(self._released) # Разблокирует форму только после finally рабочего потока и фактического закрытия COM.
self.worker.start() # До этого места никакие байты MCU не передавались.
def _capture_result(self, success, text): # Промежуточный сигнал не означает, что QThread уже закончил закрытие порта.
self._result = (success, text) # Конечное уведомление будет отправлено после штатного QThread.finished.
def _released(self): # Ссылка на поток сохраняется до завершения его finally и закрытия COM.
worker, self.worker = self.worker, None # После окончания разрешает следующую явную прошивку.
if worker is not None: worker.deleteLater() # Освобождает Qt-объект в главном потоке без уничтожения работающего QThread.
self.finished.emit(*(self._result or (False, "Поток SCI завершился без результата"))) # Форма снова доступна, когда собственный COM-сеанс гарантированно закрыт.
def cancel(self): # Отмена не вызывает terminate и не убивает чужие процессы.
if self.worker is not None: self.worker.cancel() # Рабочий поток завершит своё ближайшее конечное ожидание.
@@ -18,6 +18,7 @@ class Can485Port(QObject): # Совместимая с BoardTab оболочк
self.serial.errorOccurred.connect(self._serial_error); self.tcp.errorOccurred.connect(self._tcp_error); self.tcp.disconnected.connect(self._disconnected) # Потеря соединения останавливает циклическую отправку через обработчик панели.
self.tcp.connected.connect(self._connected) # Начальная настройка выполняется только после подтверждённого TCP handshake.
self.timeout = QTimer(self); self.timeout.setSingleShot(True); self.timeout.timeout.connect(self._timed_out) # Конечный срок TCP не блокирует GUI даже при отключённой плате.
self._shared=None # Единая сборка позволяет CAN и Modbus одновременно использовать один физический FTDI.
def _connected(self): # Завершает фазу ожидания и разрешает запрос версии.
self.timeout.stop(); self.connected.emit() # Запоздалый timeout больше не может закрыть действующую сессию.
@@ -51,7 +52,16 @@ class Can485Port(QObject): # Совместимая с BoardTab оболочк
def connect_tcp(self, host, port): # Endpoint уже проверен UI, DNS и синхронное ожидание не требуются.
self.close(); self.kind = 'Ethernet'; self._endpoint = f'{host}:{port}'; self._error = ''; self.timeout.start(5000); self.tcp.connectToHost(host, port) # Пять секунд ограничивают ожидание недоступного TCP-порта 37579.
def connect_console(self,name,port=37579): # Общий FTDI канал открывает отдельную CAN TCP сессию без захвата COM второй вкладкой.
from .console_link_port import ConsoleLink # Общий менеджер делится с Modbus и сохраняет действующий USB host HDMI.
self.close(); self.kind='USB'; self._endpoint=name; self._error=''; self.console_restored=False; self._shared=ConsoleLink.acquire(name,port,self); channel=self._shared; self.timeout.start(5000) # Начальное открытие остаётся отменяемым до подтверждения backend.
channel.readyRead.connect(self.readyRead); channel.opened.connect(self._connected); channel.errorOccurred.connect(lambda error:self._shared_error(channel,error)) # Прикладные CAN строки и ошибки принадлежат только своему логическому каналу.
def _shared_error(self,channel,error): # Ошибка локального CAN backend не должна выдавать сообщение физического QtSerialPort другого протокола.
if not self._closing: self._error=channel.errorString(); self.errorOccurred.emit(error) # Стандартный BoardTab cleanup завершает только CAN сессию.
def isOpen(self): # Штатные кнопки BoardTab учитывают реальное состояние выбранного канала.
if self._shared is not None: return self._shared.active and self._shared.isOpen() # Общий COM не считается готовым CAN до OPEN конкретного loopback backend.
return self.tcp.state() == QAbstractSocket.SocketState.ConnectedState if self.kind == 'Ethernet' else self.serial.isOpen() and (not self._console or self._console_ready) # Открытый USB tty с незавершённым nc handshake тоже не считается CAN-подключением.
def isConnecting(self): # Позволяет отменить незавершённую попытку подключения повторным нажатием кнопки.
@@ -59,6 +69,7 @@ class Can485Port(QObject): # Совместимая с BoardTab оболочк
def close(self): # Закрытие идемпотентно и не порождает рекурсивную ошибку панели.
self._closing = True; self.timeout.stop(); self.tcp.abort() # Сначала отменяет deadline и сетевые сигналы старой сессии.
if self._shared is not None: channel=self._shared; self._shared=None; channel.close(); self.console_restored=channel.link.console_restored; channel.deleteLater() # Освобождается логический CAN канал, тогда как Modbus продолжает использовать тот же FTDI.
if self._console and self.serial.isOpen(): # Дожидается реального ответа UART, чтобы close не отбросил ещё не переданный Ctrl-C в драйвере Windows.
self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # Удаляет старый поток статуса и немедленно передаёт сигнал завершения собственного foreground nc.
if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Ограничивает ожидание освобождения локальной выходной очереди.
@@ -70,16 +81,19 @@ class Can485Port(QObject): # Совместимая с BoardTab оболочк
self._console=False; self._console_ready=False; self._console_rx.clear(); self._closing = False # Не оставляет старые данные handshake для следующего COM-порта.
def readAll(self): # Возвращает сырой QByteArray для существующего LineAssembler templates.
if self._shared is not None: return self._shared.readAll() # Общий физический поток уже разделён на независимые CAN/Modbus сессии.
if self._console: data=bytes(self._console_rx); self._console_rx.clear(); return data # Консольные bytes прочитаны единожды при поиске подтверждённого заголовка.
return self.tcp.readAll() if self.kind == 'Ethernet' else self.serial.readAll() # Сегментация TCP/USB не влияет на протокол.
def write(self, data): # Длина результата позволяет обнаружить непринятую целиком команду.
if self._shared is not None: return self._shared.write(data) # Канал кодирует только транспортное обрамление, не меняя исходные CAN команды.
return self.tcp.write(data) if self.kind == 'Ethernet' else self.serial.write(data) # Порт не добавляет заголовков либо переводов строк к готовой команде.
def errorString(self): # Единая диагностика сохраняется до следующего соединения.
return self._error or (self.tcp.errorString() if self.kind == 'Ethernet' else self.serial.errorString()) # Сообщение Qt не подменяется общим «нет связи».
def portName(self): # Нижняя строка монитора показывает фактический канал.
if self._shared is not None: return self._shared.portName() # Общий COM остаётся видимым пользователю при одновременной работе двух протоколов.
return self._endpoint if self.kind == 'Ethernet' else self.serial.portName() # Сетевое соединение отличается от COM-порта платы.
def setPortName(self, name): # Штатный USB-путь BoardTab выбирает физический порт без изменения своего кода.
@@ -0,0 +1,107 @@
"""Общий владелец FTDI и независимые логические TCP-каналы CAN/Modbus поверх Linux shell.""" # Прикладные кодеки не зависят от способа совместного использования COM.
from PySide6.QtCore import QObject, Signal, QTimer, QByteArray, QCoreApplication, QElapsedTimer, QIODevice # Один Qt event loop обслуживает все логические каналы без фоновых гонок.
from PySide6.QtSerialPort import QSerialPort # Физический COM открывается ровно одним экземпляром менеджера.
import shlex # Путь установленного либо явно выбранного тестового helper передаётся shell одним безопасным аргументом.
import re # Подтверждённый root prompt отделяет shell от login/getty и запроса пароля после холодной загрузки.
class ConsoleChannel(QObject): # Контракт readAll/write/close позволяет подключить существующие thin-порты протоколов.
readyRead=Signal(); opened=Signal(); errorOccurred=Signal(object) # OPEN подтверждается loopback службой отдельно от открытия FTDI.
def __init__(self,link,number,port,parent): # Канал принадлежит конкретному потребителю, физическая сессия — приложению.
super().__init__(parent); self.link=link; self.number=number; self.port=port; self.active=False; self.closed=False; self.incoming=bytearray(); self.error='' # Соседние протоколы имеют независимые буферы и состояния.
def isOpen(self): return not self.closed # Ожидающую handshake сессию можно отменить той же кнопкой отключения.
def portName(self): return self.link.name # Панель показывает реальный COM, общий для обеих служб платы.
def errorString(self): return self.error # Ошибка сохраняется до уничтожения конкретной логической сессии.
def readAll(self): data=QByteArray(bytes(self.incoming)); self.incoming.clear(); return data # Каждый прикладной байт читается только своим протоколом.
def write(self,data): # Возвращает длину принятых в очередь данных без подтверждения внешней операции.
raw=bytes(data) # QByteArray и bytes преобразуются без изменения бинарного содержимого.
if self.closed or not self.active: return -1 # Команда не уходит в неподтверждённый или уже закрытый канал.
for offset in range(0,len(raw),512): # Короткая строка HEX позволяет чередовать CAN и Modbus при 115200 бод.
if not self.link.send(f'D{self.number:02X}:'.encode('ascii')+raw[offset:offset+512].hex().encode('ascii')+b'\n'): return -1 # Переполнение физической очереди прекращает сессию без повтора команды.
return len(raw) # Успех означает только локальную постановку в очередь QSerialPort.
def close(self): # Закрытие протокола не отбирает общий COM у оставшихся потребителей.
if self.closed: return # Идемпотентный cleanup безопасен при синхронных Qt error callbacks.
self.closed=True; self.active=False; self.incoming.clear(); self.link.release(self) # Менеджер завершает shell helper только после последнего канала.
def failed(self,message): # Ошибка одного backend не маскируется состоянием другого протокола.
if self.closed: return # Запоздалое событие не возобновляет отключённую страницу.
self.error=message; self.close(); self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Потребитель очищает собственные незавершённые запросы.
class ConsoleLink(QObject): # Один экземпляр на имя COM обслуживает сколько угодно независимых backend сессий.
links={} # Реестр процесса сохраняет одного физического владельца между вкладками SETGUI.
program='/opt/zynq-link/console_link.py' # Проверка staged-пакета может выбрать его точный локальный путь до постоянной установки helper.
@classmethod # Потребитель запрашивает канал, не управляя временем жизни общей UART-консоли.
def acquire(cls,name,port,parent=None): # Сетевой адрес не передаётся из UI: разрешены только две локальные службы платы.
if int(port) not in (37579,502): raise ValueError('Общий USB поддерживает CAN 37579 и Modbus 502') # Разрешены штатный CAN и стандартный Modbus TCP, без произвольного loopback proxy.
name=str(name).strip(); key=name.upper() if name.upper().startswith('COM') else name # Windows COM нечувствителен к регистру, Linux PTY сохраняет точное имя.
link=cls.links.get(key) # Повторная вкладка присоединяется к уже открытому физическому каналу.
if link is None: link=cls(name,key); cls.links[key]=link; QTimer.singleShot(0,link.start) # Асинхронный запуск даёт потребителю время подключить error/opened callbacks.
number=next((value for value in range(1,256) if value not in link.used),None) # Номер не переиспользуется до закрытия физической сессии, поэтому поздний DATA старого клиента не попадёт новому.
if number is None: raise RuntimeError('Исчерпаны USB-каналы платы') # Ограничение совпадает с однобайтовым номером общего протокола.
channel=ConsoleChannel(link,number,int(port),parent); link.channels[number]=channel; link.used.add(number) # Множество использованных номеров защищает от смешения с поздними queued ответами закрытого backend.
if link.ready: QTimer.singleShot(0,lambda:link.open_channel(channel)) # Существующий helper открывает только новый backend без повторной shell команды.
return channel # Никакая прикладная команда ещё не отправляется в CAN или Modbus.
def __init__(self,name,key): # Общая сессия переживает переключение и закрытие одной рабочей страницы.
super().__init__(QCoreApplication.instance()); self.name=name; self.key=key; self.serial=QSerialPort(self); self.channels={}; self.used=set(); self.incoming=bytearray(); self.ready=False; self.closing=False; self.console_restored=True # QObject приложения освобождает менеджер до завершения Qt runtime, а история номеров изолирует поздние DATA.
self.timer=QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(lambda:self.fail('Нет ответа общего USB-туннеля: проверьте root shell и /opt/zynq-link/console_link.py')) # Ошибка начального handshake имеет конечный срок 5 секунд.
self.serial.readyRead.connect(self.receive); self.serial.errorOccurred.connect(self.serial_error) # Единственный readyRead читает физические байты для всех протоколов.
def start(self): # FTDI открывается только после явного запроса первой страницы подключения.
if self.closing or not self.channels: return # Закрытый до первого event loop запрос не занимает COM.
self.serial.setPortName(self.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) # Параметры соответствуют проверенной root-консоли платы, независимо от внешней RS485.
if not self.serial.open(QIODevice.OpenModeFlag.ReadWrite): self.fail(self.serial.errorString()); return # Занятый сторонним терминалом COM не перехватывается.
self.console_restored=False; self.phase='shell'; self.login_sent=False; self.cursor_replied=False; self.timer.start(5000); self.send(b'\x15\r') # Сначала запрашивается фактический prompt: после холодного boot COM может ещё принадлежать getty, а не shell.
def start_helper(self): # Команды tty разрешены только после подтверждения root shell известной платы.
self.phase='helper'; self.incoming.clear(); self.send(("stty -echo -icanon -ixon -ixoff min 1 time 0; python3 "+shlex.quote(self.program)+"; stty sane; printf '\\n#ZYNQ-LINK-CLOSED\\n'\r").encode('ascii')) # Путь экранирован одним аргументом, завершение helper восстанавливает обычный терминал.
def send(self,data): # Общая очередь Windows ограничена независимо от скорости прикладных клиентов.
if self.closing or not self.serial.isOpen(): return False # Закрытый physical fd не накапливает команды для будущего запуска.
if self.serial.bytesToWrite()+len(data)>262144 or self.serial.write(data)!=len(data): self.fail('Переполнена очередь общего USB; уменьшите поток CAN/Modbus'); return False # Предел 256 КиБ не допускает бесконечного отставания от UART.
return True # Реальное подтверждение операции остаётся обязанностью прикладного протокола.
def open_channel(self,channel): # Каждый backend соединяется отдельно внутри уже работающего общего helper.
if not channel.closed and self.ready: self.send(f'O{channel.number:02X}:{channel.port}\n'.encode('ascii')) # Номер и порт сформированы из проверенных локальных чисел.
def receive(self): # Произвольные USB фрагменты собираются в ограниченные ASCII строки обрамления.
if self.closing: return # Во время короткого cleanup принятые байты проверяет finish, избегая рекурсии callback.
self.incoming.extend(bytes(self.serial.readAll())) # Физический поток читается ровно одним QObject.
if len(self.incoming)>16384: self.fail('Неверный поток общего USB-туннеля'); return # Ни shell, ни повреждённый backend не могут бесконечно накапливать данные.
if not self.ready and self.phase=='shell': # Login и prompt не обязаны оканчиваться переводом строки, поэтому проверяются до разбора multiplex строк.
if b'Password:' in self.incoming: self.fail('Консоль платы требует пароль: войдите через терминал и повторите подключение'); return # Пароль не угадывается и не отправляется автоматически.
if b'\x1b[6n' in self.incoming and not self.cursor_replied: self.cursor_replied=True; self.send(b'\x1b[24;80R') # Factory autologin вызывает resize с ANSI запросом курсора; стандартный ответ 24 строки и 80 столбцов освобождает shell без ввода фиктивной команды.
plain=re.sub(rb'\x1b(?:\[[0-?]*[ -/]*[@-~]|[78])',b'',bytes(self.incoming)) # ANSI save/restore cursor и resize не являются частью реального root prompt.
if re.search(rb'(?:^|[\r\n])root@image_7020:[^\r\n]*#[ \t]*$',plain): self.start_helper(); return # Только фактическая root shell разрешает запуск общего helper после штатного factory autologin.
if re.search(rb'login:[ \t]*$',plain) and not self.login_sent: self.login_sent=True; self.incoming.clear(); self.send(b'root\r') # Известное имя отправляется только getty, действительно ожидающему ввод; строка automatic login не вызывает лишнюю root команду.
return # Текст getty не попадает в Modbus/CAN parser и не считается banner helper.
while b'\n' in self.incoming: # Одно уведомление может содержать ответы нескольких логических каналов.
raw,_,tail=self.incoming.partition(b'\n'); self.incoming[:]=tail; line=raw.rstrip(b'\r') # Терминальные CR не входят в HEX данные прикладных протоколов.
if not self.ready: # Вывод shell и echo стартовой команды не становятся Modbus ответом.
if line==b'#ZYNQ-LINK 1': self.ready=True; self.timer.stop(); [self.open_channel(channel) for channel in list(self.channels.values())] # Только настоящий banner разрешает OPEN backend каналов.
continue # Старый консольный вывод не передаётся ни одному прикладному parser.
if len(line)<3: continue # Пустые строки не влияют на состояние готовых каналов.
try: number=int(line[1:3],16) # Однобайтовый номер позволяет выбрать единственного получателя.
except ValueError: self.fail('Повреждено обрамление общего USB'); return # Ошибка multiplex framing закрывает физическую сессию без повторов.
channel=self.channels.get(number) # Закрытый потребитель может оставить поздние байты, которые следует игнорировать.
if channel is None: continue # Поздний DATA не создаёт сессию автоматически.
if line[:1]==b'O' and len(line)==3: channel.active=True; channel.opened.emit() # Connected относится к установленному loopback соединению конкретной службы.
elif line[:1]==b'C' and len(line)==3: channel.failed('Служба платы закрыла USB-канал') # FIN одного сервиса не отключает остальные протоколы.
elif line[:1]==b'E' and line[3:4]==b':': channel.failed('Ошибка USB-канала службы: errno '+line[4:].decode('ascii','replace')) # Числовая причина Linux доступна рядом с соответствующей вкладкой.
elif line[:1]==b'D' and line[3:4]==b':': # Прикладные данные раскодируются только после выбора активного канала.
try: data=bytes.fromhex(line[4:].decode('ascii')) # Все управляющие значения байта остаются неизменными данными TCP потока.
except (ValueError,UnicodeError): self.fail('Повреждены HEX данные общего USB'); return # Неисправное обрамление не проходит к Modbus C-кодеку как частичный успех.
if len(channel.incoming)+len(data)>65536: channel.failed('Переполнен буфер USB-канала'); continue # Ограничение одного потребителя не мешает работе остальных.
channel.incoming.extend(data); channel.readyRead.emit() # Существующий parser получает произвольный фрагмент исходного TCP.
else: self.fail('Неизвестный ответ общего USB-туннеля'); return # Неожиданное возвращение shell не выдаётся за действующий протокольный поток.
def serial_error(self,error): # Qt NoError не считается отказом физического FTDI.
if not self.closing and self.serial.isOpen() and error not in (QSerialPort.SerialPortError.NoError,QSerialPort.SerialPortError.TimeoutError): self.fail(self.serial.errorString()) # Отключение кабеля завершает все каналы одной общей сессии.
def release(self,channel): # Канал удаляется до посылки CLOSE, исключая повторную обработку поздних данных.
self.channels.pop(channel.number,None) # Повторный cleanup не затрагивает другой номер сессии.
if self.ready and not self.closing: self.send(f'C{channel.number:02X}\n'.encode('ascii')) # Backend получает штатный обрыв собственного TCP соединения.
if not self.channels: self.finish() # Последняя страница возвращает обычную Linux-консоль и освобождает COM.
def fail(self,message): # Физическая ошибка закрывает всех владельцев без автоматического повторного запуска.
if self.closing: return # Синхронный QSerialPort error во время close не вызывает рекурсию.
channels=list(self.channels.values()); self.closing=True # Сначала запрещаются новые записи всех потребителей.
for channel in channels: channel.failed(message) # Каждая вкладка получает собственный cleanup и прекращает опрос.
self.finish() # Общий UART освобождается после доставки ошибок всем потребителям.
def finish(self): # Только последний потребитель завершает собственный foreground helper.
self.closing=True; self.timer.stop() # Остальные Qt callbacks не читают tty во время подтверждения возврата shell.
if self.serial.isOpen(): # Отсоединённый физический кабель не заставляет ждать несуществующее подтверждение.
self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # ISIG остаётся включённым, поэтому Ctrl-C завершает только собственную foreground группу helper.
if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Очередь передачи ограничена коротким ожиданием фактической отправки Ctrl-C.
elapsed=QElapsedTimer(); elapsed.start(); response=bytearray() # Консоль должна подтвердить выполнение stty sane за ограниченные 500 мс.
while elapsed.elapsed()<500 and not self.console_restored: self.serial.waitForReadyRead(25); response.extend(bytes(self.serial.readAll())); self.console_restored=b'\n#ZYNQ-LINK-CLOSED\r' in response or b'\n#ZYNQ-LINK-CLOSED\n' in response # Собственный marker отличает реальный возврат shell от простого закрытия COM.
self.serial.close() # Сторонний терминал может открыть COM после завершения последнего протокола.
if self.links.get(self.key) is self: self.links.pop(self.key) # Новый запрос пользователя создаст новую чистую физическую сессию.
self.deleteLater() # Удаление QObject выполняется штатным Qt event loop до выгрузки runtime.
File diff suppressed because one or more lines are too long
+102
View File
@@ -0,0 +1,102 @@
"""Асинхронный J-Link-порт: backup, запись, readback, проверка и отдельный reset/run.""" # Внешний процесс не блокирует цикл событий SETGUI.
from pathlib import Path # Артефакты одной операции хранятся вместе в отдельном каталоге.
import re # Итоговый запуск подтверждается выполненной командой g, а не одним кодом возврата.
import tempfile # Уникальный каталог исключает перезапись предыдущей резервной копии.
try: # Общая библиотека работает с современным Qt и существующими сборками Windows 7.
from PySide6.QtCore import QObject, QProcess, QTimer, Signal # QProcess обслуживает stdout, завершение и отмену без потоковой блокировки.
except ImportError: # PySide2 сохраняет тот же публичный контракт сигналов этого порта.
from PySide2.QtCore import QObject, QProcess, QTimer, Signal # Никакой зависимости от внутреннего устройства SETGUI здесь нет.
from set_devices.stm32_jtag import encode_hex, programming_script, start_script, verify_readback # Вся подготовка и проверка сосредоточены в переносимом модуле templates.
class Stm32JtagProgrammer(QObject): # Владеет одной аппаратной операцией до полного завершения обоих этапов.
progress = Signal(int, str) # Основная форма показывает этапы рядом с существующей полосой прогресса.
finished = Signal(bool, str) # Единственный итог освобождает форму также при ошибке запуска executable.
log = Signal(str) # Полный вывод Commander доступен в терминале и сохраняется на диск.
def __init__(self, parent=None): # Создание порта не открывает программатор и не обращается к USB.
super().__init__(parent) # Время жизни порта соответствует его Qt-владельцу.
self._process = None # None означает отсутствие запущенного Commander.
self._active = False # Флаг защищает от повторного finished после errorOccurred и finished одного процесса.
self._timer = QTimer(self) # Ограничивает зависшую внешнюю утилиту без ожидания в GUI.
self._timer.setSingleShot(True) # На один этап приходится один предельный тайм-аут.
self._timer.timeout.connect(lambda: self._stop('Истекло время ожидания J-Link; проверьте журнал')) # Остановка не объявляет частично записанный образ успешным.
@property # Форма может проверить занятость перед повторным нажатием или закрытием.
def active(self): # Состояние охватывает программирование, независимую проверку и reset/run.
return self._active # Между двумя процессами нельзя начать конкурирующую запись.
def start(self, executable, options, segments, backup_root): # Принимает уже проверенный неизменяемый план, подтверждённый пользователем.
if self._active: raise ValueError('J-Link уже выполняет операцию') # Второй запуск не подменяет исходный путь backup и цель MCU.
options.validate() # Защищает прямые вызовы порта вне стандартной формы.
self._options, self._segments, self._executable = options, segments, str(executable) # Копирует параметры текущей операции до запуска QProcess.
self._active, self._stop_reason = True, '' # Обозначает владение программатором и отсутствие запроса отмены.
try: # Ошибки каталога и снимка должны завершать операцию штатным сигналом формы.
root = Path(backup_root).resolve() # Выбранный каталог не зависит от текущей рабочей папки процесса GUI.
root.mkdir(parents=True, exist_ok=True) # Создаёт только папку артефактов пользователя, без установки инструментов.
self.folder = Path(tempfile.mkdtemp(prefix='stm32-jtag-', dir=root)) # Предыдущие backup остаются нетронутыми даже при повторной неудачной попытке.
(self.folder / 'image.hex').write_text(encode_hex(segments), encoding='utf-8') # Фиксирует проверенные адресованные данные, исключая изменение исходника во время записи.
self.log.emit(f'Артефакты J-Link и резервная копия: {self.folder}') # Путь известен оператору до первого аппаратного действия.
self.progress.emit(0, 'Резервная копия, запись и чтение Flash') # Число отражает этап, поскольку Commander сам выводит ход программирования в терминал.
self._launch('program', programming_script(self.folder, options)) # Сначала исполняется только этап с остановленным ядром и двумя SaveBin.
except (OSError, ValueError) as error: self._finish(False, str(error)) # Отказ файловой системы не оставляет интерфейс заблокированным.
def _launch(self, stage, commands): # Каждый этап использует новый QProcess и собственный проверяемый command file.
self._stage, self._output = stage, '' # Вывод текущего этапа нужен также для подтверждения факта команды запуска.
script = self.folder / (stage + '.jlink') # Точный выполненный сценарий сохраняется рядом с backup.
script.write_text(commands, encoding='utf-8') # UTF-8 без BOM совместим с установленным Commander.
process = QProcess(self) # QObject-владелец удерживает процесс до обработки сигнала finished.
self._process = process # Отмена и тайм-аут относятся только к текущему этапу.
process.setProcessChannelMode(QProcess.MergedChannels) # Ошибки stderr сохраняются в том же порядке, что служебные сообщения stdout.
process.readyReadStandardOutput.connect(self._read_output) # Строки отображаются по мере поступления без waitForFinished.
process.errorOccurred.connect(self._process_error) # Отсутствующий executable и невозможность запуска дают понятный итог.
process.finished.connect(self._process_finished) # Только штатное завершение запускает независимую проверку и следующий этап.
self._timer.start(300000) # Пять минут на этап покрывают медленный JTAG; GUI продолжает обрабатывать отмену.
process.start(self._executable, self._options.arguments() + ['-Log', str(self.folder / (stage + '-dll.log')), '-CommanderScript', str(script)]) # Путь, серийный номер и файл передаются отдельными аргументами без командной оболочки.
def _read_output(self): # Вызывается также при finished, чтобы не потерять последний фрагмент stdout.
if self._process is None: return # Сигнал уже освобождённого этапа не должен обращаться к новому процессу.
chunk = bytes(self._process.readAllStandardOutput()).decode('utf-8', errors='replace').replace('\b', '') # Управляющие backspace процентов Commander не ломают текстовый терминал.
self._output += chunk # Полный вывод сохраняется для анализа результата и диагностики ошибки.
if chunk: self.log.emit(chunk.rstrip()) # Отображает сообщения сразу, включая отсутствие питания и занятой J-Link.
def _process_error(self, error): # FailedToStart не сопровождается finished и требует самостоятельного завершения.
if self._active and error == QProcess.FailedToStart: self._finish(False, 'Не удалось запустить J-Link: ' + self._process.errorString()) # Не оставляет активный таймер после неверного пути executable.
def _process_finished(self, code, status): # Последовательно выполняет readback-проверку и отдельный reset/run.
if not self._active: return # Игнорирует повторное уведомление ранее завершённой ошибки.
self._timer.stop() # Проверка локальных файлов не должна срабатывать как тайм-аут уже завершённого Commander.
self._read_output() # Забирает остаток вывода перед сохранением журнала.
try: # Все ошибки проверки и записи журнала превращаются в один finished(False).
(self.folder / (self._stage + '.log')).write_text(self._output, encoding='utf-8') # Читаемый журнал содержит именно тот вывод, который видел оператор.
process, self._process = self._process, None # Старый QObject больше не считается владельцем текущего этапа.
process.deleteLater() # Удаление выполняется циклом Qt после выхода из обработчика finished.
if self._stop_reason: raise ValueError(self._stop_reason) # Отмена никогда не переходит к командам reset/run.
if code != 0 or status != QProcess.NormalExit: raise ValueError(f'J-Link завершился с ошибкой {code}; журнал: {self.folder}') # Успех требует нормального завершения внешней утилиты.
if self._stage == 'program': # Даже код ноль не подтверждает, что интерактивное подключение реально дошло до записи.
before, after = (self.folder / 'before.bin').read_bytes(), (self.folder / 'after.bin').read_bytes() # Отсутствующий SaveBin автоматически исключает ложный успех.
verify_readback(self._segments, self._options, before, after) # Проверяет реальные байты записанных диапазонов и защищённую страницу UKSVEP.
self.progress.emit(90, 'Flash проверена, сброс и запуск STM32') # Переход к запуску разрешён только после независимой проверки обоих снимков.
self._launch('run', start_script(self._options)) # Новый Commander больше не стирает и не программирует Flash.
else: # Второй этап должен дойти до явного возобновления исполнения ядра.
if not re.search(r'J-Link>\s*g\s*(?:\r?\n|$)', self._output): raise ValueError('J-Link не подтвердил выполнение команды запуска; проверьте журнал') # Преждевременный выход с кодом ноль не превращается в сообщение о работающем MCU.
self.progress.emit(100, 'Запись и проверка завершены, выполнен reset/run') # Проценты завершаются только после обоих этапов.
self._finish(True, f'STM32 прошит через {self._options.interface}; Flash проверена, reset/run выполнен. Backup: {self.folder / "before.bin"}') # Итог содержит доступную для восстановления резервную копию.
except (OSError, ValueError) as error: self._finish(False, str(error)) # При отказе проверки ядро не запускается новым этапом.
def cancel(self): # Вызывается штатной кнопкой отмены формы прошивки.
self._stop('J-Link: операция отменена; частичная Flash требует повторной прошивки, резервная копия сохранена в каталоге операции') # Не обещает целый образ после прерывания аппаратной записи.
def _stop(self, reason): # Общая остановка обслуживает кнопку отмены и предельный тайм-аут.
if not self._active: return # Повторная отмена завершённого порта ничего не меняет.
self._stop_reason = reason # finished процесса должен сохранить причину вместо сообщения об обычном ненулевом коде.
if self._process is not None: self._process.kill() # Останавливает только запущенный этим портом Commander, не затрагивая Keil и другие программаторы.
else: self._finish(False, reason) # Ошибка между этапами также освобождает форму без дополнительного процесса.
def _finish(self, success, message): # Единственная точка завершения защищает форму от двойных уведомлений.
if not self._active: return # Второй сигнал ошибки того же QProcess не открывает повторное окно.
self._active = False # Новая операция разрешается только после окончательного результата текущей.
self._timer.stop() # Поздний тайм-аут не должен отменить следующий пользовательский запуск.
if self._process is not None: self._process.deleteLater() # FailedToStart не использует общий обработчик finished.
self._process = None # Освобождает ссылку на завершённый QObject.
self.finished.emit(success, message) # Владелец разблокирует форму и покажет итог стандартным механизмом SETGUI.
@@ -0,0 +1,96 @@
"""Общие настройки STM32/J-Link для встраивания в существующий экран прошивки.""" # SETGUI предоставляет лишь выбранный образ и свой терминал.
from pathlib import Path # Проверяет путь внешнего инструмента до подтверждения аппаратной операции.
try: # Поддерживает обе используемые SETGUI ветви Qt без зависимости от gui_desktop.
from PySide6.QtCore import QCoreApplication, QSettings, QStandardPaths, Signal # Настройки и каталог backup принадлежат текущему приложению.
from PySide6.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QLineEdit, QComboBox, QSpinBox, QCheckBox, QPushButton, QFileDialog, QLabel # Использует обычные виджеты, доступные в Qt 5 и Qt 6.
except ImportError: # Старые Windows-сборки сохраняют тот же публичный интерфейс панели.
from PySide2.QtCore import QCoreApplication, QSettings, QStandardPaths, Signal # Не навязывает основному приложению версию Qt.
from PySide2.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QLineEdit, QComboBox, QSpinBox, QCheckBox, QPushButton, QFileDialog, QLabel # Набор контролов одинаков для обеих реализаций.
from set_devices.stm32_jtag import JLinkOptions, find_jlink, prepare_image # Общий план выполняет все проверки MCU, адресов и manifest до записи.
from .stm32_jtag import Stm32JtagProgrammer # Порт QProcess отвечает за журнал, отмену и оба этапа операции.
class Stm32JtagPanel(QWidget): # Встраивается в FirmwareTab вместо UART/CAN-настроек выбранного режима.
progress = Signal(int, str) # Перенаправляет этапы в полосу прогресса основной формы.
finished = Signal(bool, str) # Результат обрабатывается тем же методом, что остальные загрузчики.
log = Signal(str) # Вывод J-Link направляется в существующий терминал прошивки.
def __init__(self, parent=None, settings=None): # Явное хранилище позволяет изолировать настройки в тестах и других приложениях.
super().__init__(parent) # Панель следует обычному времени жизни владельца QWidget.
self.settings = settings if settings is not None else QSettings() # Не подменяет тестовый профиль глобальным пользовательским реестром.
self.programmer = Stm32JtagProgrammer(self) # Создание формы само по себе не подключает USB и не запускает Commander.
self.programmer.progress.connect(self.progress.emit) # Прогресс проходит через общий публичный интерфейс панели.
self.programmer.finished.connect(self.finished.emit) # Ошибки и успех освобождают внешнюю форму одинаковым способом.
self.programmer.log.connect(self.log.emit) # Терминал получает и служебные строки, и сообщения SEGGER.
if QCoreApplication.instance() is not None: QCoreApplication.instance().aboutToQuit.connect(self.programmer.cancel) # Закрытие приложения не оставляет скрытый процесс программирования без владельца.
root = QVBoxLayout(self) # Все настройки образуют одну колонку внутри существующей левой панели.
root.setContentsMargins(0, 0, 0, 0) # Внешние отступы уже заданы формой прошивки.
hint = QLabel('J-Link через JTAG или SWD. Доступ к BOOT0 не требуется. Перед записью сохраняется полная резервная копия Flash.') # Оператор сразу видит назначение режима и способ первого программирования.
hint.setWordWrap(True) # Описание помещается в узкую колонку рядом с терминалом.
root.addWidget(hint) # Пояснение располагается над параметрами подключения.
form = QFormLayout() # Подписи не смешиваются с UART-полями других загрузчиков.
self.executable = QLineEdit(str(self._value('executable', find_jlink()))) # Позволяет использовать SEGGER из Keil либо отдельную установленную версию.
executable_row = QHBoxLayout() # Кнопка выбора остаётся рядом с полным путём инструмента.
executable_row.addWidget(self.executable) # Введённый путь передаётся QProcess отдельным аргументом.
executable_button = QPushButton('Обзор…') # Поиск установленного инструмента не требует скачивания и установки.
executable_button.clicked.connect(self._choose_executable) # Диалог выбирает только путь, без запуска программатора.
executable_row.addWidget(executable_button) # Кнопка сохраняет естественную компактную ширину.
form.addRow('J-Link Commander', executable_row) # Имя executable явно отделено от названия MCU.
self.device = QComboBox() # Стандартные имена подходят базе устройств установленного Commander.
self.device.setEditable(True) # Другой STM32 можно указать вручную вместе с фактическим объёмом Flash.
self.device.addItems(['STM32F103RC', 'STM32F103C8', 'STM32F103ZE', 'STM32F407VE', 'STM32G474VE']) # Список охватывает используемые проектами templates семейства.
self.device.setCurrentText(str(self._value('device', 'STM32F103RC'))) # По умолчанию выбирается текущая плата, ранее проверенная через JTAG.
form.addRow('Микроконтроллер', self.device) # Полное имя цели видно до подтверждения записи.
self.interface = QComboBox() # Интерфейс не зависит от UART-скорости верхней панели SETGUI.
self.interface.addItems(['JTAG', 'SWD']) # Оба варианта используют J-Link, а не встроенный ROM Boot.
self.interface.setCurrentText(str(self._value('interface', 'JTAG'))) # Исходное подключение текущей платы выполнено по JTAG.
form.addRow('Интерфейс', self.interface) # Пользователь видит выбранный физический способ подключения.
self.serial = QLineEdit(str(self._value('serial', ''))) # Не закрепляет серийный номер чужого программатора в общей библиотеке.
self.serial.setPlaceholderText('Необязательно, если подключён один J-Link') # Несколько программаторов можно однозначно различить по серийному номеру.
form.addRow('Серийный номер J-Link', self.serial) # Поле соответствует штатному параметру Commander -USB.
self.speed = QSpinBox() # Частота задаётся числом с явными единицами.
self.speed.setRange(1, 50000) # Допускает медленную плату и быстрые поддерживаемые подключения.
self.speed.setValue(int(self._value('speed', 1000))) # Проверенная начальная частота 1000 кГц подходит текущему JTAG.
self.speed.setSuffix(' кГц') # Исключает смешение TCK/SWCLK со скоростью COM в бодах.
form.addRow('Частота JTAG/SWD', self.speed) # Значение передаётся напрямую в -speed.
self.flash_size = QSpinBox() # Полный объём нужен для сохранения Flash перед любым программированием.
self.flash_size.setRange(16, 4096) # Интервал покрывает поддерживаемый переносимым планом объём 16 КиБ..4 МиБ.
self.flash_size.setValue(int(self._value('flash_size', 256))) # У STM32F103RC установлены 256 КиБ внутренней Flash.
self.flash_size.setSuffix(' КиБ') # Длина SaveBin вычисляется из этого значения в байтах.
form.addRow('Объём Flash', self.flash_size) # Для другого MCU оператор должен указать его фактический объём.
self.uksvep = QCheckBox('UKSVEP F103RC: сохранить параметры и обновить CRC приложения') # Явный профиль разрешает специфические адреса manifest и регистров BKP.
self.uksvep.setChecked(self.settings.value('firmware/stm32-jtag/uksvep', True, type=bool)) # Выбор сохраняется независимо от общего имени MCU.
self.uksvep.setToolTip('Для другой платы снимите этот флажок. UKSVEP принимает полный app с 0x08008000 или установочный HEX boot+app.') # Объясняет действие профиля при осмысленном выборе оператором.
form.addRow('Профиль платы', self.uksvep) # Обычные STM32 не наследуют защиту и служебную запись UKSVEP автоматически.
default_backup = str(Path(QStandardPaths.writableLocation(QStandardPaths.AppLocalDataLocation)) / 'stm32-jtag') # Каталог остаётся записываемым также у установленного в Program Files приложения.
self.backup_root = QLineEdit(str(self._value('backup_root', default_backup))) # Каждая операция создаёт внутри отдельную подпапку с образами и журналами.
backup_row = QHBoxLayout() # Настройка архива не требует ручного редактирования путей Windows.
backup_row.addWidget(self.backup_root) # Полный путь остаётся доступным для копирования оператором.
backup_button = QPushButton('Обзор…') # Выбирает существующий базовый каталог резервных копий.
backup_button.clicked.connect(self._choose_backup) # Диалог не изменяет Flash и настройки программатора.
backup_row.addWidget(backup_button) # Действие располагается у соответствующего пути.
form.addRow('Резервные копии', backup_row) # Backup включён всегда и не прячется в настройках UART.
root.addLayout(form) # Готовая панель встраивается без собственной кнопки прошивки и повторной полосы прогресса.
def _value(self, name, default): # Все ключи принадлежат отдельному стабильному разделу прошивки J-Link.
return self.settings.value('firmware/stm32-jtag/' + name, default) # Прежние UART/CAN-настройки не меняются.
def _choose_executable(self): # Пользователь явно указывает уже установленный J-Link Commander.
path, _ = QFileDialog.getOpenFileName(self, 'J-Link Commander', self.executable.text(), 'J-Link (JLink.exe JLinkExe);;Все файлы (*)') # Расширение не ограничивает Linux-версию SEGGER.
if path: self.executable.setText(path) # Отмена диалога сохраняет текущий путь.
def _choose_backup(self): # Позволяет хранить backup вне каталога установки GUI и исходников.
path = QFileDialog.getExistingDirectory(self, 'Каталог резервных копий STM32', self.backup_root.text()) # Выбор каталога не запускает аппаратную операцию.
if path: self.backup_root.setText(path) # Прежний архив остаётся выбранным при отмене диалога.
def prepare(self, image): # Основная форма вызывает проверку до стандартного подтверждения записи.
executable = Path(self.executable.text().strip()) # Путь не интерпретируется как команда оболочки.
if not executable.is_file(): raise ValueError('Выберите установленный J-Link Commander (JLink.exe)') # Отказывает до блокировки формы и открытия программатора.
if not self.backup_root.text().strip(): raise ValueError('Выберите каталог резервных копий Flash') # Не сохраняет обязательный backup в непредсказуемый текущий каталог.
options = JLinkOptions(self.device.currentText().strip(), self.interface.currentText(), self.speed.value(), self.serial.text().strip(), self.flash_size.value(), self.uksvep.isChecked()) # Фиксирует все параметры одновременно с неизменяемым образом.
return str(executable), options, prepare_image(image, options), self.backup_root.text().strip() # Возвращает точный план для единственного последующего подтверждённого запуска.
def start(self, job): # Вызывается только после существующего подтверждения прошивки в FirmwareTab.
for name, widget in (('executable', self.executable), ('serial', self.serial), ('backup_root', self.backup_root)): self.settings.setValue('firmware/stm32-jtag/' + name, widget.text().strip()) # Сохраняет пути и идентификатор без доступа к остальным настройкам GUI.
for name, value in (('device', self.device.currentText()), ('interface', self.interface.currentText()), ('speed', self.speed.value()), ('flash_size', self.flash_size.value()), ('uksvep', self.uksvep.isChecked())): self.settings.setValue('firmware/stm32-jtag/' + name, value) # Следующий запуск формы восстанавливает фактическое последнее подключение.
self.programmer.start(*job) # Процесс получает уже проверенный снимок, а не повторно читает изменяемые поля формы.
+124
View File
@@ -0,0 +1,124 @@
"""Подготовка и независимая проверка J-Link-прошивки STM32 без зависимости от Qt.""" # Один план используют SETGUI и консольные клиенты templates.
from dataclasses import dataclass # Неизменяемые настройки фиксируют назначение до первой записи Flash.
from pathlib import Path # Пути передаются программатору как данные, без выполнения оболочкой.
import os # Стандартные каталоги Windows позволяют найти уже установленный J-Link.
import re # Ограничивает поля, попадающие в аргументы J-Link Commander.
import shutil # Поиск PATH сохраняет возможность использовать установленный SEGGER вне Keil.
import struct # Manifest UKSVEP содержит шесть little-endian слов uint32.
import zlib # CRC32 IEEE совпадает с валидатором production-загрузчика UKSVEP.
from firmware_image.core import HexSegment, parse_intel_hex, contiguous_image # Проверка формата и checksum использует каноническое C-ядро templates.
from .mcu_profiles import UKSVEP_F103RC_BOOT # Адрес приложения, SRAM и Product берутся из общего профиля платы.
FLASH_BASE = 0x08000000 # Внутренняя Flash STM32 отображается с этого адреса в карте памяти Cortex-M.
UKS_APP = UKSVEP_F103RC_BOOT['app_base'] # Начальные 32 КиБ UKSVEP занимает постоянный загрузчик.
UKS_PARAMS = UKS_APP + UKSVEP_F103RC_BOOT['app_size'] # Следующая страница 0x0801F800 хранит параметры и исключается из записи.
UKS_META = 0x0803F800 # Последняя страница 256-КиБ Flash содержит production-manifest, см. boot_profile.h UKSVEP.
@dataclass(frozen=True) # Значения нельзя незаметно изменить между подтверждением формы и запуском процесса.
class JLinkOptions: # Описывает конкретный STM32 и физическое подключение программатора.
device: str = 'STM32F103RC' # Имя совпадает с базой устройств J-Link; корпусный суффикс T6 не требуется.
interface: str = 'JTAG' # SWD использует тот же Commander и выбирается пользователем отдельно.
speed_khz: int = 1000 # Частота TCK/SWCLK задаётся в кГц и не связана со скоростью UART.
serial: str = '' # Пустое значение разрешает штатный выбор единственного подключённого J-Link.
flash_kib: int = 256 # Размер всей Flash задаёт длину обязательных backup и readback.
uksvep: bool = True # Только явно выбранный профиль UKSVEP включает защиту параметров и manifest.
def validate(self): # Отказ происходит до создания command file и доступа к плате.
if not re.fullmatch(r'STM32[A-Za-z0-9_]+', self.device): raise ValueError('Укажите имя STM32 из базы J-Link') # Не допускает пробелы и команды вместо идентификатора MCU.
if self.interface not in ('JTAG', 'SWD'): raise ValueError('Выберите JTAG или SWD') # Остальные интерфейсы не относятся к этой панели.
if not 1 <= self.speed_khz <= 50000: raise ValueError('Частота JTAG/SWD должна быть 1..50000 кГц') # Верхняя граница соответствует доступному диапазону формы.
if self.serial and not re.fullmatch(r'[0-9]+', self.serial): raise ValueError('Серийный номер J-Link должен содержать только цифры') # Идентификатор не должен включать дополнительные аргументы.
if not 16 <= self.flash_kib <= 4096: raise ValueError('Размер Flash должен быть 16..4096 КиБ') # Ограничивает аппаратное чтение выбранным пользователем объёмом STM32.
if self.uksvep and (self.device != 'STM32F103RC' or self.flash_kib != 256): raise ValueError('Профиль UKSVEP требует STM32F103RC и Flash 256 КиБ') # Не применяет карту F103RC и регистры BKP к другому семейству.
def arguments(self): # Возвращает отдельные аргументы QProcess без shell=True и строковой сборки команды.
self.validate() # Публичный метод безопасен также вне GUI.
args = ['-device', self.device, '-if', self.interface, '-JTAGConf', '-1,-1', '-speed', str(self.speed_khz), '-autoconnect', '1', '-ExitOnError', '1', '-NoGui', '1'] # Явная цепочка исключает интерактивный JTAGConf, преждевременно завершавший batch с кодом ноль.
return (['-USB', self.serial] if self.serial else []) + args # Серийный номер закрепляет конкретный программатор при нескольких подключениях.
def find_jlink(): # Обнаруживает существующий инструмент и ничего не скачивает в систему.
found = shutil.which('JLink.exe') or shutil.which('JLinkExe') # PATH может содержать также одноимённый сборщик модулей Java JDK.
candidates = ['C:/Keil_v5_41/ARM/Segger/JLink.exe', 'C:/Keil_v5/ARM/Segger/JLink.exe'] # Проверенные каталоги SEGGER в Keil имеют приоритет над неоднозначным PATH.
for variable in ('ProgramFiles', 'ProgramFiles(x86)'): # SEGGER может быть установлен в 32- или 64-разрядный каталог Windows.
if os.environ.get(variable): candidates.append(str(Path(os.environ[variable]) / 'SEGGER/JLink/JLink.exe')) # Не читает реестр и не требует прав администратора.
if found and not any((Path(found).parent / name).is_file() for name in ('java.exe', 'java', 'jmod.exe', 'jmod')): candidates.append(found) # Инструмент из bin Java JDK не является программатором SEGGER и не должен выбираться автоматически.
return next((str(Path(item)) for item in candidates if item and Path(item).is_file()), '') # Пустой результат предлагает явный выбор executable в форме.
def prepare_image(image, options): # Проверяет выбранный ранее FirmwareImage и сохраняет разреженную адресацию HEX.
options.validate() # Настройки проверяются до чтения внешних файлов и доступа к программатору.
path = Path(image.path) # Образ может прийти как из формы, так и из Публикатора.
if path.stat().st_size > 16 * 1024 * 1024: raise ValueError('Файл прошивки превышает 16 МиБ') # Ограничение покрывает 4 МиБ данных Intel HEX с текстовыми накладными расходами.
raw = path.read_bytes() # Читает единственный снимок исходника, который затем сравнивается с подтверждённым выбором.
if path.suffix.lower() == '.hex': # HEX уже содержит абсолютные адреса всех отдельных диапазонов Flash.
flat, base = contiguous_image(raw, options.flash_kib * 1024) # Канонический parser проверяет checksum, пересечения и полный адресный размах.
if flat != image.data or base != image.base_address: raise ValueError('Файл или адрес HEX изменён после выбора; выберите образ заново') # Не записывает новый файл под видом ранее выбранного образа и не смещает HEX.
segments = parse_intel_hex(raw) # Разрывы остаются отсутствующими записями, а не превращаются в байты FF для loadfile.
elif path.suffix.lower() == '.bin': # BIN требует явно указанного адреса, поскольку сам его не хранит.
if raw != image.data: raise ValueError('BIN изменён после выбора; выберите образ заново') # Не использует устаревший SHA-256 формы для другого содержимого.
segments = [HexSegment(image.base_address, raw)] # Адрес из формы относится к началу полезных байтов BIN.
else: raise ValueError('Для J-Link выберите Intel HEX или BIN') # OUT/ELF не обходят единый проверяемый план записи Flash.
if not segments or any(not part.data or part.start < FLASH_BASE or part.end >= FLASH_BASE + options.flash_kib * 1024 for part in segments): raise ValueError('Образ выходит за выбранный объём Flash STM32') # Не допускает запись RAM, option bytes или соседней памяти.
if options.uksvep: # Защита и manifest относятся только к конкретному порту UKSVEP.
if any(part.start < UKS_PARAMS + 2048 and part.end >= UKS_PARAMS for part in segments): raise ValueError('Образ затрагивает страницу параметров UKSVEP 0x0801F800') # Размер страницы F103RC равен 2048 байтам по профилю boot.
app = [part for part in segments if part.start < UKS_PARAMS and part.end >= UKS_APP] # Находит приложение без включения загрузчика и служебной страницы.
if app: # Запись одного app HEX также должна обновить CRC, иначе постоянный boot не разрешит запуск.
if len(app) != 1 or app[0].start != UKS_APP or app[0].end >= UKS_PARAMS: raise ValueError('Для UKSVEP нужен полный непрерывный образ приложения с 0x08008000') # Не выдаёт фрагментарный патч или смешанный BIN за полное приложение.
data = app[0].data # CRC считается по полезной длине приложения, без промежутков Flash.
if len(data) < 8: raise ValueError('В приложении отсутствует таблица векторов Cortex-M3') # Два первых слова содержат MSP и Reset_Handler.
sp, pc = struct.unpack_from('<II', data) # Векторы Cortex-M3 хранятся в little-endian.
if sp & 7 or not 0x20000000 < sp <= UKSVEP_F103RC_BOOT['stack_end'] or not pc & 1 or not UKS_APP <= (pc & ~1) < UKS_APP + len(data): raise ValueError('Неверные векторы приложения UKSVEP') # Требует SRAM 48 КиБ, выравнивание стека и Thumb-вход внутрь образа.
crc = zlib.crc32(data) & 0xFFFFFFFF # IEEE CRC32 совпадает с uks_boot_installed_valid() production-порта.
manifest = struct.pack('<6I', 0x554B5356, len(data), crc, ~len(data) & 0xFFFFFFFF, ~crc & 0xFFFFFFFF, UKSVEP_F103RC_BOOT['product']) # Magic и шесть слов заданы UKS_META_MAGIC/UKS_META_WORDS в boot_port.c.
existing = [part for part in segments if part.start < UKS_META + 2048 and part.end >= UKS_META] # Проверяет также явный manifest установочного HEX.
if existing and (len(existing) != 1 or existing[0].start != UKS_META or existing[0].data != manifest): raise ValueError('Manifest UKSVEP не соответствует приложению') # Не скрывает неверную служебную запись готового комплекта.
if not existing: segments.append(HexSegment(UKS_META, manifest)) # Для app-only JTAG формирует ту же запись активации, что UART/CAN boot.
return tuple(sorted(segments, key=lambda item: item.start)) # Неизменяемый отсортированный снимок используется для loadfile и последующего readback.
def encode_hex(segments): # Сохраняет только фактические диапазоны плана без заполнения защищённых разрывов.
lines, upper = [], None # Старшие шестнадцать бит записываются при каждом переходе между областями 64 КиБ.
def record(address, kind, data): # Стандартная строка Intel HEX содержит длину, адрес, тип, данные и дополнение суммы до нуля.
body = bytes((len(data), address >> 8, address & 255, kind)) + data # Адрес внутри записи ограничен текущим окном 64 КиБ.
return ':' + (body + bytes((-sum(body) & 255,))).hex().upper() # Проверяемая checksum делает снимок пригодным для независимого J-Link parser.
for part in segments: # Каждый диапазон начинает адресованные данные без добавления промежутков.
offset = 0 # Положение выражено в байтах текущего сегмента.
while offset < len(part.data): # Не создаёт пустых записей в конце последнего блока.
address = part.start + offset # Вычисляет абсолютный адрес следующей порции.
if address >> 16 != upper: # Type 04 обновляет старшую половину линейного адреса.
upper = address >> 16 # Последующие data-записи используют именно это окно.
lines.append(record(0, 4, upper.to_bytes(2, 'big'))) # Порядок байтов type 04 задан Intel HEX и не зависит от Cortex-M.
count = min(16, len(part.data) - offset, 0x10000 - (address & 0xFFFF)) # Строка не пересекает границу окна даже при невыровненном начале.
lines.append(record(address & 0xFFFF, 0, part.data[offset:offset + count])) # Добавляет только реальные байты исходного диапазона.
offset += count # Следующая строка продолжает непрерывные данные данного сегмента.
return '\n'.join(lines + [record(0, 1, b'')]) + '\n' # EOF завершает UTF-8/ASCII-снимок с обычными переводами строк.
def quote_path(path): # Экранирование J-Link command file отличается от аргументов системного процесса.
value = Path(path).resolve().as_posix() # Абсолютные пути исключают зависимость backup от рабочего каталога.
if any(char in value for char in ('"', '\r', '\n')): raise ValueError('Путь J-Link не должен содержать кавычки или переносы строк') # Не позволяет имени каталога стать дополнительной командой Commander.
return '"' + value + '"' # Пробелы и кириллица остаются частью одного имени файла.
def programming_script(folder, options): # Первый этап сохраняет backup и readback, оставляя ядро остановленным до независимой проверки.
folder = Path(folder) # Все артефакты принадлежат отдельному каталогу одной операции.
length = hex(options.flash_kib * 1024) # SaveBin принимает длину полного Flash в байтах.
return '\n'.join(['ExitOnError 1', 'r', 'h', f'savebin {quote_path(folder / "before.bin")} 0x08000000 {length}', f'loadfile {quote_path(folder / "image.hex")}', f'savebin {quote_path(folder / "after.bin")} 0x08000000 {length}', 'q', '']) # Полного erase нет; loadfile программирует разреженный проверенный HEX с собственной Verify.
def verify_readback(segments, options, before, after): # Проверяет аппаратные снимки независимо от кода возврата Commander.
if len(before) != options.flash_kib * 1024 or len(after) != len(before): raise ValueError('J-Link не сохранил полный backup/readback Flash; запуск отменён') # Нулевой код интерактивно прерванного Commander не считается успехом.
for part in segments: # Каждая реально записываемая область должна совпасть побайтно.
offset = part.start - FLASH_BASE # Смещение внутри полного аппаратного снимка.
if after[offset:offset + len(part.data)] != part.data: raise ValueError(f'Ошибка проверки Flash по адресу 0x{part.start:08X}; запуск отменён') # Не запускает MCU после неполного программирования.
if options.uksvep: # Проверяет сохранность старых настроек именно там, где их хранит UKSVEP.
offset = UKS_PARAMS - FLASH_BASE # Страница параметров находится между boot/app и manifest.
if before[offset:offset + 2048] != after[offset:offset + 2048]: raise ValueError('Изменилась страница параметров UKSVEP; запуск отменён') # Проверка касается реальной Flash, а не только отсутствия области в HEX.
def start_script(options): # Второй этап выполняется только после успешной проверки readback на хосте.
commands = ['ExitOnError 1', 'r'] # Сброс снимает состояние RAM-алгоритма Flash, оставшееся после loadfile.
if options.uksvep: commands += ['w4 0x4002101C 0x18000000', 'w4 0x40007000 0x00000100', 'w4 0x40006C24 0x00000000', 'w4 0x40006C28 0x00000000'] # F103RC: включение PWR/BKP, DBP bit8 и очистка DR9/DR10 убирают старый одноразовый запрос boot без стирания резервного домена.
return '\n'.join(commands + ['g', 'q', '']) # Возобновляет выполнение после reset и освобождает программатор без повторной записи Flash.