Добавить общий 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

@@ -17,6 +17,7 @@ set(SETPROTOCOL_V2_SOURCES
# Совместимые ProtoCAN/SETGUI v1 форматы переходного периода.
set(SETPROTOCOL_LEGACY_SOURCES
src/balsam_can.c
src/gui_catalog.c
src/gui_frame.c
src/pcan_abi.c
@@ -94,6 +95,9 @@ if(SETP_BUILD_TESTS)
add_executable(test_abi tests/test_abi.c)
target_link_libraries(test_abi PRIVATE setprotocol_static)
add_test(NAME stable_abi COMMAND test_abi)
add_executable(test_balsam_can tests/test_balsam_can.c)
target_link_libraries(test_balsam_can PRIVATE setprotocol_static)
add_test(NAME legacy_balsam_can COMMAND test_balsam_can)
add_executable(test_trends tests/test_trends.c)
target_link_libraries(test_trends PRIVATE setprotocol_static)
add_test(NAME shared_trends COMMAND test_trends)

View File

@@ -0,0 +1,61 @@
/**
* @file balsam_can.h
* @brief Legacy Balsam 167 extended-CAN register frames.
*
* The wire layout is taken from Balsam_167_periph/Source/Internal/ecan.c:
* one big-endian address/mask word followed by three big-endian register words.
*/
#ifndef BALSAM_CAN_H
#define BALSAM_CAN_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define BALSAM_CAN_BASE_ID 0x00BA0000UL
#define BALSAM_CAN_NODE_COUNT 13U
#define BALSAM_CAN_DATA_OFFSET 0x10U
#define BALSAM_CAN_DLC 8U
#define BALSAM_CAN_REGISTER_COUNT 3U
typedef enum {
BALSAM_CAN_TO_NODE = 0,
BALSAM_CAN_FROM_NODE = 1
} balsam_can_direction_t;
typedef struct {
uint8_t device;
uint8_t direction;
uint8_t present_mask;
uint16_t start_address;
uint16_t values[BALSAM_CAN_REGISTER_COUNT];
} balsam_can_frame_t;
/** Return non-zero for the command/data/terminal IDs used by Balsam 167. */
int balsam_can_is_id(uint32_t can_id);
/**
* Decode an 8-byte Balsam frame. Returns 1 on success, 0 for another CAN ID,
* -1 for invalid arguments and -2 for a Balsam ID with a non-8-byte payload.
*/
int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size,
balsam_can_frame_t *output);
/** Human-readable Russian name of a Balsam device. */
const char *balsam_can_device_name(uint8_t device);
/**
* Format the register name from the Balsam 167 data table into output.
* Returns the required length (excluding NUL); an empty string means unknown.
*/
size_t balsam_can_register_name(uint8_t device, uint16_t address,
char *output, size_t output_size);
#ifdef __cplusplus
}
#endif
#endif /* BALSAM_CAN_H */

View File

@@ -45,6 +45,14 @@ typedef struct {
uint8_t payload[PCAN_ABI_GUI_PAYLOAD_MAX];
} pcan_abi_gui_frame_t;
typedef struct {
uint8_t device;
uint8_t direction;
uint8_t present_mask;
uint16_t start_address;
uint16_t values[3];
} pcan_abi_balsam_frame_t;
PCAN_ABI_API uint32_t pcan_abi_version(void);
PCAN_ABI_API uint32_t pcan_abi_id_pack(uint8_t priority, uint8_t route,
@@ -58,6 +66,13 @@ PCAN_ABI_API void pcan_abi_id_unpack(uint32_t raw, uint8_t *priority,
PCAN_ABI_API uint16_t pcan_abi_crc16(const uint8_t *data, size_t size);
PCAN_ABI_API int pcan_abi_balsam_decode(uint32_t can_id,
const uint8_t *data, size_t size,
pcan_abi_balsam_frame_t *output);
PCAN_ABI_API const char *pcan_abi_balsam_device_name(uint8_t device);
PCAN_ABI_API size_t pcan_abi_balsam_register_name(
uint8_t device, uint16_t address, char *output, size_t output_size);
PCAN_ABI_API size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags,
uint32_t can_id,
const uint8_t *data, uint8_t dlc,

View File

@@ -5,6 +5,8 @@
#ifndef SETPROTOCOL_H
#define SETPROTOCOL_H
#include "balsam_can.h"
/* Основной SET protocol v2. */
#include "set_protocol.h"
#include "set_can.h"

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])
}
}

View File

