diff --git a/.gitignore b/.gitignore index e27995e..1757a1c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Desktop.ini # Generated native library (built before packaging) python/protocan/native/ + +# Кэш pytest создаётся проверками автоматически и не входит в исходники общей библиотеки. +.pytest_cache/ diff --git a/c/firmware-info/tests/test_c2000_sci_build.py b/c/firmware-info/tests/test_c2000_sci_build.py new file mode 100644 index 0000000..831fb6f --- /dev/null +++ b/c/firmware-info/tests/test_c2000_sci_build.py @@ -0,0 +1,30 @@ +"""Проверяет настоящий SCI8 TI против ограничений штатного загрузчика UlitCo.""" # Проверка не открывает COM-порт и не изменяет Flash платы. +from pathlib import Path # Находит общий сборщик и установленный TI compiler относительно теста и стандартных каталогов. +import os # Позволяет CI явно выбрать установленный TI compiler без путей конкретного пользователя. +import struct # Читает эталонный заголовок SCI8 и два слова команды перехода C28x. +import subprocess # Получает фактическую точку входа COFF официальным инструментом TI OFD. +import sys # Подключает ту же реализацию build_kernel, которую использует postbuild CCS. +import tempfile # Каждая компиляция создаёт независимые файлы SDK и результаты. +import unittest # Запускает аппаратно независимую проверку стандартной библиотекой Python. +import xml.etree.ElementTree as ET # Извлекает адрес _InitBoot из официального описания исполняемого COFF. +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) # В рабочем templates сборщик находится рядом с каталогом tests. +import c2000_sci_build as sci # Компилирует настоящее RAM-ядро без подмены TI compiler и hex2000. + +class SciKernelBuildTests(unittest.TestCase): # Фиксирует несовместимость entry=0x9000 со штатным primary_bootloader.obj. + def test_kernel_enters_at_zero_and_branches_to_initboot(self): # Проверяет транспортный вход и реальное назначение машинного перехода. + compiler = Path(os.environ.get('FIRMWARE_C2000_COMPILER', 'C:/ti/ccs1230/ccs/tools/compiler/ti-cgt-c2000_22.6.0.LTS')) # Переменная переопределяет стандартную установку TI на машинах CI. + if not (compiler / 'bin/cl2000.exe').is_file(): self.skipTest('Не установлен TI C2000 compiler') # Хостовые проверки без проприетарного toolchain остаются доступными. + try: ware = sci.find_c2000ware() # Использует тот же поиск SDK F28335, что штатная сборка проекта. + except ValueError as error: self.skipTest(str(error)) # Отсутствующий SDK явно помечает проверку как пропущенную. + with tempfile.TemporaryDirectory(prefix='ulitco-sci-entry-') as temporary: # Компиляция не изменяет исходники установленного SDK. + image = sci.build_kernel(compiler, Path(temporary) / 'f28335_sci_kernel.bin', 30000000, 0x0080, ware) # Плата использует кварц 30 МГц; маска 0x80 выбирает сектор H из комплекта, вызвавшего ошибку. + data = image.read_bytes() # Проверяет конечный транспортный BIN после настоящего hex2000. + self.assertEqual(data[:2], b'\xAA\x08') # Действующий приёмник ожидает ключ SCI8 0x08AA в порядке младшего байта вперёд. + self.assertEqual(struct.unpack_from('> 16), entry & 0xFFFF)) # Кодирование LB C28x содержит старшие шесть бит адреса в первом слове и младшие 16 во втором; переход должен вести именно в _InitBoot. + +if __name__ == '__main__': unittest.main() # Позволяет выполнить проверку отдельно от полного набора тестов библиотеки. diff --git a/c/firmware-info/tools/c2000_sci_build.py b/c/firmware-info/tools/c2000_sci_build.py index c5bd816..e59015c 100644 --- a/c/firmware-info/tools/c2000_sci_build.py +++ b/c/firmware-info/tools/c2000_sci_build.py @@ -53,12 +53,14 @@ def build_kernel(compiler, output, oscillator_hz, sectors, ware=None): # Соб text, replaced = re.subn(r"(?m)^#define\s+CPU_RATE\s+[^\r\n]+", f"#define CPU_RATE {cpu_rate:.6f}L /* Период SYSCLKOUT в наносекундах для выбранного кварца. */", text, count=1) # Не меняет закомментированные примеры частот TI. if replaced != 1: raise ValueError("В TI kernel отсутствует настройка CPU_RATE") # Отсутствующий масштаб времени не допускает вызов Flash API. config.write_text(text, encoding="utf-8") # Подготовленный заголовок действует только на RAM-ядро этой сборки. + start = source / "UlitCo_CodeStart.asm" # Штатный primary_bootloader.obj разрешает только entry=0 и после загрузки выполняет LB #0. + start.write_text(' .ref _InitBoot ; Использует штатную инициализацию стека и возврат из TI Kernel A.\n .sect "codestart" ; Карта TI размещает два слова перехода в BEGIN по словному адресу 0x000000.\n LB _InitBoot ; Передаёт управление ядру с корректным стеком вместо входа через C runtime.\n', encoding="utf-8") # Самого изменения entry недостаточно: прежний codestart вёл через _c_int00 и обходил выход _ExitBoot. sources = list(source.glob("*.c")) + list(source.glob("*.asm")); commands = [] # Локальные исходники штатного проекта компилируются без рекурсивного запуска CCS Make. project = ET.parse(original / ".project") # Связанные исходники SDK разрешаются по стандартным ссылкам проекта TI. for link in project.findall(".//linkedResources/link"): # Подключает common и headers, не копируя их в проект пользователя. location = link.findtext("locationURI", "") # Официальный проект использует INSTALLROOT_2833X. if location.startswith("INSTALLROOT_2833X/"): commands.append(device / location.split("/", 1)[1]) # Сохраняет пути выбранного SDK вместо абсолютных путей TI-разработчика. - sources += [path for path in commands if path.suffix.lower() in (".c", ".asm")] # Включает ADC calibration, delay и периферийные символы F28335. + sources += [path for path in commands if path.suffix.lower() in (".c", ".asm") and path.name != "DSP2833x_CodeStartBranch.asm"] # Включает ADC calibration, delay и регистры F28335; штатный codestart заменён переходом в _InitBoot. compiler_args = [str(compiler / "bin/cl2000.exe"), "-v28", "-ml", "-mt", "--float_support=fpu32", "--abi=coffabi", "--define=LARGE_MODEL", "--opt_level=2"] # Параметры соответствуют официальному проекту TI F28335 Kernel A. compiler_args += ["--include_path=" + str(path) for path in (source, device / "common/include", device / "headers/include", compiler / "include")] # Только заголовки выбранных compiler и SDK участвуют в сборке. objects = [] # Каждый исходник создаёт свой новый OBJ в staging. @@ -69,7 +71,7 @@ def build_kernel(compiler, output, oscillator_hz, sectors, ware=None): # Соб link_args = [str(compiler / "bin/cl2000.exe"), "-v28", "-ml", "--float_support=fpu32", "--abi=coffabi", "-z", "--rom_model", "--entry_point=_InitBoot", "--stack_size=0x200", "--output_file=" + str(out), "--map_file=" + str(out.with_suffix(".map"))] # Entry _InitBoot и стек повторяют официальную RAM-конфигурацию ядра. link_args += ["--search_path=" + str(compiler / "lib"), "--search_path=" + str(source)] + objects + [str(path) for path in commands if path.suffix.lower() == ".cmd"] + [str(source / "Flash28335_API_V210.lib"), "-lrts2800_fpu32.lib"] # Flash API берётся из лицензированного SDK на машине пользователя. subprocess.run(link_args, check=True, cwd=source) # Неполная RAM-компоновка не допускается к упаковке. - subprocess.run([str(compiler / "bin/hex2000.exe"), str(out), "-boot", "-sci8", "-b", "-o", str(output)], check=True) # Получает бинарный SCI8, который первичный загрузчик UlitCo принимает в RAM. + subprocess.run([str(compiler / "bin/hex2000.exe"), str(out), "-boot", "-sci8", "--entrypoint=0", "-b", "-o", str(output)], check=True) # SCI8 входит через адрес 0x000000, где codestart передаёт управление _InitBoot; COFF сохраняет символическую точку входа. if not output.is_file() or not output.stat().st_size: raise ValueError("TI hex2000 не создал RAM-ядро SCI") # Ложный успех процесса не оставляет неполный комплект. return output # Ядро проверяется host C-ядром до первого байта COM-передачи. diff --git a/python/set_devices/qt_ports/c2000_sci_panel.py b/python/set_devices/qt_ports/c2000_sci_panel.py index 77d423b..7364338 100644 --- a/python/set_devices/qt_ports/c2000_sci_panel.py +++ b/python/set_devices/qt_ports/c2000_sci_panel.py @@ -4,11 +4,11 @@ from pathlib import Path # Разрешает имя RAM-ядра относи 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.QtCore import QThread, Signal, QIODevice, Qt, Slot # Рабочий поток не блокирует 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.QtCore import QThread, Signal, QIODevice, Qt, Slot # Сигналы безопасно возвращают результаты в главный поток. 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 и автомат обмена не повторяются в порту. @@ -21,8 +21,16 @@ class SciWorker(QThread): # Один сеанс владеет COM-портом 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._stage, self._last_tx = "Подготовка SCI", b"" # Начальное состояние нужно также для самостоятельного создания SciWorker в проверках транспорта. self.enter, self.timeout_ms, self.stop = enter, timeout_ms, threading.Event() # Отмена проверяется во всех конечных ожиданиях. + def configure(self, kernel, image, port, baud, enter=False, timeout_ms=3000): # Повторно использует готовый Qt-поток только после полного завершения предыдущего сеанса. + if self.isRunning(): raise ValueError('Прошивка SCI ещё выполняется') # Не меняем буферы, настройки UART и событие отмены во время аппаратного обмена. + self.kernel, self.image, self.port, self.baud = kernel, image, port, baud # Фиксируем новый неизменяемый комплект и скорость UART в бодах до запуска run. + self.enter, self.timeout_ms = enter, timeout_ms # Сохраняем явный вход Monitor и тайм-аут ответа в миллисекундах для нового сеанса. + self.stop.clear() # Отмена предыдущей операции не должна автоматически отменять следующую явную попытку. + self._stage, self._last_tx = 'Подготовка SCI', b'' # Диагностика нового сеанса не использует этап или последние байты предыдущей передачи. + def cancel(self): # GUI не вызывает методы QSerialPort чужого потока. self.stop.set() # Рабочий поток сам завершит чтение и закроет COM-порт. @@ -35,6 +43,7 @@ class SciWorker(QThread): # Один сеанс владеет COM-портом def _write(self, serial, data): # Передаёт готовые байты и дожидается фактической отправки UART. self._cancelled() # Не добавляет новые данные после запроса остановки. + self._last_tx = bytes(data[:16]) # При ошибке сохраняем первые 16 октетов последней передачи, чтобы отличить команду №93, autobaud и данные ядра. 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 и запас драйвера. @@ -49,30 +58,39 @@ class SciWorker(QThread): # Один сеанс владеет COM-портом 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 не трактуется как продолжение. + if time.monotonic() > end: raise TimeoutError(f"{self._stage}: {self.port}, {self.baud} бод; получено {len(result)}/{count} байт, TX {self._last_tx.hex(' ').upper() or 'нет'}") # Отсутствие эха или успешного Flash API не трактуется как продолжение. return bytes(result) # Проверку эха и checksum выполнит C-ядро. def run(self): # Qt вызывает метод в отдельном потоке, где создаётся и закрывается COM-порт. - serial = None # Ошибка проверки образа не должна даже открывать аппаратный интерфейс. + serial, started = None, time.monotonic() # COM открывается после проверки образа; монотонные часы измеряют полную длительность текущего сеанса, включая загрузку ядра и обязательные паузы. try: # Все исключения превращаются в конечный результат GUI. session = Session(self.kernel, self.image) # Проверяет RAM-ядро и Flash-таблицу до любой управляющей команды. + self._stage = f"Открытие {self.port}" # Ошибка доступа к COM возникает до начала синхронизации с микроконтроллером. 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()) # Занятый терминалом порт не открывается вторым владельцем. + if not serial.open(QIODevice.OpenModeFlag.ReadWrite): raise OSError(f"{self._stage}: {serial.errorString()}") # Занятый терминалом порт не открывается вторым владельцем. serial.clear() # Старые данные терминала не должны стать ложным эхо autobaud. if self.enter: # Приложение UlitCo принимает переход в загрузчик лишь в разрешённом остановленном состоянии. - self.progress.emit(0, "Переход UlitCo в первичный загрузчик…") # Показывает отдельный этап до передачи RAM-ядра. + self._stage = "Вход через Monitor №93" # До смены UART контроллер принимает штатный запрос на скорости приложения. + self.progress.emit(0, f"Monitor {self.port}, 468750 бод; TX {ulitco_enter().hex(' ').upper()}") # Показывает отдельный этап до передачи 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. + exchanges, last_percent = 0, None # Номер обмена уточняет тайм-аут, а последний показанный процент ограничивает журнал одним сообщением на изменение прогресса текущего сеанса. 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. + exchanges += 1 # Нумеруем только фактические передачи, не включая обязательные паузы C-автомата. + self._stage = "Первичная синхронизация SCI" if exchanges == 1 else f"Обмен SCI №{exchanges}" # Первая ошибка отделяется от последующей передачи ядра и программирования Flash. + if exchanges == 1: self.progress.emit(0, f"{self._stage}: {self.port}, {self.baud} бод, TX {tx.hex(' ').upper()}") # До ожидания первого ответа журнал фиксирует скорость и настоящий символ синхронизации. 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-проверку. + percent = session.progress # Подтверждённый процент по-прежнему вычисляется единственным C-автоматом после проверки ответа. + if percent != last_percent: # Тысячи побайтовых подтверждений RAM-ядра не должны создавать одинаковые строки и queued-сигналы GUI. + self.progress.emit(percent, "Ядро TI / запись Flash…") # Публикуем каждое изменение процента, включая конечные 100 процентов, после успешного обмена. + last_percent = percent # Следующий байт с тем же процентом продолжит аппаратный сеанс без повторной перерисовки и записи в журнал. + self.result.emit(True, f"Передача завершена за {time.monotonic() - started:.1f} с; ядро TI подтвердило порции Flash. Проверка чтением памяти отдельно не выполнялась.") # Сообщаем полное измеренное время сеанса и подтверждение ядра, сохраняя точное указание об отсутствии отдельной проверки чтением. except (OSError, ValueError, RuntimeError) as error: self.result.emit(False, str(error)) # Ошибка остановит GUI и сохранится в журнале текущей прошивки. finally: # Владение портом освобождается также после отмены или тайм-аута. if serial is not None: serial.close() # Закрывает только созданный этим потоком COM-сеанс. @@ -84,6 +102,11 @@ class C2000SciPanel(QWidget): # Общая панель используетс def __init__(self, parent=None): # Создаёт только параметры маршрута, оставляя COM и скорость основной форме. super().__init__(parent); self.worker = None; self.metadata = {}; self.path = None # Состояние соответствует одному выбранному Flash BIN. + self._result = None # Конечный результат относится к одному активному сеансу и сбрасывается при следующем явном старте. + self._thread = SciWorker(b'', b'', '', 115200, parent=self) # Создаём Qt-метаобъект потока один раз при создании панели, а не после ручного №93 и удаления элементов GUI. + self._thread.progress.connect(self._relay_progress, Qt.QueuedConnection) # Доставка прогресса идёт в слот панели главного потока вместо прямого вызова Signal.emit из аппаратного потока. + self._thread.result.connect(self._capture_result, Qt.QueuedConnection) # Обработка результата не изменяет состояние QWidget из рабочего потока. + self._thread.finished.connect(self._released, Qt.QueuedConnection) # Освобождение активного сеанса начинается только после завершения run и закрытия собственного COM. form = QFormLayout(self) # Контролы не требуют дополнительного окна приложения. self.kernel = QLineEdit(); form.addRow("RAM-ядро TI", self.kernel) # Готовый комплект заполняет путь автоматически. button = QPushButton("Выбрать ядро…"); button.clicked.connect(self._choose); form.addRow(button) # Старые образы допускают явный выбор соответствующего ядра. @@ -119,17 +142,22 @@ class C2000SciPanel(QWidget): # Общая панель используетс 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 не передавались. + self._thread.configure(*job, port, baud, self.enter_monitor.isChecked(), self.timeout_ms.value()) # Обновляем параметры уже существующего остановленного Qt-потока, не создавая новый метаобъект при каждой прошивке. + self.worker, self._result = self._thread, None # Публичная ссылка обозначает только активный сеанс и сохраняет существующие проверки закрытия главного окна. + self.worker.start() # Возобновляем готовый QThread; COM создаётся и закрывается только внутри его run. + @Slot(int, str) # Явная сигнатура позволяет Qt доставить прогресс именно в GUI-поток панели. + def _relay_progress(self, percent, text): # Принимает межпоточный сигнал до уведомления остальных элементов интерфейса. + self.progress.emit(percent, text) # Обновление полосы и терминала прошивки начинается из главного потока. + + @Slot(bool, str) # Результат рабочего потока обрабатывается зарегистрированным слотом QWidget. 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-сеанс гарантированно закрыт. + @Slot() # Завершение потока доставляется главному потоку через явное queued-соединение. + def _released(self): # Сохраняет готовый Qt-объект для следующего сеанса вместо удаления и повторного создания. + self.worker = None # Следующая попытка разрешена только после полного завершения предыдущего run. + self.finished.emit(*(self._result or (False, 'Поток SCI завершился без результата'))) # Форма разблокируется после закрытия COM, а постоянный QThread остаётся дочерним объектом панели. def cancel(self): # Отмена не вызывает terminate и не убивает чужие процессы. if self.worker is not None: self.worker.cancel() # Рабочий поток завершит своё ближайшее конечное ожидание.