9 Commits

35 changed files with 1402 additions and 11 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

@@ -16,7 +16,8 @@ enum set_plot_operation {
SET_PLOT_VALUE = 3, /* fraction,low,high,inverted -> value (not clamped) */
SET_PLOT_DRAG = 4, /* initial,deltaPixels,length,low,high,inverted -> clamped value */
SET_PLOT_TICK_STEP = 5, /* range,lengthPixels -> nice step */
SET_PLOT_DELTA = 6 /* A,B,multiplier -> (B-A)*multiplier */
SET_PLOT_DELTA = 6, /* A,B,multiplier -> (B-A)*multiplier */
SET_PLOT_DB_DELTA = 7 /* A,B -> 20*log10(abs(B/A)); zero is invalid */
};
/** Version of this plot ABI, independently of the transport ABI. */

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

@@ -30,3 +30,5 @@ data class PlotBounds(val left: Double, val right: Double, val bottom: Double, v
fun plotTickStep(range: Double, pixels: Double): Double = NativePlot.call(5, range, pixels)[0]
fun plotDelta(a: Double, b: Double, multiplier: Double = 1.0): Double = NativePlot.call(6, a, b, multiplier)[0]
fun plotDbDelta(a: Double, b: Double): Double? =
runCatching { NativePlot.call(7, a, b)[0] }.getOrNull()

View File

@@ -1,10 +1,12 @@
package ru.setcorp.setprotocol.trends
/** Normalized top-left viewport; independent of pixels, units, toolkit and samples. */
data class PlotViewport(val x: Double = 0.0, val y: Double = 0.0, val width: Double = 1.0, val height: Double = 1.0) {
data class PlotViewport(val x: Double = 0.0, val y: Double = 0.0, val width: Double = 1.0, val height: Double = 1.0,
val locked: Boolean = false) {
fun transform(zoomX: Double = 1.0, zoomY: Double = 1.0, panX: Double = 0.0, panY: Double = 0.0,
focusX: Double = 0.5, focusY: Double = 0.5): PlotViewport {
if (locked) return this
val result = NativePlot.call(0, x, y, width, height, zoomX, zoomY, panX, panY, focusX, focusY)
return PlotViewport(result[0], result[1], result[2], result[3])
return PlotViewport(result[0], result[1], result[2], result[3], locked)
}
}

View File

@@ -10,6 +10,8 @@ enum class TrendMarker(val title: String, val horizontal: Boolean) {
data class TrendMarkers(
val xEnabled: Boolean = true,
val yEnabled: Boolean = false,
val xPairs: Int = 1,
val yPairs: Int = 1,
val selected: TrendMarker = TrendMarker.A,
val a: Double? = null,
val b: Double? = null,
@@ -20,6 +22,9 @@ data class TrendMarkers(
val g: Double? = null,
val h: Double? = null,
) {
init {
require(xPairs in 1..2 && yPairs in 1..2) { "Количество пар маркеров должно быть от 1 до 2" }
}
fun value(marker: TrendMarker): Double? = when (marker) {
TrendMarker.A -> a; TrendMarker.B -> b; TrendMarker.C -> c; TrendMarker.D -> d
TrendMarker.E -> e; TrendMarker.F -> f; TrendMarker.G -> g; TrendMarker.H -> h
@@ -49,5 +54,10 @@ data class TrendMarkers(
}.sortedWith(compareBy<Pair<TrendMarker, Double>> { it.second }
.thenBy { if (it.first == selected) 0 else 1 }).firstOrNull()?.first
}
fun enabled(marker: TrendMarker): Boolean = if (marker.horizontal) yEnabled else xEnabled
fun enabled(marker: TrendMarker): Boolean = when (marker) {
TrendMarker.A, TrendMarker.B -> xEnabled
TrendMarker.C, TrendMarker.D -> xEnabled && xPairs >= 2
TrendMarker.E, TrendMarker.F -> yEnabled
TrendMarker.G, TrendMarker.H -> yEnabled && yPairs >= 2
}
}

View File

@@ -142,7 +142,7 @@ class FirmwareCatalogClient(private val userAgent: String) {
require(fileName.matches(Regex("[A-Za-zА-Яа-яЁё0-9._ -]+\\.(bin|hex)", RegexOption.IGNORE_CASE))) {
"Некорректное имя файла прошивки №${index + 1}"
}
require(transport in setOf("rs485", "can", "stm32")) { "Некорректный канал прошивки №${index + 1}" }
require(transport in setOf("rs485", "can", "stm32", "tms")) { "Некорректный канал прошивки №${index + 1}" }
val baseAddress = row.opt("baseAddress")?.takeUnless { it == JSONObject.NULL }?.toString()?.let(::parseAddress)
add(FirmwareRelease(
product, versionName, versionCode, imageUrl, fileName, sha256,

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

@@ -25,6 +25,9 @@ class PlotContractTest {
@Test fun markerCoordinatesSurviveZoomAndDragUsesGestureStart() {
val full = PlotBounds(1000.0, 2000.0, -10.0, 10.0)
val markers = TrendMarkers().positioned(full)
assertTrue(markers.enabled(TrendMarker.A))
assertFalse(markers.enabled(TrendMarker.C))
assertTrue(markers.copy(xPairs = 2).enabled(TrendMarker.C))
val zoomed = PlotBounds(1250.0, 1750.0, -5.0, 5.0)
assertEquals(markers, markers.positioned(zoomed))
val dragged = markers.drag(TrendMarker.A, 50.0, 500.0, zoomed)
@@ -34,4 +37,12 @@ class PlotContractTest {
val crossed = markers.move(TrendMarker.A, 1900.0).move(TrendMarker.B, 1100.0)
assertEquals(-800.0, plotDelta(crossed.a!!, crossed.b!!), 0.0)
}
@Test fun lockedViewportIgnoresZoomAndPanAndDbUsesAmplitudeRatio() {
val locked = PlotViewport(locked = true)
assertEquals(locked, locked.transform(zoomX = 2.0, panY = 0.2))
assertEquals(20.0, plotDbDelta(1.0, 10.0)!!, 1e-10)
assertEquals(-20.0, plotDbDelta(10.0, 1.0)!!, 1e-10)
assertNull(plotDbDelta(0.0, 1.0))
}
}

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

@@ -11,9 +11,9 @@ static int finite_values(const double *v, size_t n) {
uint32_t set_plot_abi_version(void) { return 1U; }
size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t cap) {
static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3};
static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3, 2};
double span, fraction;
if (op > SET_PLOT_DELTA || !v || !out || n != sizes[op] ||
if (op > SET_PLOT_DB_DELTA || !v || !out || n != sizes[op] ||
cap < (op == SET_PLOT_TRANSFORM ? 4U : 1U)) return 0;
if (op == SET_PLOT_TRANSFORM) {
double w, h, fx, fy;
@@ -65,6 +65,10 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t
break;
}
case SET_PLOT_DELTA: out[0] = (v[1] - v[0]) * v[2]; break;
case SET_PLOT_DB_DELTA:
if (v[0] == 0 || v[1] == 0) return 0;
out[0] = 20 * log10(fabs(v[1] / v[0]));
break;
default: return 0;
}
return isfinite(out[0]) ? 1 : 0;

View File

@@ -20,6 +20,9 @@
{"name":"ticks","op":5,"input":[100,800],"output":[20]},
{"name":"negative_delta","op":6,"input":[10,0,1],"output":[-10]},
{"name":"milliseconds","op":6,"input":[0,0.01,1000],"output":[10]},
{"name":"db_gain","op":7,"input":[1,10],"output":[20]},
{"name":"db_attenuation","op":7,"input":[10,1],"output":[-20]},
{"name":"db_zero_reference","op":7,"input":[0,1],"output":null},
{"name":"zero_range","op":2,"input":[1,1,1,0],"output":null},
{"name":"zero_pixels","op":4,"input":[0,1,0,0,1,0],"output":null},
{"name":"bad_viewport","op":0,"input":[0,0,0,1,2,1,0,0,0.5,0.5],"output":null}

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)

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,98 @@
"""Portable RS command helpers from Set_Terminal_28335.
The byte order and CRC match ``DTrans.pas``/``UNiiefa.pas``. This module is
deliberately UI- and serial-port-independent so desktop and Android clients
can share the same request builders.
"""
from __future__ import annotations
def crc16_modbus(data: bytes, crc: int = 0xFFFF) -> int:
for byte in data:
crc ^= byte
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc & 0xFFFF
def with_crc(payload: bytes) -> bytes:
crc = crc16_modbus(payload)
return bytes(payload) + crc.to_bytes(2, "little")
def build_read_registers(controller: int, start: int, count: int) -> bytes:
_range("Адрес контроллера", controller, 0xFF)
_range("Начальный регистр", start, 0xFFFF)
if not 1 <= count <= 128 or start + count > 128:
raise ValueError("Диапазон регистров должен находиться в 0..127")
return with_crc(bytes((controller, 3)) + start.to_bytes(2, "big")
+ count.to_bytes(2, "big"))
def build_write_register(controller: int, address: int, value: int) -> bytes:
_range("Адрес контроллера", controller, 0xFF)
_range("Адрес регистра", address, 127)
_range("Значение", value, 0xFFFF)
return with_crc(bytes((controller, 6)) + address.to_bytes(2, "big")
+ value.to_bytes(2, "big"))
def build_command(controller: int, command_index: int) -> bytes:
if not 0 <= command_index <= 16:
raise ValueError("Номер команды должен быть в диапазоне 0..16")
value = 1 << command_index if command_index < 16 else 0
return build_write_register(controller, 127, value)
def expected_read_response_size(count: int) -> int:
return count * 2 + 5
def decode_read_response(data: bytes, count: int) -> tuple[int, ...]:
expected = expected_read_response_size(count)
if len(data) != expected:
raise ValueError(f"Ожидалось {expected} байт, получено {len(data)}")
if crc16_modbus(data[:-2]) != int.from_bytes(data[-2:], "little"):
raise ValueError("Ошибка CRC ответа")
# Historical replies have a three-byte header; registers are big-endian.
body = data[3:-2]
if len(body) != count * 2:
raise ValueError("Неверная длина данных ответа")
return tuple(int.from_bytes(body[offset:offset + 2], "big")
for offset in range(0, len(body), 2))
def bits_lsb_first(value: int) -> tuple[bool, ...]:
_range("Значение", value, 0xFFFF)
return tuple(bool(value & (1 << bit)) for bit in range(16))
def word_from_bits(bits) -> int:
values = tuple(bool(item) for item in bits)
if len(values) != 16:
raise ValueError("Должно быть ровно 16 бит")
return sum(1 << bit for bit, checked in enumerate(values) if checked)
def signed_word(value: int) -> int:
_range("Значение", value, 0xFFFF)
return value if value < 0x8000 else value - 0x10000
def _range(name: str, value: int, maximum: int) -> None:
if not 0 <= value <= maximum:
raise ValueError(f"{name} вне диапазона 0..{maximum}")
PROJECT_COMMANDS = {
"По умолчанию": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"23470": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "-", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"23550": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "Send", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"23550.2": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Send", "Raw", "Beep", "", "", "", "", "Log", "Reset", "Nothing at all"),
"ICE 22220.1-3": ("Test", "Zero", "Save", "Def", "Calibr", "Read", "ExtLamp", "ExtLite", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"ICE 22220.4-5": ("Test", "Def", "Save", "Load", "Raw", "Read", "ExtLamp", "ExtLite", "No log", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"Бальзам 161": ("Test", "Zero", "Save", "Def", "Calibr", "Clbr 400", "Stop", "Start", "Init", "Secret", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"Бальзам 162": ("Test", "Def", "Save", "Load", "Calibr", "Secret", "Stop", "Start", "Init", "Tune", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"Бальзам 163": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Stop", "Start", "Init", "Tune", "Secret", "-", "-", "-", "-", "Reset", "Nothing at all"),
}

View File

@@ -61,6 +61,12 @@ class PlotMath:
def delta(self, a: float, b: float, multiplier: float = 1) -> float:
return self.call(6, a, b, multiplier)[0]
def db_delta(self, a: float, b: float) -> Optional[float]:
try:
return self.call(7, a, b)[0]
except ValueError:
return None
@dataclass(frozen=True)
class Viewport:
@@ -68,12 +74,15 @@ class Viewport:
y: float = 0.0
width: float = 1.0
height: float = 1.0
locked: bool = False
def transform(self, core: PlotMath, zoom_x: float = 1, zoom_y: float = 1,
pan_x: float = 0, pan_y: float = 0,
focus_x: float = 0.5, focus_y: float = 0.5) -> "Viewport":
if self.locked:
return self
return Viewport(*core.call(0, self.x, self.y, self.width, self.height,
zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y))
zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y), locked=self.locked)
@dataclass(frozen=True)
@@ -102,6 +111,8 @@ class Bounds:
class Markers:
x_enabled: bool = True
y_enabled: bool = False
x_pairs: int = 1
y_pairs: int = 1
selected: Marker = Marker.A
a: Optional[float] = None
b: Optional[float] = None
@@ -112,11 +123,21 @@ class Markers:
g: Optional[float] = None
h: Optional[float] = None
def __post_init__(self) -> None:
if self.x_pairs not in (1, 2) or self.y_pairs not in (1, 2):
raise ValueError("Marker pair count must be 1 or 2")
def value(self, marker: Marker) -> Optional[float]:
return getattr(self, marker.name.lower())
def enabled(self, marker: Marker) -> bool:
return self.y_enabled if marker.horizontal else self.x_enabled
if marker in (Marker.A, Marker.B):
return self.x_enabled
if marker in (Marker.C, Marker.D):
return self.x_enabled and self.x_pairs >= 2
if marker in (Marker.E, Marker.F):
return self.y_enabled
return self.y_enabled and self.y_pairs >= 2
def move(self, marker: Marker, value: float) -> "Markers":
return replace(self, **{marker.name.lower(): value})

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:

View File

@@ -9,7 +9,7 @@ from pathlib import Path
from urllib.parse import urljoin, urlparse
MAX_MANIFEST_BYTES = 128 * 1024
SUPPORTED_TRANSPORTS = frozenset({"rs485", "can", "stm32"})
SUPPORTED_TRANSPORTS = frozenset({"rs485", "can", "stm32", "tms"})
@dataclass(frozen=True)

View File

@@ -0,0 +1,39 @@
from protocan.legacycan import (
LegacyCanFormat, LegacyCanSource, PROJECTS, apply_packet, decode,
empty_register_bank, encode_command, encode_write, signed_word,
)
def test_catalog_matches_legacy_projects():
assert [project.name for project in PROJECTS] == [
"Буксир", "СЭДБМ", "Ледокол", "Бальзам", "23550", "23550.X",
"23550.2", "Янтарь", "23550 БСУ",
]
assert len(next(project for project in PROJECTS if project.name == "СЭДБМ").nodes) == 15
def test_rotating_three_word_codec_and_bank():
project = next(project for project in PROJECTS if project.name == "Бальзам")
node = project.nodes[0]
wire = encode_write(project.format, node.rx_id, 24, (41, 0xFFFE, 0x1234))
assert wire.data == bytes.fromhex("FF FE 12 34 E0 18 00 29")
packet = decode(project.format, node, wire.can_id, wire.data)
assert packet is not None
assert packet.present_values == ((24, 41), (25, 0xFFFE), (26, 0x1234))
assert packet.source is LegacyCanSource.FROM_DEVICE
bank = apply_packet(empty_register_bank(), packet, 7)
assert bank[25].value == 0xFFFE
assert bank[25].revision == 7
assert signed_word(bank[25].value) == -2
def test_address_in_identifier_codec_and_command():
project = next(project for project in PROJECTS if project.name == "23550.2")
node = project.nodes[2]
assert project.format is LegacyCanFormat.ADDRESS_IN_IDENTIFIER
wire = encode_write(project.format, node.tx_id, 17, (1, 2, 0xFFFF, 4))
assert wire.can_id & 0x7F == 17
assert wire.data == bytes.fromhex("00 01 00 02 FF FF 00 04")
command = encode_command(project, node, 7)
assert command.can_id & 0x7F == 127
assert command.data == b"\x00\x80"

View File

@@ -0,0 +1,23 @@
from protocan.periph28335 import (
bits_lsb_first, build_command, build_read_registers,
build_write_register, crc16_modbus, word_from_bits,
)
def test_read_request_matches_delphi_byte_order():
request = build_read_registers(16, 24, 64)
assert request[:6] == bytes.fromhex("10 03 00 18 00 40")
assert int.from_bytes(request[-2:], "little") == crc16_modbus(request[:-2])
def test_write_and_command_use_register_127():
request = build_write_register(16, 7, 0x1234)
assert request[:6] == bytes.fromhex("10 06 00 07 12 34")
command = build_command(16, 15)
assert command[:6] == bytes.fromhex("10 06 00 7F 80 00")
def test_bits_keep_original_lsb_first_order():
bits = bits_lsb_first(0x8005)
assert bits[0] and bits[2] and bits[15]
assert word_from_bits(bits) == 0x8005

View File

@@ -5,6 +5,7 @@ import math
import os
from pathlib import Path
import unittest
from dataclasses import replace
from protocan.plot import Bounds, Marker, Markers, PlotMath, Viewport
@@ -31,6 +32,9 @@ class PlotTests(unittest.TestCase):
def test_markers_stay_in_data_coordinates_and_cross(self):
bounds = Bounds(1000, 2000, -10, 10)
markers = Markers().positioned(self.core, bounds)
self.assertTrue(markers.enabled(Marker.A))
self.assertFalse(markers.enabled(Marker.C))
self.assertTrue(replace(markers, x_pairs=2).enabled(Marker.C))
zoomed = bounds.visible(self.core, Viewport(.25, .25, .5, .5))
self.assertEqual(markers, markers.positioned(self.core, zoomed))
moved = markers.drag(self.core, Marker.A, 50, 500, zoomed)
@@ -46,6 +50,13 @@ class PlotTests(unittest.TestCase):
with self.assertRaises(ValueError):
self.core.pinch_axis(value, 10, 8)
def test_locked_viewport_and_decibel_delta(self):
locked = Viewport(locked=True)
self.assertEqual(locked, locked.transform(self.core, zoom_x=2, pan_y=.2))
self.assertAlmostEqual(20, self.core.db_delta(1, 10))
self.assertAlmostEqual(-20, self.core.db_delta(10, 1))
self.assertIsNone(self.core.db_delta(0, 1))
if __name__ == "__main__":
unittest.main()