@@ -0,0 +1,182 @@
#include "balsam_can.h"
#include <stdio.h>
#include <string.h>
static uint16_t get_be16(const uint8_t *data)
{
return (uint16_t)(((uint16_t)data[0] << 8) | data[1]);
}
static size_t copy_name(const char *name, char *output, size_t output_size)
{
size_t length = strlen(name);
if ((output != NULL) && (output_size != 0U)) {
size_t copied = length < output_size - 1U ? length : output_size - 1U;
memcpy(output, name, copied);
output[copied] = '\0';
}
return length;
}
static size_t indexed_name(const char *prefix, unsigned index,
char *output, size_t output_size)
{
int length;
if ((output == NULL) || (output_size == 0U)) {
char scratch[64];
length = snprintf(scratch, sizeof scratch, "%s %u", prefix, index);
} else {
length = snprintf(output, output_size, "%s %u", prefix, index);
}
return length > 0 ? (size_t)length : 0U;
}
int balsam_can_is_id(uint32_t can_id)
{
uint32_t relative = (can_id & 0x1FFFFFFFUL) - BALSAM_CAN_BASE_ID;
return relative < BALSAM_CAN_NODE_COUNT
|| (relative >= BALSAM_CAN_DATA_OFFSET
&& relative < BALSAM_CAN_DATA_OFFSET + BALSAM_CAN_NODE_COUNT);
}
int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size,
balsam_can_frame_t *output)
{
uint32_t relative;
uint16_t header;
if ((data == NULL) || (output == NULL)) return -1;
can_id &= 0x1FFFFFFFUL;
if (!balsam_can_is_id(can_id)) return 0;
if (size != BALSAM_CAN_DLC) return -2;
relative = can_id - BALSAM_CAN_BASE_ID;
output->direction = relative >= BALSAM_CAN_DATA_OFFSET
? BALSAM_CAN_FROM_NODE : BALSAM_CAN_TO_NODE;
output->device = (uint8_t)((relative & 0x0FU) + 1U);
header = get_be16(data);
output->present_mask = (uint8_t)((header >> 13) & 0x07U);
output->start_address = (uint16_t)(header & 0x1FFFU);
output->values[0] = get_be16(&data[2]);
output->values[1] = get_be16(&data[4]);
output->values[2] = get_be16(&data[6]);
return 1;
}
const char *balsam_can_device_name(uint8_t device)
{
static const char *const names[BALSAM_CAN_NODE_COUNT] = {
"Трансформатор 1", "Трансформатор 2", "Силовой блок 1",
"Силовой блок 2", "УМП 1", "УМП 2", "Двигатель", "ВЭП",
"Задатчик", "Узел 10", "Узел 11", "Узел 12", "Терминал"
};
return (device >= 1U && device <= BALSAM_CAN_NODE_COUNT)
? names[device - 1U] : "Неизвестный узел";
}
size_t balsam_can_register_name(uint8_t device, uint16_t address,
char *output, size_t output_size)
{
if ((device == 1U || device == 2U) && address < 20U)
return indexed_name("Диагностика T°", address + 1U, output, output_size);
if ((device == 1U || device == 2U) && address >= 0x18U && address <= 0x2BU)
return indexed_name("Показания T°", address - 0x17U, output, output_size);
if ((device == 1U || device == 2U) && address >= 0x30U && address <= 0x43U)
return indexed_name("Аварийная уставка T°", address - 0x2FU, output, output_size);
if ((device == 1U || device == 2U) && address >= 0x48U && address <= 0x5BU)
return indexed_name("Предупредительная уставка T°", address - 0x47U, output, output_size);
if ((device == 3U || device == 4U) && address < 16U)
return indexed_name("Диагностика T°", address + 1U, output, output_size);
if ((device == 3U || device == 4U) && address >= 0x18U && address <= 0x27U)
return indexed_name("Показания T°", address - 0x17U, output, output_size);
if ((device == 3U || device == 4U) && address == 0x28U)
return copy_name("Действующее Uвх1", output, output_size);
if ((device == 3U || device == 4U) && address == 0x29U)
return copy_name("Амплитудное Uвх1", output, output_size);
if ((device == 3U || device == 4U) && address == 0x2AU)
return copy_name("Действующее Uвх2", output, output_size);
if ((device == 3U || device == 4U) && address == 0x2BU)
return copy_name("Амплитудное Uвх2", output, output_size);
if ((device == 5U || device == 6U) && address == 0U)
return copy_name("Диагностика Utr a", output, output_size);
if ((device == 5U || device == 6U) && address == 1U)
return copy_name("Диагностика Utr c", output, output_size);
if ((device == 5U || device == 6U) && address == 2U)
return copy_name("Диагностика Itr a", output, output_size);
if ((device == 5U || device == 6U) && address == 3U)
return copy_name("Диагностика Itr c", output, output_size);
if ((device == 5U || device == 6U) && address == 0x18U)
return copy_name("Действующее Utr", output, output_size);
if ((device == 5U || device == 6U) && address == 0x19U)
return copy_name("Амплитудное Utr", output, output_size);
if ((device == 5U || device == 6U) && address == 0x1AU)
return copy_name("Действующее Itr", output, output_size);
if ((device == 5U || device == 6U) && address == 0x1BU)
return copy_name("Амплитудное Itr", output, output_size);
if ((device == 5U || device == 6U) && address == 0x1CU)
return copy_name("Ток СИФУ, задание (mA*10)", output, output_size);
if ((device == 5U || device == 6U) && address == 0x1DU)
return copy_name("Ток СИФУ, обратная связь (mA*10)", output, output_size);
if (device == 7U && address < 8U)
return indexed_name("Диагностика T°", address + 1U, output, output_size);
if (device == 7U && address >= 0x18U && address <= 0x1FU)
return indexed_name("Показания T°", address - 0x17U, output, output_size);
if (device == 8U) {
static const char *const vep_names[] = {
"Диагностика 380В Ф1", "Диагностика 380В Ф2",
"Диагностика 31В Ф1", "Диагностика 31В Ф2",
"Диагностика 31В UC1", "Диагностика 31В UC2",
"Диагностика 24В ПУ", "Диагностика 27В ФА",
"Диагностика 24В ПК", "Диагностика 15В ДР",
"Диагностика +24В ДТ", "Диагностика -24В ДТ",
"Диагностика 24В ПМУ", "Диагностика T° 1", "Диагностика T° 2"
};
static const char *const vep_values[] = {
"Показания 380В Ф1", "Показания 380В Ф2", "Показания 31В Ф1",
"Показания 31В Ф2", "Показания 31В UC1", "Показания 31В UC2",
"Показания 24В ПУ", "Показания 27В ФА", "Показания 24В ПК",
"Показания 15В ДР", "Показания +24В ДТ", "Показания -24В ДТ",
"Показания 24В ПМУ", "Показания T° 1", "Показания T° 2"
};
if (address < sizeof vep_names / sizeof vep_names[0])
return copy_name(vep_names[address], output, output_size);
if (address >= 0x18U && address < 0x18U + sizeof vep_values / sizeof vep_values[0])
return copy_name(vep_values[address - 0x18U], output, output_size);
if (address == 0x10U)
return copy_name("Дискретные датчики (14 бит)", output, output_size);
}
if (device == 9U && address == 0U)
return copy_name("Обороты ГЭД, об/мин", output, output_size);
if (device == 9U && address == 1U)
return copy_name("Обороты ГВ, об/мин", output, output_size);
if (device == 9U && address == 2U)
return copy_name("Лампы (8 бит)", output, output_size);
if (device == 9U && address == 3U)
return copy_name("Диоды (16 бит)", output, output_size);
if (device == 9U && address == 0x10U)
return copy_name("Кнопки (12 бит)", output, output_size);
if (address == 0x16U)
return copy_name("Дискретные входы / кнопки", output, output_size);
if (address == 0x17U)
return copy_name("Состояние джамперов", output, output_size);
if (address == 0x60U)
return copy_name("Период быстрого CAN-цикла", output, output_size);
if (address == 0x61U)
return copy_name("Период медленного CAN-цикла", output, output_size);
if (address == 0x62U)
return copy_name("Яркость индикации", output, output_size);
if (address == 0x63U)
return copy_name("Период опроса OWEN", output, output_size);
if (address == 0x7EU)
return copy_name("Последний режим", output, output_size);
if (address == 0x7FU)
return copy_name("Команды", output, output_size);
return copy_name("", output, output_size);
}

