Добавить общий API старого CAN terminal

This commit is contained in:
2026-09-04 18:19:07 +03:00
parent d9eb7dd9ad
commit 3c4ac9963d
20 changed files with 941 additions and 1 deletions

View File

@@ -12,6 +12,7 @@ LOCAL_SRC_FILES := \
set_plot_jni.c \
../../src/set_trends.c \
../../src/set_spectrum.c \
../../src/balsam_can.c \
../../src/gui_catalog.c \
../../src/gui_frame.c \
../../src/pcan_abi.c \

View File

@@ -14,6 +14,11 @@ workspace, up to 2×16384 doubles). `trends/SpectrumAnalyzer` maps timestamps an
errors but does not duplicate FFT/filter math. Run it off the UI thread.
`trends/PlotViewport` is a toolkit-free normalized zoom/pan model.
`legacycan/LegacyCanTerminal.kt` contains the two historical CAN_terminal wire
formats, the complete Projects.ini node/command catalog, and shared codecs for
register writes and command frames. Android UI code must use this module instead
of reproducing the Delphi byte rotation or CAN-ID routing rules.
`kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt` is a UI-independent
firmware release client. It reads the optional `firmware.releases` array from
the shared `update.json`, accepts only HTTPS assets, limits their size and

View File

@@ -29,6 +29,9 @@ object NativeSetProtocol {
): Long
external fun nativeUnpackId(raw: Long): IntArray?
external fun nativeCrc16(input: ByteArray): Int
external fun nativeBalsamDecode(canId: Long, input: ByteArray): IntArray?
external fun nativeBalsamDeviceName(device: Int): String
external fun nativeBalsamRegisterName(device: Int, address: Int): String
external fun nativeEncodeFrame(
sequence: Int,
flags: Int,

View File

@@ -0,0 +1,116 @@
package ru.setcorp.setprotocol.balsam
import ru.setcorp.setprotocol.NativeSetProtocol
data class BalsamRegister(val address: Int, val value: Int, val name: String) {
val displayName: String get() = name.ifBlank { "R%04X".format(address) }
val signedValue: Int get() = if (value < 0x8000) value else value - 0x10000
}
data class BalsamFrame(
val canId: Long,
val device: Int,
val deviceName: String,
val fromDevice: Boolean,
val startAddress: Int,
val presentMask: Int,
val registers: List<BalsamRegister>,
) {
fun summary(): String {
val direction = if (fromDevice) "данные" else "команда"
val values = registers.joinToString { "${it.displayName}=0x%04X (%d)".format(it.value, it.signedValue) }
.ifEmpty { "нет отмеченных регистров" }
return "BALZAM · $deviceName · $direction · $values"
}
}
/** Shared parser for Balsam_167_periph eCAN frames. */
object BalsamCanProtocol {
const val BASE_ID = 0x00BA_0000L
const val TERMINAL_REQUEST_ID = 0x00BA_001CL
const val TERMINAL_RESPONSE_ID = 0x00BA_000CL
const val PULT_REQUEST_ID = 0x0074_5019L
const val PULT_RESPONSE_ID = 0x0074_5009L
fun isLegacyId(canId: Long): Boolean {
val relative = (canId and 0x1FFF_FFFFL) - BASE_ID
return relative in 0L..12L || relative in 0x10L..0x1CL ||
canId == PULT_REQUEST_ID || canId == PULT_RESPONSE_ID
}
fun decode(canId: Long, data: ByteArray): BalsamFrame? {
if (!isRegisterId(canId) || data.size != 8) return null
val native = if (NativeSetProtocol.available) {
NativeSetProtocol.nativeBalsamDecode(canId, data)
} else null
val words = native ?: fallbackDecode(canId, data)
val device = words[0]
val mask = words[2]
val start = words[3]
val registers = (0..2).filter { mask and (4 shr it) != 0 }.map { index ->
val address = start + index
BalsamRegister(address, words[4 + index], registerName(device, address))
}
return BalsamFrame(
canId and 0x1FFF_FFFFL,
device,
deviceName(device),
words[1] == 1,
start,
mask,
registers,
)
}
fun summary(canId: Long, data: ByteArray? = null): String =
data?.let { decode(canId, it)?.summary() } ?: when (canId) {
TERMINAL_REQUEST_ID -> "BALZAM legacy · запрос терминала"
TERMINAL_RESPONSE_ID -> "BALZAM legacy · ответ терминалу"
PULT_REQUEST_ID -> "BALZAM legacy · данные пульта"
PULT_RESPONSE_ID -> "BALZAM legacy · команда пульту"
in (BASE_ID + 0x10L)..(BASE_ID + 0x1BL) ->
"BALZAM legacy · данные · ${deviceName((canId - BASE_ID - 0x0FL).toInt())}"
in BASE_ID..(BASE_ID + 0x0BL) ->
"BALZAM legacy · команда · ${deviceName((canId - BASE_ID + 1L).toInt())}"
else -> "BALZAM legacy · неизвестный ID"
}
private fun isRegisterId(canId: Long): Boolean {
val relative = (canId and 0x1FFF_FFFFL) - BASE_ID
return relative in 0L..12L || relative in 0x10L..0x1CL
}
private fun fallbackDecode(canId: Long, data: ByteArray): IntArray {
val relative = (canId and 0x1FFF_FFFFL) - BASE_ID
val header = u16be(data, 0)
return intArrayOf(
((relative and 0x0F) + 1).toInt(),
if (relative >= 0x10) 1 else 0,
(header ushr 13) and 7,
header and 0x1FFF,
u16be(data, 2), u16be(data, 4), u16be(data, 6),
)
}
private fun u16be(data: ByteArray, offset: Int): Int =
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
private fun deviceName(device: Int): String = if (NativeSetProtocol.available) {
NativeSetProtocol.nativeBalsamDeviceName(device)
} else listOf(
"Трансформатор 1", "Трансформатор 2", "Силовой блок 1", "Силовой блок 2",
"УМП 1", "УМП 2", "Двигатель", "ВЭП", "Задатчик", "Узел 10", "Узел 11",
"Узел 12", "Терминал",
).getOrElse(device - 1) { "Неизвестный узел" }
private fun registerName(device: Int, address: Int): String =
if (NativeSetProtocol.available) NativeSetProtocol.nativeBalsamRegisterName(device, address)
else when {
device in 1..2 && address in 0x18..0x2B -> "Показания T° ${address - 0x17}"
device in 3..4 && address in 0x18..0x27 -> "Показания T° ${address - 0x17}"
device == 7 && address in 0x18..0x1F -> "Показания T° ${address - 0x17}"
address == 0x17 -> "Состояние джамперов"
address == 0x7F -> "Команды"
else -> ""
}
}

View File

@@ -0,0 +1,230 @@
package ru.setcorp.setprotocol.legacycan
/** Wire formats implemented by the historical CAN_terminal application. */
enum class LegacyCanFormat {
/** Address and a three-bit presence mask are carried in DATA[4..5]. */
ROTATING_THREE_WORDS,
/** Register address is carried in CAN ID[6:0], followed by up to four words. */
ADDRESS_IN_IDENTIFIER,
}
enum class LegacyCanSource { TO_DEVICE, FROM_DEVICE }
data class LegacyCanPacket(
val address: Int,
val mask: Int,
val values: List<Int>,
val source: LegacyCanSource,
) {
val presentValues: List<Pair<Int, Int>>
get() = values.mapIndexedNotNull { index, value ->
if (formatUses(index)) address + index to value else null
}
private fun formatUses(index: Int): Boolean = mask == 0xFF || mask and (4 shr index) != 0
}
data class LegacyCanWireFrame(val canId: Long, val data: ByteArray)
data class LegacyCanRegisterValue(
val address: Int,
val value: Int = 0,
val source: LegacyCanSource? = null,
val revision: Long = 0,
)
data class LegacyCanNode(
val index: Int,
val rsAddress: Int,
val canAddress: Int,
val rxId: Long,
val txId: Long,
val name: String,
)
data class LegacyCanProject(
val name: String,
val format: LegacyCanFormat,
val baseId: Long,
val idOffset: Long,
val nodes: List<LegacyCanNode>,
val commandNames: List<String>,
) {
fun nodeFor(canId: Long): LegacyCanNode? {
val normalized = LegacyCanTerminalProtocol.routingId(format, canId)
return nodes.firstOrNull { it.rxId == normalized || it.txId == normalized }
}
}
/**
* Shared, UI-independent codec for the two protocols found in CAN_terminal.pas.
* Values are unsigned 16-bit words; callers can interpret them as signed with
* [signedWord].
*/
object LegacyCanTerminalProtocol {
fun emptyRegisterBank(): List<LegacyCanRegisterValue> =
List(128) { LegacyCanRegisterValue(it) }
fun applyPacket(
bank: List<LegacyCanRegisterValue>,
packet: LegacyCanPacket,
revision: Long,
): List<LegacyCanRegisterValue> {
require(bank.size == 128) { "Банк должен содержать 128 регистров" }
val updates = packet.presentValues.toMap()
return bank.map { current ->
updates[current.address]?.let { value ->
current.copy(value = value, source = packet.source, revision = revision)
} ?: current
}
}
fun routingId(format: LegacyCanFormat, canId: Long): Long = when (format) {
LegacyCanFormat.ROTATING_THREE_WORDS -> canId and 0x1FFF_FFFFL
LegacyCanFormat.ADDRESS_IN_IDENTIFIER -> canId and 0x1FF0_0000L
}
fun decode(
format: LegacyCanFormat,
node: LegacyCanNode,
canId: Long,
data: ByteArray,
): LegacyCanPacket? {
val route = routingId(format, canId)
val source = when (route) {
node.txId -> LegacyCanSource.TO_DEVICE
node.rxId -> LegacyCanSource.FROM_DEVICE
else -> return null
}
return when (format) {
LegacyCanFormat.ROTATING_THREE_WORDS -> {
if (data.size != 8) return null
val mask = (u8(data[4]) ushr 5) and 7
val address = ((u8(data[4]) and 0x1F) shl 8) or u8(data[5])
LegacyCanPacket(address, mask, listOf(u16be(data, 6), u16be(data, 0), u16be(data, 2)), source)
}
LegacyCanFormat.ADDRESS_IN_IDENTIFIER -> {
if (data.isEmpty() || data.size > 8 || data.size % 2 != 0) return null
LegacyCanPacket(
address = (canId and 0x7F).toInt(),
mask = 0xFF,
values = data.indices.step(2).map { u16be(data, it) },
source = source,
)
}
}
}
fun encodeWrite(
format: LegacyCanFormat,
canId: Long,
address: Int,
values: List<Int>,
): LegacyCanWireFrame {
require(address in 0..127) { "Адрес регистра должен быть в диапазоне 0..127" }
val maximum = if (format == LegacyCanFormat.ADDRESS_IN_IDENTIFIER) 4 else 3
require(values.size in 1..maximum) { "Нужно от 1 до $maximum слов данных" }
values.forEach { require(it in 0..0xFFFF) { "Значение должно быть в диапазоне 0..65535" } }
return when (format) {
LegacyCanFormat.ADDRESS_IN_IDENTIFIER -> LegacyCanWireFrame(
(canId and 0x1FF0_0000L) + address,
values.flatMap { listOf((it ushr 8).toByte(), it.toByte()) }.toByteArray(),
)
LegacyCanFormat.ROTATING_THREE_WORDS -> {
val padded = values + List(3 - values.size) { 0 }
val mask = when (values.size) { 1 -> 4; 2 -> 6; else -> 7 }
val data = byteArrayOf(
(padded[1] ushr 8).toByte(), padded[1].toByte(),
(padded[2] ushr 8).toByte(), padded[2].toByte(),
((mask shl 5) or (address ushr 8)).toByte(), address.toByte(),
(padded[0] ushr 8).toByte(), padded[0].toByte(),
)
LegacyCanWireFrame(canId and 0x1FFF_FFFFL, data)
}
}
}
fun encodeCommand(project: LegacyCanProject, node: LegacyCanNode, commandIndex: Int): LegacyCanWireFrame {
require(commandIndex in 0..16) { "Номер команды должен быть в диапазоне 0..16" }
val value = if (commandIndex < 16) 1 shl commandIndex else 0
return encodeWrite(project.format, node.rxId, 127, listOf(value))
}
fun signedWord(value: Int): Int = if (value < 0x8000) value else value - 0x10000
private fun u8(value: Byte): Int = value.toInt() and 0xFF
private fun u16be(data: ByteArray, offset: Int): Int = (u8(data[offset]) shl 8) or u8(data[offset + 1])
}
/** Project table migrated from CAN_terminal/Projects.ini (Windows-1251). */
object LegacyCanProjects {
private val defaultCommands = listOf(
"Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw",
"-", "-", "-", "-", "-", "-", "Reset", "Nothing at all",
)
private fun project(
name: String,
baseId: Long = 0,
offset: Long = 0x10,
format: LegacyCanFormat = LegacyCanFormat.ROTATING_THREE_WORDS,
specs: List<List<Any>>,
commands: List<String> = defaultCommands,
): LegacyCanProject {
val shift = if (format == LegacyCanFormat.ADDRESS_IN_IDENTIFIER) 20 else 0
val actualOffset = if (format == LegacyCanFormat.ADDRESS_IN_IDENTIFIER) 1L shl 28 else offset
val nodes = specs.map { spec ->
val index = spec[0] as Int
val canAddress = spec[1] as Int
val rsAddress = spec[2] as Int
val nodeName = spec[3] as String
val routed = canAddress.toLong() shl shift
LegacyCanNode(index, rsAddress, canAddress, baseId + routed, baseId + actualOffset + routed, nodeName)
}
return LegacyCanProject(name, format, baseId, actualOffset, nodes, commands)
}
private fun s(index: Int, can: Int, rs: Int, name: String): List<Any> = listOf(index, can, rs, name)
val all: List<LegacyCanProject> = listOf(
project("Буксир", 0x0031_8200, specs = listOf(
s(0, 0, 1, "УКСС СБ"), s(1, 1, 2, "БКСС ГД"), s(2, 2, 3, "УКСВЭП"), s(3, 3, 4, "Задатчик"),
)),
project("СЭДБМ", 0x0105_1020, specs = listOf(
s(0,0,0,"УКСС СК1 СБ1"),s(1,1,1,"УКСС СК2 СБ1"),s(2,2,2,"УКСС СК3 СБ1"),s(3,3,3,"УКСС СК4 СБ1"),
s(4,4,4,"УКССВЭП СБ1"),s(5,5,5,"Задатчик СБ1"),s(6,6,6,"БТР ИТЭС"),s(8,0x20,8,"УКСС СК1 СБ2"),
s(9,0x21,9,"УКСС СК2 СБ2"),s(10,0x22,10,"УКСС СК3 СБ2"),s(11,0x23,11,"УКСС СК4 СБ2"),
s(12,0x24,12,"УКССВЭП СБ2"),s(13,0x25,13,"Задатчик СБ2"),s(14,0x26,14,"УКСС БОИН"),s(15,0x27,15,"УКСВЭП БОИН"),
), commands = defaultCommands.toMutableList().also { it[7]="Raw"; it[8]="HiVolt" }),
project("Ледокол", 0x001C_E020, -0x20, specs = listOf(
s(0,0,1,"УКСС БВ1 ПЧ1"),s(8,1,2,"УКСС БВ1 ПЧ2"),s(1,2,3,"УКСС БВ1 ПЧ1"),s(9,3,4,"УКСС БВ2 ПЧ2"),
s(2,4,5,"УКСС БИ1 ПЧ1"),s(10,5,6,"УКСС БИ1 ПЧ2"),s(3,6,7,"УКСС БИ2 ПЧ1"),s(11,7,8,"УКСС БИ2 ПЧ2"),
s(4,8,9,"УКССВЭП1 ПЧ1"),s(12,9,10,"УКССВЭП1 ПЧ2"),s(5,10,11,"УКССВЭП2 ПЧ1"),s(13,11,12,"УКССВЭП2 ПЧ2"),
), commands = defaultCommands.toMutableList().also { it[4]="Raw"; it[5]="Read"; it[6]="ExtLamp"; it[7]="ExtLite"; it[8]="No log" }),
project("Бальзам", 0x00BA_0000, specs = listOf(
s(0,0,1,"БКСС Тр1"),s(8,1,2,"БКСС Тр2"),s(1,2,3,"УКСС СБ1"),s(9,3,4,"УКСС СБ2"),
s(2,4,5,"УКСС УМП1"),s(10,5,6,"УКСС УМП2"),s(3,6,7,"БКСС ГД"),s(4,7,9,"Задатчик"),s(5,8,11,"УКСС ВЭП"),
), commands = defaultCommands.toMutableList().also { it[6]="Stop";it[7]="Start";it[8]="Init";it[9]="Tune";it[10]="Secret";it[11]="Light";it[12]="Raw" }),
project("23550", 0x0023_5500, specs = listOf(
s(0,0,1,"Задатчик"),s(1,1,2,"Выносной пульт"),s(2,2,3,"УКСВЭП"),s(3,3,4,"БКСС ГД"),
), commands = defaultCommands.toMutableList().also { it[5]="Read";it[7]="Send";it[8]="-" }),
project("23550.X", format = LegacyCanFormat.ADDRESS_IN_IDENTIFIER, specs = listOf(
s(0,0,1,"Задатчик"),s(1,1,2,"Выносной пульт"),s(2,2,3,"УКСВЭП"),s(3,3,4,"БКСС ГД"),
), commands = defaultCommands.toMutableList().also { it[5]="Read";it[7]="Send";it[8]="-" }),
project("23550.2", format = LegacyCanFormat.ADDRESS_IN_IDENTIFIER, specs = listOf(
s(0,0,1,"Задатчик"),s(1,1,2,"Выносной пульт"),s(2,2,3,"БКСС ГД"),s(3,4,4,"УКСС СИ СБ1"),
s(4,6,6,"УКСС СВФ СБ1"),s(5,8,8,"УКСВЭП СБ1"),s(11,5,5,"УКСС СИ СБ2"),s(12,7,7,"УКСС СВФ СБ2"),
s(13,9,9,"УКСВЭП СБ2"),s(16,0x1F,16,"BroadCast"),
), commands = defaultCommands.toMutableList().also { it[5]="Calc";it[7]="Send" }),
project("Янтарь", 0x0021_3000, specs = listOf(
s(0,0,1,"УКСС БВ"),s(1,1,2,"УКСС БИ1"),s(2,2,3,"УКСС БИ2"),s(3,3,4,"БКСС ГД"),
s(4,4,5,"УКСВЭП"),s(5,5,6,"Задатчик"),s(6,6,7,"Выносной пульт"),
)),
project(
"23550 БСУ", 0x0CEB_0F1, -0x10,
specs = listOf(s(0,0,0,"БСУ1"),s(1,1,1,"БСУ2")),
commands = List(16) { "-" } + "Nothing at all",
),
)
}

View File

@@ -6,6 +6,53 @@
#include "setprotocol_abi.h"
#include "set_trends.h"
#include "set_spectrum.h"
#include "balsam_can.h"
JNIEXPORT jintArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamDecode(
JNIEnv *env, jobject self, jlong can_id, jbyteArray input)
{
(void)self;
balsam_can_frame_t frame;
jsize size;
jbyte data[BALSAM_CAN_DLC];
jint values[7];
if (input == NULL) return NULL;
size = (*env)->GetArrayLength(env, input);
if (size != (jsize)BALSAM_CAN_DLC) return NULL;
(*env)->GetByteArrayRegion(env, input, 0, size, data);
if (balsam_can_decode((uint32_t)can_id, (const uint8_t *)data,
(size_t)size, &frame) != 1) return NULL;
values[0] = frame.device;
values[1] = frame.direction;
values[2] = frame.present_mask;
values[3] = frame.start_address;
values[4] = frame.values[0];
values[5] = frame.values[1];
values[6] = frame.values[2];
jintArray result = (*env)->NewIntArray(env, 7);
if (result != NULL) (*env)->SetIntArrayRegion(env, result, 0, 7, values);
return result;
}
JNIEXPORT jstring JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamDeviceName(
JNIEnv *env, jobject self, jint device)
{
(void)self;
return (*env)->NewStringUTF(env, balsam_can_device_name((uint8_t)device));
}
JNIEXPORT jstring JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamRegisterName(
JNIEnv *env, jobject self, jint device, jint address)
{
(void)self;
char name[128];
balsam_can_register_name((uint8_t)device, (uint16_t)address,
name, sizeof name);
return (*env)->NewStringUTF(env, name);
}
JNIEXPORT jdoubleArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeSpectrum(

View File

@@ -0,0 +1,20 @@
package ru.setcorp.setprotocol.balsam
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class BalsamCanProtocolTest {
@Test
fun decodesThreeNamedSensorRegisters() {
val frame = requireNotNull(BalsamCanProtocol.decode(
0x00BA_0010L,
byteArrayOf(0xE0.toByte(), 0x18, 0x00, 0x29, 0xFF.toByte(), 0xFE.toByte(), 0x12, 0x34),
))
assertEquals(1, frame.device)
assertEquals(0x18, frame.startAddress)
assertEquals(listOf(41, 0xFFFE, 0x1234), frame.registers.map { it.value })
assertEquals("Показания T° 1", frame.registers.first().displayName)
assertTrue(frame.summary().contains("Трансформатор 1"))
}
}

View File

@@ -0,0 +1,49 @@
package ru.setcorp.setprotocol.legacycan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LegacyCanTerminalTest {
@Test fun rotatingFormatRoundTripsAllWordCounts() {
val project = LegacyCanProjects.all.first { it.name == "Бальзам" }
val node = project.nodes.first()
for (values in listOf(listOf(0x1234), listOf(0x1234, 0xABCD), listOf(0x1234, 0xABCD, 0x8001))) {
val wire = LegacyCanTerminalProtocol.encodeWrite(project.format, node.rxId, 0x18, values)
val decoded = LegacyCanTerminalProtocol.decode(project.format, node, wire.canId, wire.data)!!
assertEquals(0x18, decoded.address)
assertEquals(values, decoded.presentValues.map { it.second })
assertEquals(LegacyCanSource.FROM_DEVICE, decoded.source)
}
}
@Test fun addressInIdentifierRoundTripsFourWords() {
val project = LegacyCanProjects.all.first { it.name == "23550.2" }
val node = project.nodes.first { it.name == "БКСС ГД" }
val values = listOf(1, 2, 0x7FFF, 0xFFFF)
val wire = LegacyCanTerminalProtocol.encodeWrite(project.format, node.txId, 0x7F, values)
val decoded = LegacyCanTerminalProtocol.decode(project.format, node, wire.canId, wire.data)!!
assertEquals(values, decoded.values)
assertEquals(LegacyCanSource.TO_DEVICE, decoded.source)
assertEquals(-1, LegacyCanTerminalProtocol.signedWord(decoded.values.last()))
}
@Test fun unrelatedIdDoesNotDecode() {
val project = LegacyCanProjects.all.first()
assertNull(LegacyCanTerminalProtocol.decode(project.format, project.nodes.first(), 0x123, ByteArray(8)))
}
@Test fun packetReducerUpdatesOnlyPresentRegisters() {
val packet = LegacyCanPacket(10, 5, listOf(11, 22, 33), LegacyCanSource.FROM_DEVICE)
val bank = LegacyCanTerminalProtocol.applyPacket(LegacyCanTerminalProtocol.emptyRegisterBank(), packet, 42)
assertEquals(listOf(11, 33), listOf(bank[10].value, bank[12].value))
assertEquals(0, bank[11].value)
assertEquals(42, bank[12].revision)
}
@Test fun catalogContainsAllLegacyProjectsAndNodes() {
assertEquals(9, LegacyCanProjects.all.size)
assertEquals(10, LegacyCanProjects.all.first { it.name == "23550.2" }.nodes.size)
assertEquals("Start", LegacyCanProjects.all.first { it.name == "Бальзам" }.commandNames[7])
}
}