Compare commits
9 Commits
pysettermi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 226c2ca599 | |||
| f2956b54cd | |||
| eab8dc5e0a | |||
| 131f306131 | |||
| 4fc0ad4e14 | |||
| da055c2787 | |||
| 8fc2e1b5cf | |||
| b5b2d1ae37 | |||
| 7dfa8bd94d |
111
c/firmware-info/tests/test_identity_archive.py
Normal file
111
c/firmware-info/tests/test_identity_archive.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""Tests for preserving exact firmware bytes under pre-build provenance folders."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||||
|
from identity import archive_build, build_folder_name, export_build, verify
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveTests(unittest.TestCase):
|
||||||
|
def metadata(self, stamp="2026-10-08T12:30:00+00:00"):
|
||||||
|
return {"source": {"commit": "a1b2c3d4" + "0" * 32, "dirty": False},
|
||||||
|
"templates": {"commit": None, "dirty": None},
|
||||||
|
"build_id": "NOGIT000", "built_at_utc": stamp,
|
||||||
|
"version": [1, 2, 3]}
|
||||||
|
|
||||||
|
def test_snapshot_preserves_image_and_records_exact_sha(self):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
root = Path(root)
|
||||||
|
image = root / "firmware.bin"
|
||||||
|
payload = bytes(range(256))
|
||||||
|
image.write_bytes(payload)
|
||||||
|
metadata = root / "metadata.json"
|
||||||
|
metadata.write_text(json.dumps(self.metadata()), encoding="utf-8")
|
||||||
|
result = archive_build(image, metadata, root / "out")
|
||||||
|
folder = Path(result["directory"])
|
||||||
|
self.assertEqual(folder.name, "a1b2c3d4_2026-10-08_15-30-00_UTC+3")
|
||||||
|
self.assertEqual((folder / image.name).read_bytes(), payload)
|
||||||
|
self.assertEqual(image.read_bytes(), payload)
|
||||||
|
self.assertEqual(json.loads((folder / "firmware_identity.json").read_text()), self.metadata())
|
||||||
|
manifest = folder / (image.name + ".manifest.json")
|
||||||
|
self.assertEqual(verify(folder / image.name, manifest)["size_bytes"], 256)
|
||||||
|
(folder / image.name).write_bytes(b"corrupted")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
verify(folder / image.name, manifest)
|
||||||
|
|
||||||
|
def test_prebuild_times_keep_two_builds_separate(self):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
root = Path(root)
|
||||||
|
image, metadata = root / "firmware.hex", root / "metadata.json"
|
||||||
|
image.write_bytes(b":00000001FF\r\n")
|
||||||
|
folders = []
|
||||||
|
for stamp in ("2026-10-08T12:30:00+00:00", "2026-10-08T12:31:00+00:00"):
|
||||||
|
metadata.write_text(json.dumps(self.metadata(stamp)), encoding="utf-8")
|
||||||
|
folders.append(Path(archive_build(image, metadata, root / "out")["directory"]))
|
||||||
|
self.assertNotEqual(folders[0], folders[1])
|
||||||
|
self.assertTrue(all((folder / image.name).is_file() for folder in folders))
|
||||||
|
|
||||||
|
def test_export_copies_current_folder_only(self):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
root = Path(root)
|
||||||
|
image, metadata = root / "firmware.bin", root / "metadata.json"
|
||||||
|
image.write_bytes(b"exact current build")
|
||||||
|
# A folder with a later timestamp must not be selected for this build.
|
||||||
|
metadata.write_text(json.dumps(self.metadata("2026-10-08T12:40:00+00:00")), encoding="utf-8")
|
||||||
|
archive_build(image, metadata, root / "local")
|
||||||
|
metadata.write_text(json.dumps(self.metadata()), encoding="utf-8")
|
||||||
|
result = archive_build(image, metadata, root / "local", export=root / "exported")
|
||||||
|
copied = Path(result["exported_directory"])
|
||||||
|
self.assertEqual([p.name for p in (root / "exported").iterdir()],
|
||||||
|
["a1b2c3d4_2026-10-08_15-30-00_UTC+3"])
|
||||||
|
self.assertEqual((copied / image.name).read_bytes(), image.read_bytes())
|
||||||
|
self.assertTrue((copied / "firmware_identity.json").is_file())
|
||||||
|
self.assertTrue((copied / (image.name + ".sha256")).is_file())
|
||||||
|
verify(copied / image.name, copied / (image.name + ".manifest.json"))
|
||||||
|
|
||||||
|
def test_complete_folder_export_keeps_nested_files_and_old_destination(self):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
root = Path(root)
|
||||||
|
image, metadata = root / "firmware.bin", root / "metadata.json"
|
||||||
|
image.write_bytes(b"image")
|
||||||
|
metadata.write_text(json.dumps(self.metadata()), encoding="utf-8")
|
||||||
|
folder = Path(archive_build(image, metadata, root / "local")["directory"])
|
||||||
|
(folder / "notes").mkdir()
|
||||||
|
(folder / "notes" / "release.txt").write_text("release notes", encoding="utf-8")
|
||||||
|
old = root / "exported" / "old_build"
|
||||||
|
old.mkdir(parents=True)
|
||||||
|
(old / "keep.txt").write_text("keep", encoding="utf-8")
|
||||||
|
copied = export_build(folder, root / "exported", image.name)
|
||||||
|
self.assertEqual((copied / "notes" / "release.txt").read_text(), "release notes")
|
||||||
|
self.assertEqual((old / "keep.txt").read_text(), "keep")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
export_build(folder, folder / "recursive", image.name)
|
||||||
|
def test_offset_normalizes_to_utc_plus_three_and_dirty_is_visible(self):
|
||||||
|
metadata = self.metadata("2026-10-08T15:30:00+03:00")
|
||||||
|
metadata["source"]["dirty"] = True
|
||||||
|
self.assertEqual(build_folder_name(metadata), "a1b2c3d4+_2026-10-08_15-30-00_UTC+3")
|
||||||
|
|
||||||
|
def test_folder_date_rolls_over_at_utc_plus_three_midnight(self):
|
||||||
|
metadata = self.metadata("2026-10-08T22:30:00+00:00")
|
||||||
|
self.assertEqual(build_folder_name(metadata), "a1b2c3d4_2026-10-09_01-30-00_UTC+3")
|
||||||
|
self.assertEqual(metadata["built_at_utc"], "2026-10-08T22:30:00+00:00")
|
||||||
|
def test_archive_without_git_has_explicit_placeholder(self):
|
||||||
|
metadata = self.metadata()
|
||||||
|
metadata["source"]["commit"] = None
|
||||||
|
self.assertEqual(build_folder_name(metadata), "NOGIT000_2026-10-08_15-30-00_UTC+3")
|
||||||
|
|
||||||
|
def test_invalid_provenance_cannot_be_a_folder_name(self):
|
||||||
|
metadata = self.metadata()
|
||||||
|
metadata["source"]["commit"] = "../outside"
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
build_folder_name(metadata)
|
||||||
|
metadata = self.metadata("2026-10-08T12:30:00")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
build_folder_name(metadata)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
85
c/firmware-info/tools/c2000_postbuild.py
Normal file
85
c/firmware-info/tools/c2000_postbuild.py
Normal 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())
|
||||||
@@ -7,10 +7,13 @@ protocol encoding lives here: the generated header contains scalar constants.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -36,12 +39,14 @@ def git_state(repository: Path) -> dict:
|
|||||||
def write_atomic(path: Path, text: str):
|
def write_atomic(path: Path, text: str):
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
temporary = path.with_name(path.name + ".tmp")
|
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)
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
def prepare(repository: Path, templates: Path, config: Path, output: Path) -> dict:
|
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"]
|
version = settings["version"]
|
||||||
if (len(version) != 3 or any(type(v) is not int for v in 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)))):
|
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
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
@@ -114,9 +186,14 @@ def main():
|
|||||||
p = sub.add_parser("verify")
|
p = sub.add_parser("verify")
|
||||||
p.add_argument("--image", type=Path, required=True)
|
p.add_argument("--image", type=Path, required=True)
|
||||||
p.add_argument("--manifest", 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())
|
args = vars(parser.parse_args())
|
||||||
command = args.pop("command")
|
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))
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package ru.setcorp.setprotocol.deif
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import ru.setcorp.setflash.core.CanBridgeProtocol
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||||
|
|
||||||
|
data class DeifBinding(val index: Int, val subindex: Int = 2, val scale: Double = 1.0)
|
||||||
|
data class DeifDisplayConfig(
|
||||||
|
val node: Int = 1, val canId: Int = 0x201,
|
||||||
|
val bindings: Map<Parameter, DeifBinding> = Parameter.entries.associateWith { DeifBinding(it.index, 2, it.scale) },
|
||||||
|
)
|
||||||
|
data class DeifReading(val value: Double, val at: Long)
|
||||||
|
data class DeifState(
|
||||||
|
val config: DeifProtocol.Config = DeifProtocol.Config(),
|
||||||
|
val raw: Map<Parameter, Int> = Parameter.entries.associateWith { 0 },
|
||||||
|
val controlScales: DeifControls.Scales = DeifControls.Scales(),
|
||||||
|
val unlocked: Boolean = false, val running: Boolean = false,
|
||||||
|
val fullScreen: Boolean = false,
|
||||||
|
val displays: List<DeifDisplayConfig> = List(2) { DeifDisplayConfig() },
|
||||||
|
val readings: List<Map<Parameter, DeifReading>> = List(2) { emptyMap() },
|
||||||
|
val sent: Long = 0, val received: Long = 0, val errors: Long = 0,
|
||||||
|
val echoes: Long = 0, val echoTimes: Map<Int, Long> = emptyMap(),
|
||||||
|
val intervalMs: Long = 0, val maxGapMs: Long = 0, val maxCycleMs: Long = 0,
|
||||||
|
val status: String = "Передача выключена", val lastReceived: String = "Нет принятых данных",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Owns both timers and display freshness independently of the Android screen lifecycle. */
|
||||||
|
class DeifController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val send: suspend (CanBridgeProtocol.Frame) -> Unit,
|
||||||
|
private val save: (String) -> Unit = {},
|
||||||
|
private val now: () -> Long = { System.nanoTime() / 1_000_000 },
|
||||||
|
) {
|
||||||
|
private val mutable = MutableStateFlow(DeifState())
|
||||||
|
val state = mutable.asStateFlow()
|
||||||
|
private var job: Job? = null
|
||||||
|
private val wire = Mutex()
|
||||||
|
private var previousCycle: Long? = null
|
||||||
|
|
||||||
|
fun setFullScreen(enabled: Boolean) { mutable.value = state.value.copy(fullScreen = enabled) }
|
||||||
|
|
||||||
|
fun stop(message: String = "Передача остановлена") {
|
||||||
|
job?.cancel(); job = null
|
||||||
|
mutable.value = mutable.value.copy(running = false, status = message)
|
||||||
|
}
|
||||||
|
fun unlock(value: Boolean) {
|
||||||
|
stop()
|
||||||
|
mutable.value = mutable.value.copy(unlocked = value)
|
||||||
|
}
|
||||||
|
fun configure(config: DeifProtocol.Config) {
|
||||||
|
check(state.value.unlocked) { "Разблокируйте настройки" }
|
||||||
|
config.validate(); stop()
|
||||||
|
mutable.value = state.value.copy(config = config)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
fun select(parameter: Parameter, enabled: Boolean) {
|
||||||
|
stop()
|
||||||
|
val c = state.value.config
|
||||||
|
mutable.value = state.value.copy(config = c.copy(enabled = if (enabled) c.enabled + parameter else c.enabled - parameter))
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
fun setRaw(parameter: Parameter, raw: Int) {
|
||||||
|
require(raw in -32768..32767)
|
||||||
|
mutable.value = state.value.copy(raw = state.value.raw + (parameter to raw))
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
fun requestInstrument(field: DeifControls.Field, value: Double) {
|
||||||
|
mutable.value = state.value.copy(raw = state.value.raw + DeifControls.raw(field, value, state.value.controlScales))
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
fun configureControls(scales: DeifControls.Scales) {
|
||||||
|
check(state.value.unlocked); scales.validate(); stop()
|
||||||
|
mutable.value = state.value.copy(controlScales = scales)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
fun configureDisplay(side: Int, config: DeifDisplayConfig) {
|
||||||
|
check(state.value.unlocked)
|
||||||
|
require(config.node in 1..127 && config.canId in 0x201..0x57F)
|
||||||
|
require(config.bindings.values.all { it.index in 0..65535 && it.subindex in 0..255 && it.scale.isFinite() })
|
||||||
|
stop()
|
||||||
|
mutable.value = state.value.copy(
|
||||||
|
displays = state.value.displays.mapIndexed { i, d -> if (i == side) config else d },
|
||||||
|
readings = state.value.readings.mapIndexed { i, d -> if (i == side) emptyMap() else d },
|
||||||
|
)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
fun start(repeat: Boolean) {
|
||||||
|
if (state.value.unlocked) { stop("Заблокируйте настройки перед передачей"); return }
|
||||||
|
stop()
|
||||||
|
if (!repeat && state.value.config.enabled.isEmpty()) return
|
||||||
|
if (state.value.config.enabled.isEmpty() && !state.value.config.heartbeat) return
|
||||||
|
previousCycle = null
|
||||||
|
mutable.value = state.value.copy(running = true, sent = 0, errors = 0, echoes = 0, echoTimes = emptyMap(), maxGapMs = 0, maxCycleMs = 0)
|
||||||
|
job = scope.launch {
|
||||||
|
try {
|
||||||
|
coroutineScope {
|
||||||
|
if (repeat && state.value.config.heartbeat) launch {
|
||||||
|
while (isActive) {
|
||||||
|
transmit(DeifProtocol.heartbeat(state.value.config)); delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
val started = now()
|
||||||
|
val snapshot = state.value
|
||||||
|
val frames = DeifProtocol.cycle(snapshot.config, snapshot.raw)
|
||||||
|
frames.forEach { transmit(it) }
|
||||||
|
val elapsed = now() - started
|
||||||
|
val gap = previousCycle?.let { started - it } ?: 0
|
||||||
|
previousCycle = started
|
||||||
|
mutable.value = state.value.copy(intervalMs = gap,
|
||||||
|
maxGapMs = maxOf(state.value.maxGapMs, gap), maxCycleMs = maxOf(state.value.maxCycleMs, elapsed))
|
||||||
|
if (!repeat) break
|
||||||
|
delay(maxOf(1, snapshot.config.periodMs - elapsed))
|
||||||
|
} while (isActive)
|
||||||
|
}
|
||||||
|
mutable.value = state.value.copy(running = false)
|
||||||
|
} catch (cancelled: CancellationException) { throw cancelled }
|
||||||
|
catch (error: Exception) { stop("Ошибка передачи: ${error.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private suspend fun transmit(frame: CanBridgeProtocol.Frame) = wire.withLock {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
check(!state.value.unlocked)
|
||||||
|
DeifProtocol.validateTransmit(frame)
|
||||||
|
send(frame)
|
||||||
|
accept(0, frame)
|
||||||
|
mutable.value = state.value.copy(sent = state.value.sent + 1, status = "Передано в транспорт; приём прибором не подтверждён")
|
||||||
|
}
|
||||||
|
fun receive(frame: CanBridgeProtocol.Frame) {
|
||||||
|
if (frame.isError) {
|
||||||
|
mutable.value = state.value.copy(errors = state.value.errors + 1, lastReceived = "Ошибка CAN: %03X".format(frame.canId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.toCan) {
|
||||||
|
val d = DeifProtocol.decode(frame)
|
||||||
|
if (d.index != null && d.subindex != null) mutable.value = state.value.copy(
|
||||||
|
echoes = state.value.echoes + 1, echoTimes = state.value.echoTimes + ((d.index * 256 + d.subindex) to now()))
|
||||||
|
return // Transport TX notifications must never masquerade as SLAVE RX.
|
||||||
|
}
|
||||||
|
val decoded = DeifProtocol.decode(frame)
|
||||||
|
mutable.value = state.value.copy(received = state.value.received + 1, lastReceived = decoded.summary)
|
||||||
|
accept(1, frame)
|
||||||
|
}
|
||||||
|
private fun accept(side: Int, frame: CanBridgeProtocol.Frame) {
|
||||||
|
val d = DeifProtocol.decode(frame)
|
||||||
|
if (d.raw == null) return
|
||||||
|
val config = state.value.displays[side]
|
||||||
|
if (d.node != config.node || (d.mode == DeifProtocol.Mode.SAM && frame.canId != config.canId.toLong())) return
|
||||||
|
val readings = state.value.readings[side].toMutableMap()
|
||||||
|
config.bindings.forEach { (key, binding) ->
|
||||||
|
if (binding.index == d.index && binding.subindex == d.subindex) {
|
||||||
|
if (d.valid) readings[key] = DeifReading(d.raw * binding.scale, now()) else readings.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutable.value = state.value.copy(readings = state.value.readings.mapIndexed { i, r -> if (i == side) readings else r })
|
||||||
|
}
|
||||||
|
fun value(side: Int, parameter: Parameter): Double? = state.value.readings[side][parameter]
|
||||||
|
?.takeIf { now() - it.at <= 5000 }?.value
|
||||||
|
|
||||||
|
fun echoAge(index: Int, subindex: Int): Long? = state.value.echoTimes[index * 256 + subindex]?.let { now() - it }
|
||||||
|
|
||||||
|
fun restore(text: String) {
|
||||||
|
runCatching {
|
||||||
|
val root = JSONObject(text)
|
||||||
|
val c = DeifProtocol.Config(DeifProtocol.Mode.valueOf(root.getString("mode")), root.getInt("node"),
|
||||||
|
root.getInt("canId"), root.getLong("period"), root.getBoolean("heartbeat"), root.getInt("masterNode"),
|
||||||
|
Parameter.entries.filter { root.getJSONArray("enabled").toString().contains("\"${it.name}\"") }.toSet())
|
||||||
|
c.validate()
|
||||||
|
val raw = Parameter.entries.associateWith { root.getJSONObject("raw").getInt(it.name).also { v -> require(v in -32768..32767) } }
|
||||||
|
val displays = (0..1).map { side ->
|
||||||
|
val o = root.getJSONArray("displays").getJSONObject(side)
|
||||||
|
DeifDisplayConfig(o.getInt("node"), o.getInt("canId"), Parameter.entries.associateWith {
|
||||||
|
val b = o.getJSONObject(it.name)
|
||||||
|
DeifBinding(b.getInt("index"), b.getInt("sub"), b.getDouble("scale"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
require(displays.all { it.node in 1..127 && it.canId in 0x201..0x57F && it.bindings.values.all { b -> b.index in 0..65535 && b.subindex in 0..255 && b.scale.isFinite() } })
|
||||||
|
val controls = root.optJSONObject("controls")
|
||||||
|
val scales = controls?.let { DeifControls.Scales(it.getDouble("power100"), it.getDouble("rpm100"),
|
||||||
|
it.getDouble("angleRawDegree"), it.getDouble("powerPercentRaw")) } ?: DeifControls.Scales()
|
||||||
|
scales.validate()
|
||||||
|
mutable.value = DeifState(config = c, raw = raw, displays = displays, controlScales = scales)
|
||||||
|
}.onFailure { mutable.value = DeifState(status = "Сохранённые настройки DEIF повреждены; загружены значения по умолчанию") }
|
||||||
|
}
|
||||||
|
private fun persist() {
|
||||||
|
val s = state.value; val c = s.config
|
||||||
|
val root = JSONObject().put("mode", c.mode.name).put("node", c.node).put("canId", c.canId)
|
||||||
|
.put("controls", JSONObject().put("power100", s.controlScales.power100).put("rpm100", s.controlScales.rpm100)
|
||||||
|
.put("angleRawDegree", s.controlScales.angleRawDegree).put("powerPercentRaw", s.controlScales.powerPercentRaw))
|
||||||
|
.put("period", c.periodMs).put("heartbeat", c.heartbeat).put("masterNode", c.masterNode)
|
||||||
|
.put("enabled", JSONArray(c.enabled.map { it.name }))
|
||||||
|
.put("raw", JSONObject().also { o -> s.raw.forEach { (p, v) -> o.put(p.name, v) } })
|
||||||
|
.put("displays", JSONArray().also { a -> s.displays.forEach { d ->
|
||||||
|
a.put(JSONObject().put("node", d.node).put("canId", d.canId).also { o -> d.bindings.forEach { (p, b) ->
|
||||||
|
o.put(p.name, JSONObject().put("index", b.index).put("sub", b.subindex).put("scale", b.scale))
|
||||||
|
} })
|
||||||
|
} })
|
||||||
|
save(root.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package ru.setcorp.setprotocol.deif
|
||||||
|
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
/** SETGUI Propulsor uses the same 600 × 510 coordinate system and calibration. */
|
||||||
|
object DeifControls {
|
||||||
|
enum class Field { POWER, RPM, ANGLE }
|
||||||
|
data class Scales(val power100: Double = 1000.0, val rpm100: Double = 1000.0,
|
||||||
|
val angleRawDegree: Double = 10.0, val powerPercentRaw: Double = 10.0) {
|
||||||
|
fun validate() {
|
||||||
|
require(listOf(power100, rpm100, angleRawDegree, powerPercentRaw).all { it.isFinite() && it > 0 && it <= 100000 }) {
|
||||||
|
"Масштабы должны быть больше нуля и не больше 100000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun hit(x: Float, y: Float): Field? = when {
|
||||||
|
x in 46f..116f && y in 110f..410f -> Field.POWER
|
||||||
|
x in 174f..244f && y in 110f..410f -> Field.RPM
|
||||||
|
hypot(x - 411f, y - 264f) <= 155f -> Field.ANGLE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
fun requested(field: Field, x: Float, y: Float): Double? = when (field) {
|
||||||
|
Field.ANGLE -> if (hypot(x - 411f, y - 264f) < 12f) null else atan2((x - 411).toDouble(), (264 - y).toDouble()) * 180 / PI
|
||||||
|
else -> { val low = if (field == Field.RPM) -20.0 else 0.0
|
||||||
|
(120 - (y - 126) / 270 * (120 - low)).coerceIn(low, 120.0) }
|
||||||
|
}
|
||||||
|
fun raw(field: Field, value: Double, scales: Scales): Map<Parameter, Int> {
|
||||||
|
scales.validate(); require(value.isFinite())
|
||||||
|
fun limited(v: Double) = round(v).coerceIn(-32767.0, 32765.0).toInt()
|
||||||
|
if (field == Field.ANGLE) return mapOf(Parameter.ANGLE to limited(value * scales.angleRawDegree))
|
||||||
|
val power = field == Field.POWER
|
||||||
|
val factor = if (power) 1.0 else 10.0
|
||||||
|
val full = if (power) scales.power100 else scales.rpm100
|
||||||
|
val raw = limited(value / 100 * full * factor)
|
||||||
|
val percent = raw / factor / full * 100
|
||||||
|
return mapOf((if (power) Parameter.POWER else Parameter.RPM) to raw,
|
||||||
|
(if (power) Parameter.POWER_PERCENT else Parameter.RPM_PERCENT) to limited(percent * if (power) scales.powerPercentRaw else 10.0))
|
||||||
|
}
|
||||||
|
fun preview(raw: Map<Parameter, Int>, scales: Scales): Map<Field, Double> = mapOf(
|
||||||
|
Field.POWER to (raw[Parameter.POWER] ?: 0) / scales.power100 * 100,
|
||||||
|
Field.RPM to (raw[Parameter.RPM] ?: 0) / 10.0 / scales.rpm100 * 100,
|
||||||
|
Field.ANGLE to (raw[Parameter.ANGLE] ?: 0) / scales.angleRawDegree,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package ru.setcorp.setprotocol.deif
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||||
|
|
||||||
|
/** Host hides system chrome; controller survives rotation and maintains the active cycle. */
|
||||||
|
@Composable
|
||||||
|
fun DeifFullScreen(controller: DeifController, connected: Boolean) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
var tick by remember { mutableLongStateOf(0) }
|
||||||
|
LaunchedEffect(controller) { while (true) { delay(250); tick++ } }
|
||||||
|
BackHandler { controller.setFullScreen(false) }
|
||||||
|
Column(Modifier.fillMaxSize().background(Color(0xFF090C0E)).displayCutoutPadding()) {
|
||||||
|
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
val preview = DeifControls.preview(state.raw, state.controlScales)
|
||||||
|
@Composable fun instrument(side: Int, modifier: Modifier) {
|
||||||
|
val values = remember(state.readings, tick) { Parameter.entries.associateWith { controller.value(side, it) } }
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) {
|
||||||
|
DeifInstrument("${if (side == 0) "MASTER" else "SLAVE"} · Node ${state.displays[side].node}",
|
||||||
|
values, preview, controller::requestInstrument, Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxWidth > maxHeight) Row(Modifier.fillMaxSize()) {
|
||||||
|
instrument(0, Modifier.weight(1f).fillMaxHeight())
|
||||||
|
instrument(1, Modifier.weight(1f).fillMaxHeight())
|
||||||
|
} else Column(Modifier.fillMaxSize()) {
|
||||||
|
instrument(0, Modifier.weight(1f).fillMaxWidth())
|
||||||
|
instrument(1, Modifier.weight(1f).fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(color = Color(0xFF111820), contentColor = Color.White) {
|
||||||
|
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button({ controller.setFullScreen(false) }) { Text("Выйти из полного экрана") }
|
||||||
|
Button({ controller.start(false) }, enabled = connected && !state.unlocked && !state.running) { Text("Цикл") }
|
||||||
|
Button({ controller.start(true) }, enabled = connected && !state.unlocked && !state.running) { Text("Старт") }
|
||||||
|
Button({ controller.stop() }, enabled = state.running) { Text("Стоп") }
|
||||||
|
Text(if (!connected) "Нет подключения" else if (state.unlocked) "Настройки разблокированы" else if (state.running) "MASTER работает" else "Передача выключена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package ru.setcorp.setprotocol.deif
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
/** Vector port of SETGUI Propulsor.paintEvent, including its original silhouette. */
|
||||||
|
@Composable
|
||||||
|
fun DeifInstrument(title: String, values: Map<Parameter, Double?>, preview: Map<DeifControls.Field, Double>,
|
||||||
|
onRequest: (DeifControls.Field, Double) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
val request by rememberUpdatedState(onRequest)
|
||||||
|
Canvas(modifier.fillMaxWidth().aspectRatio(600f / 510f).pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val scale = min(size.width / 600f, size.height / 510f)
|
||||||
|
val ox = (size.width - 600 * scale) / 2
|
||||||
|
val oy = (size.height - 510 * scale) / 2
|
||||||
|
fun logical(p: androidx.compose.ui.geometry.Offset) = (p.x - ox) / scale to (p.y - oy) / scale
|
||||||
|
val (x, y) = logical(down.position)
|
||||||
|
val field = DeifControls.hit(x, y)
|
||||||
|
if (field != null) {
|
||||||
|
down.consume()
|
||||||
|
DeifControls.requested(field, x, y)?.let { request(field, it) }
|
||||||
|
do {
|
||||||
|
val change = awaitPointerEvent().changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
val (px, py) = logical(change.position)
|
||||||
|
DeifControls.requested(field, px, py)?.let { request(field, it) }
|
||||||
|
change.consume()
|
||||||
|
} while (change.pressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
val c = drawContext.canvas.nativeCanvas
|
||||||
|
val p = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
fun color(hex: String) = android.graphics.Color.parseColor(hex)
|
||||||
|
fun text(x: Float, y: Float, w: Float, h: Float, value: String, size: Float = 18f, ink: String = "#e9edf4", bold: Boolean = false) {
|
||||||
|
p.style = Paint.Style.FILL; p.color = color(ink); p.textSize = size * 1.25f
|
||||||
|
p.typeface = Typeface.create("sans-serif", if (bold) Typeface.BOLD else Typeface.NORMAL)
|
||||||
|
p.textAlign = Paint.Align.CENTER
|
||||||
|
c.drawText(value, x + w / 2, y + h / 2 - (p.ascent() + p.descent()) / 2, p)
|
||||||
|
}
|
||||||
|
fun line(x1: Float, y1: Float, x2: Float, y2: Float, ink: String, width: Float = 1f) {
|
||||||
|
p.color = color(ink); p.strokeWidth = width; p.style = Paint.Style.STROKE; p.strokeCap = Paint.Cap.BUTT
|
||||||
|
c.drawLine(x1, y1, x2, y2, p)
|
||||||
|
}
|
||||||
|
fun number(key: Parameter, suffix: String = "") = values[key]?.let { "%s%s".format(java.math.BigDecimal.valueOf(it).stripTrailingZeros().toPlainString(), suffix) } ?: "—"
|
||||||
|
c.save()
|
||||||
|
c.clipRect(0f, 0f, size.width, size.height)
|
||||||
|
c.drawColor(color("#090c0e"))
|
||||||
|
val scale = min(size.width / 600, size.height / 510)
|
||||||
|
c.translate((size.width - 600 * scale) / 2, (size.height - 510 * scale) / 2); c.scale(scale, scale)
|
||||||
|
text(15f, 12f, 570f, 40f, title, 22f)
|
||||||
|
text(205f, 54f, 95f, 25f, "Задание:", 11f, "#8798ad")
|
||||||
|
text(300f, 54f, 95f, 25f, preview[DeifControls.Field.ANGLE]?.let { "%.1f°".format(it) } ?: "—°", 11f)
|
||||||
|
for ((x, field) in listOf(64f to DeifControls.Field.POWER, 192f to DeifControls.Field.RPM)) {
|
||||||
|
val power = field == DeifControls.Field.POWER
|
||||||
|
val low = if (power) 0 else -20
|
||||||
|
fun yy(v: Double) = (126 + (120 - v) / (120 - low) * 270).toFloat()
|
||||||
|
for ((a, b, ink) in listOf(Triple(low, 0, "#ae616b"), Triple(0, 100, "#657caf"), Triple(100, 120, "#b6c799"))) {
|
||||||
|
if (b > a) { p.style = Paint.Style.FILL; p.color = color(ink); c.drawRect(x, yy(b.toDouble()), x + 34, yy(a.toDouble()), p) }
|
||||||
|
}
|
||||||
|
for (v in low..120 step 20) {
|
||||||
|
val y = yy(v.toDouble()); line(x, y, x + 34, y, "#202b3a")
|
||||||
|
if (v in listOf(low, 0, 40, 80, 120)) text(x - 40, y - 12, 36f, 24f, v.toString(), 12f)
|
||||||
|
}
|
||||||
|
val percentKey = if (power) Parameter.POWER_PERCENT else Parameter.RPM_PERCENT
|
||||||
|
val percent = values[percentKey]
|
||||||
|
if (percent != null) { val y = yy(percent.coerceIn(low.toDouble(), 120.0)); line(x - 3, y, x + 38, y, "#ffffff", 4f) }
|
||||||
|
else { p.style = Paint.Style.FILL; p.color = android.graphics.Color.argb(130, 16, 21, 29); c.drawRect(x, 126f, x + 34, 396f, p) }
|
||||||
|
preview[field]?.let { val y = yy(it.coerceIn(low.toDouble(), 120.0)); line(x - 8, y, x + 42, y, "#eeef69", 3f) }
|
||||||
|
text(x - 35, 89f, 110f, 30f, number(percentKey, "%"), 17f)
|
||||||
|
text(x - 45, 410f, 128f, 28f, if (power) "POWER" else "RPM", 17f)
|
||||||
|
text(x - 52, 444f, 145f, 30f, number(if (power) Parameter.POWER else Parameter.RPM), 22f)
|
||||||
|
text(x - 40, 478f, 125f, 22f, if (power) "RAW" else "rpm", 11f, "#8798ad")
|
||||||
|
}
|
||||||
|
val cx = 411f; val cy = 264f; val radius = 125f
|
||||||
|
val ring = RectF(cx - 120, cy - 120, cx + 120, cy + 120)
|
||||||
|
p.style = Paint.Style.STROKE; p.strokeWidth = 10f; p.strokeCap = Paint.Cap.BUTT
|
||||||
|
p.color = color("#78f033"); c.drawArc(ring, -90f, 180f, false, p)
|
||||||
|
p.color = color("#ff4935"); c.drawArc(ring, -90f, -180f, false, p)
|
||||||
|
for (degree in 0 until 360 step 5) {
|
||||||
|
val a = (degree - 90) * PI / 180
|
||||||
|
val major = degree % 30 == 0
|
||||||
|
fun xx(r: Int) = cx + r * cos(a).toFloat()
|
||||||
|
fun yy(r: Int) = cy + r * sin(a).toFloat()
|
||||||
|
line(xx(if (major) 115 else 118), yy(if (major) 115 else 118), xx(if (major) 137 else 126), yy(if (major) 137 else 126), "#e9f3ff", if (major) 2.3f else 1f)
|
||||||
|
if (major) text(xx(156) - 26, yy(156) - 16, 52f, 32f, min(degree, 360 - degree).toString() + if (degree % 90 == 0) "°" else "", 17f, bold = true)
|
||||||
|
}
|
||||||
|
val measured = values[Parameter.ANGLE]
|
||||||
|
val angle = preview[DeifControls.Field.ANGLE] ?: measured
|
||||||
|
val outline = if (angle != null) "#eeef69" else "#657181"
|
||||||
|
c.save(); c.translate(cx, cy); c.rotate((angle ?: 0.0).toFloat() + 90)
|
||||||
|
p.color = color(outline); p.style = Paint.Style.STROKE; p.strokeWidth = 5.3f; p.strokeJoin = Paint.Join.ROUND; p.strokeCap = Paint.Cap.ROUND
|
||||||
|
c.save(); c.scale(radius / 315, radius / 315); c.translate(-460f, -610f)
|
||||||
|
c.drawPath(Path().apply {
|
||||||
|
moveTo(399f, 550f); cubicTo(478f, 516f, 577f, 525f, 630f, 562f)
|
||||||
|
cubicTo(679f, 594f, 672f, 632f, 626f, 661f); cubicTo(570f, 701f, 478f, 702f, 399f, 674f); close()
|
||||||
|
}, p)
|
||||||
|
c.drawPath(Path().apply {
|
||||||
|
moveTo(399f, 550f); lineTo(360f, 552f); moveTo(318f, 563f)
|
||||||
|
cubicTo(278f, 575f, 278f, 619f, 312f, 642f); lineTo(315f, 629f); cubicTo(341f, 659f, 370f, 676f, 399f, 674f)
|
||||||
|
}, p)
|
||||||
|
c.drawPath(Path().apply {
|
||||||
|
moveTo(384f, 631f); cubicTo(348f, 615f, 307f, 555f, 317f, 506f)
|
||||||
|
cubicTo(329f, 466f, 350f, 418f, 366f, 438f); cubicTo(384f, 466f, 392f, 524f, 384f, 631f); close()
|
||||||
|
}, p)
|
||||||
|
c.drawLine(318f, 499f, 384f, 631f, p)
|
||||||
|
c.drawPath(Path().apply { moveTo(315f, 629f); cubicTo(304f, 681f, 320f, 780f, 350f, 785f); cubicTo(388f, 792f, 386f, 712f, 373f, 670f) }, p)
|
||||||
|
c.restore()
|
||||||
|
if (angle != null) {
|
||||||
|
p.style = Paint.Style.FILL
|
||||||
|
c.drawPath(Path().apply { moveTo(-115f, 0f); lineTo(-77f, -15f); lineTo(-77f, 15f); close() }, p)
|
||||||
|
c.drawPath(Path().apply { moveTo(-126f, 0f); lineTo(-138f, -5f); lineTo(-138f, 5f); close() }, p)
|
||||||
|
}
|
||||||
|
c.restore()
|
||||||
|
text(300f, 435f, 225f, 36f, number(Parameter.ANGLE, "°"), 25f)
|
||||||
|
text(278f, 478f, 275f, 22f, "AZIMUTH", 11f, "#8798ad")
|
||||||
|
c.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package ru.setcorp.setprotocol.deif
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||||
|
|
||||||
|
/** Reusable Compose port. The host supplies only transport and persistence. */
|
||||||
|
@Composable
|
||||||
|
fun DeifPanel(controller: DeifController, connected: Boolean, channel: String) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
var clock by remember { mutableLongStateOf(0) }
|
||||||
|
LaunchedEffect(controller) { while (true) { delay(250); clock++ } }
|
||||||
|
var invalidInputs by remember { mutableStateOf(emptySet<Parameter>()) }
|
||||||
|
var showRaw by remember { mutableStateOf(false) }
|
||||||
|
var controlsDialog by remember { mutableStateOf(false) }
|
||||||
|
var configuring by remember { mutableStateOf(false) }
|
||||||
|
var displaySide by remember { mutableStateOf<Int?>(null) }
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text("DEIF XDi · MASTER / SLAVE", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text("Канал: $channel · ${if (connected) "подключён" else "нет подключения"}")
|
||||||
|
Row {
|
||||||
|
Checkbox(state.unlocked, { controller.unlock(it) })
|
||||||
|
Text("Разблокировать настройки", Modifier.padding(top = 12.dp))
|
||||||
|
}
|
||||||
|
Text("${state.config.mode} · Node ${state.config.node} · STD 0x%03X · %d мс".format(state.config.canId, state.config.periodMs))
|
||||||
|
OutlinedButton(onClick = { configuring = true }, enabled = state.unlocked) { Text("Настроить передачу") }
|
||||||
|
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button({ controller.start(false) }, enabled = connected && !state.unlocked && !state.running && invalidInputs.isEmpty()) { Text("Цикл") }
|
||||||
|
Button({ controller.start(true) }, enabled = connected && !state.unlocked && !state.running && invalidInputs.isEmpty()) { Text("Старт") }
|
||||||
|
OutlinedButton({ controller.stop() }, enabled = state.running) { Text("Стоп") }
|
||||||
|
OutlinedButton({ controller.setFullScreen(true) }) { Text("На весь экран") }
|
||||||
|
}
|
||||||
|
Text("Коснитесь или перетащите POWER, RPM либо винт. Жёлтый — задание, белый — показания.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
val preview = remember(state.raw, state.controlScales) { DeifControls.preview(state.raw, state.controlScales) }
|
||||||
|
@Composable fun instrument(side: Int, modifier: Modifier) {
|
||||||
|
Column(modifier) {
|
||||||
|
val values = remember(state.readings, clock) { Parameter.entries.associateWith { controller.value(side, it) } }
|
||||||
|
DeifInstrument("${if (side == 0) "MASTER" else "SLAVE"} · Node ${state.displays[side].node}",
|
||||||
|
values, preview, { field, value ->
|
||||||
|
controller.requestInstrument(field, value)
|
||||||
|
invalidInputs = emptySet()
|
||||||
|
})
|
||||||
|
OutlinedButton({ displaySide = side }, enabled = state.unlocked) { Text("Настроить ${if (side == 0) "MASTER" else "SLAVE"}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BoxWithConstraints {
|
||||||
|
if (maxWidth >= 760.dp) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
instrument(0, Modifier.weight(1f)); instrument(1, Modifier.weight(1f))
|
||||||
|
} else Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
instrument(0, Modifier.fillMaxWidth()); instrument(1, Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton({ controlsDialog = true }, enabled = state.unlocked) { Text("Масштабы управления приборами") }
|
||||||
|
TextButton({ showRaw = !showRaw }) { Text(if (showRaw) "Скрыть RAW и выбор параметров" else "RAW и выбор параметров") }
|
||||||
|
if (showRaw) {
|
||||||
|
Parameter.entries.forEach { parameter ->
|
||||||
|
var input by remember(state.raw[parameter]) { mutableStateOf((state.raw[parameter] ?: 0).toString()) }
|
||||||
|
val raw = input.toIntOrNull()
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Checkbox(parameter in state.config.enabled, { controller.select(parameter, it) })
|
||||||
|
OutlinedTextField(input, { text ->
|
||||||
|
input = text
|
||||||
|
val value = text.toIntOrNull()
|
||||||
|
invalidInputs = if (value != null && value in -32768..32767) invalidInputs - parameter else invalidInputs + parameter
|
||||||
|
if (value != null && value in -32768..32767) controller.setRaw(parameter, value)
|
||||||
|
else controller.stop("Исправьте RAW перед передачей")
|
||||||
|
}, label = { Text("${parameter.title} (${parameter.unit}) · RAW") },
|
||||||
|
isError = raw == null || raw !in -32768..32767, singleLine = true, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
Text("0x%04X:02%s · %s".format(parameter.index, if (parameter == Parameter.ANGLE) " + :07" else "",
|
||||||
|
raw?.let { "%.1f %s".format(it * parameter.scale, parameter.unit) } ?: "—"))
|
||||||
|
}
|
||||||
|
Text("AC2: физическая единица мощности не подтверждена. Угол передаётся как фактический и заданный.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Text(state.status)
|
||||||
|
Text("Передано: ${state.sent} · RX: ${state.received} · ошибок CAN: ${state.errors}")
|
||||||
|
Text("TX-эхо gs_usb: ${state.echoes}. Эхо не является ответом DEIF.")
|
||||||
|
if (channel.contains("gs_usb")) {
|
||||||
|
val echoStatus = remember(state.echoTimes, state.config.enabled, clock) {
|
||||||
|
state.config.enabled.flatMap { p -> (if (p == Parameter.ANGLE) listOf(2, 7) else listOf(2)).map { sub ->
|
||||||
|
"%04X:%02X — %s".format(p.index, sub, controller.echoAge(p.index, sub)?.let { if (it > 3000) "нет эха >3 с" else "$it мс" } ?: "нет эха")
|
||||||
|
} }.joinToString("\n")
|
||||||
|
}
|
||||||
|
Text(echoStatus, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Text("Интервал: ${state.intervalMs} мс · макс. пауза: ${state.maxGapMs} мс · макс. цикл: ${state.maxCycleMs} мс")
|
||||||
|
Text("Heartbeat ${if (state.config.heartbeat) "включён · 500 мс" else "выключен"}. TX означает передачу в API адаптера, а не подтверждение DEIF.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
Text(state.lastReceived)
|
||||||
|
Text("SLAVE не использует TX-эхо; отсутствие ответа допустимо. Данные скрываются через 5 с. NMT, LSS и SDO записи не отправляются.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (controlsDialog) {
|
||||||
|
val scales = state.controlScales
|
||||||
|
val labels = listOf("POWER при 100%, RAW", "RPM при 100%, rpm", "Угол: RAW на 1°", "POWER%: RAW на 1%")
|
||||||
|
val values = remember { listOf(scales.power100, scales.rpm100, scales.angleRawDegree, scales.powerPercentRaw).map { mutableStateOf(it.toString()) } }
|
||||||
|
var problem by remember { mutableStateOf<String?>(null) }
|
||||||
|
AlertDialog(onDismissRequest = { controlsDialog = false }, title = { Text("Масштабы управления") }, text = {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text("Оба индикатора меняют общие задания. При работающем master значения отправятся следующим циклом.")
|
||||||
|
labels.forEachIndexed { i, label -> OutlinedTextField(values[i].value, { values[i].value = it }, label = { Text(label) }) }
|
||||||
|
problem?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
}, confirmButton = { TextButton({
|
||||||
|
runCatching { controller.configureControls(DeifControls.Scales(values[0].value.toDouble(), values[1].value.toDouble(),
|
||||||
|
values[2].value.toDouble(), values[3].value.toDouble())) }
|
||||||
|
.onSuccess { controlsDialog = false }.onFailure { problem = it.message }
|
||||||
|
}) { Text("Применить") } }, dismissButton = { TextButton({ controlsDialog = false }) { Text("Отмена") } })
|
||||||
|
}
|
||||||
|
if (configuring) {
|
||||||
|
val c = state.config
|
||||||
|
var mode by remember { mutableStateOf(c.mode) }
|
||||||
|
var node by remember { mutableStateOf(c.node.toString()) }
|
||||||
|
var id by remember { mutableStateOf(c.canId.toString(16)) }
|
||||||
|
var period by remember { mutableStateOf(c.periodMs.toString()) }
|
||||||
|
var heartbeat by remember { mutableStateOf(c.heartbeat) }
|
||||||
|
var master by remember { mutableStateOf(c.masterNode.toString()) }
|
||||||
|
var problem by remember { mutableStateOf<String?>(null) }
|
||||||
|
AlertDialog(onDismissRequest = { configuring = false }, title = { Text("Передача DEIF") }, text = {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Row { DeifProtocol.Mode.entries.forEach { m -> FilterChip(mode == m, { mode = m }, label = { Text(m.name) }) } }
|
||||||
|
Text("SAM: источник; DAM: получатель. Адрес самого прибора не изменяется.")
|
||||||
|
OutlinedTextField(node, { node = it; it.toIntOrNull()?.let { n -> id = (0x200 + n).toString(16) } }, label = { Text("Node-ID (1…127)") })
|
||||||
|
OutlinedTextField(id, { id = it }, label = { Text("STD ID, HEX (201 / 20B / 30B…)") })
|
||||||
|
OutlinedTextField(period, { period = it }, label = { Text("Период, мс") })
|
||||||
|
Row { Checkbox(heartbeat, { heartbeat = it }); Text("Отправлять heartbeat") }
|
||||||
|
OutlinedTextField(master, { master = it }, label = { Text("Node-ID master для heartbeat DAM") })
|
||||||
|
problem?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
}, confirmButton = { TextButton({
|
||||||
|
runCatching { controller.configure(c.copy(mode = mode, node = node.toInt(), canId = id.removePrefix("0x").toInt(16),
|
||||||
|
periodMs = period.toLong(), heartbeat = heartbeat, masterNode = master.toInt())) }
|
||||||
|
.onSuccess { configuring = false }.onFailure { problem = it.message }
|
||||||
|
}) { Text("Применить") } }, dismissButton = { TextButton({ configuring = false }) { Text("Отмена") } })
|
||||||
|
}
|
||||||
|
displaySide?.let { side ->
|
||||||
|
val config = state.displays[side]
|
||||||
|
var node by remember { mutableStateOf(config.node.toString()) }
|
||||||
|
var id by remember { mutableStateOf(config.canId.toString(16)) }
|
||||||
|
val fields = remember { Parameter.entries.associateWith { p ->
|
||||||
|
val b = config.bindings.getValue(p)
|
||||||
|
mutableStateOf("%04X:%02X:%s".format(b.index, b.subindex, b.scale))
|
||||||
|
} }
|
||||||
|
var problem by remember { mutableStateOf<String?>(null) }
|
||||||
|
AlertDialog(onDismissRequest = { displaySide = null }, title = { Text("Привязки ${if (side == 0) "MASTER" else "SLAVE"}") }, text = {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
OutlinedTextField(node, { node = it }, label = { Text("Node-ID: SAM источник / DAM получатель") })
|
||||||
|
OutlinedTextField(id, { id = it }, label = { Text("SAM CAN ID, HEX") })
|
||||||
|
Text("Формат: индекс HEX:подиндекс HEX:масштаб. Например 3001:02:0.1. DAM принимается по Node-ID на RPDO1…4.")
|
||||||
|
Parameter.entries.forEach { p -> OutlinedTextField(fields.getValue(p).value, { fields.getValue(p).value = it }, label = { Text("${p.title} ${p.unit}") }) }
|
||||||
|
problem?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
}, confirmButton = { TextButton({
|
||||||
|
runCatching {
|
||||||
|
controller.configureDisplay(side, DeifDisplayConfig(node.toInt(), id.removePrefix("0x").toInt(16), fields.mapValues { (_, text) ->
|
||||||
|
val parts = text.value.split(':'); require(parts.size == 3) { "Нужны индекс:подиндекс:масштаб" }
|
||||||
|
DeifBinding(parts[0].toInt(16), parts[1].toInt(16), parts[2].toDouble())
|
||||||
|
}))
|
||||||
|
}.onSuccess { displaySide = null }.onFailure { problem = it.message }
|
||||||
|
}) { Text("Применить") } }, dismissButton = { TextButton({ displaySide = null }) { Text("Отмена") } })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package ru.setcorp.setprotocol.deif
|
||||||
|
|
||||||
|
import ru.setcorp.setflash.core.CanBridgeProtocol
|
||||||
|
|
||||||
|
/** XDi-net MPDO contract shared by Android hosts; matches set_devices/deif_xdi.py. */
|
||||||
|
object DeifProtocol {
|
||||||
|
enum class Mode { SAM, DAM }
|
||||||
|
enum class Parameter(val index: Int, val title: String, val unit: String, val scale: Double) {
|
||||||
|
ANGLE(0x3001, "Угол", "°", 0.1),
|
||||||
|
RPM(0x3081, "Обороты", "rpm", 0.1),
|
||||||
|
RPM_PERCENT(0x3091, "Обороты винта", "%", 0.1),
|
||||||
|
POWER(0x3421, "Мощность AC2", "RAW", 1.0),
|
||||||
|
POWER_PERCENT(0x3431, "Мощность AC2", "%", 0.1),
|
||||||
|
}
|
||||||
|
data class Config(
|
||||||
|
val mode: Mode = Mode.SAM, val node: Int = 1, val canId: Int = 0x201,
|
||||||
|
val periodMs: Long = 100, val heartbeat: Boolean = false, val masterNode: Int = 1,
|
||||||
|
val enabled: Set<Parameter> = Parameter.entries.toSet(),
|
||||||
|
) {
|
||||||
|
fun validate() {
|
||||||
|
require(node in 1..127 && masterNode in 1..127) { "Node-ID: 1…127" }
|
||||||
|
require(periodMs in 20..60000) { "Период: 20…60000 мс" }
|
||||||
|
require(if (mode == Mode.SAM) canId == 0x200 + node || canId == 0x27F
|
||||||
|
else canId - node in listOf(0x200, 0x300, 0x400, 0x500)) { "STD ID не соответствует режиму и Node-ID" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data class Decoded(
|
||||||
|
val summary: String, val node: Int? = null, val index: Int? = null,
|
||||||
|
val subindex: Int? = null, val raw: Int? = null, val value: Double? = null,
|
||||||
|
val unit: String = "", val mode: Mode? = null, val valid: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun cycle(config: Config, raw: Map<Parameter, Int>): List<CanBridgeProtocol.Frame> {
|
||||||
|
config.validate()
|
||||||
|
val frames = Parameter.entries.filter { it in config.enabled }.map { p ->
|
||||||
|
dataFrame(config, p.index, 2, raw[p] ?: 0)
|
||||||
|
}.toMutableList()
|
||||||
|
if (Parameter.ANGLE in config.enabled) frames += dataFrame(config, 0x3001, 7, raw[Parameter.ANGLE] ?: 0)
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
private fun dataFrame(c: Config, index: Int, sub: Int, raw: Int): CanBridgeProtocol.Frame {
|
||||||
|
require(raw in -32768..32767) { "RAW: −32768…32767" }
|
||||||
|
return CanBridgeProtocol.buildFrame(c.canId.toLong(), byteArrayOf(
|
||||||
|
(c.node or if (c.mode == Mode.DAM) 0x80 else 0).toByte(),
|
||||||
|
index.toByte(), (index shr 8).toByte(), sub.toByte(), raw.toByte(), (raw shr 8).toByte(), 0, 0,
|
||||||
|
), extended = false)
|
||||||
|
}
|
||||||
|
fun heartbeat(config: Config): CanBridgeProtocol.Frame {
|
||||||
|
config.validate()
|
||||||
|
val node = if (config.mode == Mode.SAM) config.node else config.masterNode
|
||||||
|
return CanBridgeProtocol.buildFrame((0x700 + node).toLong(), byteArrayOf(5), extended = false)
|
||||||
|
}
|
||||||
|
fun decode(frame: CanBridgeProtocol.Frame): Decoded {
|
||||||
|
fun reject(s: String) = Decoded("DEIF: $s")
|
||||||
|
if (frame.isExtended || frame.isRemote || frame.isError) return reject("ожидается STD data frame")
|
||||||
|
val id = frame.canId.toInt()
|
||||||
|
val data = frame.data
|
||||||
|
if (id in 0x701..0x77F) {
|
||||||
|
if (data.size != 1) return reject("heartbeat: ожидается DLC=1")
|
||||||
|
val state = when (data[0].toInt() and 255) { 0 -> "Boot-up"; 5 -> "Operational"; else -> "состояние 0x%02X".format(data[0]) }
|
||||||
|
return Decoded("Node ${id - 0x700}: $state", node = id - 0x700, valid = true)
|
||||||
|
}
|
||||||
|
if (id !in 0x201..0x57F || data.size != 8) return reject("неизвестный ID или DLC (MPDO: 8 байт)")
|
||||||
|
val node = data[0].toInt() and 127
|
||||||
|
val mode = if (data[0].toInt() and 128 != 0) Mode.DAM else Mode.SAM
|
||||||
|
if (node == 0 || !(if (mode == Mode.DAM) id - node in listOf(0x200, 0x300, 0x400, 0x500)
|
||||||
|
else id in 0x201..0x27F && (id == 0x27F || id == 0x200 + node))) return reject("адрес не соответствует COB-ID")
|
||||||
|
val index = (data[1].toInt() and 255) or ((data[2].toInt() and 255) shl 8)
|
||||||
|
val sub = data[3].toInt() and 255
|
||||||
|
val parameter = Parameter.entries.find { it.index and 0xFFF0 == index and 0xFFF0 }
|
||||||
|
if (parameter == null || index and 15 == 0 || sub !in listOf(2, 7)) return reject("неизвестный объект 0x%04X:%02X".format(index, sub))
|
||||||
|
val raw = ((data[4].toInt() and 255) or ((data[5].toInt() and 255) shl 8)).toShort().toInt()
|
||||||
|
val status = when (raw) { -32768 -> "ниже диапазона"; 32766 -> "выше диапазона"; 32767 -> "ошибка данных"; else -> null }
|
||||||
|
val scaled = sub == 2 || parameter == Parameter.ANGLE
|
||||||
|
val value = if (status == null && scaled) raw * parameter.scale else null
|
||||||
|
val unit = if (scaled) parameter.unit else "масштаб не установлен"
|
||||||
|
val padding = if (data[6] != 0.toByte() || data[7] != 0.toByte()) " · ненулевое заполнение" else ""
|
||||||
|
val summary = "$mode Node $node · 0x%04X:%02X · %s · RAW=%d".format(index, sub, parameter.title, raw) +
|
||||||
|
" · " + (status ?: value?.let { "%.1f %s".format(it, unit) } ?: unit) + padding
|
||||||
|
return Decoded(summary, node, index, sub, raw, value, unit, mode, status == null && padding.isEmpty())
|
||||||
|
}
|
||||||
|
/** Only display data and Operational heartbeat may reach this transmit path. */
|
||||||
|
fun validateTransmit(frame: CanBridgeProtocol.Frame) {
|
||||||
|
val decoded = decode(frame)
|
||||||
|
val heartbeat = frame.canId in 0x701..0x77F && frame.data.contentEquals(byteArrayOf(5))
|
||||||
|
val data = frame.data
|
||||||
|
val known = decoded.index in Parameter.entries.map { it.index } &&
|
||||||
|
(decoded.subindex == 2 || decoded.index == 0x3001 && decoded.subindex == 7)
|
||||||
|
require(!frame.isExtended && !frame.isRemote && !frame.isError &&
|
||||||
|
(heartbeat || known && decoded.raw != null && data.size == 8 && data[6] == 0.toByte() && data[7] == 0.toByte())) {
|
||||||
|
"DEIF: разрешены только данные индикации и heartbeat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package ru.setcorp.setprotocol.deif
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifControls.Field
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DeifControlsTest {
|
||||||
|
@Test fun fullScreenTogglePreservesLiveMasterAndIsNotRestoredOnNextLaunch() = runTest {
|
||||||
|
val sent = mutableListOf<ru.setcorp.setflash.core.CanBridgeProtocol.Frame>()
|
||||||
|
var saved = ""
|
||||||
|
val controller = DeifController(backgroundScope, { sent += it }, { saved = it })
|
||||||
|
controller.requestInstrument(Field.ANGLE, -90.0)
|
||||||
|
controller.start(true); runCurrent()
|
||||||
|
controller.setFullScreen(true)
|
||||||
|
val before = sent.size
|
||||||
|
advanceTimeBy(101); runCurrent()
|
||||||
|
assertTrue(sent.size > before)
|
||||||
|
assertTrue(controller.state.value.fullScreen)
|
||||||
|
assertEquals(-900, controller.state.value.raw[Parameter.ANGLE])
|
||||||
|
controller.setFullScreen(false)
|
||||||
|
assertTrue(controller.state.value.running)
|
||||||
|
assertFalse(controller.state.value.fullScreen)
|
||||||
|
val restored = DeifController(backgroundScope, {})
|
||||||
|
restored.restore(saved)
|
||||||
|
assertFalse(restored.state.value.fullScreen)
|
||||||
|
assertFalse(restored.state.value.running)
|
||||||
|
controller.stop()
|
||||||
|
}
|
||||||
|
@Test fun hitRegionsMatchSetguiAndLeaveBackgroundScrollable() {
|
||||||
|
assertEquals(Field.POWER, DeifControls.hit(64f, 126f))
|
||||||
|
assertEquals(Field.RPM, DeifControls.hit(192f, 396f))
|
||||||
|
assertEquals(Field.ANGLE, DeifControls.hit(411f, 120f))
|
||||||
|
assertNull(DeifControls.hit(10f, 10f))
|
||||||
|
assertNull(DeifControls.hit(250f, 450f))
|
||||||
|
}
|
||||||
|
@Test fun angleHasNorthZeroAndSignedLeftRight() {
|
||||||
|
for ((point, angle) in listOf((411f to 120f) to 0.0, (550f to 264f) to 90.0,
|
||||||
|
(411f to 410f) to 180.0, (270f to 264f) to -90.0)) {
|
||||||
|
assertEquals(angle, DeifControls.requested(Field.ANGLE, point.first, point.second)!!, 0.001)
|
||||||
|
}
|
||||||
|
assertNull(DeifControls.requested(Field.ANGLE, 411f, 264f))
|
||||||
|
}
|
||||||
|
@Test fun verticalDragsIncludeOverloadAndReverseRpm() {
|
||||||
|
assertEquals(120.0, DeifControls.requested(Field.POWER, 64f, 126f)!!, 0.001)
|
||||||
|
assertEquals(0.0, DeifControls.requested(Field.POWER, 64f, 600f)!!, 0.001)
|
||||||
|
assertEquals(-20.0, DeifControls.requested(Field.RPM, 192f, 396f)!!, 0.001)
|
||||||
|
assertEquals(120.0, DeifControls.requested(Field.RPM, 192f, -50f)!!, 0.001)
|
||||||
|
}
|
||||||
|
@Test fun gesturesUpdateAbsoluteAndPercentTogetherLikeSetgui() {
|
||||||
|
val scales = DeifControls.Scales()
|
||||||
|
assertEquals(mapOf(Parameter.POWER to 600, Parameter.POWER_PERCENT to 600), DeifControls.raw(Field.POWER, 60.0, scales))
|
||||||
|
assertEquals(mapOf(Parameter.RPM to 5000, Parameter.RPM_PERCENT to 500), DeifControls.raw(Field.RPM, 50.0, scales))
|
||||||
|
assertEquals(mapOf(Parameter.RPM to -2000, Parameter.RPM_PERCENT to -200), DeifControls.raw(Field.RPM, -20.0, scales))
|
||||||
|
assertEquals(mapOf(Parameter.ANGLE to -900), DeifControls.raw(Field.ANGLE, -90.0, scales))
|
||||||
|
}
|
||||||
|
@Test fun clampingNeverProducesStatusSentinelsAndKeepsPercentConsistent() {
|
||||||
|
val scales = DeifControls.Scales(rpm100 = 10000.0)
|
||||||
|
val raw = DeifControls.raw(Field.RPM, 120.0, scales)
|
||||||
|
assertEquals(32765, raw[Parameter.RPM])
|
||||||
|
assertEquals(328, raw[Parameter.RPM_PERCENT])
|
||||||
|
assertEquals(32.765, DeifControls.preview(raw, scales)[Field.RPM]!!, 0.0001)
|
||||||
|
assertEquals(-32767, DeifControls.raw(Field.ANGLE, -180.0, scales.copy(angleRawDegree = 1000.0))[Parameter.ANGLE])
|
||||||
|
}
|
||||||
|
@Test fun commandPreviewWorksBeforeTransmissionAndScalesPersist() = runTest {
|
||||||
|
var saved = ""
|
||||||
|
val controller = DeifController(backgroundScope, { error("Must not send while editing") }, { saved = it })
|
||||||
|
controller.unlock(true)
|
||||||
|
controller.configureControls(DeifControls.Scales(power100 = 2000.0))
|
||||||
|
controller.requestInstrument(Field.POWER, 60.0)
|
||||||
|
assertEquals(1200, controller.state.value.raw[Parameter.POWER])
|
||||||
|
assertEquals(60.0, DeifControls.preview(controller.state.value.raw, controller.state.value.controlScales)[Field.POWER]!!, 0.001)
|
||||||
|
assertNull(controller.value(0, Parameter.POWER))
|
||||||
|
val restored = DeifController(backgroundScope, {})
|
||||||
|
restored.restore(saved)
|
||||||
|
assertEquals(controller.state.value.controlScales, restored.state.value.controlScales)
|
||||||
|
}
|
||||||
|
@Test fun draggingDuringMasterChangesNextCycle() = runTest {
|
||||||
|
val sent = mutableListOf<ru.setcorp.setflash.core.CanBridgeProtocol.Frame>()
|
||||||
|
val controller = DeifController(backgroundScope, { sent += it })
|
||||||
|
controller.start(true); runCurrent()
|
||||||
|
controller.requestInstrument(Field.RPM, 50.0)
|
||||||
|
advanceTimeBy(101); runCurrent()
|
||||||
|
val rpm = sent.map(DeifProtocol::decode).filter { it.index == 0x3081 }
|
||||||
|
assertEquals(0, rpm.first().raw)
|
||||||
|
assertEquals(5000, rpm.last().raw)
|
||||||
|
assertTrue(controller.state.value.running)
|
||||||
|
controller.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package ru.setcorp.setprotocol.deif
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import ru.setcorp.setflash.core.CanBridgeProtocol
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifProtocol.Config
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifProtocol.Mode
|
||||||
|
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DeifTest {
|
||||||
|
@Test fun matchesPythonSetguiReferenceCycles() {
|
||||||
|
val text = checkNotNull(javaClass.getResourceAsStream("/deif-setgui.json")).bufferedReader().use { it.readText() }
|
||||||
|
val cases = org.json.JSONArray(text)
|
||||||
|
repeat(cases.length()) { i ->
|
||||||
|
val c = cases.getJSONObject(i)
|
||||||
|
val raw = Parameter.entries.mapIndexed { n, p -> p to c.getJSONArray("raw").getInt(n) }.toMap()
|
||||||
|
val frames = DeifProtocol.cycle(Config(Mode.valueOf(c.getString("mode")), c.getInt("node"), c.getInt("canId")), raw)
|
||||||
|
frames.forEachIndexed { n, f ->
|
||||||
|
assertEquals("case $i frame $n", c.getJSONArray("frames").getString(n), f.data.joinToString("") { "%02x".format(it) })
|
||||||
|
DeifProtocol.validateTransmit(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun frame(id: Long, data: String, tx: Boolean = false) = CanBridgeProtocol.buildFrame(
|
||||||
|
id, CanBridgeProtocol.parseHex(data), extended = false, toCan = tx)
|
||||||
|
|
||||||
|
@Test fun setguiDamVectorAndBothAngles() {
|
||||||
|
val frames = DeifProtocol.cycle(Config(Mode.DAM, 11, 0x20B), mapOf(Parameter.POWER to 1234, Parameter.ANGLE to -900))
|
||||||
|
assertEquals(6, frames.size)
|
||||||
|
assertEquals(frame(0x20B, "8B 21 34 02 D2 04 00 00", true), frames[3])
|
||||||
|
assertEquals(frame(0x20B, "8B 01 30 02 7C FC 00 00", true), frames[0])
|
||||||
|
assertEquals(frame(0x20B, "8B 01 30 07 7C FC 00 00", true), frames[5])
|
||||||
|
assertTrue(frames.all { !it.isExtended && !it.isRemote })
|
||||||
|
}
|
||||||
|
@Test fun routingAndInputValidation() {
|
||||||
|
for (node in listOf(1, 11, 127)) for (base in listOf(0x200, 0x300, 0x400, 0x500)) {
|
||||||
|
val frames = DeifProtocol.cycle(Config(Mode.DAM, node, base + node), emptyMap())
|
||||||
|
assertTrue(frames.all { DeifProtocol.decode(it).valid })
|
||||||
|
frames.forEach(DeifProtocol::validateTransmit)
|
||||||
|
}
|
||||||
|
assertTrue(DeifProtocol.decode(frame(0x27F, "0B 81 30 02 D2 04 00 00")).valid)
|
||||||
|
assertFalse(DeifProtocol.decode(frame(0x201, "0B 81 30 02 D2 04 00 00")).valid)
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.cycle(Config(node = 0), emptyMap()) }
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.cycle(Config(), mapOf(Parameter.RPM to 32768)) }
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.cycle(Config(Mode.SAM, 11, 0x30B), emptyMap()) }
|
||||||
|
}
|
||||||
|
@Test fun decoderScalesStatusesAndRejectsUnknownFrames() {
|
||||||
|
assertEquals(-90.0, DeifProtocol.decode(frame(0x201, "01 01 30 07 7C FC 00 00")).value!!, 0.001)
|
||||||
|
assertEquals(123.4, DeifProtocol.decode(frame(0x201, "01 81 30 02 D2 04 00 00")).value!!, 0.001)
|
||||||
|
assertEquals("RAW", DeifProtocol.decode(frame(0x201, "01 21 34 02 D2 04 00 00")).unit)
|
||||||
|
assertNull(DeifProtocol.decode(frame(0x201, "01 81 30 07 D2 04 00 00")).value)
|
||||||
|
listOf("00 80", "FE 7F", "FF 7F").forEach { raw ->
|
||||||
|
val decoded = DeifProtocol.decode(frame(0x201, "01 81 30 02 $raw 00 00"))
|
||||||
|
assertFalse(decoded.valid); assertNull(decoded.value)
|
||||||
|
}
|
||||||
|
assertFalse(DeifProtocol.decode(frame(0x201, "01 81 30 02 00 00 00 01")).valid)
|
||||||
|
assertFalse(DeifProtocol.decode(frame(0x201, "01 81 30 02 00 00 00 00").copy(flags = CanBridgeProtocol.FLAG_IDE)).valid)
|
||||||
|
assertFalse(DeifProtocol.decode(frame(0x201, "01 81 30")).valid)
|
||||||
|
}
|
||||||
|
@Test fun transmitGuardRejectsConfigurationAndBadAddresses() {
|
||||||
|
listOf(frame(0, "01 0B"), frame(0x60B, "23 00 20 00 01 00 00 00"), frame(0x7E5, "04"),
|
||||||
|
frame(0x70B, "00"), frame(0x201, "0B 81 30 02 00 00 00 00")).forEach {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.validateTransmit(it) }
|
||||||
|
}
|
||||||
|
DeifProtocol.validateTransmit(DeifProtocol.heartbeat(Config()))
|
||||||
|
assertEquals(0x701L, DeifProtocol.heartbeat(Config(Mode.DAM, 11, 0x20B)).canId)
|
||||||
|
}
|
||||||
|
@Test fun heartbeatHasIndependentPeriodAndStopsOnUnlock() = runTest {
|
||||||
|
val sent = mutableListOf<CanBridgeProtocol.Frame>()
|
||||||
|
val controller = DeifController(backgroundScope, { sent += it }, now = { testScheduler.currentTime })
|
||||||
|
controller.unlock(true); controller.configure(Config(periodMs = 1000, heartbeat = true)); controller.unlock(false)
|
||||||
|
controller.start(true); runCurrent()
|
||||||
|
assertEquals(6, sent.count { it.data.size == 8 }); assertEquals(1, sent.count { it.data.size == 1 })
|
||||||
|
advanceTimeBy(501); runCurrent()
|
||||||
|
assertEquals(6, sent.count { it.data.size == 8 }); assertEquals(2, sent.count { it.data.size == 1 })
|
||||||
|
controller.unlock(true)
|
||||||
|
val count = sent.size; advanceTimeBy(2000); runCurrent(); assertEquals(count, sent.size)
|
||||||
|
assertFalse(controller.state.value.running)
|
||||||
|
}
|
||||||
|
@Test fun singleCycleHasNoHeartbeatAndSelectionStopsRepeat() = runTest {
|
||||||
|
val sent = mutableListOf<CanBridgeProtocol.Frame>()
|
||||||
|
val controller = DeifController(backgroundScope, { sent += it })
|
||||||
|
controller.unlock(true); controller.configure(Config(heartbeat = true, enabled = setOf(Parameter.RPM))); controller.unlock(false)
|
||||||
|
controller.start(false); runCurrent()
|
||||||
|
assertEquals(1, sent.size); assertEquals(8, sent.single().data.size)
|
||||||
|
assertFalse(controller.state.value.running)
|
||||||
|
controller.start(true); runCurrent(); controller.select(Parameter.RPM, false)
|
||||||
|
val count = sent.size; advanceTimeBy(1500); runCurrent(); assertEquals(count, sent.size)
|
||||||
|
}
|
||||||
|
@Test fun failedTransportStopsBothTimers() = runTest {
|
||||||
|
var calls = 0
|
||||||
|
val controller = DeifController(backgroundScope, { calls++; error("disconnected") })
|
||||||
|
controller.start(true); runCurrent(); advanceTimeBy(1000); runCurrent()
|
||||||
|
assertEquals(1, calls); assertFalse(controller.state.value.running)
|
||||||
|
assertEquals(0L, controller.state.value.sent)
|
||||||
|
}
|
||||||
|
@Test fun slaveNeverUsesLocalTxOrEchoAndExpires() = runTest {
|
||||||
|
var time = 0L
|
||||||
|
val controller = DeifController(backgroundScope, {}, now = { time })
|
||||||
|
controller.setRaw(Parameter.RPM, 1234); controller.start(false); runCurrent()
|
||||||
|
assertEquals(123.4, controller.value(0, Parameter.RPM)!!, 0.001)
|
||||||
|
assertNull(controller.value(1, Parameter.RPM))
|
||||||
|
controller.receive(frame(0x201, "01 81 30 02 D2 04 00 00", true))
|
||||||
|
assertNull(controller.value(1, Parameter.RPM)); assertEquals(1L, controller.state.value.echoes)
|
||||||
|
controller.receive(frame(0x201, "01 81 30 02 D2 04 00 00"))
|
||||||
|
assertEquals(123.4, controller.value(1, Parameter.RPM)!!, 0.001)
|
||||||
|
time = 5001; assertNull(controller.value(1, Parameter.RPM))
|
||||||
|
controller.receive(frame(0x201, "01 81 30 02 FF 7F 00 00"))
|
||||||
|
assertNull(controller.value(1, Parameter.RPM))
|
||||||
|
}
|
||||||
|
@Test fun settingsRestoreIsLockedAndStoppedAndKeepsBindings() = runTest {
|
||||||
|
var saved = ""
|
||||||
|
val controller = DeifController(backgroundScope, {}, { saved = it })
|
||||||
|
controller.unlock(true); controller.configure(Config(Mode.DAM, 11, 0x30B, heartbeat = true))
|
||||||
|
controller.configureDisplay(1, DeifDisplayConfig(11, 0x30B))
|
||||||
|
controller.setRaw(Parameter.POWER, 1234)
|
||||||
|
val restored = DeifController(backgroundScope, {})
|
||||||
|
restored.restore(saved)
|
||||||
|
assertEquals(controller.state.value.config, restored.state.value.config)
|
||||||
|
assertEquals(11, restored.state.value.displays[1].node)
|
||||||
|
assertEquals(1234, restored.state.value.raw[Parameter.POWER])
|
||||||
|
assertFalse(restored.state.value.running); assertFalse(restored.state.value.unlocked)
|
||||||
|
restored.receive(frame(0x30B, "8B 21 34 02 D2 04 00 00"))
|
||||||
|
assertEquals(1234.0, restored.value(1, Parameter.POWER)!!, 0.001)
|
||||||
|
}
|
||||||
|
}
|
||||||
722
c/set-protocol/tests/fixtures/deif-setgui.json
vendored
Normal file
722
c/set-protocol/tests/fixtures/deif-setgui.json
vendored
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 513,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"010130027cfc0000",
|
||||||
|
"01813002d2040000",
|
||||||
|
"01913002e8030000",
|
||||||
|
"01213402d2040000",
|
||||||
|
"01313402f4010000",
|
||||||
|
"010130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 513,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"0101300200800000",
|
||||||
|
"01813002ffff0000",
|
||||||
|
"0191300200000000",
|
||||||
|
"01213402fe7f0000",
|
||||||
|
"01313402ff7f0000",
|
||||||
|
"0101300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"010130027cfc0000",
|
||||||
|
"01813002d2040000",
|
||||||
|
"01913002e8030000",
|
||||||
|
"01213402d2040000",
|
||||||
|
"01313402f4010000",
|
||||||
|
"010130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"0101300200800000",
|
||||||
|
"01813002ffff0000",
|
||||||
|
"0191300200000000",
|
||||||
|
"01213402fe7f0000",
|
||||||
|
"01313402ff7f0000",
|
||||||
|
"0101300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 523,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"0b0130027cfc0000",
|
||||||
|
"0b813002d2040000",
|
||||||
|
"0b913002e8030000",
|
||||||
|
"0b213402d2040000",
|
||||||
|
"0b313402f4010000",
|
||||||
|
"0b0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 523,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"0b01300200800000",
|
||||||
|
"0b813002ffff0000",
|
||||||
|
"0b91300200000000",
|
||||||
|
"0b213402fe7f0000",
|
||||||
|
"0b313402ff7f0000",
|
||||||
|
"0b01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"0b0130027cfc0000",
|
||||||
|
"0b813002d2040000",
|
||||||
|
"0b913002e8030000",
|
||||||
|
"0b213402d2040000",
|
||||||
|
"0b313402f4010000",
|
||||||
|
"0b0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"0b01300200800000",
|
||||||
|
"0b813002ffff0000",
|
||||||
|
"0b91300200000000",
|
||||||
|
"0b213402fe7f0000",
|
||||||
|
"0b313402ff7f0000",
|
||||||
|
"0b01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"7f0130027cfc0000",
|
||||||
|
"7f813002d2040000",
|
||||||
|
"7f913002e8030000",
|
||||||
|
"7f213402d2040000",
|
||||||
|
"7f313402f4010000",
|
||||||
|
"7f0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"7f01300200800000",
|
||||||
|
"7f813002ffff0000",
|
||||||
|
"7f91300200000000",
|
||||||
|
"7f213402fe7f0000",
|
||||||
|
"7f313402ff7f0000",
|
||||||
|
"7f01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"7f0130027cfc0000",
|
||||||
|
"7f813002d2040000",
|
||||||
|
"7f913002e8030000",
|
||||||
|
"7f213402d2040000",
|
||||||
|
"7f313402f4010000",
|
||||||
|
"7f0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "SAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"7f01300200800000",
|
||||||
|
"7f813002ffff0000",
|
||||||
|
"7f91300200000000",
|
||||||
|
"7f213402fe7f0000",
|
||||||
|
"7f313402ff7f0000",
|
||||||
|
"7f01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 513,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"810130027cfc0000",
|
||||||
|
"81813002d2040000",
|
||||||
|
"81913002e8030000",
|
||||||
|
"81213402d2040000",
|
||||||
|
"81313402f4010000",
|
||||||
|
"810130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 513,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8101300200800000",
|
||||||
|
"81813002ffff0000",
|
||||||
|
"8191300200000000",
|
||||||
|
"81213402fe7f0000",
|
||||||
|
"81313402ff7f0000",
|
||||||
|
"8101300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 769,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"810130027cfc0000",
|
||||||
|
"81813002d2040000",
|
||||||
|
"81913002e8030000",
|
||||||
|
"81213402d2040000",
|
||||||
|
"81313402f4010000",
|
||||||
|
"810130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 769,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8101300200800000",
|
||||||
|
"81813002ffff0000",
|
||||||
|
"8191300200000000",
|
||||||
|
"81213402fe7f0000",
|
||||||
|
"81313402ff7f0000",
|
||||||
|
"8101300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 1025,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"810130027cfc0000",
|
||||||
|
"81813002d2040000",
|
||||||
|
"81913002e8030000",
|
||||||
|
"81213402d2040000",
|
||||||
|
"81313402f4010000",
|
||||||
|
"810130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 1025,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8101300200800000",
|
||||||
|
"81813002ffff0000",
|
||||||
|
"8191300200000000",
|
||||||
|
"81213402fe7f0000",
|
||||||
|
"81313402ff7f0000",
|
||||||
|
"8101300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 1281,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"810130027cfc0000",
|
||||||
|
"81813002d2040000",
|
||||||
|
"81913002e8030000",
|
||||||
|
"81213402d2040000",
|
||||||
|
"81313402f4010000",
|
||||||
|
"810130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 1,
|
||||||
|
"canId": 1281,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8101300200800000",
|
||||||
|
"81813002ffff0000",
|
||||||
|
"8191300200000000",
|
||||||
|
"81213402fe7f0000",
|
||||||
|
"81313402ff7f0000",
|
||||||
|
"8101300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 523,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8b0130027cfc0000",
|
||||||
|
"8b813002d2040000",
|
||||||
|
"8b913002e8030000",
|
||||||
|
"8b213402d2040000",
|
||||||
|
"8b313402f4010000",
|
||||||
|
"8b0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 523,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8b01300200800000",
|
||||||
|
"8b813002ffff0000",
|
||||||
|
"8b91300200000000",
|
||||||
|
"8b213402fe7f0000",
|
||||||
|
"8b313402ff7f0000",
|
||||||
|
"8b01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 779,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8b0130027cfc0000",
|
||||||
|
"8b813002d2040000",
|
||||||
|
"8b913002e8030000",
|
||||||
|
"8b213402d2040000",
|
||||||
|
"8b313402f4010000",
|
||||||
|
"8b0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 779,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8b01300200800000",
|
||||||
|
"8b813002ffff0000",
|
||||||
|
"8b91300200000000",
|
||||||
|
"8b213402fe7f0000",
|
||||||
|
"8b313402ff7f0000",
|
||||||
|
"8b01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 1035,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8b0130027cfc0000",
|
||||||
|
"8b813002d2040000",
|
||||||
|
"8b913002e8030000",
|
||||||
|
"8b213402d2040000",
|
||||||
|
"8b313402f4010000",
|
||||||
|
"8b0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 1035,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8b01300200800000",
|
||||||
|
"8b813002ffff0000",
|
||||||
|
"8b91300200000000",
|
||||||
|
"8b213402fe7f0000",
|
||||||
|
"8b313402ff7f0000",
|
||||||
|
"8b01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 1291,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8b0130027cfc0000",
|
||||||
|
"8b813002d2040000",
|
||||||
|
"8b913002e8030000",
|
||||||
|
"8b213402d2040000",
|
||||||
|
"8b313402f4010000",
|
||||||
|
"8b0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 11,
|
||||||
|
"canId": 1291,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"8b01300200800000",
|
||||||
|
"8b813002ffff0000",
|
||||||
|
"8b91300200000000",
|
||||||
|
"8b213402fe7f0000",
|
||||||
|
"8b313402ff7f0000",
|
||||||
|
"8b01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"ff0130027cfc0000",
|
||||||
|
"ff813002d2040000",
|
||||||
|
"ff913002e8030000",
|
||||||
|
"ff213402d2040000",
|
||||||
|
"ff313402f4010000",
|
||||||
|
"ff0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 639,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"ff01300200800000",
|
||||||
|
"ff813002ffff0000",
|
||||||
|
"ff91300200000000",
|
||||||
|
"ff213402fe7f0000",
|
||||||
|
"ff313402ff7f0000",
|
||||||
|
"ff01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 895,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"ff0130027cfc0000",
|
||||||
|
"ff813002d2040000",
|
||||||
|
"ff913002e8030000",
|
||||||
|
"ff213402d2040000",
|
||||||
|
"ff313402f4010000",
|
||||||
|
"ff0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 895,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"ff01300200800000",
|
||||||
|
"ff813002ffff0000",
|
||||||
|
"ff91300200000000",
|
||||||
|
"ff213402fe7f0000",
|
||||||
|
"ff313402ff7f0000",
|
||||||
|
"ff01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 1151,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"ff0130027cfc0000",
|
||||||
|
"ff813002d2040000",
|
||||||
|
"ff913002e8030000",
|
||||||
|
"ff213402d2040000",
|
||||||
|
"ff313402f4010000",
|
||||||
|
"ff0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 1151,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"ff01300200800000",
|
||||||
|
"ff813002ffff0000",
|
||||||
|
"ff91300200000000",
|
||||||
|
"ff213402fe7f0000",
|
||||||
|
"ff313402ff7f0000",
|
||||||
|
"ff01300700800000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 1407,
|
||||||
|
"raw": [
|
||||||
|
-900,
|
||||||
|
1234,
|
||||||
|
1000,
|
||||||
|
1234,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"ff0130027cfc0000",
|
||||||
|
"ff813002d2040000",
|
||||||
|
"ff913002e8030000",
|
||||||
|
"ff213402d2040000",
|
||||||
|
"ff313402f4010000",
|
||||||
|
"ff0130077cfc0000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "DAM",
|
||||||
|
"node": 127,
|
||||||
|
"canId": 1407,
|
||||||
|
"raw": [
|
||||||
|
-32768,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
32766,
|
||||||
|
32767
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
"ff01300200800000",
|
||||||
|
"ff813002ffff0000",
|
||||||
|
"ff91300200000000",
|
||||||
|
"ff213402fe7f0000",
|
||||||
|
"ff313402ff7f0000",
|
||||||
|
"ff01300700800000"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
13
firmware-version-porting.md
Normal file
13
firmware-version-porting.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Перенос контроля версии прошивки
|
||||||
|
|
||||||
|
В templates используются общий модуль `c/firmware-info` и общий публикатор `tools/firmware-publish`. При переносе подключаются конфигурация контроллера, генерация build ID, обработчик запроса версии и настройки выпуска.
|
||||||
|
|
||||||
|
- [Пошаговое подключение модуля версии](c/firmware-info/PORTING.md).
|
||||||
|
- [BAT-файлы precompile и postcompile для CCS и Keil](tools/firmware-build/README.md).
|
||||||
|
- [Настройка подготовки и публикации образов](tools/firmware-publish/PORTING.md).
|
||||||
|
|
||||||
|
В режиме CCS `--identity` precompile создаёт заголовок версии и Git-метаданные, а postcompile преобразует OUT в SCI8 HEX/BIN и создаёт manifest и SHA-256. В прежнем режиме postcompile запускает SETGUI preflight готового образа. Для каждого проекта задаются явные пути; алгоритмы остаются в templates.
|
||||||
|
|
||||||
|
[Копирование BIN, manifest и SHA-256 в каталог SETGUI после postcompile](tools/firmware-build/README.md#4-копирование-результата-в-каталог-setgui).
|
||||||
|
|
||||||
|
Для C2000 с 16-битным char требуется отдельная адаптация байтового контракта. BAT-файлы сами по себе эту адаптацию не выполняют.
|
||||||
@@ -73,7 +73,6 @@ class TmsTarget:
|
|||||||
kind: str = "tms"
|
kind: str = "tms"
|
||||||
board: int = 0
|
board: int = 0
|
||||||
load_only: bool = False
|
load_only: bool = False
|
||||||
verify: bool = True
|
|
||||||
|
|
||||||
def validate(self, size: int) -> None:
|
def validate(self, size: int) -> None:
|
||||||
if not 0 <= self.controller <= 255:
|
if not 0 <= self.controller <= 255:
|
||||||
@@ -117,8 +116,6 @@ def programming_steps(data: bytes, target: TmsTarget):
|
|||||||
raise ValueError(f"Проверка EEPROM не пройдена: код {error}, обработано {words} слов")
|
raise ValueError(f"Проверка EEPROM не пройдена: код {error}, обработано {words} слов")
|
||||||
return
|
return
|
||||||
yield tflash(t.controller, t.ram, t.flash, len(data)), 6, 180000, "CMD_TFLASH: запись Flash", 85
|
yield tflash(t.controller, t.ram, t.flash, len(data)), 6, 180000, "CMD_TFLASH: запись Flash", 85
|
||||||
if not t.verify:
|
|
||||||
return
|
|
||||||
for offset in range(0, len(data), 256):
|
for offset in range(0, len(data), 256):
|
||||||
block = data[offset:offset + 256]
|
block = data[offset:offset + 256]
|
||||||
count = (len(block) + 1) & ~1
|
count = (len(block) + 1) & ~1
|
||||||
|
|||||||
@@ -11,15 +11,12 @@ from protocan import tms_firmware as wire
|
|||||||
class TmsBootloader(QObject):
|
class TmsBootloader(QObject):
|
||||||
progress = Signal(int, str)
|
progress = Signal(int, str)
|
||||||
finished = Signal(bool, str)
|
finished = Signal(bool, str)
|
||||||
request_sent = Signal(bytes)
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._serial = QSerialPort(self)
|
self._serial = QSerialPort(self)
|
||||||
self._serial.readyRead.connect(self._read)
|
self._serial.readyRead.connect(self._read)
|
||||||
self._serial.errorOccurred.connect(self._error)
|
self._serial.errorOccurred.connect(self._error)
|
||||||
self._owned_serial = self._serial
|
|
||||||
self._owns_serial = True
|
|
||||||
self._timeout = QTimer(self)
|
self._timeout = QTimer(self)
|
||||||
self._timeout.setSingleShot(True)
|
self._timeout.setSingleShot(True)
|
||||||
self._timeout.timeout.connect(lambda: self._finish(False, f"Тайм-аут {self._stage}"))
|
self._timeout.timeout.connect(lambda: self._finish(False, f"Тайм-аут {self._stage}"))
|
||||||
@@ -34,7 +31,6 @@ class TmsBootloader(QObject):
|
|||||||
self._active = False
|
self._active = False
|
||||||
self._waiting = False
|
self._waiting = False
|
||||||
self._rx = bytearray()
|
self._rx = bytearray()
|
||||||
self.readback = bytearray()
|
|
||||||
self._stage = ""
|
self._stage = ""
|
||||||
|
|
||||||
def start(self, image, port: str, baud: int, target: wire.TmsTarget):
|
def start(self, image, port: str, baud: int, target: wire.TmsTarget):
|
||||||
@@ -49,8 +45,6 @@ class TmsBootloader(QObject):
|
|||||||
return
|
return
|
||||||
self._image, self._target = image, target
|
self._image, self._target = image, target
|
||||||
self._active = True
|
self._active = True
|
||||||
self._serial = self._owned_serial
|
|
||||||
self._owns_serial = True
|
|
||||||
self._serial.setPortName(port)
|
self._serial.setPortName(port)
|
||||||
self._serial.setBaudRate(baud)
|
self._serial.setBaudRate(baud)
|
||||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||||
@@ -60,29 +54,8 @@ class TmsBootloader(QObject):
|
|||||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||||
self._finish(False, self._serial.errorString())
|
self._finish(False, self._serial.errorString())
|
||||||
return
|
return
|
||||||
self._begin()
|
|
||||||
|
|
||||||
def start_connected(self, image, serial, target: wire.TmsTarget):
|
|
||||||
"""Use an existing port; its owner must forward RX and handle errors."""
|
|
||||||
if self._active:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
target.validate(len(image.data))
|
|
||||||
if not serial.isOpen() or serial.baudRate() <= 0:
|
|
||||||
raise ValueError("Подключите COM-порт в настройках 2812")
|
|
||||||
except ValueError as error:
|
|
||||||
self.finished.emit(False, str(error))
|
|
||||||
return
|
|
||||||
self._image, self._target = image, target
|
|
||||||
self._serial = serial
|
|
||||||
self._owns_serial = False
|
|
||||||
self._active = True
|
|
||||||
self._begin()
|
|
||||||
|
|
||||||
def _begin(self):
|
|
||||||
self.readback.clear()
|
|
||||||
self._response = None
|
self._response = None
|
||||||
self._steps = wire.programming_steps(self._image.data, self._target)
|
self._steps = wire.programming_steps(image.data, target)
|
||||||
self._advance()
|
self._advance()
|
||||||
|
|
||||||
def _advance(self):
|
def _advance(self):
|
||||||
@@ -93,7 +66,6 @@ class TmsBootloader(QObject):
|
|||||||
except StopIteration:
|
except StopIteration:
|
||||||
self._finish(True, "Образ загружен в RAM TMS" if self._target.load_only
|
self._finish(True, "Образ загружен в RAM TMS" if self._target.load_only
|
||||||
else "Запись Spartan-6 подтверждена платой" if self._target.kind == "spartan6"
|
else "Запись Spartan-6 подтверждена платой" if self._target.kind == "spartan6"
|
||||||
else "Прошивка записана; проверка отключена" if not self._target.verify
|
|
||||||
else "Прошивка завершена, записанные данные проверены")
|
else "Прошивка завершена, записанные данные проверены")
|
||||||
return
|
return
|
||||||
except (ValueError, RuntimeError) as error:
|
except (ValueError, RuntimeError) as error:
|
||||||
@@ -107,15 +79,11 @@ class TmsBootloader(QObject):
|
|||||||
if self._serial.write(request) != len(request):
|
if self._serial.write(request) != len(request):
|
||||||
self._finish(False, "Ошибка передачи " + self._stage)
|
self._finish(False, "Ошибка передачи " + self._stage)
|
||||||
return
|
return
|
||||||
self.request_sent.emit(request)
|
|
||||||
if self._active:
|
if self._active:
|
||||||
self._timeout.start(timeout + int((len(request) + self._size) * 10000 / self._serial.baudRate()))
|
self._timeout.start(timeout + int(len(request) * 10000 / self._serial.baudRate()))
|
||||||
|
|
||||||
def _read(self):
|
def _read(self):
|
||||||
self.receive(bytes(self._serial.readAll()))
|
chunk = bytes(self._serial.readAll())
|
||||||
|
|
||||||
def receive(self, chunk):
|
|
||||||
"""Forward fragments from a shared port without reading it twice."""
|
|
||||||
if not self._waiting:
|
if not self._waiting:
|
||||||
return
|
return
|
||||||
self._rx.extend(chunk)
|
self._rx.extend(chunk)
|
||||||
@@ -148,10 +116,6 @@ class TmsBootloader(QObject):
|
|||||||
self._timeout.stop()
|
self._timeout.stop()
|
||||||
self._settle.stop()
|
self._settle.stop()
|
||||||
self._waiting = False
|
self._waiting = False
|
||||||
if self._command == wire.CMD_UPLOAD and self._target.kind == "tms":
|
|
||||||
data = wire.decode_upload_reply(reply, self._target.controller, self._size - 8)
|
|
||||||
remaining = len(self._image.data) - len(self.readback)
|
|
||||||
self.readback.extend(data[:remaining])
|
|
||||||
self._response = reply
|
self._response = reply
|
||||||
self._delay.start()
|
self._delay.start()
|
||||||
|
|
||||||
@@ -169,10 +133,8 @@ class TmsBootloader(QObject):
|
|||||||
self._active = self._waiting = False
|
self._active = self._waiting = False
|
||||||
for timer in (self._timeout, self._settle, self._delay):
|
for timer in (self._timeout, self._settle, self._delay):
|
||||||
timer.stop()
|
timer.stop()
|
||||||
if self._owns_serial and self._serial.isOpen():
|
if self._serial.isOpen():
|
||||||
self._serial.close()
|
self._serial.close()
|
||||||
if not success:
|
|
||||||
self.readback.clear()
|
|
||||||
if success:
|
if success:
|
||||||
self.progress.emit(100, message)
|
self.progress.emit(100, message)
|
||||||
self.finished.emit(success, message)
|
self.finished.emit(success, message)
|
||||||
|
|||||||
171
tools/firmware-build/README.md
Normal file
171
tools/firmware-build/README.md
Normal file
File diff suppressed because one or more lines are too long
5
tools/firmware-build/ccs/firmware_identity.example.json
Normal file
5
tools/firmware-build/ccs/firmware_identity.example.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"product": "MY_CONTROLLER",
|
||||||
|
"target": "TMS320F28335",
|
||||||
|
"version": [0, 1, 0]
|
||||||
|
}
|
||||||
29
tools/firmware-build/ccs/postcompile.bat
Normal file
29
tools/firmware-build/ccs/postcompile.bat
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
if /i "%~1"=="--identity" goto identity
|
||||||
|
rem Legacy mode: SETGUI release preflight.
|
||||||
|
if "%~1"=="" goto usage
|
||||||
|
if not "%~2"=="" goto usage
|
||||||
|
if not exist "%~1" (
|
||||||
|
echo [firmware-build] Release config not found: "%~1" 1>&2
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
call "%~dp0..\..\firmware-publish\PUBLISH_FIRMWARE.bat" --config "%~f1" --preflight
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:identity
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if "%~3"=="" goto usage
|
||||||
|
if "%~4"=="" goto usage
|
||||||
|
if "%~5"=="" goto usage
|
||||||
|
if "%~6"=="" goto usage
|
||||||
|
if not "%~8"=="" goto usage
|
||||||
|
if "%~7"=="" goto identity_local
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\c2000_postbuild.py" --compiler "%~f2" --build-dir "%~f3" --artifact "%~4" --metadata "%~f5" --output "%~f6" --export "%~f7"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:identity_local
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\c2000_postbuild.py" --compiler "%~f2" --build-dir "%~f3" --artifact "%~4" --metadata "%~f5" --output "%~f6"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:usage
|
||||||
|
echo Usage: postcompile.bat --identity "compiler-root" "build-dir" "artifact-name" "generated-metadata.json" "output-dir" ["export-dir"] 1>&2
|
||||||
|
echo Legacy: postcompile.bat "firmware-release.cmd" 1>&2
|
||||||
|
exit /b 2
|
||||||
31
tools/firmware-build/ccs/precompile.bat
Normal file
31
tools/firmware-build/ccs/precompile.bat
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
if /i "%~1"=="--identity" goto identity
|
||||||
|
rem Legacy mode: build ID header only.
|
||||||
|
if "%~1"=="" goto usage
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if not "%~3"=="" goto usage
|
||||||
|
if not exist "%~1\." (
|
||||||
|
echo [firmware-build] Project directory not found: "%~1" 1>&2
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\..\..\c\firmware-info\tools\make_build_id.ps1" -Repository "%~f1" -Output "%~f2"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:identity
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if "%~3"=="" goto usage
|
||||||
|
if "%~4"=="" goto usage
|
||||||
|
if not "%~5"=="" goto usage
|
||||||
|
rem Create the editable project configuration once; never overwrite user settings.
|
||||||
|
if not exist "%~f3" (
|
||||||
|
copy /Y "%~dp0firmware_identity.example.json" "%~f3" >nul
|
||||||
|
if errorlevel 1 exit /b 2
|
||||||
|
echo [firmware-build] Created configuration: "%~f3"
|
||||||
|
echo [firmware-build] Edit product, target and version in this file, then rebuild.
|
||||||
|
)
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" prepare --repository "%~f2" --templates "%~dp0..\..\.." --config "%~f3" --output "%~f4"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:usage
|
||||||
|
echo Usage: precompile.bat --identity "project-root" "firmware_identity.json" "generated-dir" 1>&2
|
||||||
|
echo Legacy: precompile.bat "project-root" "output-header" 1>&2
|
||||||
|
exit /b 2
|
||||||
5
tools/firmware-build/keil/firmware_identity.example.json
Normal file
5
tools/firmware-build/keil/firmware_identity.example.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"product": "MY_CONTROLLER",
|
||||||
|
"target": "STM32F407",
|
||||||
|
"version": [0, 1, 0]
|
||||||
|
}
|
||||||
38
tools/firmware-build/keil/postcompile.bat
Normal file
38
tools/firmware-build/keil/postcompile.bat
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
if /i "%~1"=="--identity" goto identity
|
||||||
|
rem Legacy mode retained for existing SETGUI publication configurations.
|
||||||
|
if "%~1"=="" goto usage
|
||||||
|
if not "%~2"=="" goto usage
|
||||||
|
if not exist "%~1" (
|
||||||
|
echo [firmware-build] Release config not found: "%~1" 1>&2
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
call "%~dp0..\..\firmware-publish\PUBLISH_FIRMWARE.bat" --config "%~f1" --preflight
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:identity
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if "%~3"=="" goto usage
|
||||||
|
if not "%~5"=="" goto usage
|
||||||
|
if /i not "%~x2"==".hex" if /i not "%~x2"==".bin" (
|
||||||
|
echo [firmware-build] Expected a ready HEX or BIN image: "%~2" 1>&2
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" seal --image "%~f2" --metadata "%~f3"
|
||||||
|
if errorlevel 1 exit /b %errorlevel%
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" verify --image "%~f2" --manifest "%~f2.manifest.json"
|
||||||
|
if errorlevel 1 exit /b %errorlevel%
|
||||||
|
if "%~4"=="" goto archive_local
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" archive --image "%~f2" --metadata "%~f3" --export "%~f4"
|
||||||
|
goto archive_done
|
||||||
|
:archive_local
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" archive --image "%~f2" --metadata "%~f3"
|
||||||
|
:archive_done
|
||||||
|
if errorlevel 1 exit /b %errorlevel%
|
||||||
|
echo [firmware-build] Image: "%~f2"
|
||||||
|
echo [firmware-build] Manifest: "%~f2.manifest.json"
|
||||||
|
echo [firmware-build] SHA-256: "%~f2.sha256"
|
||||||
|
exit /b 0
|
||||||
|
:usage
|
||||||
|
echo Usage: postcompile.bat --identity "image.hex-or-bin" "generated-metadata.json" ["export-dir"] 1>&2
|
||||||
|
exit /b 2
|
||||||
24
tools/firmware-build/keil/precompile.bat
Normal file
24
tools/firmware-build/keil/precompile.bat
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
if /i "%~1"=="--identity" goto identity
|
||||||
|
rem Legacy mode: build ID header only.
|
||||||
|
if "%~1"=="" goto usage
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if not "%~3"=="" goto usage
|
||||||
|
if not exist "%~1\." (
|
||||||
|
echo [firmware-build] Project directory not found: "%~1" 1>&2
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\..\..\c\firmware-info\tools\make_build_id.ps1" -Repository "%~f1" -Output "%~f2"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:identity
|
||||||
|
if "%~2"=="" goto usage
|
||||||
|
if "%~3"=="" goto usage
|
||||||
|
if "%~4"=="" goto usage
|
||||||
|
if not "%~5"=="" goto usage
|
||||||
|
python "%~dp0..\..\..\c\firmware-info\tools\identity.py" prepare --repository "%~f2" --templates "%~dp0..\..\.." --config "%~f3" --output "%~f4"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
:usage
|
||||||
|
echo Usage: precompile.bat --identity "project-root" "firmware_identity.json" "generated-dir" 1>&2
|
||||||
|
echo Legacy: precompile.bat "project-root" "output-header" 1>&2
|
||||||
|
exit /b 2
|
||||||
Reference in New Issue
Block a user