14 Commits

Author SHA1 Message Date
0c4eab4615 Добавить общий конструктор CAN-кадров для Qt 2026-10-17 14:43:13 +03:00
5af8a8e9c8 Добавить общую Qt-консоль с фильтрами и экспортом 2026-10-17 14:43:12 +03:00
f613101124 Перезапускать импульс светодиода при каждом приёме пакета 2026-10-17 14:43:12 +03:00
cf65439400 Добавить выпуск и экспорт Flash-образов C2000 2026-10-17 14:43:11 +03:00
32e2fbedc0 Поддержать компактные записи логгера УМП v4
Разрешить 12-словные записи в запросах RTU и CAN. Распознавать статус v4 и декодировать оставшиеся сигналы с сохранением поддержки архивов v2/v3.
2026-10-12 12:25:52 +03:00
226c2ca599 chore(merge): объедини pysetterminal с main
Сохрани актуальный генератор firmware identity с экспортом всей папки сборки и временем UTC+3 при объединении веток.
2026-10-09 11:04:32 +03:00
f2956b54cd merge(deif): объедини Android-компоненты с main 2026-10-09 10:55:08 +03:00
eab8dc5e0a fix(firmware-build): support CCS artifact paths and bootstrap identity config 2026-10-08 17:57:04 +03:00
131f306131 fix(firmware-info): используй UTC+3 в именах папок
Переведи только время имени архива в UTC+3, сохрани UTC в метаданных и заголовке. Проверь переход на следующий день и копирование текущей папки.
2026-10-08 17:05:04 +03:00
4fc0ad4e14 feat(firmware-info): копируй папку текущей сборки целиком
Добавь каталог назначения последним аргументом postcompile CCS и Keil. Копируй только архив текущего запуска с сохранением имени папки, всех файлов и проверкой SHA-256. Обнови MD-команды и проверки выбора папки, вложенных файлов и ошибок экспорта.
2026-10-08 16:26:21 +03:00
da055c2787 feat(firmware-info): архивируй сборки по хешу и дате
Создай в postcompile CCS и Keil папку по commit исходников и UTC-времени precompile. Сохрани точные образы, manifest, SHA-256 и метаданные, сохраняя прежние выходные файлы. Опиши копирование каталогов в SETGUI и проверь разные даты, целостность и Git-метаданные.
2026-10-08 15:31:13 +03:00
8fc2e1b5cf fix(firmware-info): подключи Keil к JSON-схеме версии
Добавь prepare/seal для Keil через общий identity.py, образец конфигурации и команды Before/After Build. Для новой схемы не требуется отдельная release-конфигурация CMD или SETGUI. Проверь HEX/BIN, SHA-256, аргументы и пути с пробелами.
2026-10-08 15:13:34 +03:00
b5b2d1ae37 feat(firmware-info): добавь pre/post-build для CCS и Keil
Подключи общий генератор версии и SCI8-конвертацию через templates. Сохрани метаданные и SHA-256 готового BIN, совместимость с Python 3.8 и прежний SETGUI preflight. Опиши настройку CCS и локальное копирование результатов.
2026-10-08 14:58:27 +03:00
7dfa8bd94d Добавить общие компоненты DEIF для Android и интерактивные приборы
Реализовать SAM/DAM, циклическую передачу, heartbeat, привязки и диагностику. Перенести рисунок и управление MASTER/SLAVE из SETGUI, добавить полноэкранный режим и проверки совместимости кадров.
2026-10-04 23:32:49 +03:00
34 changed files with 3112 additions and 71 deletions

View 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() # Возвращает ненулевой код при нарушении контракта упаковки или экспорта.

View 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()

View 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())

View File

@@ -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))

View File

@@ -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 *`; библиотека

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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())
}
}

View File

@@ -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,
)
}

View File

@@ -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 "Передача выключена")
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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("Отмена") } })
}
}

View File

@@ -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"
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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) ||

View 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"
]
}
]

View 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-файлы сами по себе эту адаптацию не выполняют.

View File

@@ -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`,

View 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 возвращается вызывающему процессу.

View 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.

View 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_ разделяют три независимых состояния.

View 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)) # Причина ошибки остаётся доступна оператору.

View File

@@ -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

View File

@@ -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

View 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">&lt;ошибка&gt;</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() # Обе истории очищены одним действием.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
{
"product": "MY_CONTROLLER",
"target": "TMS320F28335",
"version": [0, 1, 0]
}

View 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

View 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

View File

@@ -0,0 +1,5 @@
{
"product": "MY_CONTROLLER",
"target": "STM32F407",
"version": [0, 1, 0]
}

View 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

View 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