13 Commits

Author SHA1 Message Date
20dbc2c721 fix(set-protocol): сохрани совместимость порта с ARMCC 2026-09-07 08:42:02 +03:00
e95338d781 fix(set-protocol): убери libc из порта DevBoard F407 2026-09-07 08:41:26 +03:00
daa3466a41 feat(set-protocol): добавь порт F407 для DevBoard_V1 2026-09-07 08:32:02 +03:00
c9cf797132 Добавить HTML-справочник CAN v1 и v2 2026-09-06 03:09:53 +03:00
de5dc18a43 Merge pull request 'Расширить графики и общие протоколы CAN и Periph28335' (#4) from feature/plot-can-periph-updates into master
Reviewed-on: #4
2026-09-04 19:18:54 +03:00
d44d57a7aa Убрать лишнюю строку в Legacy CAN API 2026-09-04 19:17:27 +03:00
b972b6f33c Добавить канал TMS в каталог прошивок 2026-09-04 19:15:48 +03:00
454baeed98 Добавить общий протокол Periph28335 2026-09-04 19:15:48 +03:00
01ffc0e496 Добавить общий Python API старого CAN terminal 2026-09-04 19:15:47 +03:00
3c4ac9963d Добавить общий API старого CAN terminal 2026-09-04 19:15:47 +03:00
d9eb7dd9ad feat(plot): lock axes and calculate marker levels in dB 2026-09-04 19:15:47 +03:00
f6787163dc feat(plot): configure one or two marker pairs per axis 2026-09-04 19:15:47 +03:00
5f8fc07f2a Merge pull request 'Добавить общие графики и драйвер parallel NAND' (#3) from feature/shared-plot-and-parallel-nand into master
Reviewed-on: #3
2026-09-04 12:39:12 +03:00
49 changed files with 2820 additions and 28 deletions

View File

@@ -34,6 +34,7 @@ templates/
| [`c/can-sensor`](c/can-sensor) | однокадровые SETCAN SETTINGS для 64-битных ROM | ядро: `stdint.h`; порт F1: CMSIS | callbacks либо готовый bxCAN STM32F1 |
| [`c/ds18b20`](c/ds18b20) | термометры DS18B20 поверх программной 1-Wire | `stdint.h` | Init, DelayUs, Reset, WriteBit, ReadBit — **порты STM32F103, STM32G431 и STM32G474 в комплекте** |
| [`c/set-protocol`](c/set-protocol) | единое ядро SETProtocol: SET v2, совместимые ProtoCAN/GUI v1, GAS, телеметрия, firmware flow и стабильный host ABI | C99 | COM/SLCAN/SocketCAN/USB/Ethernet или callbacks — **Windows, Android и STM32F4-порты в комплекте** |
| [`c/set-protocol/ports/stm32f407-devboard-v1`](c/set-protocol/ports/stm32f407-devboard-v1) | доступ SETGUI к Modbus-регистрам F407 через CAN485 DevBoard_V1 | `pcan_modbus_server`, STM32 HAL CAN | bxCAN FIFO0 и callbacks карты регистров |
| [`c/set-protocol/ports/stm32-bxcan`](c/set-protocol/ports/stm32-bxcan) | порт прикладного ProtoCAN для STM32, бывший SETCAN; сохранён API `PROTOCAN_*` | STM32 HAL CAN/RTC/TIM + общее ядро `pcan_id` | classic bxCAN; настройки платы предоставляет прошивка |
| [`c/protocan-boot`](c/protocan-boot) | адресная прошивка по ProtoCAN: A/B-слоты, сессия, CRC32, verify и rollback-контракт | C99 | CAN TX, erase/write Flash, boot metadata, проверка образа и reboot |
| [`c/rs485-boot`](c/rs485-boot) | прошивка по RS-485 в формате SETGUI v1: потоковый parser, CRC32 и resume | C99 | UART TX/RX, DE, Flash — **порты STM32F103 и STM32G474VET в комплекте** |

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
@@ -25,6 +26,7 @@ set(SETPROTOCOL_LEGACY_SOURCES
src/pcan_gas.c
src/pcan_id.c
src/pcan_link.c
src/pcan_modbus_server.c
src/pcan_ring.c
)
@@ -87,6 +89,10 @@ if(SETP_BUILD_TESTS)
target_link_libraries(test_transport PRIVATE setprotocol_static)
add_test(NAME legacy_transport COMMAND test_transport)
add_executable(test_modbus_server tests/test_modbus_server.c)
target_link_libraries(test_modbus_server PRIVATE setprotocol_static)
add_test(NAME protocan_modbus_server COMMAND test_modbus_server)
add_executable(test_gui tests/test_gui.c)
target_link_libraries(test_gui PRIVATE setprotocol_static)
add_test(NAME legacy_gui COMMAND test_gui)
@@ -94,6 +100,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

@@ -0,0 +1,60 @@
/**
* @file pcan_modbus_server.h
* @brief Modbus register windows transported in one classic ProtoCAN frame.
*/
#ifndef PCAN_MODBUS_SERVER_H
#define PCAN_MODBUS_SERVER_H
#include <stdbool.h>
#include <stdint.h>
#include "pcan_frame.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
PCAN_MODBUS_COIL = 0x4U,
PCAN_MODBUS_DISCRETE = 0x5U,
PCAN_MODBUS_HOLDING = 0x6U,
PCAN_MODBUS_INPUT = 0x7U
} pcan_modbus_bank_t;
typedef bool (*pcan_modbus_read_fn)(void *user, pcan_modbus_bank_t bank,
uint16_t address, uint16_t *value);
typedef bool (*pcan_modbus_write_fn)(void *user, pcan_modbus_bank_t bank,
uint16_t address, uint16_t value);
typedef struct {
uint8_t device_type;
uint8_t device_id;
pcan_modbus_read_fn read;
pcan_modbus_write_fn write;
void *user;
uint32_t requests;
uint32_t responses;
uint32_t rejected;
} pcan_modbus_server_t;
typedef enum {
PCAN_MODBUS_NOT_FOR_US = 0,
PCAN_MODBUS_HANDLED_NO_RESPONSE,
PCAN_MODBUS_RESPONSE
} pcan_modbus_result_t;
bool pcan_modbus_server_init(pcan_modbus_server_t *server,
uint8_t device_type, uint8_t device_id,
pcan_modbus_read_fn read,
pcan_modbus_write_fn write, void *user);
/** Zero DLC reads; data writes COIL/HOLDING. Register words are little-endian. */
pcan_modbus_result_t pcan_modbus_server_handle(pcan_modbus_server_t *server,
const pcan_frame_t *request,
pcan_frame_t *response);
#ifdef __cplusplus
}
#endif
#endif /* PCAN_MODBUS_SERVER_H */

