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
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
@@ -36,12 +39,14 @@ def git_state(repository: Path) -> dict:
|
||||
def write_atomic(path: Path, text: str):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
temporary.write_text(text, encoding="utf-8", newline="\n")
|
||||
# Built-in open supports newline on Python versions before 3.10.
|
||||
with open(temporary, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(text)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def prepare(repository: Path, templates: Path, config: Path, output: Path) -> dict:
|
||||
settings = json.loads(config.read_text(encoding="utf-8"))
|
||||
settings = json.loads(config.read_text(encoding="utf-8-sig"))
|
||||
version = settings["version"]
|
||||
if (len(version) != 3 or any(type(v) is not int for v in version)
|
||||
or not all(0 <= v <= limit for v, limit in zip(version, (255, 255, 999)))):
|
||||
@@ -102,6 +107,73 @@ def verify(image: Path, manifest: Path) -> dict:
|
||||
return metadata
|
||||
|
||||
|
||||
def build_folder_name(metadata: dict) -> str:
|
||||
"""Use the firmware commit and pre-build time rendered in UTC+3."""
|
||||
source = metadata.get("source") or {}
|
||||
dependency = metadata.get("templates") or {}
|
||||
commit = source.get("commit")
|
||||
if commit is not None:
|
||||
if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-fA-F]{40,64}", commit):
|
||||
raise ValueError("invalid firmware commit in pre-build metadata")
|
||||
identity = commit[:8].lower()
|
||||
if source.get("dirty") or dependency.get("dirty"):
|
||||
identity += "+"
|
||||
else:
|
||||
identity = metadata.get("build_id", "NOGIT000")
|
||||
if not isinstance(identity, str) or not re.fullmatch(r"[0-9a-fA-F]{8}|[0-9a-fA-F]{7}\+|NOGIT000", identity):
|
||||
raise ValueError("invalid build ID in pre-build metadata")
|
||||
stamp_text = metadata.get("built_at_utc")
|
||||
if not isinstance(stamp_text, str):
|
||||
raise ValueError("missing pre-build UTC timestamp")
|
||||
stamp = datetime.fromisoformat(stamp_text.replace("Z", "+00:00"))
|
||||
if stamp.tzinfo is None:
|
||||
raise ValueError("pre-build timestamp must include a timezone")
|
||||
stamp = stamp.astimezone(timezone(timedelta(hours=3)))
|
||||
return identity + "_" + stamp.strftime("%Y-%m-%d_%H-%M-%S_UTC+3")
|
||||
|
||||
|
||||
def export_build(folder: Path, export: Path, image_name: str) -> Path:
|
||||
"""Copy the current complete build folder, without scanning old archives."""
|
||||
folder, export = folder.resolve(), export.resolve()
|
||||
if export == folder or folder in export.parents:
|
||||
raise ValueError("export directory must be outside the current build folder")
|
||||
destination = export / folder.name
|
||||
verify(folder / image_name, folder / (image_name + ".manifest.json"))
|
||||
export.mkdir(parents=True, exist_ok=True)
|
||||
if destination != folder:
|
||||
shutil.copytree(folder, destination, dirs_exist_ok=True)
|
||||
verify(destination / image_name, destination / (image_name + ".manifest.json"))
|
||||
print(f"[firmware-build] Exported folder: {destination}")
|
||||
return destination
|
||||
|
||||
def archive_build(image: Path, metadata_path: Path, output: Path = None,
|
||||
extra_files=(), export: Path = None) -> dict:
|
||||
"""Keep a byte-identical build snapshot, alongside the existing flat output."""
|
||||
image, metadata_path = image.resolve(), metadata_path.resolve()
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
name = build_folder_name(metadata)
|
||||
output = (output if output is not None else image.parent / "firmware").resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
destination = output / name
|
||||
# Seal and verify staged copies before publishing files in the build folder.
|
||||
with tempfile.TemporaryDirectory(prefix=".archive-", dir=output) as temporary:
|
||||
stage = Path(temporary)
|
||||
shutil.copy2(image, stage / image.name)
|
||||
for file in extra_files:
|
||||
file = Path(file)
|
||||
shutil.copy2(file, stage / file.name)
|
||||
shutil.copy2(metadata_path, stage / "firmware_identity.json")
|
||||
seal(stage / image.name, stage / "firmware_identity.json")
|
||||
verify(stage / image.name, stage / (image.name + ".manifest.json"))
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for file in stage.iterdir():
|
||||
file.replace(destination / file.name)
|
||||
print(f"[firmware-build] Folder: {destination}")
|
||||
result = {"directory": str(destination), "file": image.name}
|
||||
if export is not None:
|
||||
result["exported_directory"] = str(export_build(destination, export, image.name))
|
||||
return result
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
@@ -114,9 +186,14 @@ def main():
|
||||
p = sub.add_parser("verify")
|
||||
p.add_argument("--image", type=Path, required=True)
|
||||
p.add_argument("--manifest", type=Path, required=True)
|
||||
p = sub.add_parser("archive")
|
||||
p.add_argument("--image", type=Path, required=True)
|
||||
p.add_argument("--metadata", dest="metadata_path", type=Path, required=True)
|
||||
p.add_argument("--output", type=Path)
|
||||
p.add_argument("--export", type=Path)
|
||||
args = vars(parser.parse_args())
|
||||
command = args.pop("command")
|
||||
result = {"prepare": prepare, "seal": seal, "verify": verify}[command](**args)
|
||||
result = {"prepare": prepare, "seal": seal, "verify": verify, "archive": archive_build}[command](**args)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
|
||||
|
||||
@@ -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-файлы сами по себе эту адаптацию не выполняют.
|
||||
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