diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifController.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifController.kt new file mode 100644 index 0000000..2cca995 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifController.kt @@ -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.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.entries.associateWith { 0 }, + val controlScales: DeifControls.Scales = DeifControls.Scales(), + val unlocked: Boolean = false, val running: Boolean = false, + val fullScreen: Boolean = false, + val displays: List = List(2) { DeifDisplayConfig() }, + val readings: List> = List(2) { emptyMap() }, + val sent: Long = 0, val received: Long = 0, val errors: Long = 0, + val echoes: Long = 0, val echoTimes: Map = 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()) + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifControls.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifControls.kt new file mode 100644 index 0000000..b178f89 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifControls.kt @@ -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 { + 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, scales: Scales): Map = 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, + ) +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifFullScreen.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifFullScreen.kt new file mode 100644 index 0000000..33a89c5 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifFullScreen.kt @@ -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 "Передача выключена") + } + } + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifInstrument.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifInstrument.kt new file mode 100644 index 0000000..55f7ada --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifInstrument.kt @@ -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, preview: Map, + 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() + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifPanel.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifPanel.kt new file mode 100644 index 0000000..a3eb557 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifPanel.kt @@ -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()) } + var showRaw by remember { mutableStateOf(false) } + var controlsDialog by remember { mutableStateOf(false) } + var configuring by remember { mutableStateOf(false) } + var displaySide by remember { mutableStateOf(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(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(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(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("Отмена") } }) + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifProtocol.kt new file mode 100644 index 0000000..553506f --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/deif/DeifProtocol.kt @@ -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.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): List { + 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" + } + } +} diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/deif/DeifControlsTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/deif/DeifControlsTest.kt new file mode 100644 index 0000000..b02d414 --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/deif/DeifControlsTest.kt @@ -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() + 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() + 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() + } +} diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/deif/DeifTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/deif/DeifTest.kt new file mode 100644 index 0000000..1afc16e --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/deif/DeifTest.kt @@ -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() + 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() + 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) + } +} diff --git a/c/set-protocol/tests/fixtures/deif-setgui.json b/c/set-protocol/tests/fixtures/deif-setgui.json new file mode 100644 index 0000000..d362ea0 --- /dev/null +++ b/c/set-protocol/tests/fixtures/deif-setgui.json @@ -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" + ] + } +]