View File

@@ -23,6 +23,7 @@
#include "pcan_gas.h"
#include "pcan_id.h"
#include "pcan_link.h"
#include "pcan_modbus_server.h"
#include "pcan_ring.h"
#endif /* PROTOCAN_TRANSPORT_H */

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,22 @@
# STM32F407 + CAN485 DevBoard_V1
Порт даёт SETGUI доступ к существующим Modbus-регистрам STM32F407 через
классический CAN. ПК подключён к USB-UART платы CAN485 DevBoard_V1, команда
`T,E` платы отправляет ProtoCAN-кадр, а этот порт отвечает тем же типом кадра.
При маршруте `Q3` плата одновременно ретранслирует поток по RS-485.
Порт не настраивает GPIO, bitrate, фильтры и не запускает CAN: это остаётся за
CubeMX-проектом. Необработанные кадры передаются callback-у приложения, поэтому
загрузчик ProtoCAN и регистровый сервис могут делить FIFO0.
Слой приложения предоставляет callback-и чтения и записи. Нулевой DLC означает
чтение; данные в кадре `HOLDING`/`COIL` — запись. За кадр передаются до четырёх
16-битных регистров или до пятнадцати coils (предел 4-битного `RegCount`).
```c
static set_devboard_v1_stm32f407_t gui_can;
set_devboard_v1_stm32f407_init(&gui_can, &hcan, 7, 3,
app_read_register, app_write_register, NULL,
app_handle_boot_frame, NULL);
for (;;) set_devboard_v1_stm32f407_poll(&gui_can);
```

View File

@@ -0,0 +1,83 @@
#include "set_devboard_v1_stm32f407.h"
static bool transmit(set_devboard_v1_stm32f407_t *port,
const pcan_frame_t *frame)
{
CAN_TxHeaderTypeDef header = {0};
uint32_t mailbox = 0U;
uint8_t data[8] = {0U};
header.ExtId = frame->id & 0x1FFFFFFFUL;
header.IDE = CAN_ID_EXT;
header.RTR = ((frame->flags & PCAN_FLAG_RTR) != 0U) ?
CAN_RTR_REMOTE : CAN_RTR_DATA;
header.DLC = frame->dlc;
header.TransmitGlobalTime = DISABLE;
for (uint8_t index = 0U; index < frame->dlc; ++index) {
data[index] = frame->data[index];
}
if (HAL_CAN_AddTxMessage(port->can, &header, data, &mailbox) != HAL_OK) {
port->tx_errors++;
return false;
}
port->tx_frames++;
return true;
}
bool set_devboard_v1_stm32f407_init(
set_devboard_v1_stm32f407_t *port, CAN_HandleTypeDef *can,
uint8_t device_type, uint8_t device_id,
pcan_modbus_read_fn read, pcan_modbus_write_fn write, void *register_user,
set_devboard_v1_unhandled_fn unhandled, void *unhandled_user)
{
if ((port == NULL) || (can == NULL)) {
return false;
}
port->can = can;
port->unhandled = unhandled;
port->unhandled_user = unhandled_user;
port->rx_frames = 0U;
port->tx_frames = 0U;
port->tx_errors = 0U;
return pcan_modbus_server_init(&port->server, device_type, device_id,
read, write, register_user);
}
size_t set_devboard_v1_stm32f407_poll(set_devboard_v1_stm32f407_t *port)
{
size_t handled = 0U;
if ((port == NULL) || (port->can == NULL)) {
return 0U;
}
while (HAL_CAN_GetRxFifoFillLevel(port->can, CAN_RX_FIFO0) != 0U) {
CAN_RxHeaderTypeDef header;
uint8_t data[8] = {0U};
pcan_frame_t request = {0};
pcan_frame_t response;
pcan_modbus_result_t result;
if (HAL_CAN_GetRxMessage(port->can, CAN_RX_FIFO0, &header, data) != HAL_OK) {
break;
}
port->rx_frames++;
request.id = (header.IDE == CAN_ID_EXT) ? header.ExtId : header.StdId;
request.flags = (header.IDE == CAN_ID_EXT) ? PCAN_FLAG_IDE : 0U;
if (header.RTR == CAN_RTR_REMOTE) {
request.flags |= PCAN_FLAG_RTR;
}
request.dlc = (header.DLC > 8U) ? 8U : (uint8_t)header.DLC;
for (uint8_t index = 0U; index < request.dlc; ++index) {
request.data[index] = data[index];
}
result = pcan_modbus_server_handle(&port->server, &request, &response);
if (result == PCAN_MODBUS_RESPONSE) {
(void)transmit(port, &response);
handled++;
} else if ((result == PCAN_MODBUS_NOT_FOR_US) &&
(port->unhandled != NULL)) {
port->unhandled(port->unhandled_user, &header, data);
}
}
return handled;
}