View File

@@ -3,6 +3,7 @@
#include <string.h>
#include "pcan_crc.h"
#include "balsam_can.h"
#include "pcan_frame.h"
#include "pcan_id.h"
#include "gui_frame.h"
@@ -60,6 +61,33 @@ uint16_t pcan_abi_crc16(const uint8_t *data, size_t size)
return pcan_crc16(data, size);
}
int pcan_abi_balsam_decode(uint32_t can_id, const uint8_t *data, size_t size,
pcan_abi_balsam_frame_t *output)
{
balsam_can_frame_t decoded;
int status;
if (output == NULL) return -1;
status = balsam_can_decode(can_id, data, size, &decoded);
if (status != 1) return status;
output->device = decoded.device;
output->direction = decoded.direction;
output->present_mask = decoded.present_mask;
output->start_address = decoded.start_address;
memcpy(output->values, decoded.values, sizeof output->values);
return 1;
}
const char *pcan_abi_balsam_device_name(uint8_t device)
{
return balsam_can_device_name(device);
}
size_t pcan_abi_balsam_register_name(uint8_t device, uint16_t address,
char *output, size_t output_size)
{
return balsam_can_register_name(device, address, output, output_size);
}
size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags,
uint32_t can_id, const uint8_t *data, uint8_t dlc,
uint8_t *output, size_t output_size)

