Compare commits
14 Commits
qcustomplo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c4eab4615 | |||
| 5af8a8e9c8 | |||
| f613101124 | |||
| cf65439400 | |||
| 32e2fbedc0 | |||
| 226c2ca599 | |||
| f2956b54cd | |||
| eab8dc5e0a | |||
| 131f306131 | |||
| 4fc0ad4e14 | |||
| da055c2787 | |||
| 8fc2e1b5cf | |||
| b5b2d1ae37 | |||
| 7dfa8bd94d |
87
c/firmware-info/tests/test_c2000_postbuild.py
Normal file
87
c/firmware-info/tests/test_c2000_postbuild.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Проверяет совместимость postbuild, экспорт двух образов и отказ от устаревшего Flash OUT.""" # Фиксирует контракт общего выпуска без зависимости от установленного TI-компилятора.
|
||||
import json # Формирует паспорт текущей сборки для проверки архива и контрольных сумм.
|
||||
from pathlib import Path # Создаёт независимые пути проекта, компилятора и результатов в каждом тесте.
|
||||
import subprocess # Использует штатное исключение ненулевого кода проектного сборщика.
|
||||
import sys # Подключает общий модуль из каталога tools текущей копии templates.
|
||||
import tempfile # Изолирует проверочные файлы от реальных прошивок и каталогов GUI.
|
||||
import unittest # Организует проверки публичного контракта postbuild стандартной библиотекой Python.
|
||||
from unittest.mock import patch # Подменяет только внешние инструменты, оставляя настоящее архивирование и SHA-256.
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) # Находит проверяемую реализацию относительно теста без путей конкретной рабочей станции.
|
||||
import c2000_postbuild as c2000 # Вызывает тот же postbuild, который запускается штатным postcompile.bat.
|
||||
from identity import verify # Проверяет байты скопированных образов по настоящему manifest.
|
||||
|
||||
|
||||
class PostbuildTests(unittest.TestCase): # Проверяет общую цепочку от готового OUT до экспортированной папки выпуска.
|
||||
def setUp(self): # Для каждой проверки создаёт отдельный проект с типичной структурой CCS.
|
||||
temporary = tempfile.TemporaryDirectory() # Все файлы сценария принадлежат временному каталогу теста.
|
||||
self.addCleanup(temporary.cleanup) # Освобождает только каталог данной проверки при любом результате.
|
||||
self.root = Path(temporary.name) # Корень не зависит от реальной прошивки и установленного templates.
|
||||
self.build = self.root / 'project/Debug' # Проверяет соглашение о расположении сборщика рядом с конфигурациями CCS.
|
||||
self.build.mkdir(parents=True) # Подготавливает рабочий каталог внешних инструментов.
|
||||
self.compiler = self.root / 'compiler' # Позволяет проверить передачу компилятора текущего проекта вместо фиксированного пути.
|
||||
(self.compiler / 'bin').mkdir(parents=True) # Воспроизводит структуру установленного C2000 compiler.
|
||||
(self.compiler / 'bin/hex2000.exe').write_bytes(b'fake tool') # Проверка наличия инструмента реальна, а выполнение будет подменено.
|
||||
self.source = self.root / 'Bin/Firmware.out' # Основной OUT размещён вне Debug, как в рабочем проекте пользователя.
|
||||
self.source.parent.mkdir() # Создаёт отдельный каталог исходного COFF и итогового Flash COFF.
|
||||
self.source.write_bytes(b'RAM image') # Даёт узнаваемые байты для проверки, что основной образ не подменён Flash-вариантом.
|
||||
self.metadata = self.build / 'metadata.json' # Один паспорт используется обоими вариантами текущего выпуска.
|
||||
self.metadata.write_text(json.dumps({'build_id': 'NOGIT000', 'built_at_utc': '2026-10-14T13:00:00+00:00', 'source': {'commit': None}, 'version': [1, 2, 3]}), encoding='utf-8') # Фиксированное время проверяет выбор папки по prebuild, а не времени файлов.
|
||||
self.output = self.build / 'firmware' # Локальный выпуск получает оба образа и их сопроводительные файлы.
|
||||
self.export = self.root / 'GUI' # Экспорт изолирован от настоящего GUI и сетевых ресурсов.
|
||||
|
||||
def fake_tool(self, command, **kwargs): # Имитирует границу внешних инструментов, оставляя реальную логику упаковки и проверки.
|
||||
if '--postbuild' in command: # Вложенная Flash-сборка обязана использовать режим без повторного Make.
|
||||
self.assertEqual(Path(command[command.index('--compiler') + 1]), self.compiler.resolve()) # Проверяет выбор текущей версии TI compiler.
|
||||
self.assertEqual(Path(command[command.index('--build-dir') + 1]), self.build.resolve()) # Проверяет использование текущих объектов и generated-паспорта.
|
||||
image = Path(command[command.index('--output') + 1]) # Результат должен создаваться по переданному временному пути.
|
||||
self.assertNotEqual(image.parent, self.source.parent) # Запрещает выдавать существующий OUT из Bin за результат текущего вызова.
|
||||
image.write_bytes(b'current Flash image') # Создаёт узнаваемый новый COFF для сравнения всех копий.
|
||||
image.with_suffix('.map').write_text('codestart 0033fff6', encoding='utf-8') # Воспроизводит обязательный второй результат сборщика.
|
||||
else: # Обычная SCI8-конверсия должна продолжать использовать основной RAM OUT.
|
||||
expected = 'Firmware_flash.out' if '_flash_rs.bin' in command[command.index('-o') + 1] else self.source.name # Только отдельная RS-конверсия должна брать Flash COFF.
|
||||
self.assertEqual(Path(command[1]).name, expected) # Проверяет, что EEPROM BIN и Flash RS BIN создаются из разных исходных образов.
|
||||
Path(command[command.index('-o') + 1]).write_bytes(b'EEPROM image') # Выпускает HEX/BIN с отличимыми байтами основного варианта.
|
||||
Path(command[command.index('-map') + 1]).write_text('RAM map', encoding='utf-8') # Сохраняет карты, необходимые настоящему архиватору.
|
||||
return subprocess.CompletedProcess(command, 0) # Подтверждает только имитируемый инструмент, не подменяя проверки выпуска.
|
||||
|
||||
def run_postbuild(self): # Вызывает публичную функцию с теми же путями, что передаёт BAT из CCS.
|
||||
return c2000.postbuild(self.compiler, self.build, str(self.source.with_suffix('')), self.metadata, self.output, self.export) # Включает настоящий экспорт и проверку manifest.
|
||||
|
||||
def test_project_without_flash_builder_keeps_eeprom_workflow(self): # Старые проекты должны работать без новых настроек или Flash-файлов.
|
||||
with patch.object(c2000.subprocess, 'run', side_effect=self.fake_tool) as calls: # Подменяет только TI Hex Utility.
|
||||
result = self.run_postbuild() # Выполняет полный прежний выпуск EEPROM.
|
||||
self.assertEqual(calls.call_count, 2) # Для HEX и BIN выполнены два вызова; дополнительного сборщика не было.
|
||||
self.assertEqual(self.source.read_bytes(), b'RAM image') # Основной OUT не изменён упаковкой.
|
||||
self.assertFalse(self.source.with_name('Firmware_flash.out').exists()) # Отсутствие builder не создаёт фиктивный Flash-образ.
|
||||
self.assertEqual(verify(result, Path(str(result) + '.manifest.json'))['size_bytes'], len(b'EEPROM image')) # Проверяет настоящее содержимое EEPROM BIN и его паспорт.
|
||||
|
||||
def test_flash_and_eeprom_share_current_export_with_separate_checksums(self): # Новый выпуск должен содержать оба варианта под своими именами.
|
||||
(self.build.parent / 'build_flash.py').write_text('# Сборщик проекта подключён явно.\n', encoding='utf-8') # Наличие сценария включает дополнительный шаг postbuild.
|
||||
with patch.object(c2000.subprocess, 'run', side_effect=self.fake_tool) as calls: # Flash и hex2000 имитируются на границе процессов.
|
||||
self.run_postbuild() # Архиватор и экспорт работают без подмены.
|
||||
self.assertEqual(calls.call_count, 4) # Один Flash-вызов, две EEPROM-конверсии и отдельный RS BIN подтверждают отсутствие рекурсивного Make.
|
||||
folder = self.export / 'NOGIT000_2026-10-14_16-00-00_UTC+3' # Имя следует времени prebuild с переводом в московский UTC+3.
|
||||
for directory in (self.source.parent, self.output, folder): # Проверяет копию для CCS, плоский выпуск и архив для GUI.
|
||||
self.assertEqual((directory / 'Firmware_flash.out').read_bytes(), b'current Flash image') # Каждая копия содержит новый Flash COFF.
|
||||
self.assertTrue((directory / 'Firmware_flash.map').is_file()) # Карта Flash не теряется при сохранении или экспорте.
|
||||
for name in ('Firmware_flash.out', 'Firmware_NOGIT000.bin', 'Firmware_flash_rs.bin'): # Контрольные суммы каждого варианта должны проверяться независимо.
|
||||
verify(folder / name, folder / (name + '.manifest.json')) # Использует реальные SHA-256 экспортированных байтов.
|
||||
self.assertEqual((folder / self.source.name).read_bytes(), b'RAM image') # Архив сохраняет исходный RAM OUT вместе с новым Flash OUT.
|
||||
|
||||
def test_failed_or_incomplete_builder_cannot_export_stale_flash(self): # Ошибка или отсутствие карты должны остановить выпуск до экспорта старого COFF.
|
||||
(self.build.parent / 'build_flash.py').write_text('# Подключён неисправный сборщик для проверки отказа.\n', encoding='utf-8') # Включает путь Flash-проверки.
|
||||
stale = self.source.with_name('Firmware_flash.out') # В Bin уже есть образ от предыдущей успешной сборки.
|
||||
stale.write_bytes(b'old Flash image') # Отличимые байты выявят ошибочную повторную публикацию.
|
||||
for failure in (subprocess.CalledProcessError(1, 'builder'), None): # Проверяет ненулевой код и ложный успех без обязательных результатов.
|
||||
with self.subTest(failure=failure): # Даёт отдельную диагностику двум независимым отказам.
|
||||
with patch.object(c2000.subprocess, 'run', side_effect=failure) as calls: # Сборщик либо падает, либо не создаёт файлов.
|
||||
with self.assertRaises((subprocess.CalledProcessError, ValueError)): # Оба отказа должны выйти наружу как ошибка postbuild.
|
||||
self.run_postbuild() # Не разрешает продолжение EEPROM-конверсии после сбоя Flash.
|
||||
self.assertEqual(calls.call_count, 1) # После отказа Flash ни hex2000, ни новый сборщик не запускаются.
|
||||
self.assertEqual(stale.read_bytes(), b'old Flash image') # Предыдущий образ сохраняется, но не считается новым результатом.
|
||||
self.assertFalse(self.export.exists()) # Неполная сборка не создаёт выпуск для GUI.
|
||||
|
||||
|
||||
if __name__ == '__main__': # Позволяет запускать проверки отдельно или через unittest discover.
|
||||
unittest.main() # Возвращает ненулевой код при нарушении контракта упаковки или экспорта.
|
||||
111
c/firmware-info/tests/test_identity_archive.py
Normal file
111
c/firmware-info/tests/test_identity_archive.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Tests for preserving exact firmware bytes under pre-build provenance folders."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
from identity import archive_build, build_folder_name, export_build, verify
|
||||
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def metadata(self, stamp="2026-10-08T12:30:00+00:00"):
|
||||
return {"source": {"commit": "a1b2c3d4" + "0" * 32, "dirty": False},
|
||||
"templates": {"commit": None, "dirty": None},
|
||||
"build_id": "NOGIT000", "built_at_utc": stamp,
|
||||
"version": [1, 2, 3]}
|
||||
|
||||
def test_snapshot_preserves_image_and_records_exact_sha(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
root = Path(root)
|
||||
image = root / "firmware.bin"
|
||||
payload = bytes(range(256))
|
||||
image.write_bytes(payload)
|
||||
metadata = root / "metadata.json"
|
||||
metadata.write_text(json.dumps(self.metadata()), encoding="utf-8")
|
||||
result = archive_build(image, metadata, root / "out")
|
||||
folder = Path(result["directory"])
|
||||
self.assertEqual(folder.name, "a1b2c3d4_2026-10-08_15-30-00_UTC+3")
|
||||
self.assertEqual((folder / image.name).read_bytes(), payload)
|
||||
self.assertEqual(image.read_bytes(), payload)
|
||||
self.assertEqual(json.loads((folder / "firmware_identity.json").read_text()), self.metadata())
|
||||
manifest = folder / (image.name + ".manifest.json")
|
||||
self.assertEqual(verify(folder / image.name, manifest)["size_bytes"], 256)
|
||||
(folder / image.name).write_bytes(b"corrupted")
|
||||
with self.assertRaises(ValueError):
|
||||
verify(folder / image.name, manifest)
|
||||
|
||||
def test_prebuild_times_keep_two_builds_separate(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
root = Path(root)
|
||||
image, metadata = root / "firmware.hex", root / "metadata.json"
|
||||
image.write_bytes(b":00000001FF\r\n")
|
||||
folders = []
|
||||
for stamp in ("2026-10-08T12:30:00+00:00", "2026-10-08T12:31:00+00:00"):
|
||||
metadata.write_text(json.dumps(self.metadata(stamp)), encoding="utf-8")
|
||||
folders.append(Path(archive_build(image, metadata, root / "out")["directory"]))
|
||||
self.assertNotEqual(folders[0], folders[1])
|
||||
self.assertTrue(all((folder / image.name).is_file() for folder in folders))
|
||||
|
||||
def test_export_copies_current_folder_only(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
root = Path(root)
|
||||
image, metadata = root / "firmware.bin", root / "metadata.json"
|
||||
image.write_bytes(b"exact current build")
|
||||
# A folder with a later timestamp must not be selected for this build.
|
||||
metadata.write_text(json.dumps(self.metadata("2026-10-08T12:40:00+00:00")), encoding="utf-8")
|
||||
archive_build(image, metadata, root / "local")
|
||||
metadata.write_text(json.dumps(self.metadata()), encoding="utf-8")
|
||||
result = archive_build(image, metadata, root / "local", export=root / "exported")
|
||||
copied = Path(result["exported_directory"])
|
||||
self.assertEqual([p.name for p in (root / "exported").iterdir()],
|
||||
["a1b2c3d4_2026-10-08_15-30-00_UTC+3"])
|
||||
self.assertEqual((copied / image.name).read_bytes(), image.read_bytes())
|
||||
self.assertTrue((copied / "firmware_identity.json").is_file())
|
||||
self.assertTrue((copied / (image.name + ".sha256")).is_file())
|
||||
verify(copied / image.name, copied / (image.name + ".manifest.json"))
|
||||
|
||||
def test_complete_folder_export_keeps_nested_files_and_old_destination(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
root = Path(root)
|
||||
image, metadata = root / "firmware.bin", root / "metadata.json"
|
||||
image.write_bytes(b"image")
|
||||
metadata.write_text(json.dumps(self.metadata()), encoding="utf-8")
|
||||
folder = Path(archive_build(image, metadata, root / "local")["directory"])
|
||||
(folder / "notes").mkdir()
|
||||
(folder / "notes" / "release.txt").write_text("release notes", encoding="utf-8")
|
||||
old = root / "exported" / "old_build"
|
||||
old.mkdir(parents=True)
|
||||
(old / "keep.txt").write_text("keep", encoding="utf-8")
|
||||
copied = export_build(folder, root / "exported", image.name)
|
||||
self.assertEqual((copied / "notes" / "release.txt").read_text(), "release notes")
|
||||
self.assertEqual((old / "keep.txt").read_text(), "keep")
|
||||
with self.assertRaises(ValueError):
|
||||
export_build(folder, folder / "recursive", image.name)
|
||||
def test_offset_normalizes_to_utc_plus_three_and_dirty_is_visible(self):
|
||||
metadata = self.metadata("2026-10-08T15:30:00+03:00")
|
||||
metadata["source"]["dirty"] = True
|
||||
self.assertEqual(build_folder_name(metadata), "a1b2c3d4+_2026-10-08_15-30-00_UTC+3")
|
||||
|
||||
def test_folder_date_rolls_over_at_utc_plus_three_midnight(self):
|
||||
metadata = self.metadata("2026-10-08T22:30:00+00:00")
|
||||
self.assertEqual(build_folder_name(metadata), "a1b2c3d4_2026-10-09_01-30-00_UTC+3")
|
||||
self.assertEqual(metadata["built_at_utc"], "2026-10-08T22:30:00+00:00")
|
||||
def test_archive_without_git_has_explicit_placeholder(self):
|
||||
metadata = self.metadata()
|
||||
metadata["source"]["commit"] = None
|
||||
self.assertEqual(build_folder_name(metadata), "NOGIT000_2026-10-08_15-30-00_UTC+3")
|
||||
|
||||
def test_invalid_provenance_cannot_be_a_folder_name(self):
|
||||
metadata = self.metadata()
|
||||
metadata["source"]["commit"] = "../outside"
|
||||
with self.assertRaises(ValueError):
|
||||
build_folder_name(metadata)
|
||||
metadata = self.metadata("2026-10-08T12:30:00")
|
||||
with self.assertRaises(ValueError):
|
||||
build_folder_name(metadata)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
119
c/firmware-info/tools/c2000_postbuild.py
Normal file
119
c/firmware-info/tools/c2000_postbuild.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Convert a CCS C2000 OUT to SCI8 HEX/BIN and seal its pre-build identity.
|
||||
|
||||
Uses only the standard library, TI hex2000 and the shared identity module.
|
||||
No network, SETGUI, or project-specific paths are required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys # Запускает проектный сборщик тем же Python, которым выполняется общий postbuild.
|
||||
import tempfile
|
||||
|
||||
from identity import archive_build, seal, verify
|
||||
|
||||
|
||||
def build_flash_variant(compiler: Path, build_dir: Path, source: Path, stage: Path) -> tuple: # Возвращает файлы дополнительного Flash-образа только для проектов с явным сборщиком.
|
||||
builder = build_dir.resolve().parent / "build_flash.py" # Соглашение CCS: Debug/Release расположены рядом с проектным сценарием сборки Flash.
|
||||
if not builder.is_file(): # Отсутствие сценария оставляет прежний выпуск EEPROM без дополнительных требований к проекту.
|
||||
return () # Пустой список не добавляет Flash-файлы в архив обычного проекта.
|
||||
image = stage / (source.stem + "_flash.out") # Отдельное имя и временный каталог исключают подмену RAM-образа и использование устаревшего Flash-файла.
|
||||
subprocess.run([sys.executable, str(builder), "--postbuild", "--compiler", str(compiler.resolve()), "--build-dir", str(build_dir.resolve()), "--output", str(image)], cwd=build_dir, check=True) # Режим postbuild использует уже собранные объекты и запрещает повторный запуск Make.
|
||||
files = (image, image.with_suffix(".map")) # Карта линковки сопровождает COFF и позволяет проверить адрес аппаратной точки входа.
|
||||
for file in files: # Оба результата должны быть выпущены текущим вызовом сборщика.
|
||||
if not file.is_file() or not file.stat().st_size: # Успешный код процесса без непустого результата не считается успешной сборкой.
|
||||
raise ValueError(f"Flash builder produced no file: {file}") # Ошибка останавливает postbuild до архивирования или экспорта неполного выпуска.
|
||||
return files # Передаёт проверенные файлы вызывающему шагу для подписи и сохранения.
|
||||
|
||||
|
||||
def postbuild(compiler: Path, build_dir: Path, artifact: str,
|
||||
metadata: Path, output: Path, export: Path = None) -> Path:
|
||||
# CCS may include the configured output directory in this macro.
|
||||
# Resolve the input separately; output filenames must use only its basename.
|
||||
artifact_path = Path(artifact)
|
||||
if not artifact or artifact_path.name in ("", ".", ".."):
|
||||
raise ValueError("artifact must name an OUT file without its extension")
|
||||
artifact = artifact_path.name
|
||||
if any(c in artifact for c in '\\/:*?"<>|'):
|
||||
raise ValueError("invalid artifact file base name")
|
||||
source_base = (artifact_path if artifact_path.is_absolute()
|
||||
else build_dir / artifact_path)
|
||||
source = Path(str(source_base) + ".out").resolve()
|
||||
tool = compiler.resolve() / "bin" / "hex2000.exe"
|
||||
if not tool.is_file():
|
||||
raise FileNotFoundError(f"C2000 Hex Utility not found: {tool}")
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"compiled OUT not found: {source}")
|
||||
metadata = metadata.resolve()
|
||||
settings = json.loads(metadata.read_text(encoding="utf-8"))
|
||||
build_id = settings["build_id"]
|
||||
if not isinstance(build_id, str) or not re.fullmatch(r"[0-9a-fA-F]{8}|[0-9a-fA-F]{7}\+|NOGIT000", build_id):
|
||||
raise ValueError("invalid pre-build identity; run precompile before building")
|
||||
if build_id == "NOGIT000":
|
||||
print("[firmware-build] Git identity unavailable: using NOGIT000")
|
||||
output = output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
# Keep failed conversions and incomplete manifests out of the final folder.
|
||||
with tempfile.TemporaryDirectory(prefix=".firmware-", dir=output) as temporary:
|
||||
stage = Path(temporary)
|
||||
hex_image = stage / (artifact + ".hex")
|
||||
bin_image = stage / (artifact + "_" + build_id + ".bin")
|
||||
flash_files = build_flash_variant(compiler, build_dir, source, stage) # Сначала собирает дополнительный Flash-вариант; его ошибка не допускает выпуск нового BIN.
|
||||
for image, mode in ((hex_image, "-i"), (bin_image, "-b")):
|
||||
subprocess.run([str(tool), str(source), "-boot", "-sci8",
|
||||
"-map", str(image.with_suffix(image.suffix + ".map")),
|
||||
"-o", str(image), mode], check=True)
|
||||
if not image.is_file() or not image.stat().st_size:
|
||||
raise ValueError(f"hex2000 produced no image: {image}")
|
||||
seal(bin_image, metadata)
|
||||
verify(bin_image, bin_image.with_suffix(bin_image.suffix + ".manifest.json"))
|
||||
if flash_files: # Проекты со сборщиком получают независимую проверку точных байтов Flash-образа.
|
||||
seal(flash_files[0], metadata) # Использует тот же паспорт prebuild для второго варианта этой сборки.
|
||||
verify(flash_files[0], flash_files[0].with_suffix(".out.manifest.json")) # Проверяет размер и SHA-256 перед сохранением результатов.
|
||||
serial_image = stage / (artifact + "_flash_rs.bin") # Отдельный SCI8 BIN содержит Flash-адреса и предназначен только для новой команды записи по RS.
|
||||
serial_map = serial_image.with_suffix('.bin.map') # Карта конверсии позволяет отличить Flash-адреса от EEPROM/RAM BIN.
|
||||
subprocess.run([str(tool), str(flash_files[0]), '-boot', '-sci8', '-map', str(serial_map), '-o', str(serial_image), '-b'], check=True) # Конвертирует именно Flash COFF с загрузочными адресами секций.
|
||||
if not serial_image.is_file() or not serial_image.stat().st_size: # Пустая конверсия не должна попасть в выпуск либо каталог GUI.
|
||||
raise ValueError(f'hex2000 produced no Flash RS image: {serial_image}') # Передаёт ошибку конверсии общему postcompile.
|
||||
seal(serial_image, metadata) # Привязывает RS BIN к той же версии, ревизии и времени текущего prebuild.
|
||||
verify(serial_image, serial_image.with_suffix('.bin.manifest.json')) # Проверяет точные байты перед копированием в Bin и архив.
|
||||
flash_files += (serial_image, serial_map, serial_image.with_suffix('.bin.manifest.json'), serial_image.with_suffix('.bin.sha256')) # Включает транспортный BIN, карту и контрольные файлы в общий выпуск Flash.
|
||||
for file in flash_files: # Сохраняет COFF и карту также рядом с обычным OUT для выбора в CCS.
|
||||
destination = source.parent / file.name # Каталог исходного OUT задаёт место назначения без путей конкретного изделия в templates.
|
||||
destination.write_bytes(file.read_bytes()) # Копирует только результаты успешного текущего вызова, сохраняя исходный RAM-образ.
|
||||
for item in stage.iterdir():
|
||||
item.replace(output / item.name)
|
||||
result = output / bin_image.name # BIN остаётся основным образом EEPROM и возвращаемым результатом общего postbuild.
|
||||
flash_archive = tuple(output / file.name for file in flash_files) # После переноса из временного каталога берёт актуальные Flash-файлы из firmware.
|
||||
if flash_files: # Добавляет сопроводительные файлы только при выполненной сборке Flash.
|
||||
flash_archive += (output / (flash_files[0].name + ".manifest.json"), output / (flash_files[0].name + ".sha256")) # Архив и GUI получают отдельный паспорт и контрольную сумму Flash COFF.
|
||||
print(f"[firmware-build] Flash OUT: {source.parent / flash_files[0].name}") # Показывает пользователю CCS конкретный файл для прошивки внутренней Flash через JTAG.
|
||||
archive_build(result, metadata, output, ( # Сохраняет файлы текущей сборки в архиве и передаёт весь архив в каталог GUI.
|
||||
source, # Копирует исходный COFF-файл OUT с символами отладки под его именем для загрузки через JTAG из GUI.
|
||||
output / hex_image.name,
|
||||
output / (hex_image.name + ".map"),
|
||||
output / (bin_image.name + ".map"), *flash_archive), export=export) # Экспортирует EEPROM и Flash в одной папке текущего выпуска.
|
||||
print(f"[firmware-build] BIN: {result}")
|
||||
print(f"[firmware-build] Manifest: {result}.manifest.json")
|
||||
print(f"[firmware-build] SHA-256: {result}.sha256")
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
for name in ("compiler", "build-dir", "metadata", "output"):
|
||||
parser.add_argument("--" + name, type=Path, required=True)
|
||||
parser.add_argument("--artifact", required=True)
|
||||
parser.add_argument("--export", type=Path)
|
||||
try:
|
||||
postbuild(**vars(parser.parse_args()))
|
||||
except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error:
|
||||
parser.exit(1, f"[firmware-build] ERROR: {error}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -7,10 +7,13 @@ protocol encoding lives here: the generated header contains scalar constants.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
@@ -36,12 +39,14 @@ def git_state(repository: Path) -> dict:
|
||||
def write_atomic(path: Path, text: str):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
temporary.write_text(text, encoding="utf-8", newline="\n")
|
||||
# Built-in open supports newline on Python versions before 3.10.
|
||||
with open(temporary, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(text)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def prepare(repository: Path, templates: Path, config: Path, output: Path) -> dict:
|
||||
settings = json.loads(config.read_text(encoding="utf-8"))
|
||||
settings = json.loads(config.read_text(encoding="utf-8-sig"))
|
||||
version = settings["version"]
|
||||
if (len(version) != 3 or any(type(v) is not int for v in version)
|
||||
or not all(0 <= v <= limit for v, limit in zip(version, (255, 255, 999)))):
|
||||
@@ -102,6 +107,73 @@ def verify(image: Path, manifest: Path) -> dict:
|
||||
return metadata
|
||||
|
||||
|
||||
def build_folder_name(metadata: dict) -> str:
|
||||
"""Use the firmware commit and pre-build time rendered in UTC+3."""
|
||||
source = metadata.get("source") or {}
|
||||
dependency = metadata.get("templates") or {}
|
||||
commit = source.get("commit")
|
||||
if commit is not None:
|
||||
if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-fA-F]{40,64}", commit):
|
||||
raise ValueError("invalid firmware commit in pre-build metadata")
|
||||
identity = commit[:8].lower()
|
||||
if source.get("dirty") or dependency.get("dirty"):
|
||||
identity += "+"
|
||||
else:
|
||||
identity = metadata.get("build_id", "NOGIT000")
|
||||
if not isinstance(identity, str) or not re.fullmatch(r"[0-9a-fA-F]{8}|[0-9a-fA-F]{7}\+|NOGIT000", identity):
|
||||
raise ValueError("invalid build ID in pre-build metadata")
|
||||
stamp_text = metadata.get("built_at_utc")
|
||||
if not isinstance(stamp_text, str):
|
||||
raise ValueError("missing pre-build UTC timestamp")
|
||||
stamp = datetime.fromisoformat(stamp_text.replace("Z", "+00:00"))
|
||||
if stamp.tzinfo is None:
|
||||
raise ValueError("pre-build timestamp must include a timezone")
|
||||
stamp = stamp.astimezone(timezone(timedelta(hours=3)))
|
||||
return identity + "_" + stamp.strftime("%Y-%m-%d_%H-%M-%S_UTC+3")
|
||||
|
||||
|
||||
def export_build(folder: Path, export: Path, image_name: str) -> Path:
|
||||
"""Copy the current complete build folder, without scanning old archives."""
|
||||
folder, export = folder.resolve(), export.resolve()
|
||||
if export == folder or folder in export.parents:
|
||||
raise ValueError("export directory must be outside the current build folder")
|
||||
destination = export / folder.name
|
||||
verify(folder / image_name, folder / (image_name + ".manifest.json"))
|
||||
export.mkdir(parents=True, exist_ok=True)
|
||||
if destination != folder:
|
||||
shutil.copytree(folder, destination, dirs_exist_ok=True)
|
||||
verify(destination / image_name, destination / (image_name + ".manifest.json"))
|
||||
print(f"[firmware-build] Exported folder: {destination}")
|
||||
return destination
|
||||
|
||||
def archive_build(image: Path, metadata_path: Path, output: Path = None,
|
||||
extra_files=(), export: Path = None) -> dict:
|
||||
"""Keep a byte-identical build snapshot, alongside the existing flat output."""
|
||||
image, metadata_path = image.resolve(), metadata_path.resolve()
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
name = build_folder_name(metadata)
|
||||
output = (output if output is not None else image.parent / "firmware").resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
destination = output / name
|
||||
# Seal and verify staged copies before publishing files in the build folder.
|
||||
with tempfile.TemporaryDirectory(prefix=".archive-", dir=output) as temporary:
|
||||
stage = Path(temporary)
|
||||
shutil.copy2(image, stage / image.name)
|
||||
for file in extra_files:
|
||||
file = Path(file)
|
||||
shutil.copy2(file, stage / file.name)
|
||||
shutil.copy2(metadata_path, stage / "firmware_identity.json")
|
||||
seal(stage / image.name, stage / "firmware_identity.json")
|
||||
verify(stage / image.name, stage / (image.name + ".manifest.json"))
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for file in stage.iterdir():
|
||||
file.replace(destination / file.name)
|
||||
print(f"[firmware-build] Folder: {destination}")
|
||||
result = {"directory": str(destination), "file": image.name}
|
||||
if export is not None:
|
||||
result["exported_directory"] = str(export_build(destination, export, image.name))
|
||||
return result
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
@@ -114,9 +186,14 @@ def main():
|
||||
p = sub.add_parser("verify")
|
||||
p.add_argument("--image", type=Path, required=True)
|
||||
p.add_argument("--manifest", type=Path, required=True)
|
||||
p = sub.add_parser("archive")
|
||||
p.add_argument("--image", type=Path, required=True)
|
||||
p.add_argument("--metadata", dest="metadata_path", type=Path, required=True)
|
||||
p.add_argument("--output", type=Path)
|
||||
p.add_argument("--export", type=Path)
|
||||
args = vars(parser.parse_args())
|
||||
command = args.pop("command")
|
||||
result = {"prepare": prepare, "seal": seal, "verify": verify}[command](**args)
|
||||
result = {"prepare": prepare, "seal": seal, "verify": verify, "archive": archive_build}[command](**args)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| `OFF` / `ON` | постоянно выключен / включён |
|
||||
| `STARTUP` | три коротких импульса, затем постоянно включён |
|
||||
| `WORK` | 100 мс включён, 900 мс выключен |
|
||||
| `ACTIVITY` | 50/50 мс |
|
||||
| `ACTIVITY` | один цикл: 100 мс включён, 900 мс выключен, затем остаётся выключенным |
|
||||
| `WARNING` | 250/250 мс |
|
||||
| `ERROR` | два импульса и пауза |
|
||||
| `CRITICAL` | три импульса и пауза |
|
||||
@@ -86,6 +86,20 @@ for (;;) {
|
||||
не начинает рисунок заново. Для нового импульса события служит
|
||||
`LedIndicator_RestartMode`.
|
||||
|
||||
Для индикации приёма вызывайте `LedIndicator_TriggerActivity` либо
|
||||
`LedIndicator_TriggerActivityAt` на каждый принятый пакет. Свечение занимает
|
||||
10% секундного цикла: 100 мс включён и 900 мс выключен. Новый приём немедленно
|
||||
перезапускает фазу свечения, даже внутри предыдущего импульса или паузы;
|
||||
очереди циклов нет. При интервалах между пакетами меньше 100 мс LED может
|
||||
гореть непрерывно до истечения 100 мс после последнего пакета. Без нового
|
||||
события `ACTIVITY` не повторяется. Настраиваемая таблица `patterns` по-прежнему
|
||||
позволяет изменить длительности и повторение для других применений.
|
||||
|
||||
```c
|
||||
LedIndicator_TriggerActivityAt(&led, 0U, received_ms); /* Каждый приём запускает цикл канала 0 от миллисекундной метки события. */
|
||||
LedIndicator_ProcessAt(&led, now_ms); /* Главный цикл обслуживает окончание свечения без задержек и ожидания следующего пакета. */
|
||||
```
|
||||
|
||||
## STM32F103, STM32F4, STM32G431 и STM32G474
|
||||
|
||||
Порт `ports/stm32-hal` принимает конкретный `TIM_HandleTypeDef *`; библиотека
|
||||
|
||||
@@ -18,7 +18,7 @@ static const LedIndicator_Pattern g_default_patterns[LED_INDICATOR_MODE_COUNT] =
|
||||
{{0U}, LEVELS_1, 1U, 1U, 1U},
|
||||
{{100U, 100U, 100U, 100U, 100U, 100U}, LEVELS_101010, 6U, 0U, 1U},
|
||||
{{100U, 900U}, LEVELS_10, 2U, 1U, 0U},
|
||||
{{50U, 50U}, LEVELS_10, 2U, 1U, 0U},
|
||||
{{100U, 900U}, LEVELS_10, 2U, 0U, 0U}, /* Один цикл приёма длится 1000 мс: 100 мс свечения составляют 10%, затем 900 мс паузы и выключение. */
|
||||
{{250U, 250U}, LEVELS_10, 2U, 1U, 0U},
|
||||
{{100U, 100U, 100U, 700U}, LEVELS_1010, 4U, 1U, 0U},
|
||||
{{100U, 100U, 100U, 100U, 100U, 700U}, LEVELS_101010, 6U, 1U, 0U}
|
||||
@@ -220,6 +220,16 @@ uint8_t LedIndicator_RestartMode(LedIndicator *instance, size_t channel,
|
||||
instance->port.now_ms(instance->port.context));
|
||||
}
|
||||
|
||||
uint8_t LedIndicator_TriggerActivityAt(LedIndicator *instance, size_t channel, uint32_t now_ms) /* Каждый пакет начинает ACTIVITY заново, даже если предыдущая вспышка ещё продолжается. */
|
||||
{ /* Использует общую проверку канала и принудительный сброс фазы в миллисекундной временной базе. */
|
||||
return LedIndicator_RestartModeAt(instance, channel, LED_INDICATOR_MODE_ACTIVITY, now_ms); /* Штатный однократный шаблон завершает цикл выключенным уровнем без свободного повторения. */
|
||||
} /* Завершает запуск одного цикла приёма без ожидания окончания свечения либо паузы. */
|
||||
|
||||
uint8_t LedIndicator_TriggerActivity(LedIndicator *instance, size_t channel) /* Предоставляет тот же перезапуск по событию с использованием часов аппаратного порта. */
|
||||
{ /* Сохраняет поведение варианта At для приложения без собственного передаваемого времени. */
|
||||
return LedIndicator_RestartMode(instance, channel, LED_INDICATOR_MODE_ACTIVITY); /* Часы порта считываются штатным API один раз на зарегистрированный приём. */
|
||||
} /* Завершает неблокирующий запуск цикла приёма с аппаратным источником времени. */
|
||||
|
||||
void LedIndicator_ProcessAt(LedIndicator *instance, uint32_t now_ms)
|
||||
{
|
||||
size_t i;
|
||||
|
||||
@@ -114,6 +114,12 @@ void LedIndicator_Process(LedIndicator *instance);
|
||||
/** Обновляет выходы с явно переданной меткой времени. */
|
||||
void LedIndicator_ProcessAt(LedIndicator *instance, uint32_t now_ms);
|
||||
|
||||
/** Начинает один цикл ACTIVITY на каждый приём; новый вызов немедленно перезапускает свечение, не создавая очередь. */
|
||||
uint8_t LedIndicator_TriggerActivityAt(LedIndicator *instance, size_t channel, uint32_t now_ms); /* Стандартный цикл: 100 мс включён, 900 мс выключен; время задаётся в миллисекундах. */
|
||||
|
||||
/** Использует часы аппаратного порта для такого же перезапуска цикла при каждом приёме. */
|
||||
uint8_t LedIndicator_TriggerActivity(LedIndicator *instance, size_t channel); /* Вызывать из контекста Process; передачу событий из ISR обеспечивает приложение. */
|
||||
|
||||
LedIndicator_Mode LedIndicator_GetMode(const LedIndicator *instance, size_t channel);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -85,6 +85,26 @@ int main(void)
|
||||
CHECK(LedIndicator_SetMode(&indicator, 3U, LED_INDICATOR_MODE_ON) == 0U);
|
||||
CHECK(LedIndicator_GetMode(&indicator, 0U) == LED_INDICATOR_MODE_WORK);
|
||||
|
||||
puts("led indicator tests passed");
|
||||
CHECK(patterns[LED_INDICATOR_MODE_ACTIVITY].duration_ms[0] == 100U && patterns[LED_INDICATOR_MODE_ACTIVITY].duration_ms[1] == 900U && patterns[LED_INDICATOR_MODE_ACTIVITY].repeat == 0U); /* Фиксирует согласованные 10% свечения и один цикл на приём в канонической таблице. */
|
||||
CHECK(LedIndicator_TriggerActivityAt(&indicator, 0U, 2500U) != 0U && fake.outputs[0] == 1U); /* Приём сразу включает канал независимо от фазы прежнего WORK. */
|
||||
LedIndicator_ProcessAt(&indicator, 2599U); CHECK(fake.outputs[0] == 1U); /* До истечения 100 мс светодиод продолжает гореть. */
|
||||
LedIndicator_ProcessAt(&indicator, 2600U); CHECK(fake.outputs[0] == 0U); /* Ровно через 100 мс начинается оставшаяся пауза 900 мс. */
|
||||
LedIndicator_ProcessAt(&indicator, 3499U); CHECK(fake.outputs[0] == 0U); /* Конец первого периода не порождает дополнительную вспышку. */
|
||||
LedIndicator_ProcessAt(&indicator, 3500U); CHECK(fake.outputs[0] == 0U); /* Однократный ACTIVITY завершается выключенным уровнем на границе 1000 мс. */
|
||||
LedIndicator_ProcessAt(&indicator, 4500U); CHECK(fake.outputs[0] == 0U); /* Без новых пакетов свободное периодическое мигание отсутствует. */
|
||||
CHECK(LedIndicator_TriggerActivityAt(&indicator, 0U, 5000U) != 0U); /* Начинает новый приём после долгого отсутствия обмена. */
|
||||
CHECK(LedIndicator_TriggerActivityAt(&indicator, 0U, 5050U) != 0U); /* Второй пакет приходит внутри текущей фазы свечения. */
|
||||
LedIndicator_ProcessAt(&indicator, 5100U); CHECK(fake.outputs[0] == 1U); /* Старый срок выключения больше не действует: отсчёт перезапущен новым пакетом. */
|
||||
LedIndicator_ProcessAt(&indicator, 5149U); CHECK(fake.outputs[0] == 1U); /* Новый пакет получает собственные полные 100 мс свечения. */
|
||||
LedIndicator_ProcessAt(&indicator, 5150U); CHECK(fake.outputs[0] == 0U); /* Новая фаза паузы начинается ровно от последнего приёма. */
|
||||
CHECK(LedIndicator_TriggerActivityAt(&indicator, 0U, 5400U) != 0U && fake.outputs[0] == 1U); /* Приём во время паузы сразу включает LED вместо ожидания границы старого периода. */
|
||||
LedIndicator_ProcessAt(&indicator, 5500U); CHECK(fake.outputs[0] == 0U); /* Повторный запуск в паузе не создаёт очередь пропущенных циклов. */
|
||||
fake.now = 7000U; CHECK(LedIndicator_TriggerActivity(&indicator, 1U) != 0U && fake.outputs[1] == 1U); /* Проверяет вариант событийного API с часами аппаратного порта и другим каналом. */
|
||||
fake.now = 7100U; LedIndicator_Process(&indicator); CHECK(fake.outputs[1] == 0U && fake.outputs[0] == 0U); /* Каналы не перезапускают друг другу фазы и не создают фоновую активность. */
|
||||
CHECK(LedIndicator_TriggerActivityAt(&indicator, 0U, UINT32_MAX - 49U) != 0U); /* Начинает импульс непосредственно перед переполнением 32-битного времени. */
|
||||
LedIndicator_ProcessAt(&indicator, 49U); CHECK(fake.outputs[0] == 1U); /* Через переполнение прошло 99 мс, поэтому свечение ещё активно. */
|
||||
LedIndicator_ProcessAt(&indicator, 50U); CHECK(fake.outputs[0] == 0U); /* Через переполнение прошло ровно 100 мс, и LED гаснет без зависания. */
|
||||
CHECK(LedIndicator_TriggerActivityAt(NULL, 0U, 0U) == 0U && LedIndicator_TriggerActivityAt(&indicator, 3U, 0U) == 0U); /* Ошибочный экземпляр и канал не вызывают запись чужого GPIO. */
|
||||
puts("led indicator tests passed: receive 100/900 ms, immediate retrigger, no repeat, timer wrap"); /* Показывает прохождение новых событийных сценариев вместе с прежней диагностикой. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.setcorp.setflash.core.CanBridgeProtocol
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
data class DeifBinding(val index: Int, val subindex: Int = 2, val scale: Double = 1.0)
|
||||
data class DeifDisplayConfig(
|
||||
val node: Int = 1, val canId: Int = 0x201,
|
||||
val bindings: Map<Parameter, DeifBinding> = Parameter.entries.associateWith { DeifBinding(it.index, 2, it.scale) },
|
||||
)
|
||||
data class DeifReading(val value: Double, val at: Long)
|
||||
data class DeifState(
|
||||
val config: DeifProtocol.Config = DeifProtocol.Config(),
|
||||
val raw: Map<Parameter, Int> = Parameter.entries.associateWith { 0 },
|
||||
val controlScales: DeifControls.Scales = DeifControls.Scales(),
|
||||
val unlocked: Boolean = false, val running: Boolean = false,
|
||||
val fullScreen: Boolean = false,
|
||||
val displays: List<DeifDisplayConfig> = List(2) { DeifDisplayConfig() },
|
||||
val readings: List<Map<Parameter, DeifReading>> = List(2) { emptyMap() },
|
||||
val sent: Long = 0, val received: Long = 0, val errors: Long = 0,
|
||||
val echoes: Long = 0, val echoTimes: Map<Int, Long> = emptyMap(),
|
||||
val intervalMs: Long = 0, val maxGapMs: Long = 0, val maxCycleMs: Long = 0,
|
||||
val status: String = "Передача выключена", val lastReceived: String = "Нет принятых данных",
|
||||
)
|
||||
|
||||
/** Owns both timers and display freshness independently of the Android screen lifecycle. */
|
||||
class DeifController(
|
||||
private val scope: CoroutineScope,
|
||||
private val send: suspend (CanBridgeProtocol.Frame) -> Unit,
|
||||
private val save: (String) -> Unit = {},
|
||||
private val now: () -> Long = { System.nanoTime() / 1_000_000 },
|
||||
) {
|
||||
private val mutable = MutableStateFlow(DeifState())
|
||||
val state = mutable.asStateFlow()
|
||||
private var job: Job? = null
|
||||
private val wire = Mutex()
|
||||
private var previousCycle: Long? = null
|
||||
|
||||
fun setFullScreen(enabled: Boolean) { mutable.value = state.value.copy(fullScreen = enabled) }
|
||||
|
||||
fun stop(message: String = "Передача остановлена") {
|
||||
job?.cancel(); job = null
|
||||
mutable.value = mutable.value.copy(running = false, status = message)
|
||||
}
|
||||
fun unlock(value: Boolean) {
|
||||
stop()
|
||||
mutable.value = mutable.value.copy(unlocked = value)
|
||||
}
|
||||
fun configure(config: DeifProtocol.Config) {
|
||||
check(state.value.unlocked) { "Разблокируйте настройки" }
|
||||
config.validate(); stop()
|
||||
mutable.value = state.value.copy(config = config)
|
||||
persist()
|
||||
}
|
||||
fun select(parameter: Parameter, enabled: Boolean) {
|
||||
stop()
|
||||
val c = state.value.config
|
||||
mutable.value = state.value.copy(config = c.copy(enabled = if (enabled) c.enabled + parameter else c.enabled - parameter))
|
||||
persist()
|
||||
}
|
||||
fun setRaw(parameter: Parameter, raw: Int) {
|
||||
require(raw in -32768..32767)
|
||||
mutable.value = state.value.copy(raw = state.value.raw + (parameter to raw))
|
||||
persist()
|
||||
}
|
||||
fun requestInstrument(field: DeifControls.Field, value: Double) {
|
||||
mutable.value = state.value.copy(raw = state.value.raw + DeifControls.raw(field, value, state.value.controlScales))
|
||||
persist()
|
||||
}
|
||||
fun configureControls(scales: DeifControls.Scales) {
|
||||
check(state.value.unlocked); scales.validate(); stop()
|
||||
mutable.value = state.value.copy(controlScales = scales)
|
||||
persist()
|
||||
}
|
||||
fun configureDisplay(side: Int, config: DeifDisplayConfig) {
|
||||
check(state.value.unlocked)
|
||||
require(config.node in 1..127 && config.canId in 0x201..0x57F)
|
||||
require(config.bindings.values.all { it.index in 0..65535 && it.subindex in 0..255 && it.scale.isFinite() })
|
||||
stop()
|
||||
mutable.value = state.value.copy(
|
||||
displays = state.value.displays.mapIndexed { i, d -> if (i == side) config else d },
|
||||
readings = state.value.readings.mapIndexed { i, d -> if (i == side) emptyMap() else d },
|
||||
)
|
||||
persist()
|
||||
}
|
||||
fun start(repeat: Boolean) {
|
||||
if (state.value.unlocked) { stop("Заблокируйте настройки перед передачей"); return }
|
||||
stop()
|
||||
if (!repeat && state.value.config.enabled.isEmpty()) return
|
||||
if (state.value.config.enabled.isEmpty() && !state.value.config.heartbeat) return
|
||||
previousCycle = null
|
||||
mutable.value = state.value.copy(running = true, sent = 0, errors = 0, echoes = 0, echoTimes = emptyMap(), maxGapMs = 0, maxCycleMs = 0)
|
||||
job = scope.launch {
|
||||
try {
|
||||
coroutineScope {
|
||||
if (repeat && state.value.config.heartbeat) launch {
|
||||
while (isActive) {
|
||||
transmit(DeifProtocol.heartbeat(state.value.config)); delay(500)
|
||||
}
|
||||
}
|
||||
do {
|
||||
val started = now()
|
||||
val snapshot = state.value
|
||||
val frames = DeifProtocol.cycle(snapshot.config, snapshot.raw)
|
||||
frames.forEach { transmit(it) }
|
||||
val elapsed = now() - started
|
||||
val gap = previousCycle?.let { started - it } ?: 0
|
||||
previousCycle = started
|
||||
mutable.value = state.value.copy(intervalMs = gap,
|
||||
maxGapMs = maxOf(state.value.maxGapMs, gap), maxCycleMs = maxOf(state.value.maxCycleMs, elapsed))
|
||||
if (!repeat) break
|
||||
delay(maxOf(1, snapshot.config.periodMs - elapsed))
|
||||
} while (isActive)
|
||||
}
|
||||
mutable.value = state.value.copy(running = false)
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (error: Exception) { stop("Ошибка передачи: ${error.message}") }
|
||||
}
|
||||
}
|
||||
private suspend fun transmit(frame: CanBridgeProtocol.Frame) = wire.withLock {
|
||||
currentCoroutineContext().ensureActive()
|
||||
check(!state.value.unlocked)
|
||||
DeifProtocol.validateTransmit(frame)
|
||||
send(frame)
|
||||
accept(0, frame)
|
||||
mutable.value = state.value.copy(sent = state.value.sent + 1, status = "Передано в транспорт; приём прибором не подтверждён")
|
||||
}
|
||||
fun receive(frame: CanBridgeProtocol.Frame) {
|
||||
if (frame.isError) {
|
||||
mutable.value = state.value.copy(errors = state.value.errors + 1, lastReceived = "Ошибка CAN: %03X".format(frame.canId))
|
||||
return
|
||||
}
|
||||
if (frame.toCan) {
|
||||
val d = DeifProtocol.decode(frame)
|
||||
if (d.index != null && d.subindex != null) mutable.value = state.value.copy(
|
||||
echoes = state.value.echoes + 1, echoTimes = state.value.echoTimes + ((d.index * 256 + d.subindex) to now()))
|
||||
return // Transport TX notifications must never masquerade as SLAVE RX.
|
||||
}
|
||||
val decoded = DeifProtocol.decode(frame)
|
||||
mutable.value = state.value.copy(received = state.value.received + 1, lastReceived = decoded.summary)
|
||||
accept(1, frame)
|
||||
}
|
||||
private fun accept(side: Int, frame: CanBridgeProtocol.Frame) {
|
||||
val d = DeifProtocol.decode(frame)
|
||||
if (d.raw == null) return
|
||||
val config = state.value.displays[side]
|
||||
if (d.node != config.node || (d.mode == DeifProtocol.Mode.SAM && frame.canId != config.canId.toLong())) return
|
||||
val readings = state.value.readings[side].toMutableMap()
|
||||
config.bindings.forEach { (key, binding) ->
|
||||
if (binding.index == d.index && binding.subindex == d.subindex) {
|
||||
if (d.valid) readings[key] = DeifReading(d.raw * binding.scale, now()) else readings.remove(key)
|
||||
}
|
||||
}
|
||||
mutable.value = state.value.copy(readings = state.value.readings.mapIndexed { i, r -> if (i == side) readings else r })
|
||||
}
|
||||
fun value(side: Int, parameter: Parameter): Double? = state.value.readings[side][parameter]
|
||||
?.takeIf { now() - it.at <= 5000 }?.value
|
||||
|
||||
fun echoAge(index: Int, subindex: Int): Long? = state.value.echoTimes[index * 256 + subindex]?.let { now() - it }
|
||||
|
||||
fun restore(text: String) {
|
||||
runCatching {
|
||||
val root = JSONObject(text)
|
||||
val c = DeifProtocol.Config(DeifProtocol.Mode.valueOf(root.getString("mode")), root.getInt("node"),
|
||||
root.getInt("canId"), root.getLong("period"), root.getBoolean("heartbeat"), root.getInt("masterNode"),
|
||||
Parameter.entries.filter { root.getJSONArray("enabled").toString().contains("\"${it.name}\"") }.toSet())
|
||||
c.validate()
|
||||
val raw = Parameter.entries.associateWith { root.getJSONObject("raw").getInt(it.name).also { v -> require(v in -32768..32767) } }
|
||||
val displays = (0..1).map { side ->
|
||||
val o = root.getJSONArray("displays").getJSONObject(side)
|
||||
DeifDisplayConfig(o.getInt("node"), o.getInt("canId"), Parameter.entries.associateWith {
|
||||
val b = o.getJSONObject(it.name)
|
||||
DeifBinding(b.getInt("index"), b.getInt("sub"), b.getDouble("scale"))
|
||||
})
|
||||
}
|
||||
require(displays.all { it.node in 1..127 && it.canId in 0x201..0x57F && it.bindings.values.all { b -> b.index in 0..65535 && b.subindex in 0..255 && b.scale.isFinite() } })
|
||||
val controls = root.optJSONObject("controls")
|
||||
val scales = controls?.let { DeifControls.Scales(it.getDouble("power100"), it.getDouble("rpm100"),
|
||||
it.getDouble("angleRawDegree"), it.getDouble("powerPercentRaw")) } ?: DeifControls.Scales()
|
||||
scales.validate()
|
||||
mutable.value = DeifState(config = c, raw = raw, displays = displays, controlScales = scales)
|
||||
}.onFailure { mutable.value = DeifState(status = "Сохранённые настройки DEIF повреждены; загружены значения по умолчанию") }
|
||||
}
|
||||
private fun persist() {
|
||||
val s = state.value; val c = s.config
|
||||
val root = JSONObject().put("mode", c.mode.name).put("node", c.node).put("canId", c.canId)
|
||||
.put("controls", JSONObject().put("power100", s.controlScales.power100).put("rpm100", s.controlScales.rpm100)
|
||||
.put("angleRawDegree", s.controlScales.angleRawDegree).put("powerPercentRaw", s.controlScales.powerPercentRaw))
|
||||
.put("period", c.periodMs).put("heartbeat", c.heartbeat).put("masterNode", c.masterNode)
|
||||
.put("enabled", JSONArray(c.enabled.map { it.name }))
|
||||
.put("raw", JSONObject().also { o -> s.raw.forEach { (p, v) -> o.put(p.name, v) } })
|
||||
.put("displays", JSONArray().also { a -> s.displays.forEach { d ->
|
||||
a.put(JSONObject().put("node", d.node).put("canId", d.canId).also { o -> d.bindings.forEach { (p, b) ->
|
||||
o.put(p.name, JSONObject().put("index", b.index).put("sub", b.subindex).put("scale", b.scale))
|
||||
} })
|
||||
} })
|
||||
save(root.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
import kotlin.math.*
|
||||
|
||||
/** SETGUI Propulsor uses the same 600 × 510 coordinate system and calibration. */
|
||||
object DeifControls {
|
||||
enum class Field { POWER, RPM, ANGLE }
|
||||
data class Scales(val power100: Double = 1000.0, val rpm100: Double = 1000.0,
|
||||
val angleRawDegree: Double = 10.0, val powerPercentRaw: Double = 10.0) {
|
||||
fun validate() {
|
||||
require(listOf(power100, rpm100, angleRawDegree, powerPercentRaw).all { it.isFinite() && it > 0 && it <= 100000 }) {
|
||||
"Масштабы должны быть больше нуля и не больше 100000"
|
||||
}
|
||||
}
|
||||
}
|
||||
fun hit(x: Float, y: Float): Field? = when {
|
||||
x in 46f..116f && y in 110f..410f -> Field.POWER
|
||||
x in 174f..244f && y in 110f..410f -> Field.RPM
|
||||
hypot(x - 411f, y - 264f) <= 155f -> Field.ANGLE
|
||||
else -> null
|
||||
}
|
||||
fun requested(field: Field, x: Float, y: Float): Double? = when (field) {
|
||||
Field.ANGLE -> if (hypot(x - 411f, y - 264f) < 12f) null else atan2((x - 411).toDouble(), (264 - y).toDouble()) * 180 / PI
|
||||
else -> { val low = if (field == Field.RPM) -20.0 else 0.0
|
||||
(120 - (y - 126) / 270 * (120 - low)).coerceIn(low, 120.0) }
|
||||
}
|
||||
fun raw(field: Field, value: Double, scales: Scales): Map<Parameter, Int> {
|
||||
scales.validate(); require(value.isFinite())
|
||||
fun limited(v: Double) = round(v).coerceIn(-32767.0, 32765.0).toInt()
|
||||
if (field == Field.ANGLE) return mapOf(Parameter.ANGLE to limited(value * scales.angleRawDegree))
|
||||
val power = field == Field.POWER
|
||||
val factor = if (power) 1.0 else 10.0
|
||||
val full = if (power) scales.power100 else scales.rpm100
|
||||
val raw = limited(value / 100 * full * factor)
|
||||
val percent = raw / factor / full * 100
|
||||
return mapOf((if (power) Parameter.POWER else Parameter.RPM) to raw,
|
||||
(if (power) Parameter.POWER_PERCENT else Parameter.RPM_PERCENT) to limited(percent * if (power) scales.powerPercentRaw else 10.0))
|
||||
}
|
||||
fun preview(raw: Map<Parameter, Int>, scales: Scales): Map<Field, Double> = mapOf(
|
||||
Field.POWER to (raw[Parameter.POWER] ?: 0) / scales.power100 * 100,
|
||||
Field.RPM to (raw[Parameter.RPM] ?: 0) / 10.0 / scales.rpm100 * 100,
|
||||
Field.ANGLE to (raw[Parameter.ANGLE] ?: 0) / scales.angleRawDegree,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
/** Host hides system chrome; controller survives rotation and maintains the active cycle. */
|
||||
@Composable
|
||||
fun DeifFullScreen(controller: DeifController, connected: Boolean) {
|
||||
val state by controller.state.collectAsState()
|
||||
var tick by remember { mutableLongStateOf(0) }
|
||||
LaunchedEffect(controller) { while (true) { delay(250); tick++ } }
|
||||
BackHandler { controller.setFullScreen(false) }
|
||||
Column(Modifier.fillMaxSize().background(Color(0xFF090C0E)).displayCutoutPadding()) {
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val preview = DeifControls.preview(state.raw, state.controlScales)
|
||||
@Composable fun instrument(side: Int, modifier: Modifier) {
|
||||
val values = remember(state.readings, tick) { Parameter.entries.associateWith { controller.value(side, it) } }
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
DeifInstrument("${if (side == 0) "MASTER" else "SLAVE"} · Node ${state.displays[side].node}",
|
||||
values, preview, controller::requestInstrument, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
if (maxWidth > maxHeight) Row(Modifier.fillMaxSize()) {
|
||||
instrument(0, Modifier.weight(1f).fillMaxHeight())
|
||||
instrument(1, Modifier.weight(1f).fillMaxHeight())
|
||||
} else Column(Modifier.fillMaxSize()) {
|
||||
instrument(0, Modifier.weight(1f).fillMaxWidth())
|
||||
instrument(1, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
Surface(color = Color(0xFF111820), contentColor = Color.White) {
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button({ controller.setFullScreen(false) }) { Text("Выйти из полного экрана") }
|
||||
Button({ controller.start(false) }, enabled = connected && !state.unlocked && !state.running) { Text("Цикл") }
|
||||
Button({ controller.start(true) }, enabled = connected && !state.unlocked && !state.running) { Text("Старт") }
|
||||
Button({ controller.stop() }, enabled = state.running) { Text("Стоп") }
|
||||
Text(if (!connected) "Нет подключения" else if (state.unlocked) "Настройки разблокированы" else if (state.running) "MASTER работает" else "Передача выключена")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
import kotlin.math.*
|
||||
|
||||
/** Vector port of SETGUI Propulsor.paintEvent, including its original silhouette. */
|
||||
@Composable
|
||||
fun DeifInstrument(title: String, values: Map<Parameter, Double?>, preview: Map<DeifControls.Field, Double>,
|
||||
onRequest: (DeifControls.Field, Double) -> Unit, modifier: Modifier = Modifier) {
|
||||
val request by rememberUpdatedState(onRequest)
|
||||
Canvas(modifier.fillMaxWidth().aspectRatio(600f / 510f).pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val scale = min(size.width / 600f, size.height / 510f)
|
||||
val ox = (size.width - 600 * scale) / 2
|
||||
val oy = (size.height - 510 * scale) / 2
|
||||
fun logical(p: androidx.compose.ui.geometry.Offset) = (p.x - ox) / scale to (p.y - oy) / scale
|
||||
val (x, y) = logical(down.position)
|
||||
val field = DeifControls.hit(x, y)
|
||||
if (field != null) {
|
||||
down.consume()
|
||||
DeifControls.requested(field, x, y)?.let { request(field, it) }
|
||||
do {
|
||||
val change = awaitPointerEvent().changes.firstOrNull { it.id == down.id } ?: break
|
||||
val (px, py) = logical(change.position)
|
||||
DeifControls.requested(field, px, py)?.let { request(field, it) }
|
||||
change.consume()
|
||||
} while (change.pressed)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
val c = drawContext.canvas.nativeCanvas
|
||||
val p = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
fun color(hex: String) = android.graphics.Color.parseColor(hex)
|
||||
fun text(x: Float, y: Float, w: Float, h: Float, value: String, size: Float = 18f, ink: String = "#e9edf4", bold: Boolean = false) {
|
||||
p.style = Paint.Style.FILL; p.color = color(ink); p.textSize = size * 1.25f
|
||||
p.typeface = Typeface.create("sans-serif", if (bold) Typeface.BOLD else Typeface.NORMAL)
|
||||
p.textAlign = Paint.Align.CENTER
|
||||
c.drawText(value, x + w / 2, y + h / 2 - (p.ascent() + p.descent()) / 2, p)
|
||||
}
|
||||
fun line(x1: Float, y1: Float, x2: Float, y2: Float, ink: String, width: Float = 1f) {
|
||||
p.color = color(ink); p.strokeWidth = width; p.style = Paint.Style.STROKE; p.strokeCap = Paint.Cap.BUTT
|
||||
c.drawLine(x1, y1, x2, y2, p)
|
||||
}
|
||||
fun number(key: Parameter, suffix: String = "") = values[key]?.let { "%s%s".format(java.math.BigDecimal.valueOf(it).stripTrailingZeros().toPlainString(), suffix) } ?: "—"
|
||||
c.save()
|
||||
c.clipRect(0f, 0f, size.width, size.height)
|
||||
c.drawColor(color("#090c0e"))
|
||||
val scale = min(size.width / 600, size.height / 510)
|
||||
c.translate((size.width - 600 * scale) / 2, (size.height - 510 * scale) / 2); c.scale(scale, scale)
|
||||
text(15f, 12f, 570f, 40f, title, 22f)
|
||||
text(205f, 54f, 95f, 25f, "Задание:", 11f, "#8798ad")
|
||||
text(300f, 54f, 95f, 25f, preview[DeifControls.Field.ANGLE]?.let { "%.1f°".format(it) } ?: "—°", 11f)
|
||||
for ((x, field) in listOf(64f to DeifControls.Field.POWER, 192f to DeifControls.Field.RPM)) {
|
||||
val power = field == DeifControls.Field.POWER
|
||||
val low = if (power) 0 else -20
|
||||
fun yy(v: Double) = (126 + (120 - v) / (120 - low) * 270).toFloat()
|
||||
for ((a, b, ink) in listOf(Triple(low, 0, "#ae616b"), Triple(0, 100, "#657caf"), Triple(100, 120, "#b6c799"))) {
|
||||
if (b > a) { p.style = Paint.Style.FILL; p.color = color(ink); c.drawRect(x, yy(b.toDouble()), x + 34, yy(a.toDouble()), p) }
|
||||
}
|
||||
for (v in low..120 step 20) {
|
||||
val y = yy(v.toDouble()); line(x, y, x + 34, y, "#202b3a")
|
||||
if (v in listOf(low, 0, 40, 80, 120)) text(x - 40, y - 12, 36f, 24f, v.toString(), 12f)
|
||||
}
|
||||
val percentKey = if (power) Parameter.POWER_PERCENT else Parameter.RPM_PERCENT
|
||||
val percent = values[percentKey]
|
||||
if (percent != null) { val y = yy(percent.coerceIn(low.toDouble(), 120.0)); line(x - 3, y, x + 38, y, "#ffffff", 4f) }
|
||||
else { p.style = Paint.Style.FILL; p.color = android.graphics.Color.argb(130, 16, 21, 29); c.drawRect(x, 126f, x + 34, 396f, p) }
|
||||
preview[field]?.let { val y = yy(it.coerceIn(low.toDouble(), 120.0)); line(x - 8, y, x + 42, y, "#eeef69", 3f) }
|
||||
text(x - 35, 89f, 110f, 30f, number(percentKey, "%"), 17f)
|
||||
text(x - 45, 410f, 128f, 28f, if (power) "POWER" else "RPM", 17f)
|
||||
text(x - 52, 444f, 145f, 30f, number(if (power) Parameter.POWER else Parameter.RPM), 22f)
|
||||
text(x - 40, 478f, 125f, 22f, if (power) "RAW" else "rpm", 11f, "#8798ad")
|
||||
}
|
||||
val cx = 411f; val cy = 264f; val radius = 125f
|
||||
val ring = RectF(cx - 120, cy - 120, cx + 120, cy + 120)
|
||||
p.style = Paint.Style.STROKE; p.strokeWidth = 10f; p.strokeCap = Paint.Cap.BUTT
|
||||
p.color = color("#78f033"); c.drawArc(ring, -90f, 180f, false, p)
|
||||
p.color = color("#ff4935"); c.drawArc(ring, -90f, -180f, false, p)
|
||||
for (degree in 0 until 360 step 5) {
|
||||
val a = (degree - 90) * PI / 180
|
||||
val major = degree % 30 == 0
|
||||
fun xx(r: Int) = cx + r * cos(a).toFloat()
|
||||
fun yy(r: Int) = cy + r * sin(a).toFloat()
|
||||
line(xx(if (major) 115 else 118), yy(if (major) 115 else 118), xx(if (major) 137 else 126), yy(if (major) 137 else 126), "#e9f3ff", if (major) 2.3f else 1f)
|
||||
if (major) text(xx(156) - 26, yy(156) - 16, 52f, 32f, min(degree, 360 - degree).toString() + if (degree % 90 == 0) "°" else "", 17f, bold = true)
|
||||
}
|
||||
val measured = values[Parameter.ANGLE]
|
||||
val angle = preview[DeifControls.Field.ANGLE] ?: measured
|
||||
val outline = if (angle != null) "#eeef69" else "#657181"
|
||||
c.save(); c.translate(cx, cy); c.rotate((angle ?: 0.0).toFloat() + 90)
|
||||
p.color = color(outline); p.style = Paint.Style.STROKE; p.strokeWidth = 5.3f; p.strokeJoin = Paint.Join.ROUND; p.strokeCap = Paint.Cap.ROUND
|
||||
c.save(); c.scale(radius / 315, radius / 315); c.translate(-460f, -610f)
|
||||
c.drawPath(Path().apply {
|
||||
moveTo(399f, 550f); cubicTo(478f, 516f, 577f, 525f, 630f, 562f)
|
||||
cubicTo(679f, 594f, 672f, 632f, 626f, 661f); cubicTo(570f, 701f, 478f, 702f, 399f, 674f); close()
|
||||
}, p)
|
||||
c.drawPath(Path().apply {
|
||||
moveTo(399f, 550f); lineTo(360f, 552f); moveTo(318f, 563f)
|
||||
cubicTo(278f, 575f, 278f, 619f, 312f, 642f); lineTo(315f, 629f); cubicTo(341f, 659f, 370f, 676f, 399f, 674f)
|
||||
}, p)
|
||||
c.drawPath(Path().apply {
|
||||
moveTo(384f, 631f); cubicTo(348f, 615f, 307f, 555f, 317f, 506f)
|
||||
cubicTo(329f, 466f, 350f, 418f, 366f, 438f); cubicTo(384f, 466f, 392f, 524f, 384f, 631f); close()
|
||||
}, p)
|
||||
c.drawLine(318f, 499f, 384f, 631f, p)
|
||||
c.drawPath(Path().apply { moveTo(315f, 629f); cubicTo(304f, 681f, 320f, 780f, 350f, 785f); cubicTo(388f, 792f, 386f, 712f, 373f, 670f) }, p)
|
||||
c.restore()
|
||||
if (angle != null) {
|
||||
p.style = Paint.Style.FILL
|
||||
c.drawPath(Path().apply { moveTo(-115f, 0f); lineTo(-77f, -15f); lineTo(-77f, 15f); close() }, p)
|
||||
c.drawPath(Path().apply { moveTo(-126f, 0f); lineTo(-138f, -5f); lineTo(-138f, 5f); close() }, p)
|
||||
}
|
||||
c.restore()
|
||||
text(300f, 435f, 225f, 36f, number(Parameter.ANGLE, "°"), 25f)
|
||||
text(278f, 478f, 275f, 22f, "AZIMUTH", 11f, "#8798ad")
|
||||
c.restore()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
/** Reusable Compose port. The host supplies only transport and persistence. */
|
||||
@Composable
|
||||
fun DeifPanel(controller: DeifController, connected: Boolean, channel: String) {
|
||||
val state by controller.state.collectAsState()
|
||||
var clock by remember { mutableLongStateOf(0) }
|
||||
LaunchedEffect(controller) { while (true) { delay(250); clock++ } }
|
||||
var invalidInputs by remember { mutableStateOf(emptySet<Parameter>()) }
|
||||
var showRaw by remember { mutableStateOf(false) }
|
||||
var controlsDialog by remember { mutableStateOf(false) }
|
||||
var configuring by remember { mutableStateOf(false) }
|
||||
var displaySide by remember { mutableStateOf<Int?>(null) }
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("DEIF XDi · MASTER / SLAVE", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Канал: $channel · ${if (connected) "подключён" else "нет подключения"}")
|
||||
Row {
|
||||
Checkbox(state.unlocked, { controller.unlock(it) })
|
||||
Text("Разблокировать настройки", Modifier.padding(top = 12.dp))
|
||||
}
|
||||
Text("${state.config.mode} · Node ${state.config.node} · STD 0x%03X · %d мс".format(state.config.canId, state.config.periodMs))
|
||||
OutlinedButton(onClick = { configuring = true }, enabled = state.unlocked) { Text("Настроить передачу") }
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button({ controller.start(false) }, enabled = connected && !state.unlocked && !state.running && invalidInputs.isEmpty()) { Text("Цикл") }
|
||||
Button({ controller.start(true) }, enabled = connected && !state.unlocked && !state.running && invalidInputs.isEmpty()) { Text("Старт") }
|
||||
OutlinedButton({ controller.stop() }, enabled = state.running) { Text("Стоп") }
|
||||
OutlinedButton({ controller.setFullScreen(true) }) { Text("На весь экран") }
|
||||
}
|
||||
Text("Коснитесь или перетащите POWER, RPM либо винт. Жёлтый — задание, белый — показания.", style = MaterialTheme.typography.bodySmall)
|
||||
val preview = remember(state.raw, state.controlScales) { DeifControls.preview(state.raw, state.controlScales) }
|
||||
@Composable fun instrument(side: Int, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
val values = remember(state.readings, clock) { Parameter.entries.associateWith { controller.value(side, it) } }
|
||||
DeifInstrument("${if (side == 0) "MASTER" else "SLAVE"} · Node ${state.displays[side].node}",
|
||||
values, preview, { field, value ->
|
||||
controller.requestInstrument(field, value)
|
||||
invalidInputs = emptySet()
|
||||
})
|
||||
OutlinedButton({ displaySide = side }, enabled = state.unlocked) { Text("Настроить ${if (side == 0) "MASTER" else "SLAVE"}") }
|
||||
}
|
||||
}
|
||||
BoxWithConstraints {
|
||||
if (maxWidth >= 760.dp) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
instrument(0, Modifier.weight(1f)); instrument(1, Modifier.weight(1f))
|
||||
} else Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
instrument(0, Modifier.fillMaxWidth()); instrument(1, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
OutlinedButton({ controlsDialog = true }, enabled = state.unlocked) { Text("Масштабы управления приборами") }
|
||||
TextButton({ showRaw = !showRaw }) { Text(if (showRaw) "Скрыть RAW и выбор параметров" else "RAW и выбор параметров") }
|
||||
if (showRaw) {
|
||||
Parameter.entries.forEach { parameter ->
|
||||
var input by remember(state.raw[parameter]) { mutableStateOf((state.raw[parameter] ?: 0).toString()) }
|
||||
val raw = input.toIntOrNull()
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Checkbox(parameter in state.config.enabled, { controller.select(parameter, it) })
|
||||
OutlinedTextField(input, { text ->
|
||||
input = text
|
||||
val value = text.toIntOrNull()
|
||||
invalidInputs = if (value != null && value in -32768..32767) invalidInputs - parameter else invalidInputs + parameter
|
||||
if (value != null && value in -32768..32767) controller.setRaw(parameter, value)
|
||||
else controller.stop("Исправьте RAW перед передачей")
|
||||
}, label = { Text("${parameter.title} (${parameter.unit}) · RAW") },
|
||||
isError = raw == null || raw !in -32768..32767, singleLine = true, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Text("0x%04X:02%s · %s".format(parameter.index, if (parameter == Parameter.ANGLE) " + :07" else "",
|
||||
raw?.let { "%.1f %s".format(it * parameter.scale, parameter.unit) } ?: "—"))
|
||||
}
|
||||
Text("AC2: физическая единица мощности не подтверждена. Угол передаётся как фактический и заданный.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(state.status)
|
||||
Text("Передано: ${state.sent} · RX: ${state.received} · ошибок CAN: ${state.errors}")
|
||||
Text("TX-эхо gs_usb: ${state.echoes}. Эхо не является ответом DEIF.")
|
||||
if (channel.contains("gs_usb")) {
|
||||
val echoStatus = remember(state.echoTimes, state.config.enabled, clock) {
|
||||
state.config.enabled.flatMap { p -> (if (p == Parameter.ANGLE) listOf(2, 7) else listOf(2)).map { sub ->
|
||||
"%04X:%02X — %s".format(p.index, sub, controller.echoAge(p.index, sub)?.let { if (it > 3000) "нет эха >3 с" else "$it мс" } ?: "нет эха")
|
||||
} }.joinToString("\n")
|
||||
}
|
||||
Text(echoStatus, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text("Интервал: ${state.intervalMs} мс · макс. пауза: ${state.maxGapMs} мс · макс. цикл: ${state.maxCycleMs} мс")
|
||||
Text("Heartbeat ${if (state.config.heartbeat) "включён · 500 мс" else "выключен"}. TX означает передачу в API адаптера, а не подтверждение DEIF.", style = MaterialTheme.typography.bodySmall)
|
||||
Text(state.lastReceived)
|
||||
Text("SLAVE не использует TX-эхо; отсутствие ответа допустимо. Данные скрываются через 5 с. NMT, LSS и SDO записи не отправляются.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (controlsDialog) {
|
||||
val scales = state.controlScales
|
||||
val labels = listOf("POWER при 100%, RAW", "RPM при 100%, rpm", "Угол: RAW на 1°", "POWER%: RAW на 1%")
|
||||
val values = remember { listOf(scales.power100, scales.rpm100, scales.angleRawDegree, scales.powerPercentRaw).map { mutableStateOf(it.toString()) } }
|
||||
var problem by remember { mutableStateOf<String?>(null) }
|
||||
AlertDialog(onDismissRequest = { controlsDialog = false }, title = { Text("Масштабы управления") }, text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text("Оба индикатора меняют общие задания. При работающем master значения отправятся следующим циклом.")
|
||||
labels.forEachIndexed { i, label -> OutlinedTextField(values[i].value, { values[i].value = it }, label = { Text(label) }) }
|
||||
problem?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}, confirmButton = { TextButton({
|
||||
runCatching { controller.configureControls(DeifControls.Scales(values[0].value.toDouble(), values[1].value.toDouble(),
|
||||
values[2].value.toDouble(), values[3].value.toDouble())) }
|
||||
.onSuccess { controlsDialog = false }.onFailure { problem = it.message }
|
||||
}) { Text("Применить") } }, dismissButton = { TextButton({ controlsDialog = false }) { Text("Отмена") } })
|
||||
}
|
||||
if (configuring) {
|
||||
val c = state.config
|
||||
var mode by remember { mutableStateOf(c.mode) }
|
||||
var node by remember { mutableStateOf(c.node.toString()) }
|
||||
var id by remember { mutableStateOf(c.canId.toString(16)) }
|
||||
var period by remember { mutableStateOf(c.periodMs.toString()) }
|
||||
var heartbeat by remember { mutableStateOf(c.heartbeat) }
|
||||
var master by remember { mutableStateOf(c.masterNode.toString()) }
|
||||
var problem by remember { mutableStateOf<String?>(null) }
|
||||
AlertDialog(onDismissRequest = { configuring = false }, title = { Text("Передача DEIF") }, text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Row { DeifProtocol.Mode.entries.forEach { m -> FilterChip(mode == m, { mode = m }, label = { Text(m.name) }) } }
|
||||
Text("SAM: источник; DAM: получатель. Адрес самого прибора не изменяется.")
|
||||
OutlinedTextField(node, { node = it; it.toIntOrNull()?.let { n -> id = (0x200 + n).toString(16) } }, label = { Text("Node-ID (1…127)") })
|
||||
OutlinedTextField(id, { id = it }, label = { Text("STD ID, HEX (201 / 20B / 30B…)") })
|
||||
OutlinedTextField(period, { period = it }, label = { Text("Период, мс") })
|
||||
Row { Checkbox(heartbeat, { heartbeat = it }); Text("Отправлять heartbeat") }
|
||||
OutlinedTextField(master, { master = it }, label = { Text("Node-ID master для heartbeat DAM") })
|
||||
problem?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}, confirmButton = { TextButton({
|
||||
runCatching { controller.configure(c.copy(mode = mode, node = node.toInt(), canId = id.removePrefix("0x").toInt(16),
|
||||
periodMs = period.toLong(), heartbeat = heartbeat, masterNode = master.toInt())) }
|
||||
.onSuccess { configuring = false }.onFailure { problem = it.message }
|
||||
}) { Text("Применить") } }, dismissButton = { TextButton({ configuring = false }) { Text("Отмена") } })
|
||||
}
|
||||
displaySide?.let { side ->
|
||||
val config = state.displays[side]
|
||||
var node by remember { mutableStateOf(config.node.toString()) }
|
||||
var id by remember { mutableStateOf(config.canId.toString(16)) }
|
||||
val fields = remember { Parameter.entries.associateWith { p ->
|
||||
val b = config.bindings.getValue(p)
|
||||
mutableStateOf("%04X:%02X:%s".format(b.index, b.subindex, b.scale))
|
||||
} }
|
||||
var problem by remember { mutableStateOf<String?>(null) }
|
||||
AlertDialog(onDismissRequest = { displaySide = null }, title = { Text("Привязки ${if (side == 0) "MASTER" else "SLAVE"}") }, text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
OutlinedTextField(node, { node = it }, label = { Text("Node-ID: SAM источник / DAM получатель") })
|
||||
OutlinedTextField(id, { id = it }, label = { Text("SAM CAN ID, HEX") })
|
||||
Text("Формат: индекс HEX:подиндекс HEX:масштаб. Например 3001:02:0.1. DAM принимается по Node-ID на RPDO1…4.")
|
||||
Parameter.entries.forEach { p -> OutlinedTextField(fields.getValue(p).value, { fields.getValue(p).value = it }, label = { Text("${p.title} ${p.unit}") }) }
|
||||
problem?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}, confirmButton = { TextButton({
|
||||
runCatching {
|
||||
controller.configureDisplay(side, DeifDisplayConfig(node.toInt(), id.removePrefix("0x").toInt(16), fields.mapValues { (_, text) ->
|
||||
val parts = text.value.split(':'); require(parts.size == 3) { "Нужны индекс:подиндекс:масштаб" }
|
||||
DeifBinding(parts[0].toInt(16), parts[1].toInt(16), parts[2].toDouble())
|
||||
}))
|
||||
}.onSuccess { displaySide = null }.onFailure { problem = it.message }
|
||||
}) { Text("Применить") } }, dismissButton = { TextButton({ displaySide = null }) { Text("Отмена") } })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import ru.setcorp.setflash.core.CanBridgeProtocol
|
||||
|
||||
/** XDi-net MPDO contract shared by Android hosts; matches set_devices/deif_xdi.py. */
|
||||
object DeifProtocol {
|
||||
enum class Mode { SAM, DAM }
|
||||
enum class Parameter(val index: Int, val title: String, val unit: String, val scale: Double) {
|
||||
ANGLE(0x3001, "Угол", "°", 0.1),
|
||||
RPM(0x3081, "Обороты", "rpm", 0.1),
|
||||
RPM_PERCENT(0x3091, "Обороты винта", "%", 0.1),
|
||||
POWER(0x3421, "Мощность AC2", "RAW", 1.0),
|
||||
POWER_PERCENT(0x3431, "Мощность AC2", "%", 0.1),
|
||||
}
|
||||
data class Config(
|
||||
val mode: Mode = Mode.SAM, val node: Int = 1, val canId: Int = 0x201,
|
||||
val periodMs: Long = 100, val heartbeat: Boolean = false, val masterNode: Int = 1,
|
||||
val enabled: Set<Parameter> = Parameter.entries.toSet(),
|
||||
) {
|
||||
fun validate() {
|
||||
require(node in 1..127 && masterNode in 1..127) { "Node-ID: 1…127" }
|
||||
require(periodMs in 20..60000) { "Период: 20…60000 мс" }
|
||||
require(if (mode == Mode.SAM) canId == 0x200 + node || canId == 0x27F
|
||||
else canId - node in listOf(0x200, 0x300, 0x400, 0x500)) { "STD ID не соответствует режиму и Node-ID" }
|
||||
}
|
||||
}
|
||||
data class Decoded(
|
||||
val summary: String, val node: Int? = null, val index: Int? = null,
|
||||
val subindex: Int? = null, val raw: Int? = null, val value: Double? = null,
|
||||
val unit: String = "", val mode: Mode? = null, val valid: Boolean = false,
|
||||
)
|
||||
|
||||
fun cycle(config: Config, raw: Map<Parameter, Int>): List<CanBridgeProtocol.Frame> {
|
||||
config.validate()
|
||||
val frames = Parameter.entries.filter { it in config.enabled }.map { p ->
|
||||
dataFrame(config, p.index, 2, raw[p] ?: 0)
|
||||
}.toMutableList()
|
||||
if (Parameter.ANGLE in config.enabled) frames += dataFrame(config, 0x3001, 7, raw[Parameter.ANGLE] ?: 0)
|
||||
return frames
|
||||
}
|
||||
private fun dataFrame(c: Config, index: Int, sub: Int, raw: Int): CanBridgeProtocol.Frame {
|
||||
require(raw in -32768..32767) { "RAW: −32768…32767" }
|
||||
return CanBridgeProtocol.buildFrame(c.canId.toLong(), byteArrayOf(
|
||||
(c.node or if (c.mode == Mode.DAM) 0x80 else 0).toByte(),
|
||||
index.toByte(), (index shr 8).toByte(), sub.toByte(), raw.toByte(), (raw shr 8).toByte(), 0, 0,
|
||||
), extended = false)
|
||||
}
|
||||
fun heartbeat(config: Config): CanBridgeProtocol.Frame {
|
||||
config.validate()
|
||||
val node = if (config.mode == Mode.SAM) config.node else config.masterNode
|
||||
return CanBridgeProtocol.buildFrame((0x700 + node).toLong(), byteArrayOf(5), extended = false)
|
||||
}
|
||||
fun decode(frame: CanBridgeProtocol.Frame): Decoded {
|
||||
fun reject(s: String) = Decoded("DEIF: $s")
|
||||
if (frame.isExtended || frame.isRemote || frame.isError) return reject("ожидается STD data frame")
|
||||
val id = frame.canId.toInt()
|
||||
val data = frame.data
|
||||
if (id in 0x701..0x77F) {
|
||||
if (data.size != 1) return reject("heartbeat: ожидается DLC=1")
|
||||
val state = when (data[0].toInt() and 255) { 0 -> "Boot-up"; 5 -> "Operational"; else -> "состояние 0x%02X".format(data[0]) }
|
||||
return Decoded("Node ${id - 0x700}: $state", node = id - 0x700, valid = true)
|
||||
}
|
||||
if (id !in 0x201..0x57F || data.size != 8) return reject("неизвестный ID или DLC (MPDO: 8 байт)")
|
||||
val node = data[0].toInt() and 127
|
||||
val mode = if (data[0].toInt() and 128 != 0) Mode.DAM else Mode.SAM
|
||||
if (node == 0 || !(if (mode == Mode.DAM) id - node in listOf(0x200, 0x300, 0x400, 0x500)
|
||||
else id in 0x201..0x27F && (id == 0x27F || id == 0x200 + node))) return reject("адрес не соответствует COB-ID")
|
||||
val index = (data[1].toInt() and 255) or ((data[2].toInt() and 255) shl 8)
|
||||
val sub = data[3].toInt() and 255
|
||||
val parameter = Parameter.entries.find { it.index and 0xFFF0 == index and 0xFFF0 }
|
||||
if (parameter == null || index and 15 == 0 || sub !in listOf(2, 7)) return reject("неизвестный объект 0x%04X:%02X".format(index, sub))
|
||||
val raw = ((data[4].toInt() and 255) or ((data[5].toInt() and 255) shl 8)).toShort().toInt()
|
||||
val status = when (raw) { -32768 -> "ниже диапазона"; 32766 -> "выше диапазона"; 32767 -> "ошибка данных"; else -> null }
|
||||
val scaled = sub == 2 || parameter == Parameter.ANGLE
|
||||
val value = if (status == null && scaled) raw * parameter.scale else null
|
||||
val unit = if (scaled) parameter.unit else "масштаб не установлен"
|
||||
val padding = if (data[6] != 0.toByte() || data[7] != 0.toByte()) " · ненулевое заполнение" else ""
|
||||
val summary = "$mode Node $node · 0x%04X:%02X · %s · RAW=%d".format(index, sub, parameter.title, raw) +
|
||||
" · " + (status ?: value?.let { "%.1f %s".format(it, unit) } ?: unit) + padding
|
||||
return Decoded(summary, node, index, sub, raw, value, unit, mode, status == null && padding.isEmpty())
|
||||
}
|
||||
/** Only display data and Operational heartbeat may reach this transmit path. */
|
||||
fun validateTransmit(frame: CanBridgeProtocol.Frame) {
|
||||
val decoded = decode(frame)
|
||||
val heartbeat = frame.canId in 0x701..0x77F && frame.data.contentEquals(byteArrayOf(5))
|
||||
val data = frame.data
|
||||
val known = decoded.index in Parameter.entries.map { it.index } &&
|
||||
(decoded.subindex == 2 || decoded.index == 0x3001 && decoded.subindex == 7)
|
||||
require(!frame.isExtended && !frame.isRemote && !frame.isError &&
|
||||
(heartbeat || known && decoded.raw != null && data.size == 8 && data[6] == 0.toByte() && data[7] == 0.toByte())) {
|
||||
"DEIF: разрешены только данные индикации и heartbeat"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.*
|
||||
import ru.setcorp.setprotocol.deif.DeifControls.Field
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DeifControlsTest {
|
||||
@Test fun fullScreenTogglePreservesLiveMasterAndIsNotRestoredOnNextLaunch() = runTest {
|
||||
val sent = mutableListOf<ru.setcorp.setflash.core.CanBridgeProtocol.Frame>()
|
||||
var saved = ""
|
||||
val controller = DeifController(backgroundScope, { sent += it }, { saved = it })
|
||||
controller.requestInstrument(Field.ANGLE, -90.0)
|
||||
controller.start(true); runCurrent()
|
||||
controller.setFullScreen(true)
|
||||
val before = sent.size
|
||||
advanceTimeBy(101); runCurrent()
|
||||
assertTrue(sent.size > before)
|
||||
assertTrue(controller.state.value.fullScreen)
|
||||
assertEquals(-900, controller.state.value.raw[Parameter.ANGLE])
|
||||
controller.setFullScreen(false)
|
||||
assertTrue(controller.state.value.running)
|
||||
assertFalse(controller.state.value.fullScreen)
|
||||
val restored = DeifController(backgroundScope, {})
|
||||
restored.restore(saved)
|
||||
assertFalse(restored.state.value.fullScreen)
|
||||
assertFalse(restored.state.value.running)
|
||||
controller.stop()
|
||||
}
|
||||
@Test fun hitRegionsMatchSetguiAndLeaveBackgroundScrollable() {
|
||||
assertEquals(Field.POWER, DeifControls.hit(64f, 126f))
|
||||
assertEquals(Field.RPM, DeifControls.hit(192f, 396f))
|
||||
assertEquals(Field.ANGLE, DeifControls.hit(411f, 120f))
|
||||
assertNull(DeifControls.hit(10f, 10f))
|
||||
assertNull(DeifControls.hit(250f, 450f))
|
||||
}
|
||||
@Test fun angleHasNorthZeroAndSignedLeftRight() {
|
||||
for ((point, angle) in listOf((411f to 120f) to 0.0, (550f to 264f) to 90.0,
|
||||
(411f to 410f) to 180.0, (270f to 264f) to -90.0)) {
|
||||
assertEquals(angle, DeifControls.requested(Field.ANGLE, point.first, point.second)!!, 0.001)
|
||||
}
|
||||
assertNull(DeifControls.requested(Field.ANGLE, 411f, 264f))
|
||||
}
|
||||
@Test fun verticalDragsIncludeOverloadAndReverseRpm() {
|
||||
assertEquals(120.0, DeifControls.requested(Field.POWER, 64f, 126f)!!, 0.001)
|
||||
assertEquals(0.0, DeifControls.requested(Field.POWER, 64f, 600f)!!, 0.001)
|
||||
assertEquals(-20.0, DeifControls.requested(Field.RPM, 192f, 396f)!!, 0.001)
|
||||
assertEquals(120.0, DeifControls.requested(Field.RPM, 192f, -50f)!!, 0.001)
|
||||
}
|
||||
@Test fun gesturesUpdateAbsoluteAndPercentTogetherLikeSetgui() {
|
||||
val scales = DeifControls.Scales()
|
||||
assertEquals(mapOf(Parameter.POWER to 600, Parameter.POWER_PERCENT to 600), DeifControls.raw(Field.POWER, 60.0, scales))
|
||||
assertEquals(mapOf(Parameter.RPM to 5000, Parameter.RPM_PERCENT to 500), DeifControls.raw(Field.RPM, 50.0, scales))
|
||||
assertEquals(mapOf(Parameter.RPM to -2000, Parameter.RPM_PERCENT to -200), DeifControls.raw(Field.RPM, -20.0, scales))
|
||||
assertEquals(mapOf(Parameter.ANGLE to -900), DeifControls.raw(Field.ANGLE, -90.0, scales))
|
||||
}
|
||||
@Test fun clampingNeverProducesStatusSentinelsAndKeepsPercentConsistent() {
|
||||
val scales = DeifControls.Scales(rpm100 = 10000.0)
|
||||
val raw = DeifControls.raw(Field.RPM, 120.0, scales)
|
||||
assertEquals(32765, raw[Parameter.RPM])
|
||||
assertEquals(328, raw[Parameter.RPM_PERCENT])
|
||||
assertEquals(32.765, DeifControls.preview(raw, scales)[Field.RPM]!!, 0.0001)
|
||||
assertEquals(-32767, DeifControls.raw(Field.ANGLE, -180.0, scales.copy(angleRawDegree = 1000.0))[Parameter.ANGLE])
|
||||
}
|
||||
@Test fun commandPreviewWorksBeforeTransmissionAndScalesPersist() = runTest {
|
||||
var saved = ""
|
||||
val controller = DeifController(backgroundScope, { error("Must not send while editing") }, { saved = it })
|
||||
controller.unlock(true)
|
||||
controller.configureControls(DeifControls.Scales(power100 = 2000.0))
|
||||
controller.requestInstrument(Field.POWER, 60.0)
|
||||
assertEquals(1200, controller.state.value.raw[Parameter.POWER])
|
||||
assertEquals(60.0, DeifControls.preview(controller.state.value.raw, controller.state.value.controlScales)[Field.POWER]!!, 0.001)
|
||||
assertNull(controller.value(0, Parameter.POWER))
|
||||
val restored = DeifController(backgroundScope, {})
|
||||
restored.restore(saved)
|
||||
assertEquals(controller.state.value.controlScales, restored.state.value.controlScales)
|
||||
}
|
||||
@Test fun draggingDuringMasterChangesNextCycle() = runTest {
|
||||
val sent = mutableListOf<ru.setcorp.setflash.core.CanBridgeProtocol.Frame>()
|
||||
val controller = DeifController(backgroundScope, { sent += it })
|
||||
controller.start(true); runCurrent()
|
||||
controller.requestInstrument(Field.RPM, 50.0)
|
||||
advanceTimeBy(101); runCurrent()
|
||||
val rpm = sent.map(DeifProtocol::decode).filter { it.index == 0x3081 }
|
||||
assertEquals(0, rpm.first().raw)
|
||||
assertEquals(5000, rpm.last().raw)
|
||||
assertTrue(controller.state.value.running)
|
||||
controller.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import ru.setcorp.setflash.core.CanBridgeProtocol
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Config
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Mode
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DeifTest {
|
||||
@Test fun matchesPythonSetguiReferenceCycles() {
|
||||
val text = checkNotNull(javaClass.getResourceAsStream("/deif-setgui.json")).bufferedReader().use { it.readText() }
|
||||
val cases = org.json.JSONArray(text)
|
||||
repeat(cases.length()) { i ->
|
||||
val c = cases.getJSONObject(i)
|
||||
val raw = Parameter.entries.mapIndexed { n, p -> p to c.getJSONArray("raw").getInt(n) }.toMap()
|
||||
val frames = DeifProtocol.cycle(Config(Mode.valueOf(c.getString("mode")), c.getInt("node"), c.getInt("canId")), raw)
|
||||
frames.forEachIndexed { n, f ->
|
||||
assertEquals("case $i frame $n", c.getJSONArray("frames").getString(n), f.data.joinToString("") { "%02x".format(it) })
|
||||
DeifProtocol.validateTransmit(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun frame(id: Long, data: String, tx: Boolean = false) = CanBridgeProtocol.buildFrame(
|
||||
id, CanBridgeProtocol.parseHex(data), extended = false, toCan = tx)
|
||||
|
||||
@Test fun setguiDamVectorAndBothAngles() {
|
||||
val frames = DeifProtocol.cycle(Config(Mode.DAM, 11, 0x20B), mapOf(Parameter.POWER to 1234, Parameter.ANGLE to -900))
|
||||
assertEquals(6, frames.size)
|
||||
assertEquals(frame(0x20B, "8B 21 34 02 D2 04 00 00", true), frames[3])
|
||||
assertEquals(frame(0x20B, "8B 01 30 02 7C FC 00 00", true), frames[0])
|
||||
assertEquals(frame(0x20B, "8B 01 30 07 7C FC 00 00", true), frames[5])
|
||||
assertTrue(frames.all { !it.isExtended && !it.isRemote })
|
||||
}
|
||||
@Test fun routingAndInputValidation() {
|
||||
for (node in listOf(1, 11, 127)) for (base in listOf(0x200, 0x300, 0x400, 0x500)) {
|
||||
val frames = DeifProtocol.cycle(Config(Mode.DAM, node, base + node), emptyMap())
|
||||
assertTrue(frames.all { DeifProtocol.decode(it).valid })
|
||||
frames.forEach(DeifProtocol::validateTransmit)
|
||||
}
|
||||
assertTrue(DeifProtocol.decode(frame(0x27F, "0B 81 30 02 D2 04 00 00")).valid)
|
||||
assertFalse(DeifProtocol.decode(frame(0x201, "0B 81 30 02 D2 04 00 00")).valid)
|
||||
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.cycle(Config(node = 0), emptyMap()) }
|
||||
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.cycle(Config(), mapOf(Parameter.RPM to 32768)) }
|
||||
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.cycle(Config(Mode.SAM, 11, 0x30B), emptyMap()) }
|
||||
}
|
||||
@Test fun decoderScalesStatusesAndRejectsUnknownFrames() {
|
||||
assertEquals(-90.0, DeifProtocol.decode(frame(0x201, "01 01 30 07 7C FC 00 00")).value!!, 0.001)
|
||||
assertEquals(123.4, DeifProtocol.decode(frame(0x201, "01 81 30 02 D2 04 00 00")).value!!, 0.001)
|
||||
assertEquals("RAW", DeifProtocol.decode(frame(0x201, "01 21 34 02 D2 04 00 00")).unit)
|
||||
assertNull(DeifProtocol.decode(frame(0x201, "01 81 30 07 D2 04 00 00")).value)
|
||||
listOf("00 80", "FE 7F", "FF 7F").forEach { raw ->
|
||||
val decoded = DeifProtocol.decode(frame(0x201, "01 81 30 02 $raw 00 00"))
|
||||
assertFalse(decoded.valid); assertNull(decoded.value)
|
||||
}
|
||||
assertFalse(DeifProtocol.decode(frame(0x201, "01 81 30 02 00 00 00 01")).valid)
|
||||
assertFalse(DeifProtocol.decode(frame(0x201, "01 81 30 02 00 00 00 00").copy(flags = CanBridgeProtocol.FLAG_IDE)).valid)
|
||||
assertFalse(DeifProtocol.decode(frame(0x201, "01 81 30")).valid)
|
||||
}
|
||||
@Test fun transmitGuardRejectsConfigurationAndBadAddresses() {
|
||||
listOf(frame(0, "01 0B"), frame(0x60B, "23 00 20 00 01 00 00 00"), frame(0x7E5, "04"),
|
||||
frame(0x70B, "00"), frame(0x201, "0B 81 30 02 00 00 00 00")).forEach {
|
||||
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.validateTransmit(it) }
|
||||
}
|
||||
DeifProtocol.validateTransmit(DeifProtocol.heartbeat(Config()))
|
||||
assertEquals(0x701L, DeifProtocol.heartbeat(Config(Mode.DAM, 11, 0x20B)).canId)
|
||||
}
|
||||
@Test fun heartbeatHasIndependentPeriodAndStopsOnUnlock() = runTest {
|
||||
val sent = mutableListOf<CanBridgeProtocol.Frame>()
|
||||
val controller = DeifController(backgroundScope, { sent += it }, now = { testScheduler.currentTime })
|
||||
controller.unlock(true); controller.configure(Config(periodMs = 1000, heartbeat = true)); controller.unlock(false)
|
||||
controller.start(true); runCurrent()
|
||||
assertEquals(6, sent.count { it.data.size == 8 }); assertEquals(1, sent.count { it.data.size == 1 })
|
||||
advanceTimeBy(501); runCurrent()
|
||||
assertEquals(6, sent.count { it.data.size == 8 }); assertEquals(2, sent.count { it.data.size == 1 })
|
||||
controller.unlock(true)
|
||||
val count = sent.size; advanceTimeBy(2000); runCurrent(); assertEquals(count, sent.size)
|
||||
assertFalse(controller.state.value.running)
|
||||
}
|
||||
@Test fun singleCycleHasNoHeartbeatAndSelectionStopsRepeat() = runTest {
|
||||
val sent = mutableListOf<CanBridgeProtocol.Frame>()
|
||||
val controller = DeifController(backgroundScope, { sent += it })
|
||||
controller.unlock(true); controller.configure(Config(heartbeat = true, enabled = setOf(Parameter.RPM))); controller.unlock(false)
|
||||
controller.start(false); runCurrent()
|
||||
assertEquals(1, sent.size); assertEquals(8, sent.single().data.size)
|
||||
assertFalse(controller.state.value.running)
|
||||
controller.start(true); runCurrent(); controller.select(Parameter.RPM, false)
|
||||
val count = sent.size; advanceTimeBy(1500); runCurrent(); assertEquals(count, sent.size)
|
||||
}
|
||||
@Test fun failedTransportStopsBothTimers() = runTest {
|
||||
var calls = 0
|
||||
val controller = DeifController(backgroundScope, { calls++; error("disconnected") })
|
||||
controller.start(true); runCurrent(); advanceTimeBy(1000); runCurrent()
|
||||
assertEquals(1, calls); assertFalse(controller.state.value.running)
|
||||
assertEquals(0L, controller.state.value.sent)
|
||||
}
|
||||
@Test fun slaveNeverUsesLocalTxOrEchoAndExpires() = runTest {
|
||||
var time = 0L
|
||||
val controller = DeifController(backgroundScope, {}, now = { time })
|
||||
controller.setRaw(Parameter.RPM, 1234); controller.start(false); runCurrent()
|
||||
assertEquals(123.4, controller.value(0, Parameter.RPM)!!, 0.001)
|
||||
assertNull(controller.value(1, Parameter.RPM))
|
||||
controller.receive(frame(0x201, "01 81 30 02 D2 04 00 00", true))
|
||||
assertNull(controller.value(1, Parameter.RPM)); assertEquals(1L, controller.state.value.echoes)
|
||||
controller.receive(frame(0x201, "01 81 30 02 D2 04 00 00"))
|
||||
assertEquals(123.4, controller.value(1, Parameter.RPM)!!, 0.001)
|
||||
time = 5001; assertNull(controller.value(1, Parameter.RPM))
|
||||
controller.receive(frame(0x201, "01 81 30 02 FF 7F 00 00"))
|
||||
assertNull(controller.value(1, Parameter.RPM))
|
||||
}
|
||||
@Test fun settingsRestoreIsLockedAndStoppedAndKeepsBindings() = runTest {
|
||||
var saved = ""
|
||||
val controller = DeifController(backgroundScope, {}, { saved = it })
|
||||
controller.unlock(true); controller.configure(Config(Mode.DAM, 11, 0x30B, heartbeat = true))
|
||||
controller.configureDisplay(1, DeifDisplayConfig(11, 0x30B))
|
||||
controller.setRaw(Parameter.POWER, 1234)
|
||||
val restored = DeifController(backgroundScope, {})
|
||||
restored.restore(saved)
|
||||
assertEquals(controller.state.value.config, restored.state.value.config)
|
||||
assertEquals(11, restored.state.value.displays[1].node)
|
||||
assertEquals(1234, restored.state.value.raw[Parameter.POWER])
|
||||
assertFalse(restored.state.value.running); assertFalse(restored.state.value.unlocked)
|
||||
restored.receive(frame(0x30B, "8B 21 34 02 D2 04 00 00"))
|
||||
assertEquals(1234.0, restored.value(1, Parameter.POWER)!!, 0.001)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ unsigned ump_validate(unsigned fn, uint16_t a, uint16_t n, int can)
|
||||
return can ? ((a == UMP_FIRMWARE_INFO && n == UMP_FIRMWARE_INFO_WORDS) ? 0 : 2)
|
||||
: ((n && n <= UMP_FIRMWARE_INFO + UMP_FIRMWARE_INFO_WORDS - a) ? 0 : 2);
|
||||
if (can) return ((a == UMP_BASE && n == 16) ||
|
||||
(a == UMP_DATA && (n == 40 || n == 80 || n == 120 || n == 42 || n == 84)) ||
|
||||
/* Разрешает целые 12-словные записи v4 до 120 слов; размеры v2/v3 сохраняются для прежних устройств. */
|
||||
(a == UMP_DATA && (n == 40 || n == 80 || n == 42 || n == 84 || (n && n <= 120 && !(n % 12U)))) ||
|
||||
(a == UMP_LIVE && n == 124)) ? 0 : 2;
|
||||
if (!n || n > 125) return 2;
|
||||
return ((a >= UMP_BASE && a < UMP_BASE+16 && n <= UMP_BASE+16-a) ||
|
||||
|
||||
722
c/set-protocol/tests/fixtures/deif-setgui.json
vendored
Normal file
722
c/set-protocol/tests/fixtures/deif-setgui.json
vendored
Normal file
@@ -0,0 +1,722 @@
|
||||
[
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 1,
|
||||
"canId": 513,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"010130027cfc0000",
|
||||
"01813002d2040000",
|
||||
"01913002e8030000",
|
||||
"01213402d2040000",
|
||||
"01313402f4010000",
|
||||
"010130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 1,
|
||||
"canId": 513,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"0101300200800000",
|
||||
"01813002ffff0000",
|
||||
"0191300200000000",
|
||||
"01213402fe7f0000",
|
||||
"01313402ff7f0000",
|
||||
"0101300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 1,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"010130027cfc0000",
|
||||
"01813002d2040000",
|
||||
"01913002e8030000",
|
||||
"01213402d2040000",
|
||||
"01313402f4010000",
|
||||
"010130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 1,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"0101300200800000",
|
||||
"01813002ffff0000",
|
||||
"0191300200000000",
|
||||
"01213402fe7f0000",
|
||||
"01313402ff7f0000",
|
||||
"0101300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 11,
|
||||
"canId": 523,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"0b0130027cfc0000",
|
||||
"0b813002d2040000",
|
||||
"0b913002e8030000",
|
||||
"0b213402d2040000",
|
||||
"0b313402f4010000",
|
||||
"0b0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 11,
|
||||
"canId": 523,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"0b01300200800000",
|
||||
"0b813002ffff0000",
|
||||
"0b91300200000000",
|
||||
"0b213402fe7f0000",
|
||||
"0b313402ff7f0000",
|
||||
"0b01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 11,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"0b0130027cfc0000",
|
||||
"0b813002d2040000",
|
||||
"0b913002e8030000",
|
||||
"0b213402d2040000",
|
||||
"0b313402f4010000",
|
||||
"0b0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 11,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"0b01300200800000",
|
||||
"0b813002ffff0000",
|
||||
"0b91300200000000",
|
||||
"0b213402fe7f0000",
|
||||
"0b313402ff7f0000",
|
||||
"0b01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 127,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"7f0130027cfc0000",
|
||||
"7f813002d2040000",
|
||||
"7f913002e8030000",
|
||||
"7f213402d2040000",
|
||||
"7f313402f4010000",
|
||||
"7f0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 127,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"7f01300200800000",
|
||||
"7f813002ffff0000",
|
||||
"7f91300200000000",
|
||||
"7f213402fe7f0000",
|
||||
"7f313402ff7f0000",
|
||||
"7f01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 127,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"7f0130027cfc0000",
|
||||
"7f813002d2040000",
|
||||
"7f913002e8030000",
|
||||
"7f213402d2040000",
|
||||
"7f313402f4010000",
|
||||
"7f0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "SAM",
|
||||
"node": 127,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"7f01300200800000",
|
||||
"7f813002ffff0000",
|
||||
"7f91300200000000",
|
||||
"7f213402fe7f0000",
|
||||
"7f313402ff7f0000",
|
||||
"7f01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 1,
|
||||
"canId": 513,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"810130027cfc0000",
|
||||
"81813002d2040000",
|
||||
"81913002e8030000",
|
||||
"81213402d2040000",
|
||||
"81313402f4010000",
|
||||
"810130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 1,
|
||||
"canId": 513,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"8101300200800000",
|
||||
"81813002ffff0000",
|
||||
"8191300200000000",
|
||||
"81213402fe7f0000",
|
||||
"81313402ff7f0000",
|
||||
"8101300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 1,
|
||||
"canId": 769,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"810130027cfc0000",
|
||||
"81813002d2040000",
|
||||
"81913002e8030000",
|
||||
"81213402d2040000",
|
||||
"81313402f4010000",
|
||||
"810130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 1,
|
||||
"canId": 769,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"8101300200800000",
|
||||
"81813002ffff0000",
|
||||
"8191300200000000",
|
||||
"81213402fe7f0000",
|
||||
"81313402ff7f0000",
|
||||
"8101300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 1,
|
||||
"canId": 1025,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"810130027cfc0000",
|
||||
"81813002d2040000",
|
||||
"81913002e8030000",
|
||||
"81213402d2040000",
|
||||
"81313402f4010000",
|
||||
"810130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 1,
|
||||
"canId": 1025,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"8101300200800000",
|
||||
"81813002ffff0000",
|
||||
"8191300200000000",
|
||||
"81213402fe7f0000",
|
||||
"81313402ff7f0000",
|
||||
"8101300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 1,
|
||||
"canId": 1281,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"810130027cfc0000",
|
||||
"81813002d2040000",
|
||||
"81913002e8030000",
|
||||
"81213402d2040000",
|
||||
"81313402f4010000",
|
||||
"810130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 1,
|
||||
"canId": 1281,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"8101300200800000",
|
||||
"81813002ffff0000",
|
||||
"8191300200000000",
|
||||
"81213402fe7f0000",
|
||||
"81313402ff7f0000",
|
||||
"8101300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 11,
|
||||
"canId": 523,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"8b0130027cfc0000",
|
||||
"8b813002d2040000",
|
||||
"8b913002e8030000",
|
||||
"8b213402d2040000",
|
||||
"8b313402f4010000",
|
||||
"8b0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 11,
|
||||
"canId": 523,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"8b01300200800000",
|
||||
"8b813002ffff0000",
|
||||
"8b91300200000000",
|
||||
"8b213402fe7f0000",
|
||||
"8b313402ff7f0000",
|
||||
"8b01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 11,
|
||||
"canId": 779,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"8b0130027cfc0000",
|
||||
"8b813002d2040000",
|
||||
"8b913002e8030000",
|
||||
"8b213402d2040000",
|
||||
"8b313402f4010000",
|
||||
"8b0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 11,
|
||||
"canId": 779,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"8b01300200800000",
|
||||
"8b813002ffff0000",
|
||||
"8b91300200000000",
|
||||
"8b213402fe7f0000",
|
||||
"8b313402ff7f0000",
|
||||
"8b01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 11,
|
||||
"canId": 1035,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"8b0130027cfc0000",
|
||||
"8b813002d2040000",
|
||||
"8b913002e8030000",
|
||||
"8b213402d2040000",
|
||||
"8b313402f4010000",
|
||||
"8b0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 11,
|
||||
"canId": 1035,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"8b01300200800000",
|
||||
"8b813002ffff0000",
|
||||
"8b91300200000000",
|
||||
"8b213402fe7f0000",
|
||||
"8b313402ff7f0000",
|
||||
"8b01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 11,
|
||||
"canId": 1291,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"8b0130027cfc0000",
|
||||
"8b813002d2040000",
|
||||
"8b913002e8030000",
|
||||
"8b213402d2040000",
|
||||
"8b313402f4010000",
|
||||
"8b0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 11,
|
||||
"canId": 1291,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"8b01300200800000",
|
||||
"8b813002ffff0000",
|
||||
"8b91300200000000",
|
||||
"8b213402fe7f0000",
|
||||
"8b313402ff7f0000",
|
||||
"8b01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 127,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"ff0130027cfc0000",
|
||||
"ff813002d2040000",
|
||||
"ff913002e8030000",
|
||||
"ff213402d2040000",
|
||||
"ff313402f4010000",
|
||||
"ff0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 127,
|
||||
"canId": 639,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"ff01300200800000",
|
||||
"ff813002ffff0000",
|
||||
"ff91300200000000",
|
||||
"ff213402fe7f0000",
|
||||
"ff313402ff7f0000",
|
||||
"ff01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 127,
|
||||
"canId": 895,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"ff0130027cfc0000",
|
||||
"ff813002d2040000",
|
||||
"ff913002e8030000",
|
||||
"ff213402d2040000",
|
||||
"ff313402f4010000",
|
||||
"ff0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 127,
|
||||
"canId": 895,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"ff01300200800000",
|
||||
"ff813002ffff0000",
|
||||
"ff91300200000000",
|
||||
"ff213402fe7f0000",
|
||||
"ff313402ff7f0000",
|
||||
"ff01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 127,
|
||||
"canId": 1151,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"ff0130027cfc0000",
|
||||
"ff813002d2040000",
|
||||
"ff913002e8030000",
|
||||
"ff213402d2040000",
|
||||
"ff313402f4010000",
|
||||
"ff0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 127,
|
||||
"canId": 1151,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"ff01300200800000",
|
||||
"ff813002ffff0000",
|
||||
"ff91300200000000",
|
||||
"ff213402fe7f0000",
|
||||
"ff313402ff7f0000",
|
||||
"ff01300700800000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 127,
|
||||
"canId": 1407,
|
||||
"raw": [
|
||||
-900,
|
||||
1234,
|
||||
1000,
|
||||
1234,
|
||||
500
|
||||
],
|
||||
"frames": [
|
||||
"ff0130027cfc0000",
|
||||
"ff813002d2040000",
|
||||
"ff913002e8030000",
|
||||
"ff213402d2040000",
|
||||
"ff313402f4010000",
|
||||
"ff0130077cfc0000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "DAM",
|
||||
"node": 127,
|
||||
"canId": 1407,
|
||||
"raw": [
|
||||
-32768,
|
||||
-1,
|
||||
0,
|
||||
32766,
|
||||
32767
|
||||
],
|
||||
"frames": [
|
||||
"ff01300200800000",
|
||||
"ff813002ffff0000",
|
||||
"ff91300200000000",
|
||||
"ff213402fe7f0000",
|
||||
"ff313402ff7f0000",
|
||||
"ff01300700800000"
|
||||
]
|
||||
}
|
||||
]
|
||||
13
firmware-version-porting.md
Normal file
13
firmware-version-porting.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Перенос контроля версии прошивки
|
||||
|
||||
В templates используются общий модуль `c/firmware-info` и общий публикатор `tools/firmware-publish`. При переносе подключаются конфигурация контроллера, генерация build ID, обработчик запроса версии и настройки выпуска.
|
||||
|
||||
- [Пошаговое подключение модуля версии](c/firmware-info/PORTING.md).
|
||||
- [BAT-файлы precompile и postcompile для CCS и Keil](tools/firmware-build/README.md).
|
||||
- [Настройка подготовки и публикации образов](tools/firmware-publish/PORTING.md).
|
||||
|
||||
В режиме CCS `--identity` precompile создаёт заголовок версии и Git-метаданные, а postcompile преобразует OUT в SCI8 HEX/BIN и создаёт manifest и SHA-256. В прежнем режиме postcompile запускает SETGUI preflight готового образа. Для каждого проекта задаются явные пути; алгоритмы остаются в templates.
|
||||
|
||||
[Копирование BIN, manifest и SHA-256 в каталог SETGUI после postcompile](tools/firmware-build/README.md#4-копирование-результата-в-каталог-setgui).
|
||||
|
||||
Для C2000 с 16-битным char требуется отдельная адаптация байтового контракта. BAT-файлы сами по себе эту адаптацию не выполняют.
|
||||
@@ -24,6 +24,13 @@ Saleae SDK подключается при обращении к устройс
|
||||
в приложении. Общая панель обработки графиков размещена в `qt_ports.plot_processing`:
|
||||
[контракт, подключение нового графика и примеры](../c/set-protocol/docs/PLOT_PROCESSING.md).
|
||||
|
||||
Конструкторы CAN-адаптера, CAN DevBoard и RS485 DevBoard используют общий
|
||||
компонент `set_devices.qt_ports.can_frame_builder.CanFrameFields`. Он задаёт
|
||||
одинаковые поля и исходный режим «Обычный CAN — ID / DLC / Data», сохраняет
|
||||
альтернативный режим SETCAN и сообщает об изменениях сигналом `changed`.
|
||||
`frame_values()` возвращает проверенные ID, IDE, Data и необязательный Body;
|
||||
упаковка конкретного транспорта и отправка остаются у приложения.
|
||||
|
||||
Пути к нативным библиотекам задаёт потребитель перед импортом:
|
||||
`SETPROTOCOL_LIBRARY`, при необходимости `ALTERA_LOGIC_LIBRARY`, `CANDLE_LIBRARY`.
|
||||
Порты не ищут DLL в каталогах SETGUI. C99-ядро собирается из `c/set-protocol`,
|
||||
|
||||
46
python/examples/console_examples.py
Normal file
46
python/examples/console_examples.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Три автономных примера общей консоли templates без подключения к аппаратуре.""" # Примеры показывают служебный текст, CAN и двоичный RS485 на одном компоненте.
|
||||
import argparse # Ключ --kind выбирает один пример для отдельного запуска.
|
||||
import time # Время в модели кадра соответствует обычному источнику пакетов приложения.
|
||||
from set_devices.can485_board import BoardFrame # Штатная модель строит RS485-пакет с настоящим CRC.
|
||||
from set_devices.can_monitor import decode_protocol # CAN расшифровывается действующим декодером templates.
|
||||
from set_devices.can_transport import build_frame # Транспортный кадр не собирается вручную в UI.
|
||||
from set_devices.qt_ports.console import ConsoleView # Все примеры используют ту же консоль, что SETGUI.
|
||||
try: # Выбираем доступную версию Qt без зависимости от приложения SETGUI.
|
||||
from PySide6.QtWidgets import QApplication, QTabWidget # Вкладки позволяют сравнить три независимых журнала.
|
||||
except ImportError: # Сохраняем совместимость примера с портом Qt 5.
|
||||
from PySide2.QtWidgets import QApplication, QTabWidget # Интерфейс запуска одинаков для обеих версий Qt.
|
||||
|
||||
|
||||
def create_examples(): # Фабрика доступна также тесту и генератору снимков интерфейса.
|
||||
service = ConsoleView(limit=4000) # Первый журнал показывает служебные ответы и команды платы.
|
||||
service.append_message(">>> V", direction="TX", color="#8fd18f") # Метка отправки демонстрирует независимый фильтр TX.
|
||||
service.append_message("#OK driver started", direction="RX", color="#6fa8dc") # Ответ устройства показывает цвет и фильтр RX.
|
||||
service.append_message("#ERR transmit failed (no ack / bus busy)", color="#ff7b7b") # Ошибка остаётся видна независимо от фильтров направления.
|
||||
can = ConsoleView(limit=4000, payloads=True) # Второй пример добавляет переключение представления настоящих байтов CAN.
|
||||
frame = build_frame(0x701, b"\x05", ide=False, rtr=False) # ID 0x701 и байт 05 задают heartbeat CANopen узла 1 в Operational.
|
||||
decoded = decode_protocol(frame, "deif") # Подпись события берётся из штатного протокольного декодера.
|
||||
can.append_message("RX CAN 0x701 · " + decoded.summary, direction="RX", payload=b"\x05") # Исходный payload позволяет сравнить режимы HEX и ASCII.
|
||||
rs485 = ConsoleView(limit=4000, payloads=True) # Третий пример показывает транспортный пакет платы отдельно от прикладного CAN.
|
||||
packet = BoardFrame(time.time(), False, 0x701, False, 1, b"\x05", "RS485").rs485_packet() # Пакет AA55 и CRC8 формируются кодеком templates.
|
||||
rs485.append_message("RX RS485 · пакет CAN485", direction="RX", payload=packet) # Консоль отображает байты, не повторяя алгоритм разбора пакета.
|
||||
for console in (can, rs485): # Двоичные примеры сразу демонстрируют одновременно байты и печатное представление.
|
||||
console.display.setCurrentText("HEX + ASCII") # Пользователь может вернуться к исходной подписи или выбрать один формат.
|
||||
return (("Служебная консоль", service), ("CAN", can), ("RS485", rs485)) # Каждый журнал владеет своей историей и настройками просмотра.
|
||||
|
||||
|
||||
def main(): # Точка входа запускается обычным Python с установленным Qt-портом templates.
|
||||
parser = argparse.ArgumentParser(description=__doc__) # Справка объясняет назначение примеров без отдельного приложения.
|
||||
parser.add_argument("--kind", choices=("all", "service", "can", "rs485"), default="all") # Один пример каждого источника доступен отдельной командой.
|
||||
args = parser.parse_args() # Пользователь выбирает источник до создания окна.
|
||||
app = QApplication.instance() or QApplication([]) # Одно приложение Qt обслуживает все примеры.
|
||||
window = QTabWidget() # Общий просмотр не связывает состояния независимых консолей.
|
||||
for key, (title, console) in zip(("service", "can", "rs485"), create_examples()): # Стабильные ключи соответствуют значениям --kind.
|
||||
if args.kind in ("all", key): window.addTab(console.panel, title) # Выбранные примеры добавляются с общей панелью инструментов.
|
||||
window.setWindowTitle("Примеры консоли templates") # Заголовок явно отличает демонстрацию от подключения к плате.
|
||||
window.resize(1100, 480) # Начальный размер в логических пикселях позволяет видеть все инструменты.
|
||||
window.show() # Окно появляется только при прямом запуске примера.
|
||||
return app.exec() # Цикл Qt обрабатывает поиск, паузу, экспорт и изменение масштаба.
|
||||
|
||||
|
||||
if __name__ == "__main__": # Импорт фабрики в тестах не должен открывать окно или запускать цикл событий.
|
||||
raise SystemExit(main()) # Код завершения Qt возвращается вызывающему процессу.
|
||||
68
python/set_devices/qt_ports/CONSOLE.md
Normal file
68
python/set_devices/qt_ports/CONSOLE.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Общая консоль Qt
|
||||
|
||||
`set_devices.qt_ports.console.ConsoleView` — общий порт представления для
|
||||
журналов SETGUI. Он не управляет портами, опросом и отправкой команд.
|
||||
|
||||
Приложение → `ConsoleView` → Qt 6 / Qt 5. Протокольные декодеры передают
|
||||
готовые подписи и необязательные исходные байты; консоль не вычисляет CRC.
|
||||
|
||||
| Файл | Назначение и зависимости |
|
||||
|---|---|
|
||||
| `set_devices/qt_ports/console.py` | Виджет и файловый экспорт; stdlib и PySide6 либо PySide2 |
|
||||
| `examples/console_examples.py` | По одному примеру служебной консоли, CAN и RS485; действующие кодеки templates |
|
||||
| `tests/test_console.py` | Проверки истории, паузы, фильтров, очистки и UTF-8; pytest и Qt |
|
||||
|
||||
## Возможности, собранные из журналов SETGUI
|
||||
|
||||
| Возможность | Источник / поведение общего компонента |
|
||||
|---|---|
|
||||
| Очистка | Канальные консоли платы, TX/RX, Modbus; удаляет также скрытые строки и очередь обновления |
|
||||
| Пауза | Эмуляторы CAN; экран заморожен, история продолжает принимать данные до заданного предела |
|
||||
| TX/RX | Диагностика и эмуляторы; служебные ошибки без направления остаются видны |
|
||||
| Автопрокрутка | Мониторы CAN и RS; её можно отключить для чтения истории |
|
||||
| Цвета | Консоль платы и монитор шины; сохраняются при переразборе фильтра |
|
||||
| HEX/ASCII | Диагностика RS; формат применяется к явно переданному payload |
|
||||
| Сохранение | Мониторы CAN и RS; экспорт фактически видимого текста в UTF-8 |
|
||||
| Поиск, копирование, перенос, масштаб | Общие инструменты всех подключённых текстовых журналов |
|
||||
|
||||
## Подключение
|
||||
|
||||
```python
|
||||
from set_devices.qt_ports.console import ConsoleView # Подключаем общий порт представления templates.
|
||||
console = ConsoleView(limit=4000, payloads=True) # Храним не более 4000 строк, включая скрытые и поступившие на паузе.
|
||||
layout.addWidget(console.panel, 1) # В layout вставляется контейнер с инструментами и редактором.
|
||||
console.append_message("RX ответ", direction="RX", payload=b"OK") # Передаём готовую подпись и байты без повторного разбора протокола.
|
||||
console.append_message("Ошибка связи", color="#ff7b7b") # Служебная ошибка остаётся видна при выключенных TX и RX.
|
||||
console.userCleared.connect(clear_frame_history) # Отдельный буфер кадров очищается тем же пользовательским действием.
|
||||
```
|
||||
|
||||
Старые вызовы `appendPlainText`, `appendHtml`, `setPlainText`, `clear`,
|
||||
`toPlainText` и `setMaximumBlockCount` поддерживаются. `setPlainText` заменяет
|
||||
снимок истории, учитывая текущую паузу. Для внешней истории кадров подключите
|
||||
`userCleared`: программный `clear()` повторно этот сигнал не испускает.
|
||||
|
||||
`directions=False` скрывает встроенные флажки, если приложение уже имеет свои
|
||||
фильтры TX/RX. `payloads=True` показывает форматы HEX/ASCII. События должны
|
||||
поступать в GUI-потоке Qt, как при работе с обычным `QPlainTextEdit`.
|
||||
|
||||
При заполнении история обновляет экран пачками с интервалом 100 мс.
|
||||
Количество строк на панели относится ко всей ограниченной истории.
|
||||
На паузе сохранение и копирование используют замороженный видимый экран.
|
||||
|
||||
## Три запускаемых примера
|
||||
|
||||
Из каталога `python`, с установленными зависимостями `.[qt]`:
|
||||
|
||||
```powershell
|
||||
python examples/console_examples.py --kind service
|
||||
python examples/console_examples.py --kind can
|
||||
python examples/console_examples.py --kind rs485
|
||||
```
|
||||
|
||||
Без `--kind` открываются все три примера. Аппаратура не требуется.
|
||||
В каждом примере можно проверить очистку, паузу, поиск, TX/RX, экспорт,
|
||||
копирование, автопрокрутку, перенос и масштаб; в CAN и RS485 — HEX/ASCII.
|
||||
|
||||
Используется в SETGUI: CAN/RS485 и служебная консоль ESP, журналы соединения
|
||||
и TX/RX, эмуляторы CAN, CAN FD, Modbus, диагностика ПМ35 и ТМС2812,
|
||||
спектр и логический анализатор Altera.
|
||||
175
python/set_devices/qt_ports/can_frame_builder.py
Normal file
175
python/set_devices/qt_ports/can_frame_builder.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Общие поля конструктора CAN и SETCAN для адаптеров и обоих каналов DevBoard."""
|
||||
from PySide6.QtCore import Signal # Сигнал изменений позволяет владельцу сбрасывать готовый кадр без зависимости от транспорта.
|
||||
from PySide6.QtGui import QFont # Моноширинный шрифт сохраняет читаемость HEX-идентификатора и данных.
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QComboBox, QSpinBox, QLineEdit, QLabel # Один набор виджетов определяет форму всех потребителей.
|
||||
from protocan import protocan as pc # Сборка полей протокола выполняется существующими обёртками общего C-ядра.
|
||||
from set_devices import can_transport as tr # Проверка CAN ID и разбор HEX используют действующий общий транспорт.
|
||||
|
||||
|
||||
DESCRIPTION = "Обычный CAN: задайте ID, DLC и Data. Для сборки ID по полям протокола выберите SETCAN." # Одинаковое пояснение режимов используется во всех панелях.
|
||||
|
||||
|
||||
class CanFrameFields(QWidget): # Компонент содержит только ввод кадра; подключение и отправка остаются у владельца.
|
||||
changed = Signal() # Любое изменение ввода отменяет ранее собранный кадр в соответствующем канале.
|
||||
|
||||
def __init__(self, settings_factory, parent=None): # Редактор SETTINGS передаётся владельцем как визуальный компонент без копирования его реализации.
|
||||
super().__init__(parent) # Родитель Qt управляет временем жизни всех полей формы.
|
||||
layout = QVBoxLayout(self) # Единый вертикальный порядок исключает различия между вкладками.
|
||||
layout.setContentsMargins(0, 0, 0, 0) # Внешние отступы задаёт панель приложения.
|
||||
self.mode = QComboBox() # Переключатель сохраняет оба набора введённых значений.
|
||||
self.mode.addItem("Обычный CAN — ID / DLC / Data", "can") # Исходный режим одинаков для всех трёх конструкторов.
|
||||
self.mode.addItem("SETCAN — поля протокола", "setcan") # Протокольный режим всегда создаёт расширенный идентификатор.
|
||||
mode_form = QFormLayout() # Первая строка остаётся видимой в обоих режимах.
|
||||
mode_form.addRow("Режим", self.mode) # Подпись поясняет назначение переключателя.
|
||||
layout.addLayout(mode_form) # Выбор режима предшествует зависимым полям.
|
||||
self.can_panel = QWidget() # Контейнер скрывает вместе подписи и редакторы обычного CAN.
|
||||
can_form = QFormLayout(self.can_panel) # Формат, идентификатор и длина выровнены общей формой.
|
||||
can_form.setContentsMargins(0, 0, 0, 0) # Поля обоих режимов начинаются на одной вертикали.
|
||||
self.format = QComboBox() # IDE задаётся явно независимо от численного значения идентификатора.
|
||||
self.format.addItem("Стандартный — 11 бит (STD)", False) # STD соответствует диапазону CAN ID 0..0x7FF.
|
||||
self.format.addItem("Расширенный — 29 бит (EXT)", True) # EXT соответствует диапазону CAN ID 0..0x1FFFFFFF.
|
||||
self.can_id = QLineEdit("123") # Исходный пример идентификатора записан в HEX.
|
||||
self.can_id.setToolTip("HEX, с префиксом 0x или без него. STD: 0..7FF; EXT: 0..1FFFFFFF.") # Указываем пределы CAN ID без неявного усечения.
|
||||
self.dlc = QSpinBox() # Пользователь явно задаёт длину обычного CAN-кадра.
|
||||
self.dlc.setRange(0, 8) # Классический CAN допускает от нуля до восьми байтов Data.
|
||||
self.dlc.setToolTip("Количество байтов Data должно точно совпадать с DLC; дополнение и обрезка не выполняются.") # Описываем строгую проверку длины.
|
||||
can_form.addRow("Формат ID (IDE)", self.format) # Формат отражает фактический флаг IDE.
|
||||
can_form.addRow("CAN ID (HEX)", self.can_id) # Явно обозначаем систему счисления идентификатора.
|
||||
can_form.addRow("DLC (0..8 байт)", self.dlc) # Длина дана в байтах полезной нагрузки.
|
||||
layout.addWidget(self.can_panel) # Обычный CAN занимает то же место, что альтернативная форма SETCAN.
|
||||
self.setcan_panel = QWidget() # Общий контейнер хранит поля составного 29-битного идентификатора.
|
||||
form = QFormLayout(self.setcan_panel) # Подписи протокола выравниваются с остальными формами.
|
||||
form.setContentsMargins(0, 0, 0, 0) # Убираем дополнительные внутренние отступы.
|
||||
self.priority = QComboBox() # Приоритет соответствует старшему полю SETCAN ID.
|
||||
self.priority.addItems(["0 — критический", "1 — стандартный"]) # Подписи сохраняют существующие значения приоритета протокола.
|
||||
self.priority.setCurrentIndex(1) # По умолчанию используется стандартный приоритет.
|
||||
self.route = QComboBox() # Направление отличает запрос мастера от сообщения устройства.
|
||||
self.route.addItems(["0 — от мастера (PM)", "1 — от устройства"]) # Сохраняем совместимые значения поля ПМ.
|
||||
self.dtype = QSpinBox() # Тип устройства выбирается отдельно от номера экземпляра.
|
||||
self.dtype.setRange(0, 7) # Поле Device Type занимает три бита SETCAN ID.
|
||||
self.dtype.setValue(1) # Сохраняем прежний исходный тип устройства.
|
||||
self.did = QSpinBox() # Номер устройства задаёт адрес внутри выбранного типа.
|
||||
self.did.setRange(0, 15) # Поле Device занимает четыре бита SETCAN ID.
|
||||
self.did.setValue(2) # Сохраняем прежний исходный адрес устройства.
|
||||
self.mtype = QComboBox() # Тип сообщения определяет подтип и назначение Body.
|
||||
self.mtype.addItems([item.name for item in pc.MsgType]) # Каталог типов берётся из общего протокольного модуля.
|
||||
self.mtype.setCurrentText(pc.MsgType.ANALOG.name) # Начальный протокольный пример остаётся аналоговым сообщением.
|
||||
self.sub = QComboBox() # Набор подтипов меняется вместе с MsgType.
|
||||
self.arg = QLineEdit("1") # Числовой аргумент поддерживает десятичный ввод и префикс 0x.
|
||||
self.settings_body = settings_factory() # Используем переданный редактор байтов Z и Y без зависимости от приложения.
|
||||
self.arg_panel = QWidget() # Альтернативные редакторы аргумента занимают одну строку.
|
||||
arg_layout = QHBoxLayout(self.arg_panel) # Горизонтальная компоновка сохраняет левое выравнивание SETTINGS.
|
||||
arg_layout.setContentsMargins(0, 0, 0, 0) # Исключаем сдвиг аргумента относительно остальных редакторов.
|
||||
arg_layout.addWidget(self.arg) # Обычный аргумент виден для всех типов кроме SETTINGS.
|
||||
arg_layout.addWidget(self.settings_body) # Два байта SETTINGS заменяют единое поле аргумента.
|
||||
arg_layout.addStretch(1) # Свободная ширина остаётся справа от редакторов.
|
||||
self.count = QSpinBox() # RegCount используется протокольными типами Modbus.
|
||||
self.count.setRange(0, 15) # Поле количества занимает четыре бита Body Modbus.
|
||||
self.count.setValue(1) # Исходный запрос относится к одному регистру.
|
||||
self.sub_label = QLabel("Подтип") # Подпись уточняется при выборе типа сообщения.
|
||||
self.arg_label = QLabel("SensorID / адрес") # Назначение аргумента зависит от MsgType.
|
||||
rows = [("Priority", self.priority), ("ПМ", self.route), ("Device Type (0..7)", self.dtype), ("Device (0..15)", self.did), ("Msg Type", self.mtype), (self.sub_label, self.sub), (self.arg_label, self.arg_panel), ("RegCount (Modbus)", self.count)] # Единый порядок полей исключает расхождение форм.
|
||||
for label, control in rows: # Все протокольные строки добавляются из одного визуального описания.
|
||||
form.addRow(label, control) # Подпись связана с соответствующим редактором.
|
||||
layout.addWidget(self.setcan_panel) # Протокольная форма показывается только в режиме SETCAN.
|
||||
self.data = QLineEdit() # Полезная нагрузка общая для обоих способов ввода идентификатора.
|
||||
self.data.setPlaceholderText("01 02 03 04 05 06 07 08") # Пример показывает формат ввода HEX-байтов.
|
||||
data_form = QFormLayout() # Общая строка Data остаётся видимой при смене режима.
|
||||
data_form.addRow("Data (HEX, до 8 байт)", self.data) # Указываем ограничение классического CAN.
|
||||
layout.addLayout(data_form) # Данные завершают форму перед кнопками владельца.
|
||||
for control in (self.mode, self.data): # Длинные поля получают достаточную ширину в обоих режимах.
|
||||
control.setMaximumWidth(420) # Предел 420 пикселей сохраняет компактность исходного конструктора.
|
||||
self.format.setMaximumWidth(340) # Название формата целиком помещается в строке шириной до 340 пикселей.
|
||||
for control in (self.priority, self.route, self.mtype, self.sub, self.arg, self.can_id): # Одинаковая ширина полей ID задаётся в общем компоненте.
|
||||
control.setMaximumWidth(220) # Предел 220 пикселей соответствует прежней форме SETCAN.
|
||||
for control in (self.dtype, self.did, self.count, self.dlc): # Короткие числовые поля не растягиваются по ширине панели.
|
||||
control.setMaximumWidth(100) # Предел 100 пикселей достаточен для всех допустимых значений.
|
||||
font = QFont("Consolas", 10) # Шрифт с фиксированной шириной знаков упрощает чтение HEX.
|
||||
for control in (self.can_id, self.data): # Числовой HEX и полезная нагрузка оформляются одинаково.
|
||||
control.setFont(font) # Используем один шрифт для всех владельцев компонента.
|
||||
self._subtype() # Настраиваем первоначальные подписи до вычисления ширины столбца.
|
||||
labels = [part.labelForField(part.itemAt(row, QFormLayout.ItemRole.FieldRole).widget()) for part in (mode_form, can_form, form, data_form) for row in range(part.rowCount())] # Собираем подписи всех форм для общего выравнивания.
|
||||
width = max(label.sizeHint().width() for label in labels) # Ширина столбца рассчитывается в пикселях по текущему шрифту Qt.
|
||||
for label in labels: # Поля обоих режимов должны начинаться в одной позиции.
|
||||
label.setMinimumWidth(width) # Самая длинная подпись задаёт единый левый столбец.
|
||||
for container in (self, self.can_panel, self.setcan_panel, self.arg_panel): # Промежуточные контейнеры наследуют фон панели без тёмных полос.
|
||||
container.setObjectName("canBuilderFields") # Локальный селектор ограничивает правило контейнерами формы.
|
||||
container.setStyleSheet("QWidget#canBuilderFields { background: transparent; }") # Редакторы сохраняют собственный штатный фон.
|
||||
self.mode.currentIndexChanged.connect(self._mode_changed) # Переключение режима одновременно меняет видимость и отменяет готовность кадра.
|
||||
self.mtype.currentIndexChanged.connect(self._subtype) # Новый MsgType обновляет подтипы и редактор Body.
|
||||
for control in (self.format, self.priority, self.route, self.mtype, self.sub): # Любой выбор из списка меняет будущий кадр.
|
||||
control.currentIndexChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем индекс Qt, поскольку владельцу нужен только сигнал устаревания результата.
|
||||
for control in (self.can_id, self.arg, self.data): # Все текстовые поля участвуют в сборке кадра.
|
||||
control.textChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем текст сигнала Qt и отменяем старый кадр даже при неверном промежуточном вводе.
|
||||
for control in (self.dlc, self.dtype, self.did, self.count, self.settings_body.z, self.settings_body.y): # Слежение включает оба байта SETTINGS.
|
||||
control.valueChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем числовой аргумент Qt, сохраняя единый сигнал необходимости новой сборки.
|
||||
self._mode_changed() # Начальная видимость одинакова у всех потребителей.
|
||||
|
||||
def _mode_changed(self): # Режим определяет только видимость; введённые значения сохраняются.
|
||||
raw = self.mode.currentData() == "can" # Полный CAN ID вводится только в обычном режиме.
|
||||
self.can_panel.setVisible(raw) # Показываем формат, ID и DLC для обычного CAN.
|
||||
self.setcan_panel.setVisible(not raw) # Альтернативно показываем составные поля SETCAN.
|
||||
self.changed.emit() # Смена способа ввода отменяет ранее собранный кадр.
|
||||
|
||||
def _subtype(self): # Общие подписи и список подтипов используются всеми экземплярами формы.
|
||||
msg_type = pc.MsgType[self.mtype.currentText()] # Значение выбирается из существующего общего каталога.
|
||||
settings = msg_type == pc.MsgType.SETTINGS # Только SETTINGS редактируется отдельными байтами Z и Y.
|
||||
self.arg.setVisible(not settings) # Единый числовой аргумент скрывается для SETTINGS.
|
||||
self.settings_body.setVisible(settings) # Двухбайтовый редактор появляется вместо числового аргумента.
|
||||
table = {pc.MsgType.BROADCAST: (pc.BroadcastType, "Broadcast.Type", "Body (0..15)"), pc.MsgType.DISCRETE: (pc.DiscreteType, "Discrete.Type", "Body (0..4095)"), pc.MsgType.ANALOG: (pc.AnalogType, "Analog.Type", "SensorID (0..4095)")} # Таблица определяет визуальные подписи существующих протокольных перечислений.
|
||||
current = self.sub.currentText() # Сохраняем выбранный подтип, если он допустим для нового сообщения.
|
||||
self.sub.clear() # Старые варианты не должны остаться в новом типе сообщения.
|
||||
self.sub.setEnabled(msg_type in table) # Отдельный подтип доступен только у трёх перечисленных типов.
|
||||
if msg_type in table: # Подписи и значения перечислений берутся из одного места.
|
||||
enum, sub_label, arg_label = table[msg_type] # Получаем визуальное описание выбранного MsgType.
|
||||
self.sub.addItems([item.name for item in enum]) # Значения берутся из общего протокольного каталога.
|
||||
if current in [item.name for item in enum]: # Совместимый выбор пользователя сохраняется.
|
||||
self.sub.setCurrentText(current) # Восстанавливаем допустимый подтип после заполнения списка.
|
||||
else: # Типы без отдельного подтипа используют Body по собственному назначению.
|
||||
sub_label = "—" # Подпись явно показывает отсутствие подтипа.
|
||||
arg_label = "Body (обычно 0)" # Для PULSE сохраняем исходное описание аргумента.
|
||||
if msg_type in (pc.MsgType.MODBUS_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # Все четыре вида Modbus задают начальный адрес.
|
||||
arg_label = "StrAdr (0..4095)" # Двенадцатибитный адрес показан в прежних единицах регистров.
|
||||
elif msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE: # GAS использует всё поле Body под адрес.
|
||||
arg_label = "Адрес регистра (0..65535)" # Полный 16-битный диапазон указан в подписи.
|
||||
elif msg_type == pc.MsgType.ERROR: # Код ошибки вводится в отдельный байт Body.
|
||||
arg_label = "ErrorCode (0..255)" # Показываем допустимый однобайтовый диапазон.
|
||||
elif settings: # SETTINGS редактируется двухбайтовым компонентом.
|
||||
arg_label = "Body: Z | Y" # Подпись соответствует порядку байтов протокола.
|
||||
self.sub_label.setText(sub_label) # Одинаковая подпись подтипа обновляется во всех вкладках.
|
||||
self.arg_label.setText(arg_label) # Аргумент всегда подписан по выбранному MsgType.
|
||||
|
||||
def body(self, msg_type, arg, count): # Общая диспетчеризация вызывает существующие обёртки C-ядра без второй реализации wire-кодека.
|
||||
if msg_type == pc.MsgType.BROADCAST: # BROADCAST объединяет подтип и четырёхбитный аргумент.
|
||||
return pc.merge_broadcast(pc.BroadcastType[self.sub.currentText()], arg & 0xF) # Маска 0xF сохраняет прежнее ограничение аргумента четырьмя битами.
|
||||
if msg_type == pc.MsgType.DISCRETE: # Дискретное сообщение использует собственное перечисление подтипов.
|
||||
return pc.merge_discrete(pc.DiscreteType[self.sub.currentText()], arg) # Поле собирается общим протокольным модулем.
|
||||
if msg_type == pc.MsgType.ANALOG: # Аналоговое сообщение передаёт SensorID в Body.
|
||||
return pc.merge_analog(pc.AnalogType[self.sub.currentText()], arg) # Существующий кодек задаёт расположение подтипа и SensorID.
|
||||
if msg_type in (pc.MsgType.MODBUS_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # У четырёх видов Modbus одинаковый формат Body.
|
||||
return pc.merge_modbus(arg, count) # Адрес и количество объединяются общей функцией протокола.
|
||||
if msg_type == pc.MsgType.ERROR: # Сохраняем существующую категорию ошибки с нулевым кодом источника.
|
||||
return pc.merge_error(0, arg) # Размещение кода ошибки остаётся ответственностью общего ядра.
|
||||
return arg & 0xFFFF # Остальные типы сохраняют прежнее ограничение Body шестнадцатью битами.
|
||||
|
||||
def frame_values(self): # Возвращаем проверенные параметры CAN без упаковки конкретного транспорта.
|
||||
data = tr.parse_hex(self.data.text()) # Общий разбор сохраняет поддержку пробелов, запятых и префиксов 0x.
|
||||
if len(data) > 8: # Классический CAN не допускает более восьми байтов данных.
|
||||
raise ValueError("данные длиннее 8 байт") # Сообщаем об ошибке до передачи в транспорт.
|
||||
if self.mode.currentData() == "can": # Обычный режим использует полный идентификатор и явный DLC.
|
||||
identifier = int(self.can_id.text().strip(), 16) # Поле ID всегда интерпретируется как HEX.
|
||||
extended = bool(self.format.currentData()) # Малый числовой ID также может иметь выбранный EXT.
|
||||
tr.validate_can_id(identifier, extended) # Проверка общего транспорта запрещает усечение ID до допустимого диапазона.
|
||||
if len(data) != self.dlc.value(): # Количество введённых байтов обязано точно совпадать с DLC.
|
||||
raise ValueError(f"DLC = {self.dlc.value()}, а Data содержит {len(data)} байт; количество байтов должно совпадать.") # Оператор видит обе длины и может исправить ввод.
|
||||
return identifier, extended, data, None # У обычного CAN нет отдельного протокольного Body.
|
||||
msg_type = pc.MsgType[self.mtype.currentText()] # SETCAN использует выбранный тип сообщения.
|
||||
arg = self.settings_body.value() if msg_type == pc.MsgType.SETTINGS else int(self.arg.text().strip() or "0", 0) # SETTINGS читается из Z/Y, остальные аргументы поддерживают привычные числовые префиксы.
|
||||
body = self.body(msg_type, arg, self.count.value()) # Один путь сборки Body применяется ко всем владельцам формы.
|
||||
identifier = pc.ProtoCanId.build(self.priority.currentIndex(), self.route.currentIndex(), self.dtype.value(), self.did.value(), msg_type, body) # Идентификатор собирается существующей обёрткой общего C-ядра.
|
||||
return identifier, True, data, body # SETCAN всегда передаётся с расширенным 29-битным идентификатором.
|
||||
|
||||
def bind(self, owner, prefix): # Сохраняем публичные имена полей приложения при переходе на общий компонент.
|
||||
names = ("mode", "can_panel", "setcan_panel", "format", "can_id", "dlc", "priority", "route", "dtype", "did", "mtype", "sub", "arg", "settings_body", "count", "data", "sub_label", "arg_label") # Совместимые атрибуты позволяют не менять интеграцию транспорта и внешние потребители.
|
||||
for name in names: # Каждое старое имя ссылается на настоящий виджет общего компонента.
|
||||
setattr(owner, prefix + name, getattr(self, name)) # Префиксы build_, builder_ и rs485_builder_ разделяют три независимых состояния.
|
||||
156
python/set_devices/qt_ports/console.py
Normal file
156
python/set_devices/qt_ports/console.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Общая консоль Qt: ограниченная история и инструменты просмотра без управления транспортом.""" # Модуль относится к порту представления templates и не реализует протоколы устройств.
|
||||
from collections import deque # Кольцевая история ограничивает память при непрерывном обмене.
|
||||
from html import escape # Текст устройства не должен становиться HTML-разметкой журнала.
|
||||
from pathlib import Path # Экспорт сохраняет явно выбранный пользователем файл в UTF-8.
|
||||
import re # Метки направления извлекаются только из начала строки после необязательного времени.
|
||||
try: # Поддерживаем актуальный Qt и существующие установки на Python для Windows 7.
|
||||
from PySide6.QtCore import QTimer, Signal # Таймер объединяет обновления заполненного журнала.
|
||||
from PySide6.QtGui import QFont, QTextDocument # Документ извлекает обычный текст из уже оформленных сообщений приложения.
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Панель использует только стандартные элементы Qt.
|
||||
except ImportError: # Старый порт сохраняет тот же публичный интерфейс консоли.
|
||||
from PySide2.QtCore import QTimer, Signal # В Qt 5 доступны те же сигналы и таймеры.
|
||||
from PySide2.QtGui import QFont, QTextDocument # Цвета и извлечение текста не зависят от версии Qt.
|
||||
from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Состав инструментов одинаков в обоих портах.
|
||||
|
||||
|
||||
class ConsoleView(QPlainTextEdit): # Наследование сохраняет привычные appendPlainText, appendHtml и toPlainText у потребителей.
|
||||
userCleared = Signal() # Владелец отдельной истории может удалить её при явном нажатии «Очистить».
|
||||
|
||||
def __init__(self, parent=None, limit=4000, directions=True, payloads=False): # Предел задан в строках, включая скрытые и поступившие на паузе.
|
||||
super().__init__(parent) # Владельца редактора впоследствии принимает контейнер panel.
|
||||
self.records = deque(maxlen=max(1, int(limit))) # Каждая запись хранит текст, оформление, направление и необязательные байты.
|
||||
self.setReadOnly(True) # Журнал показывает полученные события и не допускает ручной подмены текста.
|
||||
self.setFont(QFont("Cascadia Mono", 9)) # Моноширинный шрифт выравнивает HEX, адреса и метки времени.
|
||||
super().setMaximumBlockCount(self.records.maxlen) # Документ ограничен тем же количеством строк, что история.
|
||||
self.timer = QTimer(self) # Таймер уничтожается вместе с редактором и не переживает закрытие вкладки.
|
||||
self.timer.setSingleShot(True) # За одну пачку входящих строк достаточно одного полного обновления.
|
||||
self.timer.setInterval(100) # Интервал 100 мс ограничивает перестроения десятью в секунду при полном буфере.
|
||||
self.timer.timeout.connect(self.refresh) # Перестроение всегда читает последние записи и текущие фильтры.
|
||||
self.panel = QWidget(parent) # Контейнер можно вставить в layout или вкладку вместо самого редактора.
|
||||
layout = QVBoxLayout(self.panel) # Две компактные строки инструментов остаются над областью текста.
|
||||
layout.setContentsMargins(0, 0, 0, 0) # Внешние отступы задаёт приложение-потребитель.
|
||||
actions = QHBoxLayout() # Основные операции сгруппированы отдельно от фильтров просмотра.
|
||||
self.clear_button = QPushButton("Очистить") # Очистка удаляет также скрытую историю и накопленное на паузе.
|
||||
self.clear_button.clicked.connect(self._user_clear) # Сигнал владельцу позволяет очистить дополнительный буфер кадров.
|
||||
actions.addWidget(self.clear_button) # Очистка постоянно доступна у левого края консоли.
|
||||
self.pause = QCheckBox("Пауза") # Пауза замораживает экран, продолжая сохранять входящие сообщения.
|
||||
actions.addWidget(self.pause) # Пауза относится только к этому экземпляру журнала.
|
||||
self.follow = QCheckBox("Автопрокрутка") # Прокрутку можно отключить для чтения старых записей.
|
||||
self.follow.setChecked(True) # По умолчанию новые события видны сразу.
|
||||
actions.addWidget(self.follow) # Управление расположено рядом с паузой просмотра.
|
||||
for title, callback in (("Копировать", self.copy_visible), ("Сохранить…", self.save_dialog), ("−", lambda: self.zoomOut(1)), ("+", lambda: self.zoomIn(1))): # Масштаб меняется на один шаг Qt без изменения данных.
|
||||
button = QPushButton(title) # Кнопки используют тему и шрифт приложения.
|
||||
button.clicked.connect(callback) # Обработчик вызывается только по явному действию пользователя.
|
||||
actions.addWidget(button) # Одинаковый порядок действий используется всеми консолями.
|
||||
actions.addStretch(1) # Свободное место не растягивает подписи инструментов.
|
||||
layout.addLayout(actions) # Основная строка отделена от параметров отображения.
|
||||
filters = QHBoxLayout() # Поиск и направления занимают вторую строку для умеренной минимальной ширины.
|
||||
self.search = QLineEdit() # Фильтр работает по обычному тексту всех сохранённых строк.
|
||||
self.search.setPlaceholderText("Поиск / фильтр текста") # Подпись показывает, что скрытые строки остаются в истории.
|
||||
filters.addWidget(self.search, 1) # Поле поиска получает свободную ширину консоли.
|
||||
self.show_tx, self.show_rx = QCheckBox("TX"), QCheckBox("RX") # Служебные сообщения не относятся ни к одному направлению.
|
||||
for control in (self.show_tx, self.show_rx): # Оба фильтра действуют независимо и применяются к истории.
|
||||
control.setChecked(True) # Первое открытие показывает весь обмен.
|
||||
control.setVisible(directions) # Потребители со своими фильтрами могут скрыть дублирующие элементы.
|
||||
filters.addWidget(control) # Направления расположены рядом с текстовым фильтром.
|
||||
self.display = QComboBox() # Формат байтов относится только к сообщениям с явно переданным payload.
|
||||
self.display.addItems(("Как получено", "HEX", "ASCII", "HEX + ASCII")) # Обычный текст и ответы устройства не перекодируются.
|
||||
self.display.setToolTip("HEX/ASCII применяется к байтам, переданным через append_message(payload=…).") # Приложение явно определяет границы полезной нагрузки.
|
||||
self.display.setVisible(payloads) # Выбор байтового представления показывается только потребителю с явным payload.
|
||||
filters.addWidget(self.display) # Представление можно менять без повторной передачи данных.
|
||||
self.wrap = QCheckBox("Перенос") # Перенос длинных строк удобен на узких панелях.
|
||||
filters.addWidget(self.wrap) # Переключатель не изменяет исходный текст при экспорте.
|
||||
self.count = QLabel() # Счётчик показывает объём ограниченной истории, включая скрытые строки.
|
||||
filters.addWidget(self.count) # Количество остаётся видно при включённом фильтре.
|
||||
layout.addLayout(filters) # Параметры находятся непосредственно над текстом.
|
||||
layout.addWidget(self, 1) # Редактор занимает оставшуюся высоту контейнера.
|
||||
for control in (self.pause, self.show_tx, self.show_rx, self.follow): # Изменения параметров применяются к уже накопленным данным.
|
||||
control.toggled.connect(self.refresh) # Снятие паузы показывает сохранённые во время неё события.
|
||||
self.search.textChanged.connect(self.refresh) # Поиск немедленно обновляет видимую часть истории.
|
||||
self.display.currentIndexChanged.connect(self.refresh) # Выбор HEX/ASCII заново форматирует сохранённый payload.
|
||||
self.wrap.toggled.connect(lambda checked: self.setLineWrapMode(QPlainTextEdit.WidgetWidth if checked else QPlainTextEdit.NoWrap)) # Отключённый перенос сохраняет горизонтальную прокрутку.
|
||||
self.setLineWrapMode(QPlainTextEdit.NoWrap) # Начальный режим совпадает с выключенным флажком переноса.
|
||||
self.refresh() # Пустой журнал сразу получает корректный счётчик.
|
||||
|
||||
def setMaximumBlockCount(self, limit): # Сохраняем совместимость с существующими настройками журналов SETGUI.
|
||||
self.records = deque(self.records, maxlen=max(1, int(limit))) # Предел ограничивает и скрытую историю, а не только документ.
|
||||
super().setMaximumBlockCount(self.records.maxlen) # Согласуем вместимость редактора с кольцевым буфером.
|
||||
self.refresh() # Уменьшение лимита сразу удаляет вытесненные строки с экрана.
|
||||
|
||||
def append_message(self, text, direction="", color=None, payload=None, _html=None, _defer=False): # Принимаем готовый текст и данные, не выполняя разбор протокола.
|
||||
for line in str(text).split("\n"): # Многострочные расшифровки также подчиняются общему лимиту строк.
|
||||
match = re.match(r"^(?:\[[^\]]+\]\s*|\d\d:\d\d:\d\d(?:\.\d+)?\s*)?(TX|RX|>>>)\s", line) # Направление распознаётся только у начальной метки, а не по словам внутри ошибки.
|
||||
role = direction or (("TX" if match[1] == ">>>" else match[1]) if match else "") # Явная роль имеет приоритет над текстовой эвристикой.
|
||||
html = _html or ('<span style="color:%s;white-space:pre">%s</span>' % (escape(color, quote=True), escape(line)) if color else "") # Безопасное оформление сохраняет исходный текст устройства.
|
||||
record = (line, html, role, bytes(payload) if payload is not None else None) # Снимок payload не зависит от последующих изменений входного bytearray.
|
||||
expired = len(self.records) == self.records.maxlen # Вытеснение требует убрать старую строку даже при скрытых новых событиях.
|
||||
self.records.append(record) # На паузе и под фильтрами данные продолжают сохраняться в пределах лимита.
|
||||
if not self.pause.isChecked() and not _defer: # Пауза и пакетная замена замораживают документ, но не историю.
|
||||
if expired or self.timer.isActive(): # Заполненный буфер обновляется пачками для устойчивого интенсивного приёма.
|
||||
if not self.timer.isActive(): self.timer.start() # Следующие события не отодвигают уже запланированное обновление.
|
||||
elif self._visible(record): self._append_record(record) # Обычный поток дописывает одну строку без переразбора всей истории.
|
||||
self.count.setText("%d строк" % len(self.records)) # Счётчик истории обновляется даже при замороженном экране.
|
||||
|
||||
def appendPlainText(self, text): # Старые потребители могут продолжать писать обычный текст.
|
||||
self.append_message(text) # Ограничения, пауза и фильтры применяются через единый путь записи.
|
||||
|
||||
def appendHtml(self, html): # Прежнее цветное оформление приложения остаётся доступно через API QPlainTextEdit.
|
||||
document = QTextDocument() # Временный документ преобразует форматированный фрагмент в строки для поиска.
|
||||
document.setHtml(html) # Разметка поступает от приложения; внешние данные оно должно экранировать.
|
||||
text = document.toPlainText() # Обычный текст используется для поиска и определения направления.
|
||||
self.append_message(text, _html=html if "\n" not in text else None) # Однострочное оформление сохраняется без полного обновления истории.
|
||||
|
||||
def setPlainText(self, text): # Снимок внешней истории полностью заменяет данные этого представления.
|
||||
self.timer.stop() # Старое отложенное обновление не должно вернуть предыдущий снимок.
|
||||
self.records.clear() # Сохраняется только актуальный результат форматирования потребителя.
|
||||
if not self.pause.isChecked(): super().clear() # При паузе прежний экран сохраняется до возобновления.
|
||||
if text: self.append_message(text, _defer=True) # Пустой снимок не создаёт фиктивную запись.
|
||||
self.refresh() # Поиск и фильтры применяются к новому снимку целиком.
|
||||
|
||||
def clear(self): # Программная очистка удаляет все данные и отменяет ожидающее обновление.
|
||||
self.timer.stop() # Запланированное обновление больше не требуется.
|
||||
self.records.clear() # Скрытые записи и накопленное на паузе не должны вернуться.
|
||||
super().clear() # Явная очистка действует сразу даже при замороженном экране.
|
||||
self.count.setText("0 строк") # Счётчик согласован с удалённой историей.
|
||||
|
||||
def _user_clear(self): # Только действие оператора уведомляет владельца внешней истории.
|
||||
self.clear() # Сначала очищаем собственный буфер и видимый документ.
|
||||
self.userCleared.emit() # Потребитель может безопасно вызвать clear повторно без рекурсивного сигнала.
|
||||
|
||||
def _visible(self, record): # Единое условие применяется при добавлении и полном обновлении.
|
||||
text, _html, role, _payload = record # Поиск использует исходный текст сообщения независимо от выбранного формата байтов.
|
||||
return (role != "TX" or self.show_tx.isChecked()) and (role != "RX" or self.show_rx.isChecked()) and self.search.text().casefold() in text.casefold() # Служебные сообщения сохраняются при отключённых направлениях.
|
||||
|
||||
def _append_record(self, record): # Добавляем одну подготовленную запись, учитывая формат и положение прокрутки.
|
||||
text, html, _role, payload = record # Байты присутствуют только при явной передаче payload приложением.
|
||||
bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Запоминаем позицию до изменения размера документа.
|
||||
mode = self.display.currentIndex() # Нулевой режим сохраняет текст и цвета, заданные потребителем.
|
||||
if payload is not None and mode: # Формат меняет только подписанную приложением полезную нагрузку.
|
||||
hexadecimal = payload.hex(" ").upper() # HEX показывает каждый исходный байт двумя цифрами.
|
||||
ascii_text = "".join(chr(value) if 32 <= value <= 126 else "." for value in payload) # Диапазон печатного ASCII задан стандартом; управляющие байты заменяются точками.
|
||||
super().appendPlainText(text + " · " + (hexadecimal if mode == 1 else ascii_text if mode == 2 else hexadecimal + " | " + ascii_text)) # Исходная подпись не теряется при смене представления байтов.
|
||||
elif html: super().appendHtml(html) # Сохраняем безопасное оформление или прежний фрагмент приложения.
|
||||
else: super().appendPlainText(text) # Обычный текст не интерпретируется как HTML.
|
||||
bar.setValue(bar.maximum() if self.follow.isChecked() else position) # Отключённая автопрокрутка сохраняет место чтения.
|
||||
|
||||
def refresh(self, *_args): # Сигналы Qt могут передавать индекс, флаг или текст.
|
||||
self.timer.stop() # Немедленное обновление заменяет ожидающее пакетное перестроение.
|
||||
if self.pause.isChecked(): return # Изменения фильтров во время паузы применятся после её снятия.
|
||||
bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Сохраняем место чтения до очистки документа.
|
||||
super().clear() # Очищаем только документ, сохраняя ограниченную историю.
|
||||
for record in self.records: # Показываем оставшиеся записи в исходном порядке.
|
||||
if self._visible(record): self._append_record(record) # Скрытые строки остаются доступными для следующего выбора фильтра.
|
||||
bar.setValue(bar.maximum() if self.follow.isChecked() else min(position, bar.maximum())) # При сокращении документа ограничиваем прежнюю позицию новым диапазоном.
|
||||
self.count.setText("%d строк" % len(self.records)) # Счётчик включает временно скрытые записи.
|
||||
|
||||
def copy_visible(self): # Экспортируется фактически показанный текст, включая состояние паузы и фильтра.
|
||||
QApplication.clipboard().setText(self.toPlainText()) # Копирование всей видимой консоли не требует ручного выделения.
|
||||
|
||||
def save_to(self, path): # Отдельный метод позволяет проверить экспорт без системного диалога.
|
||||
Path(path).write_text(self.toPlainText(), encoding="utf-8") # UTF-8 сохраняет русские сообщения и расшифровки протоколов.
|
||||
|
||||
def save_dialog(self): # Пользователь самостоятельно выбирает назначение журнала.
|
||||
path, _filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console.txt", "Текст UTF-8 (*.txt);;Все файлы (*)") # Диалог не выполняет автоматической записи при отмене.
|
||||
if path: # Записываем только явно выбранный файл.
|
||||
try: self.save_to(path) # Обрабатываем отказ файловой системы без прерывания приёма данных.
|
||||
except OSError as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору.
|
||||
@@ -22,6 +22,12 @@ FAST_CAPABILITY = 0x1220
|
||||
FAST_DATA = 0x2000
|
||||
WORDS = 40
|
||||
CAPACITY = 64000 // (2 * WORDS)
|
||||
# Компактная запись v4 имеет 12 слов; старые 40/42 слова остаются поддержанными для чтения архивов.
|
||||
COMPACT_WORDS = 12
|
||||
# Ёмкость 760 точек сохраняет прежнее число записей постинтервала и поведение кольцевых банков.
|
||||
COMPACT_CAPACITY = 760
|
||||
# Ключи соответствуют индексам оставшихся полей v2/v3, значения — индексам этих же каналов в компактном v4.
|
||||
COMPACT_INDEX = {2: 2, 3: 3, 4: 4, 5: 5, 9: 6, 24: 7, 25: 8, 40: 9, 41: 10}
|
||||
FIELDS = (
|
||||
('off_trigger', 'Событие отключения', 2, 8),
|
||||
('post_truncated', 'Усечение постинтервала', 2, 9),
|
||||
@@ -33,68 +39,37 @@ FIELDS = (
|
||||
('start_out', 'Выход START_DPTB', 5, 0),
|
||||
('stop_out', 'Выход STOP_DPTB', 5, 1),
|
||||
('res_out', 'Выход RES_OUT_2', 5, 2),
|
||||
('impulse', 'Остаток импульса, мс', 6, None),
|
||||
('wake', 'Инициализация ЦАП, тики ЦАП', 7, None),
|
||||
('dac', 'Код ЦАП', 8, None),
|
||||
('current', 'Расчётное задание тока, мА', 9, None),
|
||||
('sensor0', 'Измерение 0', 10, None),
|
||||
('sensor1', 'Измерение 1', 11, None),
|
||||
('sensor2', 'Измерение 2', 12, None),
|
||||
('sensor3', 'Измерение 3', 13, None),
|
||||
('error0', 'Ошибки 0 (маска)', 14, None),
|
||||
('error1', 'Ошибки 1 (маска)', 15, None),
|
||||
('error2', 'Ошибки 2 (маска)', 16, None),
|
||||
('error3', 'Ошибки 3 (маска)', 17, None),
|
||||
('can_header', 'CAN адрес/маска', 18, None),
|
||||
('can0', 'CAN слово 0', 19, None),
|
||||
('can1', 'CAN слово 1', 20, None),
|
||||
('can2', 'CAN слово 2', 21, None),
|
||||
('can_count', 'Количество CAN RX (mod 65536)', 22, None),
|
||||
('can_timeout', 'Таймауты CAN TX (mod 65536)', 23, None),
|
||||
('commands', 'Команды (маска)', 3, None),
|
||||
('go', 'Начальная уставка, мА', 24, None),
|
||||
('stop', 'Конечная уставка, мА', 25, None),
|
||||
('dac_min', 'Код калибровки 4 мА', 26, None),
|
||||
('dac_max', 'Код калибровки 20 мА', 27, None),
|
||||
('dac_cal', 'Калибровочный код ЦАП', 28, None),
|
||||
('dac04', 'DAC_04 (регистр)', 29, None),
|
||||
('dac20', 'DAC_20 (регистр)', 30, None),
|
||||
('ramp', 'Счётчик разгона', 31, None),
|
||||
('duration', 'Длительность разгона, тики ЦАП', 32, None),
|
||||
('effective_go', 'Начальная уставка после ограничений, мА', 33, None),
|
||||
('effective_stop', 'Конечная уставка после ограничений, мА', 34, None),
|
||||
('position', 'Положение разгона (kod)', 35, None),
|
||||
('adc0', 'АЦП канал 0', 36, None),
|
||||
('adc1', 'АЦП канал 1', 37, None),
|
||||
('adc2', 'АЦП канал 2', 38, None),
|
||||
('adc3', 'АЦП канал 3', 39, None),
|
||||
('zpt1', 'ЗПТ1: U1 + U2 (ПМ67, рег. 306)', 40, None),
|
||||
('zpt2', 'ЗПТ2: U3 + U4 (ПМ67, рег. 9)', 41, None),
|
||||
)
|
||||
|
||||
|
||||
def request(controller: int, function: int, address: int, value: int, *, record_words: int = WORDS) -> bytes:
|
||||
if not 0 <= controller <= 255 or not 0 <= value <= 65535:
|
||||
raise ValueError('Недопустимый адрес контроллера или значение')
|
||||
if record_words not in (40, 42):
|
||||
raise ValueError('Неверный размер записи логгера')
|
||||
capacity = {40: 800, 42: 760}[record_words]
|
||||
if function == 3:
|
||||
valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
|
||||
(address == DATA and 1 <= value <= 120 and (value % WORDS == 0 or value in (42, 84))) or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4)
|
||||
or (address == FAST_CAPABILITY and value == 4)
|
||||
or (FAST_DATA <= address < FAST_DATA + capacity * record_words
|
||||
and (address - FAST_DATA) % record_words == 0
|
||||
and 1 <= value <= 120 and value % record_words == 0
|
||||
and address + value <= FAST_DATA + capacity * record_words))
|
||||
else:
|
||||
valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) or
|
||||
(address == PROTECTION + 3 and 1 <= value <= 60000) or
|
||||
address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
|
||||
(address == EMU_CONTROL + 2 and value in (0, 1)))
|
||||
if not valid:
|
||||
raise ValueError('Недопустимый запрос логгера УМП')
|
||||
return protocol.with_crc(bytes((controller, function)) + address.to_bytes(2, 'big') + value.to_bytes(2, 'big'))
|
||||
def request(controller: int, function: int, address: int, value: int, *, record_words: int = WORDS) -> bytes: # Проверяет запрос с учётом ширины записи, прочитанной из статуса платы.
|
||||
if not 0 <= controller <= 255 or not 0 <= value <= 65535: # Ограничивает адрес одним октетом, значение — 16-битным словом.
|
||||
raise ValueError('Недопустимый адрес контроллера или значение') # Запрещает усечение входных параметров при упаковке.
|
||||
if record_words not in (COMPACT_WORDS, 40, 42): # Допускает только известные компактный и прежние форматы записи.
|
||||
raise ValueError('Неверный размер записи логгера') # Не позволяет строить адреса по неизвестной ширине.
|
||||
capacity = {COMPACT_WORDS: COMPACT_CAPACITY, 40: 800, 42: 760}[record_words] # Выбирает реальную границу банка по формату.
|
||||
if function == 3: # Разрешает только существующие диагностические блоки чтения.
|
||||
valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) # Читает полный блок защиты либо дескриптор журнала.
|
||||
or (address == DATA and 1 <= value <= 120 and (value % COMPACT_WORDS == 0 or value in (40, 80, 42, 84))) # Принимает целые записи v4 и исторические блоки v2/v3.
|
||||
or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4) # Сохраняет фиксированные размеры онлайн-блока и управления эмуляцией.
|
||||
or (address == FAST_CAPABILITY and value == 4) # Проверяет возможность ускоренного архивного чтения.
|
||||
or (FAST_DATA <= address < FAST_DATA + capacity * record_words # Ограничивает адрес началом и концом реального банка.
|
||||
and (address - FAST_DATA) % record_words == 0 # Требует начала блока на границе записи.
|
||||
and 1 <= value <= 120 and value % record_words == 0 # Передаёт до 120 слов, содержащих целое число записей.
|
||||
and address + value <= FAST_DATA + capacity * record_words)) # Исключает пересечение конца банка.
|
||||
else: # Запись разрешена только прежним управляющим адресам.
|
||||
valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) # Проверяет маску эмуляции защиты.
|
||||
or (address == PROTECTION + 3 and 1 <= value <= 60000) # Проверяет тайм-аут свежести CAN в миллисекундах.
|
||||
or address in (BASE, BASE + 1, BASE + 2, BASE + 3) # Оставляет команды управления журналом без запуска УМП.
|
||||
or (address == EMU_CONTROL + 2 and value in (0, 1))) # Сохраняет двоичное включение эмулятора.
|
||||
if not valid: # Запрещает запросы вне согласованных адресов и размеров.
|
||||
raise ValueError('Недопустимый запрос логгера УМП') # Возвращает прежнее понятное сообщение вызывающему коду.
|
||||
return protocol.with_crc(bytes((controller, function)) + address.to_bytes(2, 'big') + value.to_bytes(2, 'big')) # Передаёт 16-битный адрес и добавляет общий Modbus CRC.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -114,27 +89,27 @@ class Status:
|
||||
@classmethod
|
||||
def decode(cls, words):
|
||||
if (len(words) != 16 or words[0] != 0x554D or
|
||||
(words[1], words[4], words[5]) not in ((2, 40, 800), (3, 42, 760))):
|
||||
raise ValueError('Прошивка не поддерживает логгер УМП v2/v3')
|
||||
(words[1], words[4], words[5]) not in ((2, 40, 800), (3, 42, 760), (4, COMPACT_WORDS, COMPACT_CAPACITY))): # Проверяет согласованные версию, ширину и ёмкость.
|
||||
raise ValueError('Прошивка не поддерживает логгер УМП v2/v3/v4') # Перечисляет известные форматы без разрешения неизвестной прошивки.
|
||||
if words[9] != 6:
|
||||
raise ValueError('Логгер УМП доступен только на плате нагрузки dsk_LOAD')
|
||||
if words[2] not in (0, 1, 2, 3) or words[3] > words[5] or not 1 <= words[6] <= 1000 or words[11] != 1000 or words[13] > words[5] or words[14] > 1000 or words[15] not in (0, 1, 3):
|
||||
post_limit = max(1000, min(65535, words[5] // 10 * words[6])) if words[1] >= 3 else 1000 # Постинтервал v3/v4 занимает 10% точек; при 760 точках и 20 мс его оценка достигает 1520 мс.
|
||||
if words[2] not in (0, 1, 2, 3) or words[3] > words[5] or not 1 <= words[6] <= 1000 or words[11] != 1000 or words[13] > words[5] or words[14] > post_limit or words[15] not in (0, 1, 3):
|
||||
raise ValueError('Повреждён статус логгера')
|
||||
return cls(words[2], words[3], words[6], words[10], words[11], *words[12:16], words[4], words[5])
|
||||
|
||||
|
||||
def sample(words, frequency=1000):
|
||||
if len(words) not in (40, 42) or frequency <= 0:
|
||||
if len(words) not in (COMPACT_WORDS, 40, 42) or frequency <= 0: # Допускает только известную ширину записи и положительную частоту времени.
|
||||
raise ValueError('Неверная запись логгера')
|
||||
result = {'time_ms': (words[0] | words[1] << 16) * 1000 / frequency, 'event': words[2]}
|
||||
for key, _label, index, bit in FIELDS:
|
||||
value = words[index] if index < len(words) else float('nan')
|
||||
physical_index = COMPACT_INDEX[index] if len(words) == COMPACT_WORDS else index # Выбирает индекс оставшегося поля по фактической версии записи.
|
||||
value = words[physical_index] if physical_index < len(words) else float('nan') # ЗПТ в старых 40-словных архивах остаётся неизвестным.
|
||||
if bit is not None:
|
||||
value = (value >> bit) & 1
|
||||
elif 10 <= index <= 13 and value >= 32768:
|
||||
value -= 65536
|
||||
# Физические единицы приводим здесь, одинаково для CSV и графика.
|
||||
if key in ('current', 'effective_go', 'effective_stop'):
|
||||
if key == 'current': # Сохранившееся задание имеет масштаб 0,1 мА; уставки и напряжения передаются целыми единицами.
|
||||
value /= 10.0
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
@@ -27,7 +27,8 @@ def build_request(mode, token, function, address, value):
|
||||
if not 1 <= token <= 255:
|
||||
raise ValueError('Неверный номер CAN-запроса')
|
||||
valid = ((function == 3 and ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
|
||||
(address == DATA and value in (40, 80, 120, 42, 84)) or
|
||||
# До 120 слов передают целые 12-словные записи v4; старые блоки v2/v3 остаются разрешены.
|
||||
(address == DATA and (value in (40, 80, 42, 84) or 0 < value <= 120 and value % 12 == 0)) or
|
||||
(address == LIVE and value == MAX_WORDS) or (address == EMU_CONTROL and value == 4))) or
|
||||
(function == 6 and ((BASE <= address <= BASE+3 and 0 <= value <= 65535) or
|
||||
(address == PROTECTION+2 and value in (0, 1, 2, 3)) or
|
||||
|
||||
77
python/tests/test_console.py
Normal file
77
python/tests/test_console.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Проверки общей консоли на настоящем Qt без физического устройства.""" # Проверяем сохранность истории, фильтры и очистку при асинхронном обновлении.
|
||||
import os # Виртуальная платформа выбирается до импорта Qt.
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # Тесты не открывают окна на рабочем столе.
|
||||
import pytest # Фикстура даёт каждому сценарию независимую консоль.
|
||||
from set_devices.qt_ports.console import ConsoleView, QApplication # Используем реальный порт templates, а не имитацию обработчиков.
|
||||
|
||||
|
||||
@pytest.fixture # Создаём отдельный журнал для проверки независимости состояний.
|
||||
def console(): # Ссылка на приложение удерживается до завершения сценария.
|
||||
app = QApplication.instance() or QApplication([]) # Qt требует приложение перед созданием виджетов.
|
||||
view = ConsoleView(limit=3, payloads=True) # Малый предел проверяет вытеснение без большого массива данных.
|
||||
yield view # Сценарий управляет настоящими сигналами флажков и кнопок.
|
||||
view.panel.deleteLater() # Удаляется контейнер вместе с редактором и таймером.
|
||||
app.processEvents() # Отложенное удаление доставляется до следующего сценария.
|
||||
|
||||
|
||||
def test_pause_keeps_bounded_history_and_clear_cannot_resurrect(console): # Очистка на паузе должна удалять также невидимые данные.
|
||||
console.appendPlainText("первая") # Первая запись сразу видна на экране.
|
||||
console.pause.setChecked(True) # Замораживаем только представление.
|
||||
for index in range(5): console.appendPlainText("строка %d" % index) # На паузе поступает больше данных, чем помещается в истории.
|
||||
assert console.toPlainText() == "первая" and len(console.records) == 3 # Экран заморожен, память ограничена.
|
||||
console.pause.setChecked(False) # Возобновление показывает последние сохранённые строки.
|
||||
assert console.toPlainText().splitlines() == ["строка 2", "строка 3", "строка 4"] # Вытеснённые записи больше не появляются.
|
||||
console.appendPlainText("ожидает таймер") # Полный буфер ставит пакетное обновление в очередь.
|
||||
assert console.timer.isActive() # Проверяем именно очистку при ожидающем обновлении.
|
||||
console.pause.setChecked(True) # Очистка должна работать и при замороженном экране.
|
||||
console.clear_button.click() # Пользователь удаляет весь журнал через общую панель.
|
||||
console.pause.setChecked(False) # Возобновление не должно вернуть старые данные.
|
||||
assert not console.records and not console.toPlainText() and not console.timer.isActive() # Документ, история и очередь согласованы.
|
||||
|
||||
|
||||
def test_filters_restore_hidden_rows_and_preserve_service_errors(console): # Фильтры направлений не должны скрывать ошибки без метки TX/RX.
|
||||
console.appendPlainText("[12:00:00] TX команда") # Начальная метка после времени определяет направление.
|
||||
console.appendPlainText("12:00:01.123 RX ответ") # Поддерживаем существующий формат временных меток SETGUI.
|
||||
console.appendPlainText("#ERR RX timeout") # Слово RX внутри ошибки не делает её принятым пакетом.
|
||||
console.show_rx.setChecked(False) # Скрываем только входящие пакеты.
|
||||
console.show_tx.setChecked(False) # Оба направления можно выключить одновременно.
|
||||
assert console.toPlainText() == "#ERR RX timeout" # Служебная ошибка остаётся доступной оператору.
|
||||
console.search.setText("ОТВЕТ") # Регистр запроса не должен влиять на поиск.
|
||||
console.show_rx.setChecked(True) # Входящие данные восстанавливаются из сохранённой истории.
|
||||
assert console.toPlainText() == "12:00:01.123 RX ответ" # Поиск применяется к той же записи без повторного приёма.
|
||||
console.search.clear() # Снимаем текстовый фильтр.
|
||||
assert len(console.records) == 3 # Фильтры не удаляют события.
|
||||
|
||||
|
||||
def test_payload_and_utf8_export_use_visible_snapshot(console, tmp_path): # Сохранение и копирование соответствуют выбранному представлению.
|
||||
console.append_message("RX пакет <текст>", direction="RX", payload=b"A\x00\xff") # Payload содержит печатный, управляющий и не-ASCII байты.
|
||||
console.display.setCurrentText("HEX + ASCII") # Выбираем совместное представление байтов.
|
||||
assert console.toPlainText() == "RX пакет <текст> · 41 00 FF | A.." # Непечатные байты заменяются точками без потери HEX.
|
||||
path = tmp_path / "журнал.txt" # Проверяем кириллицу в имени и содержимом файла.
|
||||
console.save_to(path) # Экспорт выполняется без диалога, сохраняя тот же код записи.
|
||||
assert path.read_text(encoding="utf-8") == console.toPlainText() # UTF-8 сохраняет фактически видимый текст.
|
||||
console.copy_visible() # Кнопка копирует весь показанный журнал.
|
||||
assert QApplication.clipboard().text() == console.toPlainText() # Буфер обмена совпадает с экспортом.
|
||||
|
||||
|
||||
def test_multiline_html_and_snapshot_obey_limit(console): # Предел относится к строкам, а не к числу вызовов записи.
|
||||
console.appendPlainText("0\n1\n2\n3\n4") # Один пакет расшифровки превышает лимит документа и истории.
|
||||
console.refresh() # Применяем запланированное пакетное обновление.
|
||||
assert console.toPlainText() == "2\n3\n4" # Старые строки не возвращаются при смене фильтра.
|
||||
console.setPlainText("снимок") # Потребитель может передать результат повторного разбора кадров целиком.
|
||||
console.appendHtml('<span style="color:#ff0000"><ошибка></span>') # Проверяем совместимость прежнего цветного API SETGUI.
|
||||
console.search.setText("ошибка") # Поиск должен работать по тексту без тегов разметки.
|
||||
assert console.toPlainText() == "<ошибка>" # Спецсимволы устройства остаются обычным текстом.
|
||||
console.search.clear() # Возвращаем обе записи с исходным оформлением.
|
||||
assert console.toPlainText() == "снимок\n<ошибка>" # Фильтр не теряет предыдущий снимок истории.
|
||||
|
||||
|
||||
def test_clear_notifies_external_owner_once(console): # Владелец может очистить свой буфер и повторно вызвать clear без рекурсии.
|
||||
external = ["старый кадр"] # Имитируем отдельную историю протокольных кадров приложения.
|
||||
def clear_owner(): # Обработчик очищает внешний источник повторного форматирования.
|
||||
external.clear() # Старые кадры не вернутся после смены парсера.
|
||||
console.clear() # Повторный программный вызов не должен снова эмитировать пользовательский сигнал.
|
||||
console.userCleared.connect(clear_owner) # Подключаем тот же контракт, что консоли платы и TX/RX SETGUI.
|
||||
console.appendPlainText("строка") # До очистки журнал содержит данные.
|
||||
console.clear_button.click() # Нажимаем настоящую кнопку Qt.
|
||||
assert not external and not console.toPlainText() # Обе истории очищены одним действием.
|
||||
303
tools/firmware-build/README.md
Normal file
303
tools/firmware-build/README.md
Normal file
File diff suppressed because one or more lines are too long
5
tools/firmware-build/ccs/firmware_identity.example.json
Normal file
5
tools/firmware-build/ccs/firmware_identity.example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"product": "MY_CONTROLLER",
|
||||
"target": "TMS320F28335",
|
||||
"version": [0, 1, 0]
|
||||
}
|
||||
29
tools/firmware-build/ccs/postcompile.bat
Normal file
29
tools/firmware-build/ccs/postcompile.bat
Normal file
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
if /i "%~1"=="--identity" goto identity
|
||||
rem Legacy mode: SETGUI release preflight.
|
||||
if "%~1"=="" goto usage
|
||||
if not "%~2"=="" goto usage
|
||||
if not exist "%~1" (
|
||||
echo [firmware-build] Release config not found: "%~1" 1>&2
|
||||
exit /b 2
|
||||
)
|
||||
call "%~dp0..\..\firmware-publish\PUBLISH_FIRMWARE.bat" --config "%~f1" --preflight
|
||||
exit /b %errorlevel%
|
||||
:identity
|
||||
if "%~2"=="" goto usage
|
||||
if "%~3"=="" goto usage
|
||||
if "%~4"=="" goto usage
|
||||
if "%~5"=="" goto usage
|
||||
if "%~6"=="" goto usage
|
||||
if not "%~8"=="" goto usage
|
||||
if "%~7"=="" goto identity_local
|
||||
python "%~dp0..\..\..\c\firmware-info\tools\c2000_postbuild.py" --compiler "%~f2" --build-dir "%~f3" --artifact "%~4" --metadata "%~f5" --output "%~f6" --export "%~f7"
|
||||
exit /b %errorlevel%
|
||||
:identity_local
|
||||
python "%~dp0..\..\..\c\firmware-info\tools\c2000_postbuild.py" --compiler "%~f2" --build-dir "%~f3" --artifact "%~4" --metadata "%~f5" --output "%~f6"
|
||||
exit /b %errorlevel%
|
||||
:usage
|
||||
echo Usage: postcompile.bat --identity "compiler-root" "build-dir" "artifact-name" "generated-metadata.json" "output-dir" ["export-dir"] 1>&2
|
||||
echo Legacy: postcompile.bat "firmware-release.cmd" 1>&2
|
||||
exit /b 2
|
||||
31
tools/firmware-build/ccs/precompile.bat
Normal file
31
tools/firmware-build/ccs/precompile.bat
Normal file
@@ -0,0 +1,31 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
if /i "%~1"=="--identity" goto identity
|
||||
rem Legacy mode: build ID header only.
|
||||
if "%~1"=="" goto usage
|
||||
if "%~2"=="" goto usage
|
||||
if not "%~3"=="" goto usage
|
||||
if not exist "%~1\." (
|
||||
echo [firmware-build] Project directory not found: "%~1" 1>&2
|
||||
exit /b 2
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\..\..\c\firmware-info\tools\make_build_id.ps1" -Repository "%~f1" -Output "%~f2"
|
||||
exit /b %errorlevel%
|
||||
:identity
|
||||
if "%~2"=="" goto usage
|
||||
if "%~3"=="" goto usage
|
||||
if "%~4"=="" goto usage
|
||||
if not "%~5"=="" goto usage
|
||||
rem Create the editable project configuration once; never overwrite user settings.
|
||||
if not exist "%~f3" (
|
||||
copy /Y "%~dp0firmware_identity.example.json" "%~f3" >nul
|
||||
if errorlevel 1 exit /b 2
|
||||
echo [firmware-build] Created configuration: "%~f3"
|
||||
echo [firmware-build] Edit product, target and version in this file, then rebuild.
|
||||
)
|
||||
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" prepare --repository "%~f2" --templates "%~dp0..\..\.." --config "%~f3" --output "%~f4"
|
||||
exit /b %errorlevel%
|
||||
:usage
|
||||
echo Usage: precompile.bat --identity "project-root" "firmware_identity.json" "generated-dir" 1>&2
|
||||
echo Legacy: precompile.bat "project-root" "output-header" 1>&2
|
||||
exit /b 2
|
||||
5
tools/firmware-build/keil/firmware_identity.example.json
Normal file
5
tools/firmware-build/keil/firmware_identity.example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"product": "MY_CONTROLLER",
|
||||
"target": "STM32F407",
|
||||
"version": [0, 1, 0]
|
||||
}
|
||||
38
tools/firmware-build/keil/postcompile.bat
Normal file
38
tools/firmware-build/keil/postcompile.bat
Normal file
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
if /i "%~1"=="--identity" goto identity
|
||||
rem Legacy mode retained for existing SETGUI publication configurations.
|
||||
if "%~1"=="" goto usage
|
||||
if not "%~2"=="" goto usage
|
||||
if not exist "%~1" (
|
||||
echo [firmware-build] Release config not found: "%~1" 1>&2
|
||||
exit /b 2
|
||||
)
|
||||
call "%~dp0..\..\firmware-publish\PUBLISH_FIRMWARE.bat" --config "%~f1" --preflight
|
||||
exit /b %errorlevel%
|
||||
:identity
|
||||
if "%~2"=="" goto usage
|
||||
if "%~3"=="" goto usage
|
||||
if not "%~5"=="" goto usage
|
||||
if /i not "%~x2"==".hex" if /i not "%~x2"==".bin" (
|
||||
echo [firmware-build] Expected a ready HEX or BIN image: "%~2" 1>&2
|
||||
exit /b 2
|
||||
)
|
||||
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" seal --image "%~f2" --metadata "%~f3"
|
||||
if errorlevel 1 exit /b %errorlevel%
|
||||
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" verify --image "%~f2" --manifest "%~f2.manifest.json"
|
||||
if errorlevel 1 exit /b %errorlevel%
|
||||
if "%~4"=="" goto archive_local
|
||||
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" archive --image "%~f2" --metadata "%~f3" --export "%~f4"
|
||||
goto archive_done
|
||||
:archive_local
|
||||
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" archive --image "%~f2" --metadata "%~f3"
|
||||
:archive_done
|
||||
if errorlevel 1 exit /b %errorlevel%
|
||||
echo [firmware-build] Image: "%~f2"
|
||||
echo [firmware-build] Manifest: "%~f2.manifest.json"
|
||||
echo [firmware-build] SHA-256: "%~f2.sha256"
|
||||
exit /b 0
|
||||
:usage
|
||||
echo Usage: postcompile.bat --identity "image.hex-or-bin" "generated-metadata.json" ["export-dir"] 1>&2
|
||||
exit /b 2
|
||||
24
tools/firmware-build/keil/precompile.bat
Normal file
24
tools/firmware-build/keil/precompile.bat
Normal file
@@ -0,0 +1,24 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
if /i "%~1"=="--identity" goto identity
|
||||
rem Legacy mode: build ID header only.
|
||||
if "%~1"=="" goto usage
|
||||
if "%~2"=="" goto usage
|
||||
if not "%~3"=="" goto usage
|
||||
if not exist "%~1\." (
|
||||
echo [firmware-build] Project directory not found: "%~1" 1>&2
|
||||
exit /b 2
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\..\..\c\firmware-info\tools\make_build_id.ps1" -Repository "%~f1" -Output "%~f2"
|
||||
exit /b %errorlevel%
|
||||
:identity
|
||||
if "%~2"=="" goto usage
|
||||
if "%~3"=="" goto usage
|
||||
if "%~4"=="" goto usage
|
||||
if not "%~5"=="" goto usage
|
||||
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" prepare --repository "%~f2" --templates "%~dp0..\..\.." --config "%~f3" --output "%~f4"
|
||||
exit /b %errorlevel%
|
||||
:usage
|
||||
echo Usage: precompile.bat --identity "project-root" "firmware_identity.json" "generated-dir" 1>&2
|
||||
echo Legacy: precompile.bat "project-root" "output-header" 1>&2
|
||||
exit /b 2
|
||||
Reference in New Issue
Block a user