View File

@@ -0,0 +1,38 @@
/** STM32F407 bxCAN port for SETGUI through CAN485 DevBoard_V1. */
#ifndef SET_DEVBOARD_V1_STM32F407_H
#define SET_DEVBOARD_V1_STM32F407_H
#include "stm32f4xx_hal.h"
#include "pcan_modbus_server.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef void (*set_devboard_v1_unhandled_fn)(void *user,
const CAN_RxHeaderTypeDef *header,
const uint8_t data[8]);
typedef struct {
CAN_HandleTypeDef *can;
pcan_modbus_server_t server;
set_devboard_v1_unhandled_fn unhandled;
void *unhandled_user;
uint32_t rx_frames;
uint32_t tx_frames;
uint32_t tx_errors;
} set_devboard_v1_stm32f407_t;
bool set_devboard_v1_stm32f407_init(
set_devboard_v1_stm32f407_t *port, CAN_HandleTypeDef *can,
uint8_t device_type, uint8_t device_id,
pcan_modbus_read_fn read, pcan_modbus_write_fn write, void *register_user,
set_devboard_v1_unhandled_fn unhandled, void *unhandled_user);
size_t set_devboard_v1_stm32f407_poll(set_devboard_v1_stm32f407_t *port);
#ifdef __cplusplus
}
#endif
#endif

View File

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

View File

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

View File

