Расширить DEIF, загрузку CAN и общую обработку сигналов

Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt.
Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor.
Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов.
Обновить справочники устройств, проекты CCS и регрессионные проверки.
This commit is contained in:
2026-10-24 15:40:51 +03:00
parent a5e46d4627
commit 41e24629ce
170 changed files with 43762 additions and 281 deletions
+16
View File
@@ -68,3 +68,19 @@ def test_confirm_and_progress_status_correlate_session():
assert reply['status'] == 0 and reply['command'] == 8 and reply['next_block'] == 123
assert service.decode(frame, discovery=True) is None
assert CanBootServices(CanBootTarget(device_type=3, device=7, session_id=6)).decode(frame) is None
@pytest.mark.parametrize("code,expected", [(0, 0), (0, 8), (0, 16), (8, 0), (8, 8), (8, 16)]) # Проверяет повторные и запоздалые OK/SEQUENCE_ERROR после подтверждённого первого окна.
def test_duplicate_data_status_does_not_resend_or_rewind_window(code, expected): # Один поздний ответ не должен порождать дополнительные 16 кадров в очереди MCU.
target = CanBootTarget(device_type=1, device=8, product_type=0x2833) # Использует адрес задатчика, на котором наблюдалось зависание передачи.
client = CanBootTransfer(SimpleNamespace(data=bytes(512), version=1, crc32=0), target) # Несколько окон позволяют проверить ответ до завершения передачи.
def status_frame(command, status_code=0, next_block=0): # Формирует тестовый STATUS в той же сессии, что запрос клиента.
return build_frame(ProtoCanId.build(1, 1, 1, 8, 12, 0x100 | command), struct.pack("<BBHI", status_code, 0, next_block, 0), to_can=False) # Session 1 и слот A отделяют проверку повторов от адресной фильтрации.
client.start() # Начинает обычную последовательность ENTER_BOOT и метаданных.
for command in (2, 3, 4, 5): # Переводит production-клиент к первому окну через штатные подтверждения этапов.
client.handle_status(status_frame(command)) # Каждый управляющий ответ разрешает только следующий этап протокола.
outgoing, _ = client.handle_status(status_frame(0, next_block=16)) # Подтверждает первое окно и ставит в отправку блоки 16–31.
assert len(outgoing) == 16 and client.next_block == 16 # Контрольное состояние должно содержать новое неподтверждённое окно.
for _ in range(16): # Остаток прежнего окна способен вернуть до 16 одинаковых ошибок последовательности.
assert client.handle_status(status_frame(0, code, expected)) == ([], "") # Дубликаты не пересылают текущее окно и не отодвигают транспортный тайм-аут.
assert client.next_block == 16 and client.stage == "data" # Запоздалый SEQUENCE_ERROR не откатывает уже подтверждённые блоки.
+19
View File
@@ -0,0 +1,19 @@
"""Общие DEIF-векторы проверяют ctypes теми же данными, что C и Android/JVM.""" # Тесты не требуют Qt или структуры Desktop-приложения.
from pathlib import Path # Fixture входит в общий репозиторий templates.
import pytest # Параметризация даёт независимый результат для каждой эталонной строки.
from set_devices.deif_native import get_deif_native # Python не использует альтернативный декодер при отсутствии C.
from set_devices.deif_display import DeifDisplay # Пассивная модель является общим API библиотеки.
FIXTURE = Path(__file__).resolve().parents[2] / 'c/set-protocol/tests/fixtures/deif-xdi-v1.tsv' # Точный общий источник тестов для трёх портов.
CASES = [line.split() for line in FIXTURE.read_text(encoding='utf-8').splitlines() if line and not line.startswith('#')] # Здесь разбирается текст fixture, а не протокол CAN.
@pytest.mark.parametrize('row', CASES) # Каждый ошибочный или валидный wire-кадр проверяется отдельно.
def test_ctypes_matches_c_and_jni_vectors(row): # Числовой результат должен совпасть с независимым описанием ожидаемого кадра.
identifier, payload, flags, result, raw, index, sub = row # Столбцы fixture не зависят от внутреннего layout C.
flags = int(flags) # EXT/RTR являются входными метаданными теста.
code, meta = get_deif_native().inspect_frame(int(identifier, 16), bytes.fromhex(payload), bool(flags & 1), bool(flags & 2)) # Wire-разбор выполняется единственным C-кодеком.
assert (code, meta[4], meta[2], meta[3]) == (int(result), int(raw), int(index, 16), int(sub, 16)) # RAW и адрес объекта совпадают с C- и Kotlin-проверкой.
def test_passive_angle_is_received_without_gui_or_simulation(): # Ошибка со снимка воспроизводится без запуска серверных сервисов.
model = DeifDisplay(source='CAN / мост', device_node=13) # Физический адрес SLAVE не становится источником SAM.
assert model.accept('CAN / мост', 0x201, bytes.fromhex('01 01 30 07 84 03 00 00'), now=10) # Точный кадр пользователя несёт RAW 900.
assert model.angle_selection(now=10) == (90.0, True) # Сетевое задание видно как 90°, с явной пометкой.
assert model.value('angle', now=10) is None # Принятое задание не превращается в фиктивный фактический объект 0x02.
assert model.angle_selection(now=15.001) == (None, False) # При полном молчании наблюдатель уважает прежний срок свежести пять секунд.
+4 -1
View File
@@ -126,7 +126,10 @@ def test_legacy_can_full_transaction_and_retransmission():
assert all(ProtoCanId.parse(f.can_id).msg_type == 11 for f in frames)
frames, _ = t.handle_status(status(t, 0, code=8, expected=8))
assert ProtoCanId.parse(frames[0].can_id).body == 8
assert frames[-1].data == bytes([128,129]) + b"\xff" * 6
assert [ProtoCanId.parse(frame.can_id).body for frame in frames] == list(range(8, 16)) # Восстановление повторяет хвост до абсолютной границы ACK 16 и не посылает преждевременно блок следующего окна.
frames, _ = t.handle_status(status(t, 0, expected=16)) # Настоящее ядро подтверждает блок 16 до передачи последнего неполного блока 16.
assert len(frames) == 1 and ProtoCanId.parse(frames[0].can_id).body == 16 # После ACK предыдущего окна остаётся ровно один блок образа из 130 байт.
assert frames[0].data == bytes([128,129]) + b"\xff" * 6 # Дополнение последнего блока до восьми байт сохраняет прежний wire-формат.
frames, _ = t.handle_status(status(t, 0, expected=17))
assert t.stage == "verify"
for cmd in (6, 7, 9):
@@ -0,0 +1,141 @@
"""Контракт сравнения проверяется с чужим canvas без импорта приложения.""" # Фиксируем возможность повторного использования Qt-порта.
import json # Сохранённый отчёт проверяется как машиночитаемые данные.
from pathlib import Path # Временный CSV и отчёт используют явные пути.
import tempfile # Проверки не затрагивают пользовательские файлы.
import time # Монотонный срок ограничивает ожидание worker.
from types import SimpleNamespace # Источник и кривая не зависят от моделей приложения.
import unittest # Набор можно запустить вне тестового окружения потребителя.
from unittest.mock import patch # Подменяем выбор файла, сохраняя реальную запись JSON.
try: # Используем доступную в окружении версию Qt.
from PySide6.QtWidgets import QApplication, QCheckBox, QDialog, QWidget # Qt6 предоставляет базовые элементы тестового canvas.
except ImportError: # Qt5 проверяется непосредственно, без слоя совместимости приложения.
from PySide2.QtWidgets import QApplication, QCheckBox, QDialog, QWidget # Публичный порт обязан поддерживать оба binding.
from protocan.plot import Bounds, Viewport # Общие контейнеры координат не требуют конкретного рендерера.
from set_devices.qt_ports.reference_comparison import ReferenceComparisonDialog, ReferenceComparisonPanel # Импортируем общий порт напрямую.
from set_devices.qt_ports.reference_overlay import ReferenceOverlayPanel # Прямое наложение предоставляет только параметры и данные, не создавая canvas.
class Canvas(QWidget): # Минимальный сторонний график реализует документированный контракт.
def __init__(self): # В тесте отсутствует зависимость от графика приложения.
super().__init__() # QSplitter принимает обычный QWidget.
self._series = [] # Панель заполняет кривые через внедрённую фабрику.
self.axis_lock_x, self.axis_lock_y = QCheckBox(self), QCheckBox(self) # Флажки поддерживают снятие фиксации осей.
self.axis_limits = None # Ручные границы сбрасываются при новом результате.
self.reset_interaction() # Начинаем с нейтрального масштаба.
self._sync_bounds() # Задаём известные границы тестовой записи.
def reset_interaction(self): # Общая панель сбрасывает навигацию перед показом кривых.
self.viewport = Viewport() # Нормированная область начинается с полного масштаба.
def _sync_bounds(self): # Тестовым трём отсчётам достаточно постоянной общей области.
self.visible_bounds = Bounds(0., 2., 0., 10.) # Интервал 0–2 мс и амплитуда 0–10 охватывают контрольные данные.
def make_series(key, name, **data): # Формат контейнера кривой определяет потребитель.
return SimpleNamespace(key=key, name=name, **data) # Принимаем полные массивы и цвет без дополнительных преобразований.
class ReferencePortTests(unittest.TestCase): # Проверяем интеграционный контракт независимо от GUI-потребителя.
@classmethod # Один экземпляр QApplication обслуживает весь процесс проверок.
def setUpClass(cls): # Доставка сигналов worker требует реального приложения Qt.
cls.app = QApplication.instance() or QApplication([]) # Повторно используем существующий цикл событий при совместном запуске тестов.
def setUp(self): # Данные каждого сценария изолированы от остальных.
self.temp = tempfile.TemporaryDirectory() # Каталог удаляется после завершения всех операций.
self.root = Path(self.temp.name) # Потребитель явно задаёт начальную папку.
self.path = self.root / 'reference.csv' # Файл хранит известный линейный эталон.
self.path.write_text('time_ms,U (V)\n0,1\n1,3\n2,5\n', encoding='utf-8') # Три амплитуды позволяют проверить полный остаток и число точек.
self.sources = [(SimpleNamespace(name='U (V)', display_value=lambda value: value * 2), [(0., 1.), (1., 2.), (2., 3.)])] # После внешнего множителя измерение отличается от эталона на 1 В.
self.panels = [] # Созданные виджеты закрываются до удаления файлов.
def tearDown(self): # Очистка обязательна даже при ошибке утверждения.
for panel in self.panels: # Завершаем все экземпляры панели и диалога.
panel.close() # Закрытие останавливает worker или запрашивает его отмену.
self.wait_idle(panel) # Ожидаем завершение до удаления QObject.
panel.deleteLater() # Дочерние элементы освобождаются очередью Qt.
self.app.processEvents() # Доставляем отложенные сигналы завершённых заданий.
self.temp.cleanup() # Фоновые операции больше не используют файлы.
def create(self, cls=ReferenceComparisonPanel, sources=None): # Один контракт подключает встроенную панель и отдельное окно.
panel = cls(self.sources if sources is None else sources, source_path='capture.csv', plot_factory=Canvas, series_factory=make_series, path_provider=lambda name: str(self.root / name)) # Все зависимости передаются извне без импорта прикладного GUI.
self.panels.append(panel) # Регистрируем объект для гарантированного завершения.
return panel # Сценарий использует обычные методы общего порта.
def wait_idle(self, panel): # Короткие задания не должны зависеть от скорости машины.
deadline = time.monotonic() + 5 # Пять секунд ограничивают время ожидания трёхточечного файла.
while (panel._worker is not None or getattr(panel, '_pending', False) or (hasattr(panel, '_timer') and panel._timer.isActive())) and time.monotonic() < deadline: # Для слоя эталона ждём также автоматически запланированный расчёт.
self.app.processEvents() # Доставляем result и finished без зависимости от QtTest.
time.sleep(.001) # Одна миллисекунда освобождает процессор для worker.
self.assertIsNone(panel._worker) # Дальнейшие проверки выполняются только после остановки задания.
self.assertFalse(getattr(panel, '_pending', False)) # Новый снимок параметров также должен быть обработан.
def test_custom_canvas_transform_and_paths(self): # Проверяем пользовательский цикл на независимом canvas.
panel = self.create() # Фабрики графика и кривой принадлежат тестовому потребителю.
panel.load_reference(self.path) # Чтение CSV выполняется настоящим фоновым потоком.
self.wait_idle(panel) # Сравнение запускается после принятия эталона.
panel._calculate() # Применяем внешний множитель к полному измерению.
self.wait_idle(panel) # Ждём формирования таблицы и кривых.
self.assertIsInstance(panel.plot, Canvas) # Библиотека не навязывает собственный canvas.
self.assertEqual(1., panel.comparison.metrics['rmse']) # Ошибка 1 В подтверждает корректность преобразования источника.
self.assertEqual([2., 4., 6.], list(panel.plot._series[0].values)) # Фабрика получает все исходные амплитуды после преобразования.
panel.mode.setCurrentIndex(1) # Переходим от наложения к отклонению без нового расчёта.
self.assertEqual([1., 1., 1.], list(panel.plot._series[0].values)) # Остаток сохраняет ожидаемый знак на каждой точке.
with patch('set_devices.qt_ports.reference_comparison.QFileDialog.getOpenFileName', return_value=('', '')) as choose: # Отмена выбора позволяет проверить только начальную папку.
panel._open_reference() # Вызываем штатный обработчик открытия эталона.
self.assertEqual(str(self.root), choose.call_args[0][2]) # Путь предоставляется потребителем библиотеки.
output = self.root / 'report.json' # Экспорт направляется в изолированный каталог теста.
with patch('set_devices.qt_ports.reference_comparison.QFileDialog.getSaveFileName', return_value=(str(output), '')) as choose: # Подменяем только выбор имени файла.
panel._save_report() # Реальная запись проверяет UTF-8 и сериализацию показателей.
self.assertEqual(str(self.root / 'reference_comparison.json'), choose.call_args[0][2]) # Предложенное имя отчёта также проходит через provider.
report = json.loads(output.read_text(encoding='utf-8')) # Читаем результат с диска, не подменяя алгоритм экспорта.
self.assertEqual(('capture.csv', 3), (report['measured_file'], report['metrics']['count'])) # Отчёт сохраняет происхождение и полное число сравнённых точек.
def test_dialog_and_empty_source_contract(self): # Отдельное окно сохраняет совместимость без возможности расчёта пустого источника.
dialog = self.create(ReferenceComparisonDialog, sources=[]) # Источник может временно отсутствовать у другого потребителя.
self.assertIsInstance(dialog, QDialog) # Сохраняется настоящий API QDialog, а не имитация через QWidget.
dialog.load_reference(self.path) # Одного эталона недостаточно для численного сравнения.
self.wait_idle(dialog) # После чтения проверяем окончательное состояние элементов.
self.assertFalse(dialog.calculate_button.isEnabled()) # Не допускаем обращения к индексу -1 пустого списка.
dialog.reject() # Прежний способ закрытия отдельного окна поддерживается напрямую.
def test_overlay_loads_automatically_without_canvas(self): # Пользовательский потребитель получает кривую сразу после чтения CSV.
panel = ReferenceOverlayPanel(self.sources, source_path='capture.csv', path_provider=lambda name: str(self.root / name)) # Фабрики canvas и новой таблицы не нужны.
self.panels.append(panel) # Гарантируем остановку worker до удаления временных файлов.
events = [] # Сигнал сообщает существующему графику о готовом дополнительном слое.
panel.changed.connect(lambda: events.append(True)) # Не создаём даже тестовый canvas ради получения кривой.
panel.load_reference(self.path) # После чтения статистика и временное совмещение запускаются автоматически.
self.wait_idle(panel) # Ожидаем обе стадии фонового задания.
specs, data = panel.overlay() # Потребитель добавляет эти данные к своим исходным кривым.
self.assertFalse(hasattr(panel, 'plot')) # Слой принципиально не может подменить исходный canvas.
self.assertEqual(1, len(events)) # Один файл публикует одно готовое наложение.
self.assertEqual('Эталон: U (V)', specs[0][1]) # Легенда различает оригинал и эталон с одинаковым именем канала.
self.assertEqual([(0., 1.), (1., 3.), (2., 5.)], list(data[panel.RESULT_KEY])) # Сохраняем полную сетку и исходные амплитуды эталона.
self.assertEqual(1., panel.comparison.metrics['rmse']) # Внешнее преобразование измерения учитывается в статистике.
self.assertTrue(panel.report_button.isEnabled()) # Автоматический расчёт делает доступным актуальный отчёт.
self.assertEqual(14, panel.results.table.rowCount()) # Полные расчёты доступны переносимому потребителю вместе с данными наложения.
self.assertEqual('1', panel.results.table.item(6, 1).text()) # Восстановленная таблица содержит фактический RMSE, а не только подписи.
self.assertTrue(panel.results.export_button.isEnabled()) # Экспорт доступен непосредственно рядом с подробной таблицей.
output = self.root / 'detailed-report.json' # Подробная область сохраняет актуальный расчёт в изолированный файл.
with patch('set_devices.qt_ports.reference_overlay.QFileDialog.getSaveFileName', return_value=(str(output), '')): # Подменяем только выбор места, сохраняя обработчик экспорта.
panel.results.export_button.click() # Проверяем кнопку восстановленной подробной таблицы.
self.assertEqual(1., json.loads(output.read_text(encoding='utf-8'))['metrics']['rmse']) # JSON содержит фактическое отклонение этого результата.
panel.results.delta_curve.setChecked(True) # Добавление остатка не требует собственной фабрики графика.
specs, data = panel.overlay() # Потребитель получает эталон и отдельный слой отклонения вместе.
self.assertEqual(2, len(specs)) # Обе дополнительные кривые можно добавить к сохранённому оригиналу.
self.assertEqual([1., 1., 1.], list(data[panel.DIFFERENCE_KEY].values)) # Численные остатки совпадают с преобразованными амплитудами измерения.
panel.set_sources([(self.sources[0][0], [(10., 1.), (11., 2.), (12., 3.)])]) # Новый оригинальный кадр не требует повторного выбора файла эталона.
self.wait_idle(panel) # Совмещение времени обновляется после смены источника.
self.assertEqual([10., 11., 12.], panel.trace.times) # Эталон переходит на исходную временную шкалу нового измерения.
self.assertEqual([10., 11., 12.], panel.difference.times) # Остаток следует той же исходной шкале времени без скрытого обнуления.
panel.shift.setValue(10000.) # Сдвиг 10000 мкс выводит эталон за пределы трёхточечного измерения.
self.wait_idle(panel) # Параметр применяется без дополнительной кнопки расчёта.
self.assertIsNone(panel.comparison) # Отсутствие перекрытия исключает только статистику.
self.assertEqual([20., 21., 22.], panel.trace.times) # Кривая остаётся доступной исходному графику даже без общей части.
self.assertEqual(0, panel.results.table.rowCount()) # Старые расчёты не остаются видимыми после потери перекрытия.
self.assertFalse(panel.results.delta_curve.isEnabled() or panel.results.export_button.isEnabled()) # Без корректного результата отключаются остаток и экспорт, но не исходный график.
if __name__ == '__main__': # Набор можно выполнить стандартным Python без pytest.
unittest.main() # Общий порт проверяется отдельно от репозитория потребителя.