Compare commits
1 Commits
main
...
deif-andro
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dfa8bd94d |
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user