@@ -0,0 +1,144 @@
#include "pcan_modbus_server.h"
#include <stddef.h>
#include "pcan_id.h"
static bool is_bank(uint8_t value)
{
return (value >= (uint8_t)PCAN_MODBUS_COIL) &&
(value <= (uint8_t)PCAN_MODBUS_INPUT);
}
bool pcan_modbus_server_init(pcan_modbus_server_t *server,
uint8_t device_type, uint8_t device_id,
pcan_modbus_read_fn read,
pcan_modbus_write_fn write, void *user)
{
if ((server == NULL) || (read == NULL) || (device_type > 7U) ||
(device_id > 15U)) {
return false;
}
server->device_type = device_type;
server->device_id = device_id;
server->read = read;
server->write = write;
server->user = user;
server->requests = 0U;
server->responses = 0U;
server->rejected = 0U;
return true;
}
static pcan_modbus_result_t reject(pcan_modbus_server_t *server)
{
server->rejected++;
return PCAN_MODBUS_HANDLED_NO_RESPONSE;
}
pcan_modbus_result_t pcan_modbus_server_handle(pcan_modbus_server_t *server,
const pcan_frame_t *request,
pcan_frame_t *response)
{
pcan_id_t id;
uint16_t address;
uint8_t count;
pcan_modbus_bank_t bank;
if ((server == NULL) || (request == NULL) || (response == NULL) ||
((request->flags & PCAN_FLAG_IDE) == 0U)) {
return PCAN_MODBUS_NOT_FOR_US;
}
pcan_id_unpack(request->id, &id);
if ((id.route != PCAN_ROUTE_FROM_PM) ||
(id.device_type != server->device_type) ||
(id.device_id != server->device_id) || !is_bank(id.msg_type)) {
return PCAN_MODBUS_NOT_FOR_US;
}
server->requests++;
address = (uint16_t)(id.msg_body >> 4U);
count = (uint8_t)(id.msg_body & 0x0FU);
bank = (pcan_modbus_bank_t)id.msg_type;
if (count == 0U) {
return reject(server);
}
*response = *request;
id.route = PCAN_ROUTE_FROM_DEVICE;
response->id = pcan_id_pack(&id);
response->flags = PCAN_FLAG_IDE;
response->seq = 0U;
if (request->dlc == 0U) {
uint16_t packed = 0U;
if ((bank == PCAN_MODBUS_COIL) || (bank == PCAN_MODBUS_DISCRETE)) {
if (count > 16U) {
return reject(server);
}
for (uint8_t index = 0U; index < count; ++index) {
uint16_t value = 0U;
if (!server->read(server->user, bank,
(uint16_t)(address + index), &value)) {
return reject(server);
}
if (value != 0U) {
packed |= (uint16_t)(1U << index);
}
}
response->dlc = 2U;
response->data[0] = (uint8_t)packed;
response->data[1] = (uint8_t)(packed >> 8U);
} else {
if (count > 4U) {
return reject(server);
}
response->dlc = (uint8_t)(count * 2U);
for (uint8_t index = 0U; index < count; ++index) {
uint16_t value = 0U;
if (!server->read(server->user, bank,
(uint16_t)(address + index), &value)) {
return reject(server);
}
response->data[index * 2U] = (uint8_t)value;
response->data[(index * 2U) + 1U] = (uint8_t)(value >> 8U);
}
}
} else {
if ((bank == PCAN_MODBUS_INPUT) || (bank == PCAN_MODBUS_DISCRETE) ||
(server->write == NULL)) {
return reject(server);
}
if (bank == PCAN_MODBUS_COIL) {
uint16_t bits;
if ((count > 16U) || (request->dlc != 2U)) {
return reject(server);
}
bits = (uint16_t)((uint16_t)request->data[0] |
((uint16_t)request->data[1] << 8U));
for (uint8_t index = 0U; index < count; ++index) {
if (!server->write(server->user, bank,
(uint16_t)(address + index),
(uint16_t)((bits >> index) & 1U))) {
return reject(server);
}
}
} else {
if ((count > 4U) || (request->dlc != (uint8_t)(count * 2U))) {
return reject(server);
}
for (uint8_t index = 0U; index < count; ++index) {
uint16_t value = (uint16_t)(request->data[index * 2U] |
((uint16_t)request->data[(index * 2U) + 1U] << 8U));
if (!server->write(server->user, bank,
(uint16_t)(address + index), value)) {
return reject(server);
}
}
}
response->dlc = request->dlc;
}
server->responses++;
return PCAN_MODBUS_RESPONSE;
}

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

@@ -0,0 +1,90 @@
#include <stdio.h>
#include <string.h>
#include "pcan_id.h"
#include "pcan_modbus_server.h"
static uint16_t holding[32];
static uint16_t input[32];
static uint16_t coils;
static bool read_word(void *user, pcan_modbus_bank_t bank,
uint16_t address, uint16_t *value)
{
(void)user;
if (address >= 32U) return false;
if (bank == PCAN_MODBUS_HOLDING) *value = holding[address];
else if (bank == PCAN_MODBUS_INPUT) *value = input[address];
else if (bank == PCAN_MODBUS_COIL) *value = (uint16_t)((coils >> address) & 1U);
else return false;
return true;
}
static bool write_word(void *user, pcan_modbus_bank_t bank,
uint16_t address, uint16_t value)
{
(void)user;
if (address >= 32U) return false;
if (bank == PCAN_MODBUS_HOLDING) holding[address] = value;
else if (bank == PCAN_MODBUS_COIL && address < 16U) {
if (value) coils |= (uint16_t)(1U << address);
else coils &= (uint16_t)~(1U << address);
} else return false;
return true;
}
static pcan_frame_t request(uint8_t type, uint16_t address, uint8_t count)
{
pcan_id_t id = {0};
pcan_frame_t frame = {0};
id.priority = PCAN_PRIORITY_STANDARD;
id.route = PCAN_ROUTE_FROM_PM;
id.device_type = 7U;
id.device_id = 3U;
id.msg_type = type;
id.msg_body = pcan_body_modbus(address, count);
frame.id = pcan_id_pack(&id);
frame.flags = PCAN_FLAG_IDE;
return frame;
}
#define CHECK(x) do { if (!(x)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); return 1; \
} } while (0)
int main(void)
{
pcan_modbus_server_t server;
pcan_frame_t req;
pcan_frame_t rsp;
pcan_id_t id;
memset(holding, 0, sizeof(holding));
memset(input, 0, sizeof(input));
input[10] = 0x1234U;
input[11] = 0xABCDU;
CHECK(pcan_modbus_server_init(&server, 7U, 3U, read_word, write_word, NULL));
req = request(PCAN_MODBUS_INPUT, 10U, 2U);
CHECK(pcan_modbus_server_handle(&server, &req, &rsp) == PCAN_MODBUS_RESPONSE);
CHECK(rsp.dlc == 4U && rsp.data[0] == 0x34U && rsp.data[1] == 0x12U);
CHECK(rsp.data[2] == 0xCDU && rsp.data[3] == 0xABU);
pcan_id_unpack(rsp.id, &id);
CHECK(id.route == PCAN_ROUTE_FROM_DEVICE);
req = request(PCAN_MODBUS_HOLDING, 3U, 2U);
req.dlc = 4U;
req.data[0] = 0x22U; req.data[1] = 0x11U;
req.data[2] = 0x44U; req.data[3] = 0x33U;
CHECK(pcan_modbus_server_handle(&server, &req, &rsp) == PCAN_MODBUS_RESPONSE);
CHECK(holding[3] == 0x1122U && holding[4] == 0x3344U);
req = request(PCAN_MODBUS_COIL, 2U, 3U);
req.dlc = 2U; req.data[0] = 0x05U; req.data[1] = 0U;
CHECK(pcan_modbus_server_handle(&server, &req, &rsp) == PCAN_MODBUS_RESPONSE);
CHECK(coils == 0x0014U);
req = request(PCAN_MODBUS_INPUT, 0U, 5U);
CHECK(pcan_modbus_server_handle(&server, &req, &rsp) == PCAN_MODBUS_HANDLED_NO_RESPONSE);
CHECK(server.requests == 4U && server.responses == 3U && server.rejected == 1U);
return 0;
}