View File

@@ -0,0 +1,30 @@
#include <stdio.h>
#include <string.h>
#include "balsam_can.h"
#define CHECK(x) do { if (!(x)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); return 1; \
} } while (0)
int main(void)
{
const uint8_t packet[8] = { 0xE0, 0x18, 0x00, 0x29, 0xFF, 0xFE, 0x12, 0x34 };
balsam_can_frame_t frame;
char name[64];
CHECK(balsam_can_is_id(0x00BA0010UL));
CHECK(balsam_can_is_id(0x00BA001CUL));
CHECK(!balsam_can_is_id(0x00BA001DUL));
CHECK(balsam_can_decode(0x00BA0010UL, packet, sizeof packet, &frame) == 1);
CHECK(frame.device == 1U);
CHECK(frame.direction == BALSAM_CAN_FROM_NODE);
CHECK(frame.present_mask == 7U);
CHECK(frame.start_address == 0x18U);
CHECK(frame.values[0] == 41U && frame.values[1] == 0xFFFEU
&& frame.values[2] == 0x1234U);
CHECK(balsam_can_register_name(1U, 0x18U, name, sizeof name) > 0U);
CHECK(strstr(name, "T° 1") != NULL);
CHECK(balsam_can_decode(0x00BA0010UL, packet, 7U, &frame) == -2);
return 0;
}

View File

