9 Commits

Author SHA1 Message Date
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
20 changed files with 2236 additions and 4 deletions

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,85 @@
"""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 tempfile
from identity import archive_build, seal, verify
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")
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"))
for item in stage.iterdir():
item.replace(output / item.name)
result = output / bin_image.name
archive_build(result, metadata, output, (
output / hex_image.name,
output / (hex_image.name + ".map"),
output / (bin_image.name + ".map")), export=export)
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

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

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

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