View File

@@ -21,9 +21,9 @@ 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",
"pcan_gas.c", "pcan_modbus_server.c",
)
]

View File

@@ -0,0 +1,379 @@
# Разбор CAN-кадров ProtoCAN Boot v1 и SETProtocol v2
Документ описывает wire-форматы двух протоколов обновления прошивки:
- **v1** — `templates/c/protocan-boot`, одна команда или 8 байт образа в одном
Extended CAN-кадре;
- **v2** — `templates/c/set-protocol`, полный кадр SETProtocol разбивается на
несколько Extended CAN-кадров.
Все многобайтные поля payload передаются **little-endian**. CAN ID — 29-битный.
Для рабочего кода нужно использовать канонические реализации из `templates`,
а приведённый ниже Python-парсер удобен для анализатора, логов и отладки.
## 1. ProtoCAN Boot v1
### 1.1. Разметка Extended CAN ID
```text
bits size field
28 1 Priority
27 1 Route: 0 = host -> device, 1 = device -> host
26..24 3 Device Type
23..20 4 Device ID
19..16 4 Message Type
15..0 16 Message Body
```
Формула:
```text
ID = Priority << 28 |
Route << 27 |
DeviceType << 24 |
DeviceID << 20 |
MessageType << 16 |
MessageBody
```
Типы загрузочных сообщений:
| Message Type | Имя | Message Body | CAN payload |
|---:|---|---|---|
| `0x9` | `BOOT_CONTROL` | `SessionID << 8 \| Command` | параметры команды |
| `0xA` | `BOOT_DATA_A` | индекс блока | 8 байт слота A |
| `0xB` | `BOOT_DATA_B` | индекс блока | 8 байт слота B |
| `0xC` | `BOOT_STATUS` | `SessionID << 8 \| Command` | статус и прогресс |
| `0xD` | `BOOT_DISCOVERY` | подтип | информация об устройстве |
Команды `BOOT_CONTROL`:
| Код | Команда | Payload |
|---:|---|---|
| `0x01` | `IDENTIFY` | пустой |
| `0x02` | `ENTER_BOOT` | пустой |
| `0x03` | `BEGIN_IMAGE` | `image_size u32`, `image_crc32 u32` |
| `0x04` | `BEGIN_COMPAT` | `product u16`, `hw_min u8`, `hw_max u8`, `version u32` |
| `0x05` | `ERASE` | пустой |
| `0x06` | `VERIFY` | пустой |
| `0x07` | `COMMIT` | пустой |
| `0x08` | `CONFIRM` | пустой |
| `0x09` | `REBOOT` | пустой |
| `0x0A` | `ABORT` | пустой |
| `0x0B` | `QUERY_PROGRESS` | пустой |
`BOOT_STATUS` всегда содержит 8 байт:
```text
offset size field
0 1 status
1 1 target_slot
2 2 next_block u16 LE
4 4 running_crc32 u32 LE
```
`BOOT_DISCOVERY` с body `1` содержит:
```text
offset size field
0 2 product_type u16 LE
2 1 hardware_revision
3 1 protocol_version = 1
4 4 firmware_version u32 LE
```
Пример запроса `IDENTIFY` для `DeviceType=7`, `DeviceID=13`:
```text
CAN ID: 17D90001
DLC: 0
```
### 1.2. Python-парсер v1
```python
def parse_v1(can_id: int, data: bytes) -> dict:
if not 0 <= can_id <= 0x1FFFFFFF:
raise ValueError("неверный Extended CAN ID")
if len(data) > 8:
raise ValueError("DLC больше 8")
result = {
"version": 1,
"priority": (can_id >> 28) & 0x01,
"route": (can_id >> 27) & 0x01,
"device_type": (can_id >> 24) & 0x07,
"device_id": (can_id >> 20) & 0x0F,
"message_type": (can_id >> 16) & 0x0F,
"message_body": can_id & 0xFFFF,
"data": bytes(data),
}
msg_type = result["message_type"]
body = result["message_body"]
if msg_type in (0x9, 0xC):
result["session_id"] = (body >> 8) & 0xFF
result["command"] = body & 0xFF
elif msg_type in (0xA, 0xB):
result["slot"] = msg_type - 0xA
result["block_index"] = body
if msg_type == 0xC:
if len(data) != 8:
raise ValueError("BOOT_STATUS должен содержать 8 байт")
result.update({
"status": data[0],
"target_slot": data[1],
"next_block": int.from_bytes(data[2:4], "little"),
"running_crc32": int.from_bytes(data[4:8], "little"),
})
elif msg_type == 0xD and body == 1:
if len(data) != 8:
raise ValueError("BOOT_DISCOVERY должен содержать 8 байт")
result.update({
"product_type": int.from_bytes(data[0:2], "little"),
"hardware_revision": data[2],
"protocol_version": data[3],
"firmware_version": int.from_bytes(data[4:8], "little"),
})
return result
```
## 2. SETProtocol v2 поверх classic CAN
В v2 CAN-кадр является только транспортным сегментом. Сначала нужно собрать
полный SETP-пакет, и только затем разбирать его заголовок, payload и CRC32.
### 2.1. Разметка Extended CAN ID
```text
bits size field
28..24 5 Prefix = 0x12
23..16 8 Destination node
15..8 8 Source node
7 1 Priority
6..0 7 Channel
```
Формула:
```text
ID = 0x12 << 24 |
Destination << 16 |
Source << 8 |
Priority << 7 |
Channel
```
### 2.2. CAN-сегменты
Первый байт CAN payload — PCI:
| PCI | Назначение | Формат CAN payload |
|---:|---|---|
| `0x10` | первый сегмент | `10`, `total_length u16 LE`, первые 5 байт SETP |
| `0x20..0x2F` | продолжение | `2N`, следующие 17 байт SETP |
| `0x30..0x32` | flow control | `3S`, `block_size`, `st_min_ms` |
`N` — циклический номер сегмента `1..15,0..`; следующий сегмент обязан иметь
ожидаемый номер, тот же CAN ID и прийти до тайм-аута сборки 500 мс.
### 2.3. Внутренний кадр SETProtocol v2
```text
offset size field
0 2 SOF = A5 5A
2 1 version = 02
3 1 flags
4 2 message_type u16 LE
6 2 source u16 LE
8 2 destination u16 LE
10 2 sequence u16 LE
12 2 payload_length u16 LE
14 N payload
14+N 4 CRC32 IEEE u32 LE
```
CRC32 считается по байтам от `version` на offset 2 до конца payload. Поля
`source`, `destination` и `priority` внутреннего заголовка должны совпадать с
CAN ID.
Флаги:
| Бит | Значение |
|---:|---|
| `0x01` | RESPONSE |
| `0x02` | EVENT |
| `0x04` | ERROR |
| `0x08` | ACK_REQUIRED |
| `0x10` | MORE |
| `0x20` | PRIORITY |
Каждый response начинается с `status u16 LE`. Основные firmware message types:
`FW_BEGIN=0x0100`, `FW_DATA=0x0101`, `FW_END=0x0102`, `FW_ABORT=0x0103`,
`FW_STATUS=0x0104`, `FW_ACTIVATE=0x0105`.
Пример `PING` к BALZAM node `13`, source `0`, sequence `1`, priority `1`,
channel `1`:
```text
Полный SETP:
A5 5A 02 28 01 00 00 00 0D 00 01 00 00 00 E7 29 51 40
CAN ID 120D0081, сегменты:
10 12 00 A5 5A 02 28 01
21 00 00 00 0D 00 01 00
22 00 00 E7 29 51 40
```
### 2.4. Python-парсер и сборщик v2
```python
import binascii
def parse_v2_can_id(can_id: int) -> dict:
if not 0 <= can_id <= 0x1FFFFFFF:
raise ValueError("неверный Extended CAN ID")
if (can_id >> 24) & 0x1F != 0x12:
raise ValueError("не SETProtocol v2 CAN ID")
return {
"destination": (can_id >> 16) & 0xFF,
"source": (can_id >> 8) & 0xFF,
"priority": (can_id >> 7) & 0x01,
"channel": can_id & 0x7F,
}
def parse_setp(packet: bytes, can_id: int) -> dict:
if len(packet) < 18 or packet[:2] != b"\xA5\x5A":
raise ValueError("нет полного SETP-кадра")
if packet[2] != 2:
raise ValueError("неподдерживаемая версия SETP")
flags = packet[3]
if flags & 0xC0:
raise ValueError("установлены зарезервированные флаги")
payload_length = int.from_bytes(packet[12:14], "little")
if len(packet) != 14 + payload_length + 4:
raise ValueError("не совпадает payload_length")
expected_crc = int.from_bytes(packet[-4:], "little")
actual_crc = binascii.crc32(packet[2:-4]) & 0xFFFFFFFF
if actual_crc != expected_crc:
raise ValueError("ошибка CRC32 SETP")
address = parse_v2_can_id(can_id)
source = int.from_bytes(packet[6:8], "little")
destination = int.from_bytes(packet[8:10], "little")
priority = int(bool(flags & 0x20))
if (source, destination, priority) != (
address["source"], address["destination"], address["priority"]
):
raise ValueError("SETP header не совпадает с CAN ID")
payload = packet[14:-4]
result = {
"version": 2,
"flags": flags,
"message_type": int.from_bytes(packet[4:6], "little"),
"source": source,
"destination": destination,
"sequence": int.from_bytes(packet[10:12], "little"),
"payload": payload,
"can": address,
}
if flags & 0x01:
if len(payload) < 2:
raise ValueError("response не содержит status")
result["status"] = int.from_bytes(payload[:2], "little")
result["body"] = payload[2:]
return result
class V2CanReassembler:
def __init__(self, timeout_ms: int = 500):
self.timeout_ms = timeout_ms
self.reset()
def reset(self):
self.can_id = None
self.total = 0
self.data = bytearray()
self.next_sequence = 1
self.deadline_ms = 0
def feed(self, can_id: int, data: bytes, now_ms: int):
parse_v2_can_id(can_id)
if not 1 <= len(data) <= 8:
raise ValueError("DLC вне диапазона 1..8")
if self.can_id is not None and now_ms >= self.deadline_ms:
self.reset()
raise ValueError("тайм-аут сборки SETP")
pci_type = data[0] & 0xF0
if pci_type == 0x10:
if len(data) != 8:
raise ValueError("первый сегмент должен иметь DLC 8")
total = int.from_bytes(data[1:3], "little")
if not 18 <= total <= 530:
raise ValueError("неверный размер SETP")
self.can_id = can_id
self.total = total
self.data = bytearray(data[3:])
self.next_sequence = 1
self.deadline_ms = now_ms + self.timeout_ms
return None
if pci_type == 0x20:
sequence = data[0] & 0x0F
if (
self.can_id is None
or can_id != self.can_id
or sequence != self.next_sequence
or len(data) < 2
):
self.reset()
raise ValueError("ошибка последовательности CAN-сегментов")
if len(data) - 1 > self.total - len(self.data):
self.reset()
raise ValueError("лишние байты CAN-сегмента")
self.data.extend(data[1:])
self.next_sequence = (self.next_sequence + 1) & 0x0F
self.deadline_ms = now_ms + self.timeout_ms
if len(self.data) == self.total:
packet = bytes(self.data)
packet_can_id = self.can_id
self.reset()
return parse_setp(packet, packet_can_id)
return None
if pci_type == 0x30:
return {"flow_control": data[0] & 0x0F, "data": data[1:]}
raise ValueError("неизвестный PCI")
```
В SETGUI эти операции уже реализованы в
`third_party/templates/python/setprotocol/can.py`; собственный parser нужен
только внешнему анализатору или диагностическому скрипту.
## 3. Как отличать v1 от v2
Для используемых сейчас адресов достаточно следующих признаков:
- v2: верхние пять бит CAN ID равны `0x12`, PCI начинается с `0x10`, `0x2N`
или `0x3S`, после reassembly присутствует `A5 5A 02`;
- v1: `MessageType` в битах `19..16` равен `0x9..0xD`, каждый кадр разбирается
самостоятельно.
Однако универсальное автоопределение только по одному CAN ID невозможно:
комбинация `Priority/Route/DeviceType` v1 теоретически тоже может дать верхнее
поле `0x12`, а первый байт firmware data v1 может случайно совпасть с PCI.
Надёжный анализатор должен учитывать настроенный режим узла либо подтвердить v2
только после сборки кадра с корректными `A5 5A 02`, длиной и CRC32.
## 4. Канонические исходники
- v1 ID и state machine: `third_party/templates/c/protocan-boot/src/pcan_boot.c`;
- v2 CAN transport: `third_party/templates/c/set-protocol/src/set_can.c`;
- v2 frame/CRC: `third_party/templates/c/set-protocol/src/set_protocol.c`;
- v2 firmware payload: `third_party/templates/c/set-protocol/src/set_firmware.c`;
- Python v2 CAN: `third_party/templates/python/setprotocol/can.py`.

View File

@@ -4,9 +4,22 @@ param()
$ErrorActionPreference = 'Stop'
$outputPath = Join-Path $PSScriptRoot 'setprotocol.html'
$sourcePath = Join-Path $PSScriptRoot '..\c\set-protocol\docs\SETPROTOCOL.md'
$canFrameSourcePath = Join-Path $PSScriptRoot 'CAN_FRAME_PARSE_V1_V2.md'
$markdown = Get-Content -Raw -LiteralPath $sourcePath -Encoding UTF8
$html = (ConvertFrom-Markdown -InputObject $markdown).Html
function Convert-DocumentationMarkdown {
param([string]$Path)
$markdown = Get-Content -Raw -LiteralPath $Path -Encoding UTF8
$html = (ConvertFrom-Markdown -InputObject $markdown).Html
# Wide protocol tables must scroll horizontally instead of squeezing their
# contents into unreadable one-character columns on a narrow viewport.
$html = $html -replace '<table>', '<div class="table-wrap"><table>'
$html = $html -replace '</table>', '</table></div>'
return $html
}
$html = Convert-DocumentationMarkdown -Path $sourcePath
$generatedBlock = @"
<!-- SETPROTOCOL:START -->
<article class="card full-doc" data-source="c/set-protocol/docs/SETPROTOCOL.md">
@@ -15,6 +28,15 @@ $html
<!-- SETPROTOCOL:END -->
"@
$canFrameHtml = Convert-DocumentationMarkdown -Path $canFrameSourcePath
$canFrameBlock = @"
<!-- CAN-FRAME-PARSE:START -->
<article class="card full-doc" data-source="doc/CAN_FRAME_PARSE_V1_V2.md">
$canFrameHtml
</article>
<!-- CAN-FRAME-PARSE:END -->
"@
$page = Get-Content -Raw -LiteralPath $outputPath -Encoding UTF8
$pattern = '(?s)<!-- SETPROTOCOL:START -->.*?<!-- SETPROTOCOL:END -->'
if ($page -notmatch $pattern) {
@@ -26,6 +48,16 @@ $page = [regex]::Replace($page, $pattern, [System.Text.RegularExpressions.MatchE
$generatedBlock
}, 1)
$canFramePattern = '(?s)<!-- CAN-FRAME-PARSE:START -->.*?<!-- CAN-FRAME-PARSE:END -->'
if ($page -notmatch $canFramePattern) {
throw 'Не найдены маркеры CAN-FRAME-PARSE:START/END в doc/setprotocol.html.'
}
$page = [regex]::Replace($page, $canFramePattern, [System.Text.RegularExpressions.MatchEvaluator]{
param($match)
$canFrameBlock
}, 1)
$page = $page.TrimEnd("`r", "`n") + [Environment]::NewLine
$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($outputPath, $page, $utf8WithoutBom)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

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

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

View File

@@ -0,0 +1,60 @@
"""CAN485 DevBoard_V1 USB command and frame helpers (no GUI/serial dependency)."""
from __future__ import annotations
import re
from dataclasses import dataclass
FRAME_RE = re.compile(
r"^\[\s*(\d+)\.(\d{3})\]\s+(?:(RS485)\s+)?"
r"(EXT|STD)\s+0x([0-9A-Fa-f]+)\s+"
r"(RTR\s+)?DLC=(\d)(?:\s+DATA=([0-9A-Fa-f ]*))?"
)
@dataclass(frozen=True, slots=True)
class BoardFrame:
timestamp: float
extended: bool
can_id: int
data: bytes
rtr: bool = False
source: str = "CAN"
def parse_frame_line(line: str) -> BoardFrame | None:
match = FRAME_RE.match(line.strip())
if match is None:
return None
seconds, millis, source, frame_type, raw_id, raw_rtr, raw_dlc, raw_data = match.groups()
dlc = int(raw_dlc)
data = bytes.fromhex(raw_data or "")
rtr = bool(raw_rtr)
if (not rtr and len(data) != dlc) or (rtr and data):
return None
extended = frame_type == "EXT"
can_id = int(raw_id, 16)
if can_id > (0x1FFFFFFF if extended else 0x7FF):
return None
return BoardFrame(int(seconds) + int(millis) / 1000.0, extended, can_id,
data, rtr, source or "CAN")
def command_transmit(frame: BoardFrame) -> bytes:
if len(frame.data) > 8:
raise ValueError("DLC cannot exceed 8")
kind = "E" if frame.extended else "S"
return f"T,{kind},{frame.can_id:X},{len(frame.data)},{frame.data.hex().upper()}\n".encode("ascii")
def command_setup(*, can_bitrate: int = 500, rs485_baud: int = 512000,
route: int = 3) -> tuple[bytes, ...]:
if can_bitrate not in (25, 50, 100, 125, 250, 500, 800, 1000):
raise ValueError("unsupported CAN bitrate")
if not 1200 <= rs485_baud <= 4_000_000:
raise ValueError("unsupported RS485 baud")
if route not in range(4):
raise ValueError("route must be 0..3")
return (f"S{can_bitrate}\n".encode("ascii"), b"M0\n",
f"B{rs485_baud}\n".encode("ascii"), f"Q{route}\n".encode("ascii"))

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,14 @@
from protocan.devboard_v1 import BoardFrame, command_setup, command_transmit, parse_frame_line
def test_parse_can_and_rs485_lines():
can = parse_frame_line("[ 12.345] EXT 0x073700A2 DLC=2 DATA=34 12 |4.|")
rs = parse_frame_line("[ 12.346] RS485 STD 0x123 RTR DLC=4")
assert can and can.can_id == 0x073700A2 and can.data == b"\x34\x12"
assert rs and rs.source == "RS485" and rs.rtr and rs.data == b""
def test_commands_are_firmware_compatible_and_lf_terminated():
frame = BoardFrame(0.0, True, 0x073700A2, b"\x34\x12")
assert command_transmit(frame) == b"T,E,73700A2,2,3412\n"
assert command_setup() == (b"S500\n", b"M0\n", b"B512000\n", b"Q3\n")

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()