@@ -21,7 +21,7 @@ JNI_INCLUDES: list[Path] = []
SOURCES = [
ROOT / "src" / name for name in (
"set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c",
"gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c",
"balsam_can.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c",
"pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c",
"pcan_gas.c",
)

View File

@@ -5,9 +5,11 @@ from .native import (
NativeGuiParser, NativeParser, NativeProtocol, NativeProtocolUnavailable,
get_native_core, get_native_protocol,
)
from .balsam import BalsamFrame, BalsamRegister, decode as decode_balsam
__all__ = [
"NativeCore", "NativeCoreUnavailable", "NativeFrame", "NativeGuiFrame",
"NativeGuiParser", "NativeParser", "NativeProtocol",
"NativeProtocolUnavailable", "get_native_core", "get_native_protocol",
"BalsamFrame", "BalsamRegister", "decode_balsam",
]

98
python/protocan/balsam.py Normal file
View File

@@ -0,0 +1,98 @@
"""Balsam 167 legacy CAN register decoder backed by the shared C99 core."""
from __future__ import annotations
from dataclasses import dataclass
from .native import NativeProtocolUnavailable, get_native_protocol
BASE_ID = 0x00BA0000
DATA_OFFSET = 0x10
NODE_COUNT = 13
@dataclass(frozen=True)
class BalsamRegister:
address: int
value: int
name: str
@property
def signed_value(self) -> int:
return self.value if self.value < 0x8000 else self.value - 0x10000
@dataclass(frozen=True)
class BalsamFrame:
can_id: int
device: int
device_name: str
from_device: bool
start_address: int
present_mask: int
registers: tuple[BalsamRegister, ...]
@property
def summary(self) -> str:
direction = "данные" if self.from_device else "команда"
values = ", ".join(
"%s=0x%04X (%d)" % (item.name or "R%04X" % item.address,
item.value, item.signed_value)
for item in self.registers
) or "нет отмеченных регистров"
return "BALZAM · %s · %s · %s" % (self.device_name, direction, values)
def is_balsam_id(can_id: int) -> bool:
relative = (can_id & 0x1FFFFFFF) - BASE_ID
return (0 <= relative < NODE_COUNT
or DATA_OFFSET <= relative < DATA_OFFSET + NODE_COUNT)
def decode(can_id: int, data: bytes, native=None) -> BalsamFrame | None:
"""Decode one abstract Balsam frame: BE mask/address plus three BE words."""
if native is None:
try:
native = get_native_protocol()
except NativeProtocolUnavailable:
return _decode_fallback(can_id, data)
status, decoded = native.balsam_decode(can_id, bytes(data))
if status == 0:
return None
if status == -2:
raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes")
if status != 1 or decoded is None:
raise ValueError("invalid BALZAM CAN frame")
device, direction, mask, start, values = decoded
registers = tuple(
BalsamRegister(start + index, values[index],
native.balsam_register_name(device, start + index))
for index in range(3) if mask & (4 >> index)
)
return BalsamFrame(can_id & 0x1FFFFFFF, device,
native.balsam_device_name(device), direction == 1,
start, mask, registers)
def _decode_fallback(can_id: int, data: bytes) -> BalsamFrame | None:
if not is_balsam_id(can_id):
return None
if len(data) != 8:
raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes")
relative = (can_id & 0x1FFFFFFF) - BASE_ID
device = (relative & 0x0F) + 1
header = int.from_bytes(data[:2], "big")
values = tuple(int.from_bytes(data[offset:offset + 2], "big")
for offset in (2, 4, 6))
names = {
1: "Трансформатор 1", 2: "Трансформатор 2",
3: "Силовой блок 1", 4: "Силовой блок 2", 5: "УМП 1", 6: "УМП 2",
7: "Двигатель", 8: "ВЭП", 9: "Задатчик", 13: "Терминал",
}
start, mask = header & 0x1FFF, (header >> 13) & 7
registers = tuple(BalsamRegister(start + i, values[i], "")
for i in range(3) if mask & (4 >> i))
return BalsamFrame(can_id & 0x1FFFFFFF, device,
names.get(device, "Узел %d" % device),
relative >= DATA_OFFSET, start, mask, registers)

View File

@@ -41,6 +41,16 @@ class _AbiGuiFrame(ctypes.Structure):
]
class _AbiBalsamFrame(ctypes.Structure):
_fields_ = [
("device", ctypes.c_uint8),
("direction", ctypes.c_uint8),
("present_mask", ctypes.c_uint8),
("start_address", ctypes.c_uint16),
("values", ctypes.c_uint16 * 3),
]
@dataclass(frozen=True)
class NativeFrame:
sequence: int
@@ -118,6 +128,17 @@ class NativeProtocol:
]
lib.pcan_abi_crc16.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
lib.pcan_abi_crc16.restype = ctypes.c_uint16
lib.pcan_abi_balsam_decode.argtypes = [
ctypes.c_uint32, ctypes.c_void_p, ctypes.c_size_t,
ctypes.POINTER(_AbiBalsamFrame),
]
lib.pcan_abi_balsam_decode.restype = ctypes.c_int
lib.pcan_abi_balsam_device_name.argtypes = [ctypes.c_uint8]
lib.pcan_abi_balsam_device_name.restype = ctypes.c_char_p
lib.pcan_abi_balsam_register_name.argtypes = [
ctypes.c_uint8, ctypes.c_uint16, ctypes.c_void_p, ctypes.c_size_t,
]
lib.pcan_abi_balsam_register_name.restype = ctypes.c_size_t
lib.pcan_abi_frame_encode.argtypes = [
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint32,
ctypes.c_void_p, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t,
@@ -178,6 +199,27 @@ class NativeProtocol:
source = (ctypes.c_uint8 * len(data)).from_buffer_copy(data) if data else None
return int(self.lib.pcan_abi_crc16(source, len(data)))
def balsam_decode(self, can_id: int, data: bytes):
source = (ctypes.c_uint8 * len(data)).from_buffer_copy(data) if data else None
output = _AbiBalsamFrame()
status = int(self.lib.pcan_abi_balsam_decode(
can_id, source, len(data), ctypes.byref(output)))
if status != 1:
return status, None
return status, (int(output.device), int(output.direction),
int(output.present_mask), int(output.start_address),
tuple(int(value) for value in output.values))
def balsam_device_name(self, device: int) -> str:
value = self.lib.pcan_abi_balsam_device_name(device)
return value.decode("utf-8") if value else ""
def balsam_register_name(self, device: int, address: int) -> str:
output = ctypes.create_string_buffer(128)
self.lib.pcan_abi_balsam_register_name(
device, address, output, len(output))
return output.value.decode("utf-8")
def encode(self, sequence: int, flags: int, can_id: int, data: bytes) -> bytes:
if len(data) > 8:
raise ValueError("DLC cannot exceed 8 bytes")

View File

@@ -392,6 +392,11 @@ class Decoded:
registers: Optional[List[tuple]] = None
#: Замечания о нарушениях протокола
warnings: List[str] = field(default_factory=list)
#: Имя прикладного протокола для GUI, когда это не ProtoCAN.
protocol: str = "ProtoCAN"
#: UI labels for protocols whose identifier is not a ProtoCAN bit field.
device_label: str = ""
message_label: str = ""
def _ascii(data: bytes) -> str: