From 3c4ac9963dc8504566f35abb9591017deabfb336 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 18:19:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20API=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20CAN=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- c/set-protocol/CMakeLists.txt | 4 + c/set-protocol/include/balsam_can.h | 61 +++++ c/set-protocol/include/pcan_abi.h | 15 ++ c/set-protocol/include/setprotocol.h | 2 + c/set-protocol/ports/android/Android.mk | 1 + c/set-protocol/ports/android/README.md | 5 + .../setcorp/setprotocol/NativeSetProtocol.kt | 3 + .../setprotocol/balsam/BalsamCanProtocol.kt | 116 +++++++++ .../legacycan/LegacyCanTerminal.kt | 230 ++++++++++++++++++ .../ports/android/setprotocol_jni.c | 47 ++++ .../balsam/BalsamCanProtocolTest.kt | 20 ++ .../legacycan/LegacyCanTerminalTest.kt | 49 ++++ c/set-protocol/src/balsam_can.c | 182 ++++++++++++++ c/set-protocol/src/pcan_abi.c | 28 +++ c/set-protocol/tests/test_balsam_can.c | 30 +++ c/set-protocol/tools/build_host.py | 2 +- python/protocan/__init__.py | 2 + python/protocan/balsam.py | 98 ++++++++ python/protocan/native.py | 42 ++++ python/protocan/protocan.py | 5 + 20 files changed, 941 insertions(+), 1 deletion(-) create mode 100644 c/set-protocol/include/balsam_can.h create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt create mode 100644 c/set-protocol/src/balsam_can.c create mode 100644 c/set-protocol/tests/test_balsam_can.c create mode 100644 python/protocan/balsam.py diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt index 27f19c6..30dc23a 100644 --- a/c/set-protocol/CMakeLists.txt +++ b/c/set-protocol/CMakeLists.txt @@ -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) diff --git a/c/set-protocol/include/balsam_can.h b/c/set-protocol/include/balsam_can.h new file mode 100644 index 0000000..95feacb --- /dev/null +++ b/c/set-protocol/include/balsam_can.h @@ -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 +#include + +#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 */ diff --git a/c/set-protocol/include/pcan_abi.h b/c/set-protocol/include/pcan_abi.h index 5e1c23a..4ce3b03 100644 --- a/c/set-protocol/include/pcan_abi.h +++ b/c/set-protocol/include/pcan_abi.h @@ -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, diff --git a/c/set-protocol/include/setprotocol.h b/c/set-protocol/include/setprotocol.h index 3cf2a3c..a680583 100644 --- a/c/set-protocol/include/setprotocol.h +++ b/c/set-protocol/include/setprotocol.h @@ -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" diff --git a/c/set-protocol/ports/android/Android.mk b/c/set-protocol/ports/android/Android.mk index b9c7b81..e25efce 100644 --- a/c/set-protocol/ports/android/Android.mk +++ b/c/set-protocol/ports/android/Android.mk @@ -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 \ diff --git a/c/set-protocol/ports/android/README.md b/c/set-protocol/ports/android/README.md index 73616aa..7ba353c 100644 --- a/c/set-protocol/ports/android/README.md +++ b/c/set-protocol/ports/android/README.md @@ -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 diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt index 40707b1..5e45407 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt @@ -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, diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt new file mode 100644 index 0000000..e1fd17d --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt @@ -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, +) { + 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 -> "" + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt new file mode 100644 index 0000000..0a3cf26 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt @@ -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, + val source: LegacyCanSource, +) { + val presentValues: List> + 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, + val commandNames: List, +) { + 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 = + List(128) { LegacyCanRegisterValue(it) } + + fun applyPacket( + bank: List, + packet: LegacyCanPacket, + revision: Long, + ): List { + 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, + ): 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>, + commands: List = 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 = listOf(index, can, rs, name) + + val all: List = 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", + ), + ) +} diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c index 863c2e5..1b5c5a0 100644 --- a/c/set-protocol/ports/android/setprotocol_jni.c +++ b/c/set-protocol/ports/android/setprotocol_jni.c @@ -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( diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt new file mode 100644 index 0000000..305b93b --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt @@ -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")) + } +} diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt new file mode 100644 index 0000000..a4c63c0 --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt @@ -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]) + } +} diff --git a/c/set-protocol/src/balsam_can.c b/c/set-protocol/src/balsam_can.c new file mode 100644 index 0000000..53ea9f2 --- /dev/null +++ b/c/set-protocol/src/balsam_can.c @@ -0,0 +1,182 @@ +#include "balsam_can.h" + +#include +#include + +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); +} diff --git a/c/set-protocol/src/pcan_abi.c b/c/set-protocol/src/pcan_abi.c index 55e8293..8094e42 100644 --- a/c/set-protocol/src/pcan_abi.c +++ b/c/set-protocol/src/pcan_abi.c @@ -3,6 +3,7 @@ #include #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) diff --git a/c/set-protocol/tests/test_balsam_can.c b/c/set-protocol/tests/test_balsam_can.c new file mode 100644 index 0000000..d00fe2e --- /dev/null +++ b/c/set-protocol/tests/test_balsam_can.c @@ -0,0 +1,30 @@ +#include +#include + +#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; +} diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py index a978283..2f54914 100644 --- a/c/set-protocol/tools/build_host.py +++ b/c/set-protocol/tools/build_host.py @@ -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", ) diff --git a/python/protocan/__init__.py b/python/protocan/__init__.py index cbba06a..d7d39f3 100644 --- a/python/protocan/__init__.py +++ b/python/protocan/__init__.py @@ -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", ] diff --git a/python/protocan/balsam.py b/python/protocan/balsam.py new file mode 100644 index 0000000..1d602ff --- /dev/null +++ b/python/protocan/balsam.py @@ -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) diff --git a/python/protocan/native.py b/python/protocan/native.py index 40c8f11..23aad9d 100644 --- a/python/protocan/native.py +++ b/python/protocan/native.py @@ -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") diff --git a/python/protocan/protocan.py b/python/protocan/protocan.py index 8a2789e..7ed69e2 100644 --- a/python/protocan/protocan.py +++ b/python/protocan/protocan.py @@ -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: