From 2316a5a26a0da11744ee1fc18fd7389c27fd4905 Mon Sep 17 00:00:00 2001
From: Andrey
Date: Fri, 4 Sep 2026 20:39:04 +0300
Subject: [PATCH 01/14] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D1=82=D1=8C=20Android=20API=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE?=
=?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B0=20=D0=9F=D0=9C35?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
c/set-protocol/README.md | 6 +
.../periph28335/Periph28335Protocol.kt | 112 ++++++++++++++++++
.../setcorp/setprotocol/trends/TrendModels.kt | 4 +-
.../periph28335/Periph28335ProtocolTest.kt | 35 ++++++
4 files changed, 155 insertions(+), 2 deletions(-)
create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/periph28335/Periph28335Protocol.kt
create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/periph28335/Periph28335ProtocolTest.kt
diff --git a/c/set-protocol/README.md b/c/set-protocol/README.md
index 6bd2b4d..8842a43 100644
--- a/c/set-protocol/README.md
+++ b/c/set-protocol/README.md
@@ -21,6 +21,12 @@ TCP, UART/DMA и аппаратный CAN подключаются портам
Масштабирование, координаты и измерительные маркеры Android/SETGUI используют
общий `set_plot.c`: [границы модулей, ABI и проверки](docs/GUI_PLOT.md).
+Прямой терминал ПМ35/TMS320F28335 использует единый wire contract
+`Set_Terminal_28335`: функции MODBUS RTU `03/06`, 128 регистров и CRC16.
+Эталонные адаптеры находятся в
+[`python/protocan/periph28335.py`](../../python/protocan/periph28335.py) и
+[`ports/android/kotlin/.../periph28335`](ports/android/kotlin/ru/setcorp/setprotocol/periph28335).
+
## Структура
| Каталог | Назначение |
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/periph28335/Periph28335Protocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/periph28335/Periph28335Protocol.kt
new file mode 100644
index 0000000..a08a149
--- /dev/null
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/periph28335/Periph28335Protocol.kt
@@ -0,0 +1,112 @@
+package ru.setcorp.setprotocol.periph28335
+
+/** Shared wire protocol migrated from Set_Terminal_28335/DTrans.pas and UNiiefa.pas. */
+object Periph28335Protocol {
+ const val REGISTER_COUNT = 128
+ const val DEFAULT_CONTROLLER = 16
+ const val DEFAULT_BAUD_RATE = 115_200
+
+ val projectCommands: Map> = linkedMapOf(
+ "По умолчанию" to listOf("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
+ "23470" to listOf("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "-", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
+ "23550" to listOf("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "Send", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
+ "23550.2" to listOf("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Send", "Raw", "Beep", "", "", "", "", "Log", "Reset", "Nothing at all"),
+ "ICE 22220.1-3" to listOf("Test", "Zero", "Save", "Def", "Calibr", "Read", "ExtLamp", "ExtLite", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
+ "ICE 22220.4-5" to listOf("Test", "Def", "Save", "Load", "Raw", "Read", "ExtLamp", "ExtLite", "No log", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
+ "Бальзам 161" to listOf("Test", "Zero", "Save", "Def", "Calibr", "Clbr 400", "Stop", "Start", "Init", "Secret", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
+ "Бальзам 162" to listOf("Test", "Def", "Save", "Load", "Calibr", "Secret", "Stop", "Start", "Init", "Tune", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
+ "Бальзам 163" to listOf("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Stop", "Start", "Init", "Tune", "Secret", "-", "-", "-", "-", "Reset", "Nothing at all"),
+ )
+
+ fun crc16Modbus(data: ByteArray, initial: Int = 0xFFFF): Int {
+ var crc = initial and 0xFFFF
+ data.forEach { value ->
+ crc = crc xor (value.toInt() and 0xFF)
+ repeat(8) {
+ crc = if (crc and 1 != 0) (crc ushr 1) xor 0xA001 else crc ushr 1
+ }
+ }
+ return crc and 0xFFFF
+ }
+
+ fun withCrc(payload: ByteArray): ByteArray {
+ val crc = crc16Modbus(payload)
+ return payload + byteArrayOf(crc.toByte(), (crc ushr 8).toByte())
+ }
+
+ fun buildReadRegisters(controller: Int, start: Int, count: Int): ByteArray {
+ requireRange("Адрес контроллера", controller, 0xFF)
+ requireRange("Начальный регистр", start, 0xFFFF)
+ require(count in 1..REGISTER_COUNT && start + count <= REGISTER_COUNT) {
+ "Диапазон регистров должен находиться в 0..127"
+ }
+ return withCrc(byteArrayOf(
+ controller.toByte(), 3,
+ (start ushr 8).toByte(), start.toByte(),
+ (count ushr 8).toByte(), count.toByte(),
+ ))
+ }
+
+ fun buildWriteRegister(controller: Int, address: Int, value: Int): ByteArray {
+ requireRange("Адрес контроллера", controller, 0xFF)
+ requireRange("Адрес регистра", address, REGISTER_COUNT - 1)
+ requireRange("Значение", value, 0xFFFF)
+ return withCrc(byteArrayOf(
+ controller.toByte(), 6,
+ (address ushr 8).toByte(), address.toByte(),
+ (value ushr 8).toByte(), value.toByte(),
+ ))
+ }
+
+ fun buildCommand(controller: Int, commandIndex: Int): ByteArray {
+ require(commandIndex in 0..16) { "Номер команды должен быть в диапазоне 0..16" }
+ return buildWriteRegister(controller, 127, if (commandIndex < 16) 1 shl commandIndex else 0)
+ }
+
+ fun expectedReadResponseSize(count: Int): Int {
+ require(count in 1..REGISTER_COUNT)
+ return count * 2 + 5
+ }
+
+ fun decodeReadResponse(data: ByteArray, controller: Int, count: Int): List {
+ val expected = expectedReadResponseSize(count)
+ require(data.size == expected) { "Ожидалось $expected байт, получено ${data.size}" }
+ validateCrc(data)
+ require(data[0].toInt() and 0xFF == controller) { "Ответ другого контроллера" }
+ require(data[1].toInt() and 0xFF == 3) { "Неверная функция ответа" }
+ require(data[2].toInt() and 0xFF == count * 2) { "Неверная длина данных ответа" }
+ return (0 until count).map { index ->
+ val offset = 3 + index * 2
+ ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
+ }
+ }
+
+ fun validateWriteResponse(data: ByteArray, request: ByteArray): Boolean =
+ data.size == 8 && request.size == 8 && data.contentEquals(request) && runCatching { validateCrc(data) }.isSuccess
+
+ fun bitsLsbFirst(value: Int): List {
+ requireRange("Значение", value, 0xFFFF)
+ return (0 until 16).map { bit -> value and (1 shl bit) != 0 }
+ }
+
+ fun wordFromBits(bits: List): Int {
+ require(bits.size == 16) { "Должно быть ровно 16 бит" }
+ return bits.foldIndexed(0) { bit, value, checked -> if (checked) value or (1 shl bit) else value }
+ }
+
+ fun signedWord(value: Int): Int {
+ requireRange("Значение", value, 0xFFFF)
+ return if (value < 0x8000) value else value - 0x10000
+ }
+
+ private fun validateCrc(data: ByteArray) {
+ require(data.size >= 2)
+ val expected = crc16Modbus(data.copyOf(data.size - 2))
+ val actual = (data[data.lastIndex - 1].toInt() and 0xFF) or ((data.last().toInt() and 0xFF) shl 8)
+ require(actual == expected) { "Ошибка CRC ответа" }
+ }
+
+ private fun requireRange(name: String, value: Int, maximum: Int) {
+ require(value in 0..maximum) { "$name вне диапазона 0..$maximum" }
+ }
+}
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendModels.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendModels.kt
index c254742..54bc95a 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendModels.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendModels.kt
@@ -6,7 +6,7 @@ enum class TrendSection { SIGNALS, CHART }
/** File-format IDs; host applications map their connection profiles to these IDs. */
enum class TrendProfile(val title: String) {
- SET_V1("SET GUI v1"), TMS2812("TMS320F2812 / BALZAM"), CAN_BRIDGE("CAN ↔ RS485"),
+ SET_V1("SET GUI v1"), TMS2812("TMS320F2812 / BALZAM"), TMS28335("TMS320F28335 / ПМ35"), CAN_BRIDGE("CAN ↔ RS485"),
GS_USB_CAN("CANgaroo / gs_usb"), SLCAN("SKLab SLCAN"), CANGAROO_SLCAN("CANgaroo / SLCAN"),
BALZAM_CAN("Старый CAN BALZAM"),
}
@@ -29,7 +29,7 @@ enum class TrendValueType(val title: String) {
}
fun TrendProfile.trendSources(): List = when (this) {
- TrendProfile.TMS2812 -> listOf(TrendSource.TMS_MEMORY)
+ TrendProfile.TMS2812, TrendProfile.TMS28335 -> listOf(TrendSource.TMS_MEMORY)
TrendProfile.SET_V1 -> listOf(TrendSource.SET_GAS, TrendSource.SET_SENSOR)
TrendProfile.BALZAM_CAN -> listOf(TrendSource.CAN_RAW)
else -> listOf(TrendSource.CAN_GAS, TrendSource.CAN_RAW)
diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/periph28335/Periph28335ProtocolTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/periph28335/Periph28335ProtocolTest.kt
new file mode 100644
index 0000000..93b7ef0
--- /dev/null
+++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/periph28335/Periph28335ProtocolTest.kt
@@ -0,0 +1,35 @@
+package ru.setcorp.setprotocol.periph28335
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class Periph28335ProtocolTest {
+ @Test fun readRequestMatchesDelphiByteOrder() {
+ val request = Periph28335Protocol.buildReadRegisters(16, 24, 64)
+ assertTrue(request.copyOf(6).contentEquals(byteArrayOf(0x10, 0x03, 0x00, 0x18, 0x00, 0x40)))
+ assertEquals(
+ Periph28335Protocol.crc16Modbus(request.copyOf(6)),
+ (request[6].toInt() and 0xFF) or ((request[7].toInt() and 0xFF) shl 8),
+ )
+ }
+
+ @Test fun writeAndCommandUseRegister127() {
+ assertTrue(
+ Periph28335Protocol.buildWriteRegister(16, 7, 0x1234).copyOf(6)
+ .contentEquals(byteArrayOf(0x10, 0x06, 0x00, 0x07, 0x12, 0x34)),
+ )
+ assertTrue(
+ Periph28335Protocol.buildCommand(16, 15).copyOf(6)
+ .contentEquals(byteArrayOf(0x10, 0x06, 0x00, 0x7F, 0x80.toByte(), 0x00)),
+ )
+ }
+
+ @Test fun responseAndBitsRoundTrip() {
+ val response = Periph28335Protocol.withCrc(byteArrayOf(0x10, 0x03, 0x04, 0x80.toByte(), 0x05, 0x12, 0x34))
+ assertEquals(listOf(0x8005, 0x1234), Periph28335Protocol.decodeReadResponse(response, 16, 2))
+ val bits = Periph28335Protocol.bitsLsbFirst(0x8005)
+ assertTrue(bits[0] && bits[2] && bits[15])
+ assertEquals(0x8005, Periph28335Protocol.wordFromBits(bits))
+ }
+}
From 6a82b309ccdafb839b3c3fbb9fd87bf9dd6efda1 Mon Sep 17 00:00:00 2001
From: Andrey
Date: Fri, 4 Sep 2026 21:06:40 +0300
Subject: [PATCH 02/14] Move PM35 protocol into shared C core
---
RULES.md | 80 ++++++++
c/set-protocol/CMakeLists.txt | 4 +
c/set-protocol/include/pcan_abi.h | 28 +++
c/set-protocol/include/periph28335.h | 68 +++++++
c/set-protocol/include/setprotocol.h | 1 +
c/set-protocol/ports/android/Android.mk | 1 +
.../setcorp/setprotocol/NativeSetProtocol.kt | 16 +-
.../periph28335/Periph28335Protocol.kt | 105 ++++++-----
.../ports/android/setprotocol_jni.c | 176 ++++++++++++++++++
c/set-protocol/src/pcan_abi.c | 74 ++++++++
c/set-protocol/src/periph28335.c | 171 +++++++++++++++++
c/set-protocol/tests/test_periph28335.c | 32 ++++
c/set-protocol/tools/build_host.py | 7 +-
doc/CROSS_PLATFORM_AUDIT.md | 43 +++++
python/protocan/native.py | 115 ++++++++++++
python/protocan/periph28335.py | 93 ++++-----
16 files changed, 919 insertions(+), 95 deletions(-)
create mode 100644 RULES.md
create mode 100644 c/set-protocol/include/periph28335.h
create mode 100644 c/set-protocol/src/periph28335.c
create mode 100644 c/set-protocol/tests/test_periph28335.c
create mode 100644 doc/CROSS_PLATFORM_AUDIT.md
diff --git a/RULES.md b/RULES.md
new file mode 100644
index 0000000..d2bf731
--- /dev/null
+++ b/RULES.md
@@ -0,0 +1,80 @@
+# Правила общего кроссплатформенного кода
+
+Этот репозиторий — единственный источник общих алгоритмов для `GUI_Android`,
+`SETGUI`, прошивок и будущих GUI. Копирование одной реализации между Kotlin,
+Python, C# или другим языком запрещено.
+
+## 1. Граница C-ядра и порта
+
+В `c/set-protocol` на C99 обязательно размещаются:
+
+- форматы кадров и идентификаторов, CRC/checksum, endian-преобразования;
+- построение команд, разбор и проверка ответов;
+- автоматы обмена, сегментация, повтор, таймаутные состояния без системных часов;
+- общие вычисления, таблицы и каталоги, влияющие на поведение протокола;
+- проверка образов прошивки и других бинарных форматов.
+
+Порт на языке GUI содержит только:
+
+- вызовы C через стабильный ABI (`ctypes`, JNI, P/Invoke, Swift FFI и т. п.);
+- преобразование C-структур в модели языка без повторения алгоритма;
+- работу с USB, COM, Bluetooth, SocketCAN и API операционной системы;
+- жизненный цикл, потоки, разрешения, хранение настроек и UI;
+- локализованный текст и чисто визуальные преобразования.
+
+Порт не вычисляет CRC, не собирает wire-пакет и не разбирает его поля заново.
+Если для функции C-ядро недоступно, приложение сообщает об ошибке сборки или
+загрузки. Алгоритмический fallback на языке GUI запрещён: он снова создаёт две
+версии протокола.
+
+## 2. Разделение контроллеров
+
+Профили контроллеров нельзя сливать по совпадению названия транспорта:
+
+- **ПМ67 / TMS320F2812** — основной контроллер, собственные RS и CAN;
+- **ПМ35 / TMS320F28335 periph** — отдельный контроллер и отдельный CAN для
+ настроечного терминала, а также собственный прямой RS232/485-протокол.
+
+Выбор профиля выполняется в GUI, но выбранный профиль вызывает свой отдельный
+модуль C-ядра. Наличие одной CAN-линии не даёт права удалить или подменить
+другую.
+
+## 3. Порядок изменения протокола
+
+1. Добавить или изменить публичный заголовок и реализацию в
+ `c/set-protocol/include` и `c/set-protocol/src`.
+2. Зафиксировать эталонные байты и ошибочные случаи в C-тесте.
+3. При необходимости расширить `pcan_abi.h`, сохраняя бинарную совместимость.
+4. Добавить тонкие порты в `ports/` и `python/`; в них не должно быть
+ второго кодека.
+5. Одними и теми же векторами проверить C, Python и Android/JVM.
+6. Собрать SETGUI и Android с одним commit submodule `templates`.
+
+Изменение только в одном GUI считается незавершённым. Сначала меняется
+`templates`, затем оба потребителя обновляют ссылку submodule на проверенный
+commit.
+
+## 4. Требования к C-ядру
+
+- C99, без зависимости от GUI и конкретной ОС.
+- Буферы и их размеры передаются явно; владение памятью остаётся у вызывающего.
+- Для MCU основная логика не требует heap, исключений или файловой системы.
+- Endian и размеры целых задаются через `stdint.h`, структуры wire-формата не
+ передаются через ABI без явного стабильного представления.
+- Экспорт shared library идёт через `PCAN_ABI_API`; существующие символы не
+ меняют смысл и сигнатуру.
+- Ошибки возвращаются детерминированным кодом и тестируются наряду с успехом.
+
+## 5. Проверка на ревью
+
+Изменение нельзя принимать, если ответ «да» хотя бы на один вопрос:
+
+- появился одинаковый CRC/parser/builder в двух языках;
+- UI знает byte offset, endian или служебный байт wire-протокола;
+- Python и Kotlin содержат одинаковую таблицу команд, влияющую на обмен;
+- добавлен тихий fallback, поведение которого отличается от C;
+- обновлён один GUI без обновления и теста `templates`;
+- ПМ67 и ПМ35 сведены к одному соединению или одному состоянию контроллера.
+
+Текущее состояние и очередь переноса перечислены в
+[`doc/CROSS_PLATFORM_AUDIT.md`](doc/CROSS_PLATFORM_AUDIT.md).
diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt
index 30dc23a..b0f027d 100644
--- a/c/set-protocol/CMakeLists.txt
+++ b/c/set-protocol/CMakeLists.txt
@@ -18,6 +18,7 @@ set(SETPROTOCOL_V2_SOURCES
# Совместимые ProtoCAN/SETGUI v1 форматы переходного периода.
set(SETPROTOCOL_LEGACY_SOURCES
src/balsam_can.c
+ src/periph28335.c
src/gui_catalog.c
src/gui_frame.c
src/pcan_abi.c
@@ -98,6 +99,9 @@ if(SETP_BUILD_TESTS)
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_periph28335 tests/test_periph28335.c)
+ target_link_libraries(test_periph28335 PRIVATE setprotocol_static)
+ add_test(NAME shared_periph28335 COMMAND test_periph28335)
add_executable(test_trends tests/test_trends.c)
target_link_libraries(test_trends PRIVATE setprotocol_static)
add_test(NAME shared_trends COMMAND test_trends)
diff --git a/c/set-protocol/include/pcan_abi.h b/c/set-protocol/include/pcan_abi.h
index 4ce3b03..2205331 100644
--- a/c/set-protocol/include/pcan_abi.h
+++ b/c/set-protocol/include/pcan_abi.h
@@ -73,6 +73,34 @@ 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);
+/* PM35/TMS320F28335 direct RS232/485 register terminal. */
+PCAN_ABI_API uint16_t pcan_abi_periph28335_crc16(
+ const uint8_t *data, size_t size);
+PCAN_ABI_API size_t pcan_abi_periph28335_append_crc(
+ const uint8_t *payload, size_t payload_size,
+ uint8_t *output, size_t output_size);
+PCAN_ABI_API size_t pcan_abi_periph28335_build_read(
+ uint8_t controller, uint16_t start, uint16_t count,
+ uint8_t *output, size_t output_size);
+PCAN_ABI_API size_t pcan_abi_periph28335_build_write(
+ uint8_t controller, uint16_t address, uint16_t value,
+ uint8_t *output, size_t output_size);
+PCAN_ABI_API size_t pcan_abi_periph28335_build_command(
+ uint8_t controller, uint8_t command_index,
+ uint8_t *output, size_t output_size);
+PCAN_ABI_API size_t pcan_abi_periph28335_expected_read_size(uint16_t count);
+PCAN_ABI_API int pcan_abi_periph28335_decode_read(
+ const uint8_t *data, size_t size, uint8_t controller, uint16_t count,
+ uint16_t *output, size_t output_count);
+PCAN_ABI_API int pcan_abi_periph28335_validate_write(
+ const uint8_t *response, size_t response_size,
+ const uint8_t *request, size_t request_size);
+PCAN_ABI_API size_t pcan_abi_periph28335_project_count(void);
+PCAN_ABI_API const char *pcan_abi_periph28335_project_name(
+ size_t project_index);
+PCAN_ABI_API const char *pcan_abi_periph28335_command_name(
+ size_t project_index, size_t command_index);
+
PCAN_ABI_API size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags,
uint32_t can_id,
const uint8_t *data, uint8_t dlc,
diff --git a/c/set-protocol/include/periph28335.h b/c/set-protocol/include/periph28335.h
new file mode 100644
index 0000000..408353e
--- /dev/null
+++ b/c/set-protocol/include/periph28335.h
@@ -0,0 +1,68 @@
+/**
+ * @file periph28335.h
+ * @brief Shared PM35/TMS320F28335 register protocol.
+ *
+ * The protocol is independent from PM67/TMS320F2812 CAN and RS channels.
+ * It implements the direct RS232/485 register terminal used by PM35.
+ */
+#ifndef PERIPH28335_H
+#define PERIPH28335_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define PERIPH28335_REGISTER_COUNT 128U
+#define PERIPH28335_REQUEST_SIZE 8U
+#define PERIPH28335_COMMAND_COUNT 17U
+
+typedef enum {
+ PERIPH28335_OK = 0,
+ PERIPH28335_ERROR_ARGUMENT = -1,
+ PERIPH28335_ERROR_RANGE = -2,
+ PERIPH28335_ERROR_LENGTH = -3,
+ PERIPH28335_ERROR_CRC = -4,
+ PERIPH28335_ERROR_HEADER = -5,
+ PERIPH28335_ERROR_CAPACITY = -6
+} periph28335_status_t;
+
+uint16_t periph28335_crc16_modbus(const uint8_t *data, size_t size);
+
+size_t periph28335_append_crc(const uint8_t *payload, size_t payload_size,
+ uint8_t *output, size_t output_size);
+
+size_t periph28335_build_read_registers(
+ uint8_t controller, uint16_t start, uint16_t count,
+ uint8_t *output, size_t output_size);
+
+size_t periph28335_build_write_register(
+ uint8_t controller, uint16_t address, uint16_t value,
+ uint8_t *output, size_t output_size);
+
+size_t periph28335_build_command(
+ uint8_t controller, uint8_t command_index,
+ uint8_t *output, size_t output_size);
+
+size_t periph28335_expected_read_response_size(uint16_t count);
+
+int periph28335_decode_read_response(
+ const uint8_t *data, size_t size, uint8_t controller, uint16_t count,
+ uint16_t *output, size_t output_count);
+
+int periph28335_validate_write_response(
+ const uint8_t *response, size_t response_size,
+ const uint8_t *request, size_t request_size);
+
+size_t periph28335_project_count(void);
+const char *periph28335_project_name(size_t project_index);
+const char *periph28335_command_name(size_t project_index,
+ size_t command_index);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* PERIPH28335_H */
diff --git a/c/set-protocol/include/setprotocol.h b/c/set-protocol/include/setprotocol.h
index a680583..03520c3 100644
--- a/c/set-protocol/include/setprotocol.h
+++ b/c/set-protocol/include/setprotocol.h
@@ -6,6 +6,7 @@
#define SETPROTOCOL_H
#include "balsam_can.h"
+#include "periph28335.h"
/* Основной SET protocol v2. */
#include "set_protocol.h"
diff --git a/c/set-protocol/ports/android/Android.mk b/c/set-protocol/ports/android/Android.mk
index e25efce..9eceeb8 100644
--- a/c/set-protocol/ports/android/Android.mk
+++ b/c/set-protocol/ports/android/Android.mk
@@ -13,6 +13,7 @@ LOCAL_SRC_FILES := \
../../src/set_trends.c \
../../src/set_spectrum.c \
../../src/balsam_can.c \
+ ../../src/periph28335.c \
../../src/gui_catalog.c \
../../src/gui_frame.c \
../../src/pcan_abi.c \
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
index 5e45407..8aa7255 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
@@ -4,7 +4,10 @@ package ru.setcorp.setprotocol
object NativeSetProtocol {
val available: Boolean by lazy {
runCatching {
- System.loadLibrary("setprotocol")
+ val hostLibrary = System.getProperty("setprotocol.library")
+ ?: System.getProperty("setplot.library")
+ if (hostLibrary != null) System.load(hostLibrary)
+ else System.loadLibrary("setprotocol")
nativeAbiVersion() == 1
}.getOrDefault(false)
}
@@ -32,6 +35,17 @@ object NativeSetProtocol {
external fun nativeBalsamDecode(canId: Long, input: ByteArray): IntArray?
external fun nativeBalsamDeviceName(device: Int): String
external fun nativeBalsamRegisterName(device: Int, address: Int): String
+ external fun nativePeriph28335Crc16(input: ByteArray): Int
+ external fun nativePeriph28335AppendCrc(input: ByteArray): ByteArray?
+ external fun nativePeriph28335BuildRead(controller: Int, start: Int, count: Int): ByteArray?
+ external fun nativePeriph28335BuildWrite(controller: Int, address: Int, value: Int): ByteArray?
+ external fun nativePeriph28335BuildCommand(controller: Int, commandIndex: Int): ByteArray?
+ external fun nativePeriph28335ExpectedReadSize(count: Int): Int
+ external fun nativePeriph28335DecodeRead(input: ByteArray, controller: Int, count: Int): IntArray?
+ external fun nativePeriph28335ValidateWrite(response: ByteArray, request: ByteArray): Int
+ external fun nativePeriph28335ProjectCount(): Int
+ external fun nativePeriph28335ProjectName(projectIndex: Int): String?
+ external fun nativePeriph28335CommandName(projectIndex: Int, commandIndex: Int): String?
external fun nativeEncodeFrame(
sequence: Int,
flags: Int,
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/periph28335/Periph28335Protocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/periph28335/Periph28335Protocol.kt
index a08a149..0856830 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/periph28335/Periph28335Protocol.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/periph28335/Periph28335Protocol.kt
@@ -1,37 +1,42 @@
package ru.setcorp.setprotocol.periph28335
-/** Shared wire protocol migrated from Set_Terminal_28335/DTrans.pas and UNiiefa.pas. */
+import ru.setcorp.setprotocol.NativeSetProtocol
+
+/** Kotlin UI adapter; all PM35 wire logic and the command catalog live in C99. */
object Periph28335Protocol {
const val REGISTER_COUNT = 128
const val DEFAULT_CONTROLLER = 16
const val DEFAULT_BAUD_RATE = 115_200
- val projectCommands: Map> = linkedMapOf(
- "По умолчанию" to listOf("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
- "23470" to listOf("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "-", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
- "23550" to listOf("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "Send", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
- "23550.2" to listOf("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Send", "Raw", "Beep", "", "", "", "", "Log", "Reset", "Nothing at all"),
- "ICE 22220.1-3" to listOf("Test", "Zero", "Save", "Def", "Calibr", "Read", "ExtLamp", "ExtLite", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
- "ICE 22220.4-5" to listOf("Test", "Def", "Save", "Load", "Raw", "Read", "ExtLamp", "ExtLite", "No log", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
- "Бальзам 161" to listOf("Test", "Zero", "Save", "Def", "Calibr", "Clbr 400", "Stop", "Start", "Init", "Secret", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
- "Бальзам 162" to listOf("Test", "Def", "Save", "Load", "Calibr", "Secret", "Stop", "Start", "Init", "Tune", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
- "Бальзам 163" to listOf("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Stop", "Start", "Init", "Tune", "Secret", "-", "-", "-", "-", "Reset", "Nothing at all"),
- )
-
- fun crc16Modbus(data: ByteArray, initial: Int = 0xFFFF): Int {
- var crc = initial and 0xFFFF
- data.forEach { value ->
- crc = crc xor (value.toInt() and 0xFF)
- repeat(8) {
- crc = if (crc and 1 != 0) (crc ushr 1) xor 0xA001 else crc ushr 1
+ val projectCommands: Map> by lazy {
+ requireNative()
+ buildMap {
+ repeat(NativeSetProtocol.nativePeriph28335ProjectCount()) { project ->
+ val name = requireNotNull(
+ NativeSetProtocol.nativePeriph28335ProjectName(project),
+ ) { "Повреждён каталог проектов ПМ35" }
+ put(name, List(17) { command ->
+ requireNotNull(
+ NativeSetProtocol.nativePeriph28335CommandName(project, command),
+ ) { "Повреждён каталог команд ПМ35" }
+ })
}
}
- return crc and 0xFFFF
+ }
+
+ fun crc16Modbus(data: ByteArray, initial: Int = 0xFFFF): Int {
+ require(initial == 0xFFFF) {
+ "Произвольное начальное значение CRC не входит в протокол ПМ35"
+ }
+ requireNative()
+ return NativeSetProtocol.nativePeriph28335Crc16(data)
}
fun withCrc(payload: ByteArray): ByteArray {
- val crc = crc16Modbus(payload)
- return payload + byteArrayOf(crc.toByte(), (crc ushr 8).toByte())
+ requireNative()
+ return requireNotNull(NativeSetProtocol.nativePeriph28335AppendCrc(payload)) {
+ "SETProtocol отклонил данные ПМ35"
+ }
}
fun buildReadRegisters(controller: Int, start: Int, count: Int): ByteArray {
@@ -40,50 +45,51 @@ object Periph28335Protocol {
require(count in 1..REGISTER_COUNT && start + count <= REGISTER_COUNT) {
"Диапазон регистров должен находиться в 0..127"
}
- return withCrc(byteArrayOf(
- controller.toByte(), 3,
- (start ushr 8).toByte(), start.toByte(),
- (count ushr 8).toByte(), count.toByte(),
- ))
+ requireNative()
+ return requireNotNull(
+ NativeSetProtocol.nativePeriph28335BuildRead(controller, start, count),
+ ) { "SETProtocol отклонил запрос чтения ПМ35" }
}
fun buildWriteRegister(controller: Int, address: Int, value: Int): ByteArray {
requireRange("Адрес контроллера", controller, 0xFF)
requireRange("Адрес регистра", address, REGISTER_COUNT - 1)
requireRange("Значение", value, 0xFFFF)
- return withCrc(byteArrayOf(
- controller.toByte(), 6,
- (address ushr 8).toByte(), address.toByte(),
- (value ushr 8).toByte(), value.toByte(),
- ))
+ requireNative()
+ return requireNotNull(
+ NativeSetProtocol.nativePeriph28335BuildWrite(controller, address, value),
+ ) { "SETProtocol отклонил запрос записи ПМ35" }
}
fun buildCommand(controller: Int, commandIndex: Int): ByteArray {
require(commandIndex in 0..16) { "Номер команды должен быть в диапазоне 0..16" }
- return buildWriteRegister(controller, 127, if (commandIndex < 16) 1 shl commandIndex else 0)
+ requireNative()
+ return requireNotNull(
+ NativeSetProtocol.nativePeriph28335BuildCommand(controller, commandIndex),
+ ) { "SETProtocol отклонил команду ПМ35" }
}
fun expectedReadResponseSize(count: Int): Int {
require(count in 1..REGISTER_COUNT)
- return count * 2 + 5
+ requireNative()
+ return NativeSetProtocol.nativePeriph28335ExpectedReadSize(count)
}
fun decodeReadResponse(data: ByteArray, controller: Int, count: Int): List {
val expected = expectedReadResponseSize(count)
require(data.size == expected) { "Ожидалось $expected байт, получено ${data.size}" }
- validateCrc(data)
- require(data[0].toInt() and 0xFF == controller) { "Ответ другого контроллера" }
- require(data[1].toInt() and 0xFF == 3) { "Неверная функция ответа" }
- require(data[2].toInt() and 0xFF == count * 2) { "Неверная длина данных ответа" }
- return (0 until count).map { index ->
- val offset = 3 + index * 2
- ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
- }
+ requireNative()
+ return requireNotNull(
+ NativeSetProtocol.nativePeriph28335DecodeRead(data, controller, count),
+ ) { "Повреждён ответ ПМ35: заголовок, длина или CRC" }.toList()
}
- fun validateWriteResponse(data: ByteArray, request: ByteArray): Boolean =
- data.size == 8 && request.size == 8 && data.contentEquals(request) && runCatching { validateCrc(data) }.isSuccess
+ fun validateWriteResponse(data: ByteArray, request: ByteArray): Boolean {
+ requireNative()
+ return NativeSetProtocol.nativePeriph28335ValidateWrite(data, request) == 0
+ }
+ // Presentation-only conversions stay in the GUI port; they do not define wire bytes.
fun bitsLsbFirst(value: Int): List {
requireRange("Значение", value, 0xFFFF)
return (0 until 16).map { bit -> value and (1 shl bit) != 0 }
@@ -91,7 +97,9 @@ object Periph28335Protocol {
fun wordFromBits(bits: List): Int {
require(bits.size == 16) { "Должно быть ровно 16 бит" }
- return bits.foldIndexed(0) { bit, value, checked -> if (checked) value or (1 shl bit) else value }
+ return bits.foldIndexed(0) { bit, value, checked ->
+ if (checked) value or (1 shl bit) else value
+ }
}
fun signedWord(value: Int): Int {
@@ -99,11 +107,8 @@ object Periph28335Protocol {
return if (value < 0x8000) value else value - 0x10000
}
- private fun validateCrc(data: ByteArray) {
- require(data.size >= 2)
- val expected = crc16Modbus(data.copyOf(data.size - 2))
- val actual = (data[data.lastIndex - 1].toInt() and 0xFF) or ((data.last().toInt() and 0xFF) shl 8)
- require(actual == expected) { "Ошибка CRC ответа" }
+ private fun requireNative() {
+ check(NativeSetProtocol.available) { "Нативное ядро SETProtocol недоступно" }
}
private fun requireRange(name: String, value: Int, maximum: Int) {
diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c
index 1b5c5a0..7f916f7 100644
--- a/c/set-protocol/ports/android/setprotocol_jni.c
+++ b/c/set-protocol/ports/android/setprotocol_jni.c
@@ -7,6 +7,182 @@
#include "set_trends.h"
#include "set_spectrum.h"
#include "balsam_can.h"
+#include "periph28335.h"
+
+JNIEXPORT jint JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335Crc16(
+ JNIEnv *env, jobject self, jbyteArray input)
+{
+ (void)self;
+ if (input == NULL) return 0;
+ jsize size = (*env)->GetArrayLength(env, input);
+ jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
+ if ((data == NULL) && (size != 0)) return 0;
+ uint16_t crc = periph28335_crc16_modbus((const uint8_t *)data, (size_t)size);
+ if (data != NULL) (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
+ return (jint)crc;
+}
+
+JNIEXPORT jbyteArray JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335AppendCrc(
+ JNIEnv *env, jobject self, jbyteArray input)
+{
+ (void)self;
+ if (input == NULL) return NULL;
+ jsize size = (*env)->GetArrayLength(env, input);
+ jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
+ if ((data == NULL) && (size != 0)) return NULL;
+ uint8_t *output = (uint8_t *)malloc((size_t)size + 2U);
+ if (output == NULL) {
+ if (data != NULL) (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
+ return NULL;
+ }
+ size_t written = periph28335_append_crc(
+ (const uint8_t *)data, (size_t)size, output, (size_t)size + 2U);
+ if (data != NULL) (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
+ if (written == 0U) { free(output); return NULL; }
+ jbyteArray result = (*env)->NewByteArray(env, (jsize)written);
+ if (result != NULL) (*env)->SetByteArrayRegion(
+ env, result, 0, (jsize)written, (const jbyte *)output);
+ free(output);
+ return result;
+}
+
+JNIEXPORT jbyteArray JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335BuildRead(
+ JNIEnv *env, jobject self, jint controller, jint start, jint count)
+{
+ (void)self;
+ uint8_t output[PERIPH28335_REQUEST_SIZE];
+ if (controller < 0 || controller > 255 || start < 0 || start > 65535 ||
+ count < 0 || count > 65535) return NULL;
+ size_t written = periph28335_build_read_registers(
+ (uint8_t)controller, (uint16_t)start, (uint16_t)count,
+ output, sizeof output);
+ if (written == 0U) return NULL;
+ jbyteArray result = (*env)->NewByteArray(env, (jsize)written);
+ if (result != NULL) (*env)->SetByteArrayRegion(
+ env, result, 0, (jsize)written, (const jbyte *)output);
+ return result;
+}
+
+JNIEXPORT jbyteArray JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335BuildWrite(
+ JNIEnv *env, jobject self, jint controller, jint address, jint value)
+{
+ (void)self;
+ uint8_t output[PERIPH28335_REQUEST_SIZE];
+ if (controller < 0 || controller > 255 || address < 0 || address > 65535 ||
+ value < 0 || value > 65535) return NULL;
+ size_t written = periph28335_build_write_register(
+ (uint8_t)controller, (uint16_t)address, (uint16_t)value,
+ output, sizeof output);
+ if (written == 0U) return NULL;
+ jbyteArray result = (*env)->NewByteArray(env, (jsize)written);
+ if (result != NULL) (*env)->SetByteArrayRegion(
+ env, result, 0, (jsize)written, (const jbyte *)output);
+ return result;
+}
+
+JNIEXPORT jbyteArray JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335BuildCommand(
+ JNIEnv *env, jobject self, jint controller, jint command_index)
+{
+ (void)self;
+ uint8_t output[PERIPH28335_REQUEST_SIZE];
+ if (controller < 0 || controller > 255 || command_index < 0 || command_index > 255) return NULL;
+ size_t written = periph28335_build_command(
+ (uint8_t)controller, (uint8_t)command_index, output, sizeof output);
+ if (written == 0U) return NULL;
+ jbyteArray result = (*env)->NewByteArray(env, (jsize)written);
+ if (result != NULL) (*env)->SetByteArrayRegion(
+ env, result, 0, (jsize)written, (const jbyte *)output);
+ return result;
+}
+
+JNIEXPORT jint JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335ExpectedReadSize(
+ JNIEnv *env, jobject self, jint count)
+{
+ (void)env; (void)self;
+ if (count < 0 || count > 65535) return 0;
+ return (jint)periph28335_expected_read_response_size((uint16_t)count);
+}
+
+JNIEXPORT jintArray JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335DecodeRead(
+ JNIEnv *env, jobject self, jbyteArray input, jint controller, jint count)
+{
+ (void)self;
+ if (input == NULL || controller < 0 || controller > 255 ||
+ count < 1 || count > (jint)PERIPH28335_REGISTER_COUNT) return NULL;
+ jsize size = (*env)->GetArrayLength(env, input);
+ jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
+ if ((data == NULL) && (size != 0)) return NULL;
+ uint16_t words[PERIPH28335_REGISTER_COUNT];
+ int status = periph28335_decode_read_response(
+ (const uint8_t *)data, (size_t)size, (uint8_t)controller,
+ (uint16_t)count, words, PERIPH28335_REGISTER_COUNT);
+ if (data != NULL) (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
+ if (status != PERIPH28335_OK) return NULL;
+ jint values[PERIPH28335_REGISTER_COUNT];
+ for (jint index = 0; index < count; ++index) values[index] = words[index];
+ jintArray result = (*env)->NewIntArray(env, count);
+ if (result != NULL) (*env)->SetIntArrayRegion(env, result, 0, count, values);
+ return result;
+}
+
+JNIEXPORT jint JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335ValidateWrite(
+ JNIEnv *env, jobject self, jbyteArray response, jbyteArray request)
+{
+ (void)self;
+ if (response == NULL || request == NULL) return PERIPH28335_ERROR_ARGUMENT;
+ jsize response_size = (*env)->GetArrayLength(env, response);
+ jsize request_size = (*env)->GetArrayLength(env, request);
+ jbyte *response_data = (*env)->GetByteArrayElements(env, response, NULL);
+ jbyte *request_data = (*env)->GetByteArrayElements(env, request, NULL);
+ if (response_data == NULL || request_data == NULL) {
+ if (response_data != NULL) (*env)->ReleaseByteArrayElements(env, response, response_data, JNI_ABORT);
+ if (request_data != NULL) (*env)->ReleaseByteArrayElements(env, request, request_data, JNI_ABORT);
+ return PERIPH28335_ERROR_ARGUMENT;
+ }
+ int status = periph28335_validate_write_response(
+ (const uint8_t *)response_data, (size_t)response_size,
+ (const uint8_t *)request_data, (size_t)request_size);
+ (*env)->ReleaseByteArrayElements(env, response, response_data, JNI_ABORT);
+ (*env)->ReleaseByteArrayElements(env, request, request_data, JNI_ABORT);
+ return status;
+}
+
+JNIEXPORT jint JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335ProjectCount(
+ JNIEnv *env, jobject self)
+{
+ (void)env; (void)self;
+ return (jint)periph28335_project_count();
+}
+
+JNIEXPORT jstring JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335ProjectName(
+ JNIEnv *env, jobject self, jint project_index)
+{
+ (void)self;
+ const char *name = project_index >= 0
+ ? periph28335_project_name((size_t)project_index) : NULL;
+ return name != NULL ? (*env)->NewStringUTF(env, name) : NULL;
+}
+
+JNIEXPORT jstring JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335CommandName(
+ JNIEnv *env, jobject self, jint project_index, jint command_index)
+{
+ (void)self;
+ const char *name = project_index >= 0 && command_index >= 0
+ ? periph28335_command_name((size_t)project_index, (size_t)command_index)
+ : NULL;
+ return name != NULL ? (*env)->NewStringUTF(env, name) : NULL;
+}
JNIEXPORT jintArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamDecode(
diff --git a/c/set-protocol/src/pcan_abi.c b/c/set-protocol/src/pcan_abi.c
index 8094e42..adf58a1 100644
--- a/c/set-protocol/src/pcan_abi.c
+++ b/c/set-protocol/src/pcan_abi.c
@@ -4,6 +4,7 @@
#include "pcan_crc.h"
#include "balsam_can.h"
+#include "periph28335.h"
#include "pcan_frame.h"
#include "pcan_id.h"
#include "gui_frame.h"
@@ -88,6 +89,79 @@ size_t pcan_abi_balsam_register_name(uint8_t device, uint16_t address,
return balsam_can_register_name(device, address, output, output_size);
}
+uint16_t pcan_abi_periph28335_crc16(const uint8_t *data, size_t size)
+{
+ return periph28335_crc16_modbus(data, size);
+}
+
+size_t pcan_abi_periph28335_append_crc(
+ const uint8_t *payload, size_t payload_size,
+ uint8_t *output, size_t output_size)
+{
+ return periph28335_append_crc(payload, payload_size, output, output_size);
+}
+
+size_t pcan_abi_periph28335_build_read(
+ uint8_t controller, uint16_t start, uint16_t count,
+ uint8_t *output, size_t output_size)
+{
+ return periph28335_build_read_registers(
+ controller, start, count, output, output_size);
+}
+
+size_t pcan_abi_periph28335_build_write(
+ uint8_t controller, uint16_t address, uint16_t value,
+ uint8_t *output, size_t output_size)
+{
+ return periph28335_build_write_register(
+ controller, address, value, output, output_size);
+}
+
+size_t pcan_abi_periph28335_build_command(
+ uint8_t controller, uint8_t command_index,
+ uint8_t *output, size_t output_size)
+{
+ return periph28335_build_command(
+ controller, command_index, output, output_size);
+}
+
+size_t pcan_abi_periph28335_expected_read_size(uint16_t count)
+{
+ return periph28335_expected_read_response_size(count);
+}
+
+int pcan_abi_periph28335_decode_read(
+ const uint8_t *data, size_t size, uint8_t controller, uint16_t count,
+ uint16_t *output, size_t output_count)
+{
+ return periph28335_decode_read_response(
+ data, size, controller, count, output, output_count);
+}
+
+int pcan_abi_periph28335_validate_write(
+ const uint8_t *response, size_t response_size,
+ const uint8_t *request, size_t request_size)
+{
+ return periph28335_validate_write_response(
+ response, response_size, request, request_size);
+}
+
+size_t pcan_abi_periph28335_project_count(void)
+{
+ return periph28335_project_count();
+}
+
+const char *pcan_abi_periph28335_project_name(size_t project_index)
+{
+ return periph28335_project_name(project_index);
+}
+
+const char *pcan_abi_periph28335_command_name(
+ size_t project_index, size_t command_index)
+{
+ return periph28335_command_name(project_index, command_index);
+}
+
size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags,
uint32_t can_id, const uint8_t *data, uint8_t dlc,
uint8_t *output, size_t output_size)
diff --git a/c/set-protocol/src/periph28335.c b/c/set-protocol/src/periph28335.c
new file mode 100644
index 0000000..dee241f
--- /dev/null
+++ b/c/set-protocol/src/periph28335.c
@@ -0,0 +1,171 @@
+#include "periph28335.h"
+
+#include
+
+typedef struct {
+ const char *project;
+ const char *commands[PERIPH28335_COMMAND_COUNT];
+} periph28335_project_t;
+
+static const periph28335_project_t projects[] = {
+ { "По умолчанию", { "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" } }
+};
+
+static void put_be16(uint8_t *output, uint16_t value)
+{
+ output[0] = (uint8_t)(value >> 8);
+ output[1] = (uint8_t)value;
+}
+
+static uint16_t get_be16(const uint8_t *input)
+{
+ return (uint16_t)(((uint16_t)input[0] << 8) | input[1]);
+}
+
+uint16_t periph28335_crc16_modbus(const uint8_t *data, size_t size)
+{
+ uint16_t crc = 0xFFFFU;
+ size_t index;
+ uint8_t bit;
+ if ((data == NULL) && (size != 0U)) return 0U;
+ for (index = 0U; index < size; ++index) {
+ crc ^= data[index];
+ for (bit = 0U; bit < 8U; ++bit) {
+ crc = (crc & 1U) != 0U
+ ? (uint16_t)((crc >> 1) ^ 0xA001U)
+ : (uint16_t)(crc >> 1);
+ }
+ }
+ return crc;
+}
+
+size_t periph28335_append_crc(const uint8_t *payload, size_t payload_size,
+ uint8_t *output, size_t output_size)
+{
+ uint16_t crc;
+ if ((output == NULL) || ((payload == NULL) && (payload_size != 0U)) ||
+ (payload_size > output_size) || (output_size - payload_size < 2U)) {
+ return 0U;
+ }
+ if (payload_size != 0U) memcpy(output, payload, payload_size);
+ crc = periph28335_crc16_modbus(payload, payload_size);
+ output[payload_size] = (uint8_t)crc;
+ output[payload_size + 1U] = (uint8_t)(crc >> 8);
+ return payload_size + 2U;
+}
+
+size_t periph28335_build_read_registers(
+ uint8_t controller, uint16_t start, uint16_t count,
+ uint8_t *output, size_t output_size)
+{
+ uint8_t payload[6];
+ if ((count == 0U) || (count > PERIPH28335_REGISTER_COUNT) ||
+ (start >= PERIPH28335_REGISTER_COUNT) ||
+ ((uint32_t)start + count > PERIPH28335_REGISTER_COUNT)) return 0U;
+ payload[0] = controller;
+ payload[1] = 3U;
+ put_be16(&payload[2], start);
+ put_be16(&payload[4], count);
+ return periph28335_append_crc(payload, sizeof payload, output, output_size);
+}
+
+size_t periph28335_build_write_register(
+ uint8_t controller, uint16_t address, uint16_t value,
+ uint8_t *output, size_t output_size)
+{
+ uint8_t payload[6];
+ if (address >= PERIPH28335_REGISTER_COUNT) return 0U;
+ payload[0] = controller;
+ payload[1] = 6U;
+ put_be16(&payload[2], address);
+ put_be16(&payload[4], value);
+ return periph28335_append_crc(payload, sizeof payload, output, output_size);
+}
+
+size_t periph28335_build_command(
+ uint8_t controller, uint8_t command_index,
+ uint8_t *output, size_t output_size)
+{
+ uint16_t value;
+ if (command_index >= PERIPH28335_COMMAND_COUNT) return 0U;
+ value = command_index < 16U ? (uint16_t)(1UL << command_index) : 0U;
+ return periph28335_build_write_register(
+ controller, PERIPH28335_REGISTER_COUNT - 1U, value,
+ output, output_size);
+}
+
+size_t periph28335_expected_read_response_size(uint16_t count)
+{
+ return (count >= 1U && count <= PERIPH28335_REGISTER_COUNT)
+ ? (size_t)count * 2U + 5U : 0U;
+}
+
+int periph28335_decode_read_response(
+ const uint8_t *data, size_t size, uint8_t controller, uint16_t count,
+ uint16_t *output, size_t output_count)
+{
+ size_t expected = periph28335_expected_read_response_size(count);
+ uint16_t crc;
+ size_t index;
+ if ((data == NULL) || (output == NULL)) return PERIPH28335_ERROR_ARGUMENT;
+ if (expected == 0U) return PERIPH28335_ERROR_RANGE;
+ if (size != expected) return PERIPH28335_ERROR_LENGTH;
+ if (output_count < count) return PERIPH28335_ERROR_CAPACITY;
+ crc = periph28335_crc16_modbus(data, size - 2U);
+ if ((data[size - 2U] != (uint8_t)crc) ||
+ (data[size - 1U] != (uint8_t)(crc >> 8))) {
+ return PERIPH28335_ERROR_CRC;
+ }
+ if ((data[0] != controller) || (data[1] != 3U) ||
+ (data[2] != (uint8_t)(count * 2U))) {
+ return PERIPH28335_ERROR_HEADER;
+ }
+ for (index = 0U; index < count; ++index) {
+ output[index] = get_be16(&data[3U + index * 2U]);
+ }
+ return PERIPH28335_OK;
+}
+
+int periph28335_validate_write_response(
+ const uint8_t *response, size_t response_size,
+ const uint8_t *request, size_t request_size)
+{
+ uint16_t crc;
+ if ((response == NULL) || (request == NULL)) return PERIPH28335_ERROR_ARGUMENT;
+ if ((response_size != PERIPH28335_REQUEST_SIZE) ||
+ (request_size != PERIPH28335_REQUEST_SIZE)) return PERIPH28335_ERROR_LENGTH;
+ crc = periph28335_crc16_modbus(response, response_size - 2U);
+ if ((response[response_size - 2U] != (uint8_t)crc) ||
+ (response[response_size - 1U] != (uint8_t)(crc >> 8))) {
+ return PERIPH28335_ERROR_CRC;
+ }
+ return memcmp(response, request, PERIPH28335_REQUEST_SIZE) == 0
+ ? PERIPH28335_OK : PERIPH28335_ERROR_HEADER;
+}
+
+size_t periph28335_project_count(void)
+{
+ return sizeof projects / sizeof projects[0];
+}
+
+const char *periph28335_project_name(size_t project_index)
+{
+ return project_index < periph28335_project_count()
+ ? projects[project_index].project : NULL;
+}
+
+const char *periph28335_command_name(size_t project_index,
+ size_t command_index)
+{
+ return project_index < periph28335_project_count() &&
+ command_index < PERIPH28335_COMMAND_COUNT
+ ? projects[project_index].commands[command_index] : NULL;
+}
diff --git a/c/set-protocol/tests/test_periph28335.c b/c/set-protocol/tests/test_periph28335.c
new file mode 100644
index 0000000..424ba70
--- /dev/null
+++ b/c/set-protocol/tests/test_periph28335.c
@@ -0,0 +1,32 @@
+#include
+#include
+
+#include "periph28335.h"
+
+int main(void)
+{
+ uint8_t request[PERIPH28335_REQUEST_SIZE];
+ static const uint8_t read_prefix[] = { 0x10U, 0x03U, 0x00U, 0x18U, 0x00U, 0x40U };
+ static const uint8_t response_payload[] = { 0x10U, 0x03U, 0x04U, 0x80U, 0x05U, 0x12U, 0x34U };
+ uint8_t response[sizeof response_payload + 2U];
+ uint16_t words[2];
+
+ if (periph28335_build_read_registers(16U, 24U, 64U, request, sizeof request)
+ != sizeof request || memcmp(request, read_prefix, sizeof read_prefix) != 0) return 1;
+ if (periph28335_build_write_register(16U, 7U, 0x1234U, request, sizeof request)
+ != sizeof request || memcmp(request, "\x10\x06\x00\x07\x12\x34", 6U) != 0) return 2;
+ if (periph28335_build_command(16U, 15U, request, sizeof request)
+ != sizeof request || memcmp(request, "\x10\x06\x00\x7F\x80\x00", 6U) != 0) return 3;
+ if (periph28335_append_crc(response_payload, sizeof response_payload,
+ response, sizeof response) != sizeof response) return 4;
+ if (periph28335_decode_read_response(response, sizeof response, 16U, 2U,
+ words, 2U) != PERIPH28335_OK || words[0] != 0x8005U || words[1] != 0x1234U) return 5;
+ response[0] = 17U;
+ if (periph28335_decode_read_response(response, sizeof response, 16U, 2U,
+ words, 2U) != PERIPH28335_ERROR_CRC) return 6;
+ if (periph28335_project_count() != 9U ||
+ strcmp(periph28335_project_name(2U), "23550") != 0 ||
+ strcmp(periph28335_command_name(2U, 7U), "Send") != 0) return 7;
+ puts("PM35/TMS320F28335 protocol tests passed");
+ return 0;
+}
diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py
index 2f54914..4d6ed08 100644
--- a/c/set-protocol/tools/build_host.py
+++ b/c/set-protocol/tools/build_host.py
@@ -21,7 +21,7 @@ JNI_INCLUDES: list[Path] = []
SOURCES = [
ROOT / "src" / name for name in (
"set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c",
- "balsam_can.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c",
+ "balsam_can.c", "periph28335.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",
)
@@ -84,7 +84,10 @@ def main() -> int:
if not (include / "jni.h").is_file():
parser.error("--java-home must contain include/jni.h")
JNI_INCLUDES.extend([include, include / {"Windows": "win32", "Darwin": "darwin"}.get(platform.system(), "linux")])
- SOURCES.append(ROOT / "ports" / "android" / "set_plot_jni.c")
+ SOURCES.extend([
+ ROOT / "ports" / "android" / "set_plot_jni.c",
+ ROOT / "ports" / "android" / "setprotocol_jni.c",
+ ])
output = args.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
build_dir = output.parent / ".setprotocol-build"
diff --git a/doc/CROSS_PLATFORM_AUDIT.md b/doc/CROSS_PLATFORM_AUDIT.md
new file mode 100644
index 0000000..871dc1e
--- /dev/null
+++ b/doc/CROSS_PLATFORM_AUDIT.md
@@ -0,0 +1,43 @@
+# Аудит общего кода GUI_Android и SETGUI
+
+Дата проверки: 2026-09-04.
+
+## Уже является общим C-ядром
+
+| Область | Каноническая реализация |
+|---|---|
+| SETProtocol v2, CAN segmentation, firmware flow, telemetry | `c/set-protocol/src/set_*.c` |
+| ProtoCAN ID, кадр, parser, link, CRC-CCITT | `c/set-protocol/src/pcan_*.c` |
+| GUI frame и каталог | `c/set-protocol/src/gui_*.c` |
+| CAN Бальзам | `c/set-protocol/src/balsam_can.c` |
+| График, тренды и спектр | `c/set-protocol/src/set_plot.c`, `set_trends.c`, `set_spectrum.c` |
+| ПМ35 / TMS320F28335: MODBUS-подобные запросы, CRC16/Modbus, ответы и каталог команд | `c/set-protocol/src/periph28335.c` |
+
+Python вызывает это ядро через `python/protocan/native.py`, Android — через
+`ports/android/setprotocol_jni.c`. Файлы на Python и Kotlin являются портами и
+не должны содержать wire-алгоритм.
+
+## Найденное общее, которое ещё нужно перенести
+
+| Приоритет | Android | SETGUI | Что вынести в C |
+|---|---|---|---|
+| 1 | `core/Tms2812Protocol.kt` | `core/tms_flash.py`, `core/tms_terminal.py` | весь RS-протокол ПМ67/2812, CRC, команды и validators |
+| 2 | `core/Ds18b20Protocol.kt` | `core/ds18b20.py` | кодеки списков/данных/EEPROM, CRC8 и значения датчиков |
+| 3 | `core/CanFirmwareProtocol.kt` | `core/can_firmware.py` | автомат CAN boot, статусы, окна блоков |
+| 4 | `core/SlCanProtocol.kt` | `adapters/slcan_adapter.py` и CAN transport | ASCII codec/parser SLCAN; доступ к COM остаётся в портах |
+| 5 | `core/FirmwareImage.kt` | `core/firmware.py` | Intel HEX parser, CRC32/SHA и правила диапазонов |
+
+Это очередь миграции, а не разрешение поддерживать две реализации. При первом
+изменении любой строки из таблицы сначала создаётся соответствующий модуль C и
+общие тестовые векторы.
+
+## Что должно остаться платформенным
+
+- Compose и PySide widgets, навигация и внешний вид;
+- Android USB host, разрешения, foreground lifecycle;
+- Windows COM/MOXA/драйверы и выбор последовательного порта;
+- хранилище настроек, диалоги файлов и уведомления;
+- привязка моделей C к Kotlin/Python и локализованные сообщения UI.
+
+Таким образом, язык GUI можно менять без повторного написания протокола: новый
+клиент реализует только FFI и свой транспортный/UI-порт.
diff --git a/python/protocan/native.py b/python/protocan/native.py
index 23aad9d..b30f40f 100644
--- a/python/protocan/native.py
+++ b/python/protocan/native.py
@@ -139,6 +139,44 @@ class NativeProtocol:
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_periph28335_crc16.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
+ lib.pcan_abi_periph28335_crc16.restype = ctypes.c_uint16
+ lib.pcan_abi_periph28335_append_crc.argtypes = [
+ ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t,
+ ]
+ lib.pcan_abi_periph28335_append_crc.restype = ctypes.c_size_t
+ lib.pcan_abi_periph28335_build_read.argtypes = [
+ ctypes.c_uint8, ctypes.c_uint16, ctypes.c_uint16,
+ ctypes.c_void_p, ctypes.c_size_t,
+ ]
+ lib.pcan_abi_periph28335_build_read.restype = ctypes.c_size_t
+ lib.pcan_abi_periph28335_build_write.argtypes = [
+ ctypes.c_uint8, ctypes.c_uint16, ctypes.c_uint16,
+ ctypes.c_void_p, ctypes.c_size_t,
+ ]
+ lib.pcan_abi_periph28335_build_write.restype = ctypes.c_size_t
+ lib.pcan_abi_periph28335_build_command.argtypes = [
+ ctypes.c_uint8, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t,
+ ]
+ lib.pcan_abi_periph28335_build_command.restype = ctypes.c_size_t
+ lib.pcan_abi_periph28335_expected_read_size.argtypes = [ctypes.c_uint16]
+ lib.pcan_abi_periph28335_expected_read_size.restype = ctypes.c_size_t
+ lib.pcan_abi_periph28335_decode_read.argtypes = [
+ ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint8, ctypes.c_uint16,
+ ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t,
+ ]
+ lib.pcan_abi_periph28335_decode_read.restype = ctypes.c_int
+ lib.pcan_abi_periph28335_validate_write.argtypes = [
+ ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t,
+ ]
+ lib.pcan_abi_periph28335_validate_write.restype = ctypes.c_int
+ lib.pcan_abi_periph28335_project_count.restype = ctypes.c_size_t
+ lib.pcan_abi_periph28335_project_name.argtypes = [ctypes.c_size_t]
+ lib.pcan_abi_periph28335_project_name.restype = ctypes.c_char_p
+ lib.pcan_abi_periph28335_command_name.argtypes = [
+ ctypes.c_size_t, ctypes.c_size_t,
+ ]
+ lib.pcan_abi_periph28335_command_name.restype = ctypes.c_char_p
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,
@@ -220,6 +258,83 @@ class NativeProtocol:
device, address, output, len(output))
return output.value.decode("utf-8")
+ @staticmethod
+ def _bytes_buffer(data: bytes):
+ return (ctypes.c_uint8 * len(data)).from_buffer_copy(data) if data else None
+
+ def periph28335_crc16(self, data: bytes) -> int:
+ return int(self.lib.pcan_abi_periph28335_crc16(
+ self._bytes_buffer(data), len(data)))
+
+ def periph28335_append_crc(self, payload: bytes) -> bytes:
+ source = self._bytes_buffer(payload)
+ output = (ctypes.c_uint8 * (len(payload) + 2))()
+ size = int(self.lib.pcan_abi_periph28335_append_crc(
+ source, len(payload), output, len(output)))
+ if size == 0:
+ raise ValueError("SETProtocol rejected PM35 payload")
+ return bytes(output[:size])
+
+ def periph28335_build_read(self, controller: int, start: int,
+ count: int) -> bytes:
+ output = (ctypes.c_uint8 * 8)()
+ size = int(self.lib.pcan_abi_periph28335_build_read(
+ controller, start, count, output, len(output)))
+ if size == 0:
+ raise ValueError("SETProtocol rejected PM35 read request")
+ return bytes(output[:size])
+
+ def periph28335_build_write(self, controller: int, address: int,
+ value: int) -> bytes:
+ output = (ctypes.c_uint8 * 8)()
+ size = int(self.lib.pcan_abi_periph28335_build_write(
+ controller, address, value, output, len(output)))
+ if size == 0:
+ raise ValueError("SETProtocol rejected PM35 write request")
+ return bytes(output[:size])
+
+ def periph28335_build_command(self, controller: int,
+ command_index: int) -> bytes:
+ output = (ctypes.c_uint8 * 8)()
+ size = int(self.lib.pcan_abi_periph28335_build_command(
+ controller, command_index, output, len(output)))
+ if size == 0:
+ raise ValueError("SETProtocol rejected PM35 command")
+ return bytes(output[:size])
+
+ def periph28335_expected_read_size(self, count: int) -> int:
+ return int(self.lib.pcan_abi_periph28335_expected_read_size(count))
+
+ def periph28335_decode_read(self, data: bytes, controller: int,
+ count: int) -> tuple[int, tuple[int, ...]]:
+ source = self._bytes_buffer(data)
+ output = (ctypes.c_uint16 * count)()
+ status = int(self.lib.pcan_abi_periph28335_decode_read(
+ source, len(data), controller, count, output, count))
+ return status, tuple(int(value) for value in output) if status == 0 else ()
+
+ def periph28335_validate_write(self, response: bytes,
+ request: bytes) -> int:
+ return int(self.lib.pcan_abi_periph28335_validate_write(
+ self._bytes_buffer(response), len(response),
+ self._bytes_buffer(request), len(request)))
+
+ def periph28335_catalog(self) -> dict[str, tuple[str, ...]]:
+ result: dict[str, tuple[str, ...]] = {}
+ for project in range(int(self.lib.pcan_abi_periph28335_project_count())):
+ raw_name = self.lib.pcan_abi_periph28335_project_name(project)
+ if not raw_name:
+ raise NativeProtocolUnavailable("invalid PM35 project catalog")
+ commands = []
+ for command in range(17):
+ raw_command = self.lib.pcan_abi_periph28335_command_name(
+ project, command)
+ if raw_command is None:
+ raise NativeProtocolUnavailable("invalid PM35 command catalog")
+ commands.append(raw_command.decode("utf-8"))
+ result[raw_name.decode("utf-8")] = tuple(commands)
+ return result
+
def encode(self, sequence: int, flags: int, can_id: int, data: bytes) -> bytes:
if len(data) > 8:
raise ValueError("DLC cannot exceed 8 bytes")
diff --git a/python/protocan/periph28335.py b/python/protocan/periph28335.py
index 05a6c04..8eb7549 100644
--- a/python/protocan/periph28335.py
+++ b/python/protocan/periph28335.py
@@ -1,66 +1,85 @@
-"""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.
-"""
+"""Thin Python port of the shared C99 PM35/TMS320F28335 protocol."""
from __future__ import annotations
+from .native import get_native_protocol
+
+
+REGISTER_COUNT = 128
+DEFAULT_CONTROLLER = 16
+DEFAULT_BAUD_RATE = 115_200
+
+
+def _core():
+ return get_native_protocol()
+
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
+ if crc != 0xFFFF:
+ raise ValueError("Произвольное начальное значение CRC не входит в протокол ПМ35")
+ return _core().periph28335_crc16(bytes(data))
def with_crc(payload: bytes) -> bytes:
- crc = crc16_modbus(payload)
- return bytes(payload) + crc.to_bytes(2, "little")
+ return _core().periph28335_append_crc(bytes(payload))
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:
+ if not 1 <= count <= REGISTER_COUNT or start + count > REGISTER_COUNT:
raise ValueError("Диапазон регистров должен находиться в 0..127")
- return with_crc(bytes((controller, 3)) + start.to_bytes(2, "big")
- + count.to_bytes(2, "big"))
+ return _core().periph28335_build_read(controller, start, count)
def build_write_register(controller: int, address: int, value: int) -> bytes:
_range("Адрес контроллера", controller, 0xFF)
- _range("Адрес регистра", address, 127)
+ _range("Адрес регистра", address, REGISTER_COUNT - 1)
_range("Значение", value, 0xFFFF)
- return with_crc(bytes((controller, 6)) + address.to_bytes(2, "big")
- + value.to_bytes(2, "big"))
+ return _core().periph28335_build_write(controller, address, value)
def build_command(controller: int, command_index: int) -> bytes:
+ _range("Адрес контроллера", controller, 0xFF)
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)
+ return _core().periph28335_build_command(controller, command_index)
def expected_read_response_size(count: int) -> int:
- return count * 2 + 5
+ if not 1 <= count <= REGISTER_COUNT:
+ raise ValueError("Число регистров должно быть в диапазоне 1..128")
+ size = _core().periph28335_expected_read_size(count)
+ if size == 0:
+ raise ValueError("SETProtocol отклонил размер ответа ПМ35")
+ return size
-def decode_read_response(data: bytes, count: int) -> tuple[int, ...]:
+def decode_read_response(data: bytes, count: int,
+ controller: int | None = None) -> 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))
+ expected_controller = data[0] if controller is None and data else controller
+ if expected_controller is None:
+ raise ValueError("Пустой ответ ПМ35")
+ status, values = _core().periph28335_decode_read(
+ bytes(data), expected_controller, count)
+ errors = {
+ -1: "Неверные аргументы ответа",
+ -2: "Диапазон регистров вне 0..127",
+ -3: "Неверная длина данных ответа",
+ -4: "Ошибка CRC ответа",
+ -5: "Неверный заголовок ответа",
+ -6: "Недостаточный буфер ответа",
+ }
+ if status != 0:
+ raise ValueError(errors.get(status, f"Ошибка ответа ПМ35: {status}"))
+ return values
+
+
+def validate_write_response(data: bytes, request: bytes) -> bool:
+ return _core().periph28335_validate_write(bytes(data), bytes(request)) == 0
def bits_lsb_first(value: int) -> tuple[bool, ...]:
@@ -85,14 +104,4 @@ def _range(name: str, value: int, maximum: int) -> None:
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"),
-}
+PROJECT_COMMANDS = _core().periph28335_catalog()
From 78d3f6690be0ddb12428ad14dd38971d48b32891 Mon Sep 17 00:00:00 2001
From: Andrey
Date: Fri, 4 Sep 2026 21:10:02 +0300
Subject: [PATCH 03/14] Align Android parser statistics with C core
---
.../kotlin/ru/setcorp/setflash/core/CanBridgeProtocol.kt | 4 +++-
doc/CROSS_PLATFORM_AUDIT.md | 5 ++++-
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/CanBridgeProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/CanBridgeProtocol.kt
index 9a6cd0b..599c6df 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/CanBridgeProtocol.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/CanBridgeProtocol.kt
@@ -175,7 +175,9 @@ object CanBridgeProtocol {
stats = Stats(
frames = values[0],
crcErrors = values[1],
- resyncBytes = values[2] + values[3],
+ // The former Kotlin parser counted one resync step
+ // for a rejected CRC frame in addition to stray bytes.
+ resyncBytes = values[1] + values[2] + values[3],
sequenceLost = values[4],
)
}
diff --git a/doc/CROSS_PLATFORM_AUDIT.md b/doc/CROSS_PLATFORM_AUDIT.md
index 871dc1e..eaaba20 100644
--- a/doc/CROSS_PLATFORM_AUDIT.md
+++ b/doc/CROSS_PLATFORM_AUDIT.md
@@ -15,12 +15,15 @@
Python вызывает это ядро через `python/protocan/native.py`, Android — через
`ports/android/setprotocol_jni.c`. Файлы на Python и Kotlin являются портами и
-не должны содержать wire-алгоритм.
+не должны содержать wire-алгоритм. В старых портах ещё встречаются fallback-
+реализации; они отмечены ниже и удаляются по мере включения обязательной
+загрузки C-ядра.
## Найденное общее, которое ещё нужно перенести
| Приоритет | Android | SETGUI | Что вынести в C |
|---|---|---|---|
+| 0 | `ports/android/.../CanBridgeProtocol.kt`, `GuiProtocol.kt` | Python transport wrappers | удалить оставшиеся алгоритмические fallback, так как codec/parser уже есть в C |
| 1 | `core/Tms2812Protocol.kt` | `core/tms_flash.py`, `core/tms_terminal.py` | весь RS-протокол ПМ67/2812, CRC, команды и validators |
| 2 | `core/Ds18b20Protocol.kt` | `core/ds18b20.py` | кодеки списков/данных/EEPROM, CRC8 и значения датчиков |
| 3 | `core/CanFirmwareProtocol.kt` | `core/can_firmware.py` | автомат CAN boot, статусы, окна блоков |
From b1f7b965f469d340ab1d0fe2e562906e10173de8 Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sat, 5 Sep 2026 02:37:11 +0300
Subject: [PATCH 04/14] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80?=
=?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20API=20?=
=?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B8=20GAS?=
=?UTF-8?q?=20=D0=BE=D0=B1=D0=BC=D0=B5=D0=BD=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
c/set-protocol/docs/GUI_PLOT.md | 8 ++-
c/set-protocol/docs/GUI_TRENDS.md | 4 +-
c/set-protocol/include/set_plot.h | 5 +-
c/set-protocol/include/set_spectrum.h | 9 +++
c/set-protocol/include/set_trends.h | 7 +++
.../setcorp/setprotocol/NativeSetProtocol.kt | 6 ++
.../setcorp/setprotocol/trends/GuiGasWatch.kt | 31 ++++------
.../setcorp/setprotocol/trends/NativePlot.kt | 7 +++
.../setprotocol/trends/TrendSpectrum.kt | 10 ++++
.../ports/android/setprotocol_jni.c | 60 +++++++++++++++++++
.../setprotocol/trends/PlotViewportTest.kt | 8 +++
c/set-protocol/src/set_plot.c | 12 ++--
c/set-protocol/src/set_spectrum.c | 39 ++++++++++++
c/set-protocol/src/set_trends.c | 13 ++++
c/set-protocol/tests/test_plot.c | 9 ++-
c/set-protocol/tests/test_spectrum.c | 9 +++
c/set-protocol/tests/test_trends.c | 3 +
python/protocan/plot.py | 9 ++-
python/protocan/spectrum.py | 23 +++++++
python/protocan/trends.py | 50 ++++++++++++++--
python/tests/test_spectrum.py | 3 +
python/tests/test_trends.py | 8 ++-
22 files changed, 294 insertions(+), 39 deletions(-)
diff --git a/c/set-protocol/docs/GUI_PLOT.md b/c/set-protocol/docs/GUI_PLOT.md
index d57233b..97f9eda 100644
--- a/c/set-protocol/docs/GUI_PLOT.md
+++ b/c/set-protocol/docs/GUI_PLOT.md
@@ -3,7 +3,7 @@
Численная логика находится в `include/set_plot.h` и `src/set_plot.c`.
Это модуль C99 без Qt, Android, транспорта, динамической памяти и глобального
состояния. Он собирается в существующую библиотеку SETProtocol; отдельная DLL
-для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 1`.
+для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 2`.
| Общее в templates | Адаптер приложения |
|---|---|
@@ -13,6 +13,7 @@
| Перевод значения в долю экрана и обратно, инверсия Y | Canvas/QPainter и оформление шкал |
| Перемещение маркера от начальной координаты, ограничение видимой областью | Захват линии пальцем/мышью, редактор положения |
| Разность B−A, D−C и множитель единиц, шаги шкалы 1/2/5 | Подписи, цвета, миллисекунды/герцы/единицы сигнала |
+| Проверка абсолютных границ X/Y для фиксации осей | Диалог ввода и хранение отдельных границ времени/FFT |
| Модели маркеров и их размещение в Kotlin/Python-портах | Жизненный цикл экрана, очистка и выбор источника |
Модель маркеров: A/B — координаты X и вертикальные линии во всю высоту поля;
@@ -59,8 +60,9 @@ SETGUI: `ui/plot_interaction.py` адаптирует общий модуль к
используются исходные метки приёма. Дискретные дорожки имеют общую X-шкалу,
а маркеры уровня Y относятся к аналоговому полю.
-Расчёт FFT не является частью этого модуля. Android вычисляет спектр через
-`set_spectrum.c`; вкладка спектра SETGUI получает готовые уровни в дБмВ от
+Расчёт FFT не является частью этого модуля. Android и SETGUI вычисляют спектр через
+`set_spectrum.c`; там же находится общий поиск доминирующего узкополосного пика,
+а трёхсекундный таймер его отображения остаётся состоянием GUI. Вкладка спектра SETGUI получает готовые уровни в дБмВ от
прибора. Общая интерактивная часть не меняет эти данные или единицы.
## Проверка и сборка
diff --git a/c/set-protocol/docs/GUI_TRENDS.md b/c/set-protocol/docs/GUI_TRENDS.md
index 6a3819b..2898b89 100644
--- a/c/set-protocol/docs/GUI_TRENDS.md
+++ b/c/set-protocol/docs/GUI_TRENDS.md
@@ -12,7 +12,7 @@
| `include/set_trends.h`, `src/set_trends.c` | C99: фильтрация GAS/raw CAN, signed/unsigned word, payload подписки SET GUI; использует `pcan_id` и ABI export macro |
| `ports/android/kotlin/ru/setcorp/setprotocol/trends/` | Модель, валидация JSON, ограниченная история, GAS_WATCH; JVM + org.json, без Android/Compose |
| `ports/android/setprotocol_jni.c` | Только преобразование JNI-аргументов |
-| `python/protocan/trends.py` | Модель, JSON, история и `NativeTrends`; Python 3.9+, stdlib, без Qt |
+| `python/protocan/trends.py` | Модель, JSON, история и `NativeTrends` (word/CAN/GAS request/ack/data); Python 3.9+, stdlib, без Qt |
| `tests/fixtures/trends-v1.json` | Один образец для тестов обоих GUI и обмена настройками |
## Формат файла
@@ -44,6 +44,8 @@ UTF-8 JSON: `format = "setflash-trends"`, `version = 1`, `profiles` — слов
CAN — пассивный приём, без записи GAS и автоматической отправки запросов.
GAS принимает только входящие FROM_DEVICE; TX/RTR/ошибки исключены.
+Полный `GAS_WATCH_DATA`, включая 32-битную метку прибора, разбирается функцией
+`set_trend_watch_decode`; UI не знает смещений полей и endian.
SET GUI оформляет подписку в порядке адресов. ACK должен подтвердить весь
список: при частичном принятии нельзя определить пропущенные адреса, поэтому
строить график по смещённым индексам запрещено. При паузе порт приложения
diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h
index 2ae6847..df7cc60 100644
--- a/c/set-protocol/include/set_plot.h
+++ b/c/set-protocol/include/set_plot.h
@@ -17,10 +17,11 @@ enum set_plot_operation {
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_DB_DELTA = 7 /* A,B -> 20*log10(abs(B/A)); zero is invalid */
+ SET_PLOT_DB_DELTA = 7, /* A,B -> 20*log10(abs(B/A)); zero is invalid */
+ SET_PLOT_LIMITS = 8 /* xMin,xMax,yMin,yMax -> validated unchanged limits */
};
-/** Version of this plot ABI, independently of the transport ABI. */
+/** Version of this plot ABI, independently of the transport ABI (currently 2). */
PCAN_ABI_API uint32_t set_plot_abi_version(void);
/** Evaluates one operation. Returns output count, or 0 for invalid arguments.
* TRANSFORM rejects malformed viewports; invalid gesture values return the
diff --git a/c/set-protocol/include/set_spectrum.h b/c/set-protocol/include/set_spectrum.h
index 554c1a5..8747924 100644
--- a/c/set-protocol/include/set_spectrum.h
+++ b/c/set-protocol/include/set_spectrum.h
@@ -28,6 +28,15 @@ PCAN_ABI_API int set_spectrum_analyze(const double *times, const double *values,
size_t max_size, int window, int filter, double low_hz, double high_hz, int remove_mean,
double *amplitudes, size_t capacity, double *meta);
+/** Find the strongest non-DC local maximum above both the absolute floor and
+ * relative_threshold * median(non-DC amplitudes). The caller supplies scratch
+ * storage of at least count-1 doubles. peak = {frequency_hz, amplitude}.
+ * Returns 1 when found, 0 when no narrow-band peak exists, -1 on invalid input.
+ */
+PCAN_ABI_API int set_spectrum_dominant_peak(const double *amplitudes, size_t count,
+ double bin_hz, double relative_threshold, double absolute_floor,
+ double *scratch, size_t scratch_capacity, double *peak, size_t peak_capacity);
+
#ifdef __cplusplus
}
#endif
diff --git a/c/set-protocol/include/set_trends.h b/c/set-protocol/include/set_trends.h
index 1693d4a..b187f0c 100644
--- a/c/set-protocol/include/set_trends.h
+++ b/c/set-protocol/include/set_trends.h
@@ -48,6 +48,13 @@ PCAN_ABI_API int set_trend_watch_ack(
PCAN_ABI_API int set_trend_watch_values(
const uint8_t *payload, size_t size, uint16_t *words, size_t capacity);
+/** Decode the complete GAS_WATCH_DATA payload including its device timestamp.
+ * This is the preferred ABI for GUI ports; the older values-only symbol remains
+ * available for binary compatibility.
+ */
+PCAN_ABI_API int set_trend_watch_decode(const uint8_t *payload, size_t size,
+ uint32_t *timestamp_ms, uint16_t *words, size_t capacity);
+
#ifdef __cplusplus
}
#endif
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
index 8aa7255..bc11653 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
@@ -16,12 +16,18 @@ object NativeSetProtocol {
/** {status, N, Fs, jitter, amplitudes...}; status != 0 has no amplitudes. */
external fun nativeSpectrum(times: DoubleArray, values: DoubleArray, maxSize: Int,
window: Int, filter: Int, lowHz: Double, highHz: Double, removeMean: Boolean): DoubleArray?
+ /** {frequencyHz, amplitude}, empty when no narrow-band peak is present. */
+ external fun nativeSpectrumPeak(amplitudes: DoubleArray, binHz: Double,
+ relativeThreshold: Double, absoluteFloor: Double): DoubleArray?
external fun nativeTrendCanValue(
source: Int, address: Long, deviceType: Int, device: Int, byteOffset: Int,
extended: Boolean, signed: Boolean, canId: Long, flags: Int, input: ByteArray,
): Int
external fun nativeTrendWatchRequest(period: Int, addresses: IntArray): ByteArray?
+ external fun nativeTrendWatchAck(input: ByteArray, period: Int, count: Int): Boolean
external fun nativeTrendWatchValues(input: ByteArray): IntArray?
+ /** {unsigned timestampMs, word0, ...}. */
+ external fun nativeTrendWatchDecode(input: ByteArray): LongArray?
external fun nativePackId(
priority: Int,
route: Int,
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt
index 5a814ad..924ed9c 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt
@@ -10,37 +10,28 @@ object GuiGasWatch {
fun request(periodMs: Int, addresses: List): ByteArray {
require(periodMs in 0..65535 && addresses.size <= 64 && addresses.all { it in 0..65535 })
- if (NativeSetProtocol.available) return requireNotNull(NativeSetProtocol.nativeTrendWatchRequest(periodMs, addresses.toIntArray()))
- return ByteArray(4 + addresses.size * 2).also { output ->
- put16(output, 0, periodMs)
- put16(output, 2, addresses.size)
- addresses.forEachIndexed { index, address -> put16(output, 4 + index * 2, address) }
- }
+ check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" }
+ return requireNotNull(NativeSetProtocol.nativeTrendWatchRequest(periodMs, addresses.toIntArray()))
}
fun validateAck(payload: ByteArray, periodMs: Int, count: Int) {
- require(payload.size == 4 && read16(payload, 0) == periodMs && read16(payload, 2) == count) {
+ require(NativeSetProtocol.available && NativeSetProtocol.nativeTrendWatchAck(payload, periodMs, count)) {
"Прибор принял не все адреса GAS. Проверьте карту регистров; отображение по неполной подписке невозможно"
}
}
fun values(payload: ByteArray, expectedCount: Int): List {
- val values = if (NativeSetProtocol.available) {
- requireNotNull(NativeSetProtocol.nativeTrendWatchValues(payload)) { "Повреждён GAS_WATCH_DATA" }.toList()
- } else {
- require(payload.size >= 6) { "GAS_WATCH_DATA короче заголовка" }
- val count = read16(payload, 4)
- require(count <= 64 && payload.size == 6 + count * 2) { "Неверная длина GAS_WATCH_DATA" }
- List(count) { read16(payload, 6 + it * 2) }
- }
+ check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" }
+ val values = requireNotNull(NativeSetProtocol.nativeTrendWatchValues(payload)) { "Повреждён GAS_WATCH_DATA" }.toList()
require(values.size == expectedCount) { "Число значений GAS не соответствует подписке" }
return values
}
- private fun read16(data: ByteArray, offset: Int): Int =
- (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
- private fun put16(data: ByteArray, offset: Int, value: Int) {
- data[offset] = value.toByte()
- data[offset + 1] = (value ushr 8).toByte()
+ data class Sample(val timestampMs: Long, val values: List)
+ fun decode(payload: ByteArray, expectedCount: Int): Sample {
+ check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" }
+ val decoded = requireNotNull(NativeSetProtocol.nativeTrendWatchDecode(payload)) { "Повреждён GAS_WATCH_DATA" }
+ require(decoded.size == expectedCount + 1) { "Число значений GAS не соответствует подписке" }
+ return Sample(decoded[0], decoded.drop(1).map(Long::toInt))
}
}
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt
index 92f264b..1ce5a53 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt
@@ -32,3 +32,10 @@ fun plotTickStep(range: Double, pixels: Double): Double = NativePlot.call(5, ran
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()
+
+data class PlotLimits(val xMin: Double, val xMax: Double, val yMin: Double, val yMax: Double) {
+ fun validated(): PlotLimits {
+ val values = NativePlot.call(8, xMin, xMax, yMin, yMax)
+ return PlotLimits(values[0], values[1], values[2], values[3])
+ }
+}
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt
index 5f02bd3..cf8679c 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt
@@ -34,6 +34,8 @@ data class TrendSpectrum(
val binHz: Double get() = if (size > 0) sampleRate / size else 0.0
}
+data class SpectrumPeak(val frequencyHz: Double, val amplitude: Double)
+
/** Math is implemented once in C and used unchanged by JNI and ctypes. */
object SpectrumAnalyzer {
fun analyze(points: List, options: SpectrumOptions): TrendSpectrum {
@@ -64,4 +66,12 @@ object SpectrumAnalyzer {
return TrendSpectrum(output[1].toInt(), output[2], output[3],
if (error == null) output.drop(4) else emptyList(), error)
}
+
+ fun dominantPeak(spectrum: TrendSpectrum, relativeThreshold: Double = 3.0,
+ absoluteFloor: Double = 1e-6): SpectrumPeak? {
+ if (spectrum.error != null || spectrum.size <= 0 || spectrum.amplitudes.size < 3) return null
+ val result = NativeSetProtocol.nativeSpectrumPeak(spectrum.amplitudes.toDoubleArray(),
+ spectrum.binHz, relativeThreshold, absoluteFloor) ?: return null
+ return result.takeIf { it.size == 2 }?.let { SpectrumPeak(it[0], it[1]) }
+ }
}
diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c
index 7f916f7..57da780 100644
--- a/c/set-protocol/ports/android/setprotocol_jni.c
+++ b/c/set-protocol/ports/android/setprotocol_jni.c
@@ -276,6 +276,30 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendCanValue(
(uint8_t)flags, (const uint8_t *)data, (size_t)size);
}
+JNIEXPORT jdoubleArray JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeSpectrumPeak(
+ JNIEnv *env, jobject self, jdoubleArray input, jdouble bin_hz,
+ jdouble relative_threshold, jdouble absolute_floor)
+{
+ (void)self;
+ jsize count = input == NULL ? 0 : (*env)->GetArrayLength(env, input);
+ if (count < 3 || count > (jsize)(SET_SPECTRUM_MAX / 2U + 1U)) return NULL;
+ double *buffer = (double *)malloc(sizeof(double) * (size_t)(count * 2 - 1));
+ if (buffer == NULL) return NULL;
+ double *amplitudes = buffer, *scratch = buffer + count, peak[2];
+ (*env)->GetDoubleArrayRegion(env, input, 0, count, amplitudes);
+ int status = (*env)->ExceptionCheck(env) ? -1 : set_spectrum_dominant_peak(
+ amplitudes, (size_t)count, bin_hz, relative_threshold, absolute_floor,
+ scratch, (size_t)count - 1U, peak, 2U);
+ jdoubleArray result = NULL;
+ if (status >= 0) {
+ result = (*env)->NewDoubleArray(env, status == 1 ? 2 : 0);
+ if (result != NULL && status == 1) (*env)->SetDoubleArrayRegion(env, result, 0, 2, peak);
+ }
+ free(buffer);
+ return result;
+}
+
JNIEXPORT jbyteArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest(
JNIEnv *env, jobject self, jint period, jintArray input)
@@ -298,6 +322,20 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest(
return result;
}
+JNIEXPORT jboolean JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchAck(
+ JNIEnv *env, jobject self, jbyteArray input, jint period, jint count)
+{
+ (void)self;
+ if (input == NULL || period < 0 || period > 65535 || count < 0) return JNI_FALSE;
+ jsize size = (*env)->GetArrayLength(env, input);
+ if (size != 4) return JNI_FALSE;
+ jbyte payload[4];
+ (*env)->GetByteArrayRegion(env, input, 0, size, payload);
+ return set_trend_watch_ack((const uint8_t *)payload, (size_t)size,
+ (uint16_t)period, (size_t)count) ? JNI_TRUE : JNI_FALSE;
+}
+
JNIEXPORT jintArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues(
JNIEnv *env, jobject self, jbyteArray input)
@@ -317,6 +355,28 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues(
return result;
}
+JNIEXPORT jlongArray JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchDecode(
+ JNIEnv *env, jobject self, jbyteArray input)
+{
+ (void)self;
+ jsize size = input == NULL ? 0 : (*env)->GetArrayLength(env, input);
+ if (size < 6 || size > (jsize)(6U + 2U * SET_TREND_WATCH_MAX)) return NULL;
+ jbyte payload[6U + 2U * SET_TREND_WATCH_MAX];
+ uint16_t words[SET_TREND_WATCH_MAX];
+ uint32_t timestamp = 0U;
+ jlong values[1U + SET_TREND_WATCH_MAX];
+ (*env)->GetByteArrayRegion(env, input, 0, size, payload);
+ int count = set_trend_watch_decode((const uint8_t *)payload, (size_t)size,
+ ×tamp, words, SET_TREND_WATCH_MAX);
+ if (count < 0) return NULL;
+ values[0] = (jlong)timestamp;
+ for (int i = 0; i < count; ++i) values[i + 1] = (jlong)words[i];
+ jlongArray result = (*env)->NewLongArray(env, count + 1);
+ if (result != NULL) (*env)->SetLongArrayRegion(env, result, 0, count + 1, values);
+ return result;
+}
+
typedef struct {
uint8_t *storage;
size_t storage_size;
diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt
index d9918d2..af6962b 100644
--- a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt
+++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt
@@ -36,4 +36,12 @@ class PlotViewportTest {
}
SpectrumOptions().validate()
}
+ @Test fun absoluteLimitsAndDominantPeakUseNativeCore() {
+ assertEquals(PlotLimits(0.0, 500.0, -1.0, 1.0),
+ PlotLimits(0.0, 500.0, -1.0, 1.0).validated())
+ assertTrue(runCatching { PlotLimits(1.0, 1.0, -1.0, 1.0).validated() }.isFailure)
+ val spectrum = TrendSpectrum(12, 60.0, 0.0,
+ listOf(10.0, .01, .02, .8, .03, .4, .02))
+ assertEquals(15.0, SpectrumAnalyzer.dominantPeak(spectrum)!!.frequencyHz, 0.0)
+ }
}
diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c
index 66ed13c..368275b 100644
--- a/c/set-protocol/src/set_plot.c
+++ b/c/set-protocol/src/set_plot.c
@@ -8,13 +8,13 @@ static int finite_values(const double *v, size_t n) {
return 1;
}
-uint32_t set_plot_abi_version(void) { return 1U; }
+uint32_t set_plot_abi_version(void) { return 2U; }
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, 2};
+ static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3, 2, 4};
double span, fraction;
- if (op > SET_PLOT_DB_DELTA || !v || !out || n != sizes[op] ||
- cap < (op == SET_PLOT_TRANSFORM ? 4U : 1U)) return 0;
+ if (op > SET_PLOT_LIMITS || !v || !out || n != sizes[op] ||
+ cap < (op == SET_PLOT_TRANSFORM || op == SET_PLOT_LIMITS ? 4U : 1U)) return 0;
if (op == SET_PLOT_TRANSFORM) {
double w, h, fx, fy;
if (!finite_values(v, 4) || v[2] < 1.0/128 || v[2] > 1 ||
@@ -69,6 +69,10 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t
if (v[0] == 0 || v[1] == 0) return 0;
out[0] = 20 * log10(fabs(v[1] / v[0]));
break;
+ case SET_PLOT_LIMITS:
+ if (v[0] >= v[1] || v[2] >= v[3]) return 0;
+ out[0] = v[0]; out[1] = v[1]; out[2] = v[2]; out[3] = v[3];
+ return 4;
default: return 0;
}
return isfinite(out[0]) ? 1 : 0;
diff --git a/c/set-protocol/src/set_spectrum.c b/c/set-protocol/src/set_spectrum.c
index a73003e..259f0ec 100644
--- a/c/set-protocol/src/set_spectrum.c
+++ b/c/set-protocol/src/set_spectrum.c
@@ -4,6 +4,13 @@
#define PI 3.14159265358979323846
+static int compare_double(const void *left, const void *right)
+{
+ const double a = *(const double *)left;
+ const double b = *(const double *)right;
+ return (a > b) - (a < b);
+}
+
static double window_value(int window, size_t index, size_t n)
{
double phase = 2.0 * PI * (double)index / (double)n;
@@ -124,3 +131,35 @@ int set_spectrum_analyze(const double *times, const double *values, size_t count
free(scratch);
return result;
}
+
+int set_spectrum_dominant_peak(const double *amplitudes, size_t count,
+ double bin_hz, double relative_threshold, double absolute_floor,
+ double *scratch, size_t scratch_capacity, double *peak, size_t peak_capacity)
+{
+ size_t i, usable = 0U, peak_bin = 0U;
+ double peak_amplitude = -1.0, median, threshold;
+ if (amplitudes == NULL || scratch == NULL || peak == NULL || count < 3U ||
+ scratch_capacity < count - 1U || peak_capacity < 2U || !isfinite(bin_hz) ||
+ bin_hz <= 0.0 || !isfinite(relative_threshold) || relative_threshold <= 0.0 ||
+ !isfinite(absolute_floor) || absolute_floor < 0.0) return -1;
+ for (i = 1U; i < count; ++i) {
+ if (isfinite(amplitudes[i]) && amplitudes[i] >= 0.0)
+ scratch[usable++] = amplitudes[i];
+ }
+ if (usable < 2U) return 0;
+ qsort(scratch, usable, sizeof(double), compare_double);
+ median = scratch[usable / 2U];
+ for (i = 1U; i + 1U < count; ++i) {
+ const double value = amplitudes[i];
+ if (isfinite(value) && isfinite(amplitudes[i - 1U]) && isfinite(amplitudes[i + 1U]) &&
+ value >= amplitudes[i - 1U] && value > amplitudes[i + 1U] && value > peak_amplitude) {
+ peak_bin = i;
+ peak_amplitude = value;
+ }
+ }
+ threshold = fmax(absolute_floor, median * relative_threshold);
+ if (peak_bin == 0U || peak_amplitude < threshold) return 0;
+ peak[0] = (double)peak_bin * bin_hz;
+ peak[1] = peak_amplitude;
+ return 1;
+}
diff --git a/c/set-protocol/src/set_trends.c b/c/set-protocol/src/set_trends.c
index c1c2203..4c3da43 100644
--- a/c/set-protocol/src/set_trends.c
+++ b/c/set-protocol/src/set_trends.c
@@ -6,6 +6,12 @@ static uint16_t get16(const uint8_t *p)
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
+static uint32_t get32(const uint8_t *p)
+{
+ return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
+ ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
+}
+
static void put16(uint8_t *p, uint16_t value)
{
p[0] = (uint8_t)value;
@@ -63,6 +69,12 @@ int set_trend_watch_ack(const uint8_t *payload, size_t size, uint16_t period_ms,
}
int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words, size_t capacity)
+{
+ return set_trend_watch_decode(payload, size, NULL, words, capacity);
+}
+
+int set_trend_watch_decode(const uint8_t *payload, size_t size, uint32_t *timestamp_ms,
+ uint16_t *words, size_t capacity)
{
size_t i, count;
if (payload == NULL || size < 6U) return -1;
@@ -70,5 +82,6 @@ int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words,
if (count > SET_TREND_WATCH_MAX || size != 6U + count * 2U || count > capacity ||
(count > 0U && words == NULL)) return -1;
for (i = 0U; i < count; ++i) words[i] = get16(payload + 6U + i * 2U);
+ if (timestamp_ms != NULL) *timestamp_ms = get32(payload);
return (int)count;
}
diff --git a/c/set-protocol/tests/test_plot.c b/c/set-protocol/tests/test_plot.c
index e606956..6615548 100644
--- a/c/set-protocol/tests/test_plot.c
+++ b/c/set-protocol/tests/test_plot.c
@@ -9,7 +9,7 @@ int main(void) {
const double y[] = {15, 10, 30, 1};
const double drag[] = {0, 100, 500, -10, 10, 1};
double back[4], bad[] = {NAN, 10, 8};
- assert(set_plot_abi_version() == 1);
+ assert(set_plot_abi_version() == 2);
assert(set_plot_eval(SET_PLOT_TRANSFORM, zoom, 10, output, 4) == 4);
assert(output[0] == .125 && output[1] == 0 && output[2] == .5 && output[3] == 1);
assert(output[4] == 12345);
@@ -26,6 +26,13 @@ int main(void) {
assert(output[0] == y[0]);
assert(set_plot_eval(SET_PLOT_DRAG, drag, 6, output, 4) == 1);
assert(output[0] == -4);
+ {
+ const double limits[] = {0, 500, -2, 2};
+ const double reversed[] = {1, 1, -2, 2};
+ assert(set_plot_eval(SET_PLOT_LIMITS, limits, 4, output, 4) == 4);
+ assert(output[0] == 0 && output[1] == 500 && output[2] == -2 && output[3] == 2);
+ assert(set_plot_eval(SET_PLOT_LIMITS, reversed, 4, output, 4) == 0);
+ }
puts("shared plot: OK");
return 0;
}
diff --git a/c/set-protocol/tests/test_spectrum.c b/c/set-protocol/tests/test_spectrum.c
index 0de2e94..64a36b7 100644
--- a/c/set-protocol/tests/test_spectrum.c
+++ b/c/set-protocol/tests/test_spectrum.c
@@ -17,6 +17,15 @@ int main(void)
assert(set_spectrum_analyze(times, values, 32, 32, 0, SET_FILTER_LOW_PASS, 0, 16, 1, output, 17, meta) == SET_SPECTRUM_CUTOFF);
times[12] = times[11];
assert(set_spectrum_analyze(times, values, 32, 32, 0, 0, 0, 0, 1, output, 17, meta) == SET_SPECTRUM_TIMING);
+ {
+ const double amplitudes[] = {10.0, .01, .02, .8, .03, .4, .02};
+ const double noise[] = {0, .10, .12, .11, .09, .10};
+ double scratch[6], peak[2];
+ assert(set_spectrum_dominant_peak(amplitudes, 7, 5, 3, 1e-6, scratch, 6, peak, 2) == 1);
+ assert(peak[0] == 15 && peak[1] == .8);
+ assert(set_spectrum_dominant_peak(noise, 6, 1, 3, 1e-6, scratch, 6, peak, 2) == 0);
+ assert(set_spectrum_dominant_peak(amplitudes, 7, 0, 3, 1e-6, scratch, 6, peak, 2) == -1);
+ }
puts("shared spectrum: OK");
return 0;
}
diff --git a/c/set-protocol/tests/test_trends.c b/c/set-protocol/tests/test_trends.c
index 97f10ee..f357bd2 100644
--- a/c/set-protocol/tests/test_trends.c
+++ b/c/set-protocol/tests/test_trends.c
@@ -34,8 +34,11 @@ int main(void)
assert(!set_trend_watch_ack(expected, 4, 1000, 3));
const uint8_t packet[] = {1, 2, 3, 4, 2, 0, 52, 18, 255, 255};
uint16_t words[64];
+ uint32_t timestamp = 0;
assert(set_trend_watch_values(packet, sizeof(packet), words, 64) == 2);
assert(words[0] == 0x1234 && words[1] == 0xFFFF);
+ assert(set_trend_watch_decode(packet, sizeof(packet), ×tamp, words, 64) == 2);
+ assert(timestamp == 0x04030201UL);
assert(set_trend_watch_values(packet, sizeof(packet) - 1, words, 64) == -1);
assert(set_trend_watch_values(packet, sizeof(packet), words, 1) == -1);
puts("Shared trend tests passed");
diff --git a/python/protocan/plot.py b/python/protocan/plot.py
index 67248f5..967638d 100644
--- a/python/protocan/plot.py
+++ b/python/protocan/plot.py
@@ -37,7 +37,7 @@ class PlotMath:
self.library = library
library.set_plot_abi_version.restype = ctypes.c_uint32
library.set_plot_abi_version.argtypes = []
- if library.set_plot_abi_version() != 1:
+ if library.set_plot_abi_version() != 2:
raise RuntimeError("Unsupported plot ABI")
library.set_plot_eval.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_double),
ctypes.c_size_t, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t]
@@ -67,6 +67,10 @@ class PlotMath:
except ValueError:
return None
+ def limits(self, left: float, right: float, bottom: float, top: float) -> "Bounds":
+ """Validate absolute axis limits in the shared core."""
+ return Bounds(*self.call(8, left, right, bottom, top))
+
@dataclass(frozen=True)
class Viewport:
@@ -92,6 +96,9 @@ class Bounds:
bottom: float
top: float
+ def validated(self, core: PlotMath) -> "Bounds":
+ return core.limits(self.left, self.right, self.bottom, self.top)
+
def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float:
return core.call(2, value, self.bottom if horizontal else self.left,
self.top if horizontal else self.right, int(horizontal))[0]
diff --git a/python/protocan/spectrum.py b/python/protocan/spectrum.py
index 1ce87ec..07b1eb5 100644
--- a/python/protocan/spectrum.py
+++ b/python/protocan/spectrum.py
@@ -37,6 +37,12 @@ class Spectrum:
return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes)))
+@dataclass(frozen=True)
+class SpectrumPeak:
+ frequency_hz: float
+ amplitude: float
+
+
class NativeSpectrum:
def __init__(self, library: ctypes.CDLL):
self.lib = library
@@ -46,6 +52,11 @@ class NativeSpectrum:
ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_int,
pointer, ctypes.c_size_t, pointer]
self._analyze.restype = ctypes.c_int
+ self._peak = library.set_spectrum_dominant_peak
+ self._peak.argtypes = [pointer, ctypes.c_size_t, ctypes.c_double,
+ ctypes.c_double, ctypes.c_double, pointer, ctypes.c_size_t,
+ pointer, ctypes.c_size_t]
+ self._peak.restype = ctypes.c_int
def analyze(self, times, values, *, max_size=4096, window=Window.HANN, filter=Filter.NONE,
low_hz=10.0, high_hz=100.0, remove_mean=True) -> Spectrum:
@@ -69,3 +80,15 @@ class NativeSpectrum:
raise ValueError(message.get(status, "FFT failed"))
n = int(meta[0])
return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1]))
+
+ def dominant_peak(self, spectrum: Spectrum, *, relative_threshold: float = 3.0,
+ absolute_floor: float = 1e-6) -> SpectrumPeak | None:
+ amplitudes = (ctypes.c_double * len(spectrum.amplitudes))(*spectrum.amplitudes)
+ scratch = (ctypes.c_double * max(1, len(spectrum.amplitudes) - 1))()
+ output = (ctypes.c_double * 2)()
+ status = self._peak(amplitudes, len(spectrum.amplitudes),
+ spectrum.sample_rate / spectrum.size, relative_threshold, absolute_floor,
+ scratch, len(scratch), output, 2)
+ if status < 0:
+ raise ValueError("Invalid spectrum peak input")
+ return SpectrumPeak(output[0], output[1]) if status else None
diff --git a/python/protocan/trends.py b/python/protocan/trends.py
index e047e1b..94c67fa 100644
--- a/python/protocan/trends.py
+++ b/python/protocan/trends.py
@@ -99,12 +99,6 @@ class TrendSignal:
if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6:
raise ValueError("Word offset must be 0..6")
- def word_value(self, word: int) -> float:
- if not 0 <= word <= 65535:
- raise ValueError("Not a 16-bit word")
- return float(word - 65536 if self.valueType == "INT16" and word >= 32768 else word)
-
-
def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None:
ids = set()
for profile, signals in settings.items():
@@ -178,6 +172,20 @@ class NativeTrends:
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8,
ctypes.c_uint32, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t]
self.decode.restype = ctypes.c_int32
+ self._word = library.set_trend_word_value
+ self._word.argtypes = [ctypes.c_uint16, ctypes.c_uint8]
+ self._word.restype = ctypes.c_int32
+ self._watch_request = library.set_trend_watch_request
+ self._watch_request.argtypes = [ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16),
+ ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t]
+ self._watch_request.restype = ctypes.c_size_t
+ self._watch_ack = library.set_trend_watch_ack
+ self._watch_ack.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint16, ctypes.c_size_t]
+ self._watch_ack.restype = ctypes.c_int
+ self._watch_decode = library.set_trend_watch_decode
+ self._watch_decode.argtypes = [ctypes.c_void_p, ctypes.c_size_t,
+ ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t]
+ self._watch_decode.restype = ctypes.c_int
def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None:
if signal.source not in ("CAN_GAS", "CAN_RAW"):
@@ -191,3 +199,33 @@ class NativeTrends:
signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags,
payload, len(data))
return None if value == -2147483648 else float(value)
+
+ def word_value(self, signal: TrendSignal, word: int) -> float:
+ if not 0 <= word <= 65535:
+ raise ValueError("Not a 16-bit word")
+ return float(self._word(word, signal.valueType == "INT16"))
+
+ def watch_request(self, period_ms: int, addresses: list[int]) -> bytes:
+ if not 0 <= period_ms <= 65535 or len(addresses) > MAX_SIGNALS or any(
+ type(address) is not int or not 0 <= address <= 65535 for address in addresses):
+ raise ValueError("Invalid GAS watch request")
+ source = (ctypes.c_uint16 * len(addresses))(*addresses)
+ output = (ctypes.c_uint8 * (4 + len(addresses) * 2))()
+ size = self._watch_request(period_ms, source, len(addresses), output, len(output))
+ if not size:
+ raise ValueError("Invalid GAS watch request")
+ return bytes(output[:size])
+
+ def validate_watch_ack(self, payload: bytes, period_ms: int, count: int) -> None:
+ data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
+ if not self._watch_ack(data, len(payload), period_ms, count):
+ raise ValueError("Device did not accept the complete GAS subscription")
+
+ def watch_values(self, payload: bytes, expected_count: int | None = None) -> tuple[int, list[int]]:
+ data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
+ timestamp = ctypes.c_uint32()
+ words = (ctypes.c_uint16 * MAX_SIGNALS)()
+ count = self._watch_decode(data, len(payload), ctypes.byref(timestamp), words, MAX_SIGNALS)
+ if count < 0 or expected_count is not None and count != expected_count:
+ raise ValueError("Invalid GAS watch data")
+ return timestamp.value, list(words[:count])
diff --git a/python/tests/test_spectrum.py b/python/tests/test_spectrum.py
index febddd0..60be5ac 100644
--- a/python/tests/test_spectrum.py
+++ b/python/tests/test_spectrum.py
@@ -35,6 +35,9 @@ class SpectrumTests(unittest.TestCase):
peak = max(range(len(result.amplitudes)), key=result.amplitudes.__getitem__)
self.assertEqual(64, result.frequencies[peak])
self.assertAlmostEqual(3.25, result.amplitudes[peak], places=9)
+ detected = self.core.dominant_peak(result)
+ self.assertAlmostEqual(64, detected.frequency_hz, places=9)
+ self.assertAlmostEqual(3.25, detected.amplitude, places=9)
def test_dc_and_nyquist_are_not_doubled(self):
times, _ = self.sample()
diff --git a/python/tests/test_trends.py b/python/tests/test_trends.py
index b18474b..9a82963 100644
--- a/python/tests/test_trends.py
+++ b/python/tests/test_trends.py
@@ -57,7 +57,6 @@ class TrendTests(unittest.TestCase):
for value in ("-1", "+1", "FF", "0x", "1.0", "256"):
with self.assertRaises(ValueError):
parse_address(value, 255)
- self.assertEqual(-2.0, TrendSignal("s", valueType="INT16").word_value(65534))
def test_bounded_history(self):
history = TrendHistory()
@@ -81,6 +80,13 @@ class TrendTests(unittest.TestCase):
self.assertIsNone(core.can_value(signal, frame_id ^ 0x08000000, 1, data))
raw = replace(signal, source="CAN_RAW", address="0x321", extended=False, byteOffset=2)
self.assertEqual(-2.0, core.can_value(raw, 0x321, 0, data))
+ self.assertEqual(-2.0, core.word_value(TrendSignal("s", valueType="INT16"), 65534))
+ self.assertEqual(bytes([232, 3, 2, 0, 52, 18, 255, 255]),
+ core.watch_request(1000, [0x1234, 0xFFFF]))
+ core.validate_watch_ack(bytes([232, 3, 2, 0]), 1000, 2)
+ timestamp, words = core.watch_values(bytes([1, 2, 3, 4, 2, 0, 52, 18, 255, 255]), 2)
+ self.assertEqual(0x04030201, timestamp)
+ self.assertEqual([0x1234, 0xFFFF], words)
if __name__ == "__main__":
From be066a57ebd41d2a6290d7de3af81937a96e843d Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sat, 5 Sep 2026 02:43:07 +0300
Subject: [PATCH 05/14] =?UTF-8?q?=D0=94=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD?=
=?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BA?=
=?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20=D0=B3=D1=80=D0=B0?=
=?UTF-8?q?=D0=BD=D0=B8=D1=86=20=D0=BE=D1=81=D0=B5=D0=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
c/set-protocol/tests/fixtures/plot-v1.json | 4 +++-
python/tests/test_plot.py | 3 +++
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/c/set-protocol/tests/fixtures/plot-v1.json b/c/set-protocol/tests/fixtures/plot-v1.json
index 1e8715d..b5b9a34 100644
--- a/c/set-protocol/tests/fixtures/plot-v1.json
+++ b/c/set-protocol/tests/fixtures/plot-v1.json
@@ -1,5 +1,5 @@
{
- "version": 1,
+ "version": 2,
"cases": [
{"name":"zoom_x_anchor","op":0,"input":[0,0,1,1,2,1,0,0,0.25,0.5],"output":[0.125,0,0.5,1]},
{"name":"zoom_y_anchor","op":0,"input":[0,0,1,1,1,4,0,0,0.5,0.75],"output":[0,0.5625,1,0.25]},
@@ -23,6 +23,8 @@
{"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":"absolute_limits","op":8,"input":[0,500,-2,2],"output":[0,500,-2,2]},
+ {"name":"reversed_x_limits","op":8,"input":[1,1,-2,2],"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}
diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py
index a2ba188..ba3281f 100644
--- a/python/tests/test_plot.py
+++ b/python/tests/test_plot.py
@@ -56,6 +56,9 @@ class PlotTests(unittest.TestCase):
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))
+ self.assertEqual(Bounds(0, 500, -2, 2), self.core.limits(0, 500, -2, 2))
+ with self.assertRaises(ValueError):
+ self.core.limits(1, 1, -2, 2)
if __name__ == "__main__":
From f5f15f6a04ef3b0fd4adf61ef02720c9e4719524 Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sat, 5 Sep 2026 02:44:08 +0300
Subject: [PATCH 06/14] =?UTF-8?q?=D0=A1=D0=BE=D1=85=D1=80=D0=B0=D0=BD?=
=?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82?=
=?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20ABI=20=D0=B3=D1=80=D0=B0?=
=?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=BE=D0=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
c/set-protocol/docs/GUI_PLOT.md | 3 ++-
c/set-protocol/include/set_plot.h | 2 +-
c/set-protocol/src/set_plot.c | 2 +-
c/set-protocol/tests/fixtures/plot-v1.json | 2 +-
c/set-protocol/tests/test_plot.c | 2 +-
python/protocan/plot.py | 2 +-
6 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/c/set-protocol/docs/GUI_PLOT.md b/c/set-protocol/docs/GUI_PLOT.md
index 97f9eda..52234d8 100644
--- a/c/set-protocol/docs/GUI_PLOT.md
+++ b/c/set-protocol/docs/GUI_PLOT.md
@@ -3,7 +3,8 @@
Численная логика находится в `include/set_plot.h` и `src/set_plot.c`.
Это модуль C99 без Qt, Android, транспорта, динамической памяти и глобального
состояния. Он собирается в существующую библиотеку SETProtocol; отдельная DLL
-для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 2`.
+для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 1`;
+новые операции добавляются без изменения существующих значений и сигнатур.
| Общее в templates | Адаптер приложения |
|---|---|
diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h
index df7cc60..8e0b006 100644
--- a/c/set-protocol/include/set_plot.h
+++ b/c/set-protocol/include/set_plot.h
@@ -21,7 +21,7 @@ enum set_plot_operation {
SET_PLOT_LIMITS = 8 /* xMin,xMax,yMin,yMax -> validated unchanged limits */
};
-/** Version of this plot ABI, independently of the transport ABI (currently 2). */
+/** Version of this additive plot ABI, independently of the transport ABI. */
PCAN_ABI_API uint32_t set_plot_abi_version(void);
/** Evaluates one operation. Returns output count, or 0 for invalid arguments.
* TRANSFORM rejects malformed viewports; invalid gesture values return the
diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c
index 368275b..ab9d99f 100644
--- a/c/set-protocol/src/set_plot.c
+++ b/c/set-protocol/src/set_plot.c
@@ -8,7 +8,7 @@ static int finite_values(const double *v, size_t n) {
return 1;
}
-uint32_t set_plot_abi_version(void) { return 2U; }
+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, 2, 4};
diff --git a/c/set-protocol/tests/fixtures/plot-v1.json b/c/set-protocol/tests/fixtures/plot-v1.json
index b5b9a34..a06e11f 100644
--- a/c/set-protocol/tests/fixtures/plot-v1.json
+++ b/c/set-protocol/tests/fixtures/plot-v1.json
@@ -1,5 +1,5 @@
{
- "version": 2,
+ "version": 1,
"cases": [
{"name":"zoom_x_anchor","op":0,"input":[0,0,1,1,2,1,0,0,0.25,0.5],"output":[0.125,0,0.5,1]},
{"name":"zoom_y_anchor","op":0,"input":[0,0,1,1,1,4,0,0,0.5,0.75],"output":[0,0.5625,1,0.25]},
diff --git a/c/set-protocol/tests/test_plot.c b/c/set-protocol/tests/test_plot.c
index 6615548..640621c 100644
--- a/c/set-protocol/tests/test_plot.c
+++ b/c/set-protocol/tests/test_plot.c
@@ -9,7 +9,7 @@ int main(void) {
const double y[] = {15, 10, 30, 1};
const double drag[] = {0, 100, 500, -10, 10, 1};
double back[4], bad[] = {NAN, 10, 8};
- assert(set_plot_abi_version() == 2);
+ assert(set_plot_abi_version() == 1);
assert(set_plot_eval(SET_PLOT_TRANSFORM, zoom, 10, output, 4) == 4);
assert(output[0] == .125 && output[1] == 0 && output[2] == .5 && output[3] == 1);
assert(output[4] == 12345);
diff --git a/python/protocan/plot.py b/python/protocan/plot.py
index 967638d..26bfeaf 100644
--- a/python/protocan/plot.py
+++ b/python/protocan/plot.py
@@ -37,7 +37,7 @@ class PlotMath:
self.library = library
library.set_plot_abi_version.restype = ctypes.c_uint32
library.set_plot_abi_version.argtypes = []
- if library.set_plot_abi_version() != 2:
+ if library.set_plot_abi_version() != 1:
raise RuntimeError("Unsupported plot ABI")
library.set_plot_eval.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_double),
ctypes.c_size_t, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t]
From e691dfc3378572b97bab1e24b61f07abddb03165 Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sat, 5 Sep 2026 03:05:17 +0300
Subject: [PATCH 07/14] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86?=
=?UTF-8?q?=D0=B8=D1=8E=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0?=
=?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=88=D0=B8=D0=B2=D0=BE=D0=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
python/setprotocol/firmware_publish.py | 145 +++++++++++++++++++++++++
python/tests/test_firmware_publish.py | 93 ++++++++++++++++
2 files changed, 238 insertions(+)
create mode 100644 python/setprotocol/firmware_publish.py
create mode 100644 python/tests/test_firmware_publish.py
diff --git a/python/setprotocol/firmware_publish.py b/python/setprotocol/firmware_publish.py
new file mode 100644
index 0000000..c1f86a2
--- /dev/null
+++ b/python/setprotocol/firmware_publish.py
@@ -0,0 +1,145 @@
+"""Reusable helpers for publishing the shared firmware release catalog."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+from .firmware_catalog import (
+ MAX_MANIFEST_BYTES,
+ SUPPORTED_TRANSPORTS,
+ parse_firmware_catalog,
+)
+
+MAX_FIRMWARE_BYTES = 128 * 1024 * 1024
+
+
+@dataclass(frozen=True)
+class FirmwarePublication:
+ """Metadata required to publish one firmware image."""
+
+ path: Path
+ product: str
+ version_name: str
+ version_code: int
+ transport: str
+ base_address: int | None = None
+ notes: str = ""
+
+ def validate(self) -> None:
+ if not self.path.is_file():
+ raise ValueError(f"Firmware file is missing: {self.path}")
+ if self.path.suffix.lower() not in {".bin", ".hex"}:
+ raise ValueError("Firmware file must have a .bin or .hex extension")
+ size = self.path.stat().st_size
+ if size <= 0:
+ raise ValueError("Firmware file is empty")
+ if size > MAX_FIRMWARE_BYTES:
+ raise ValueError("Firmware file exceeds the maximum size")
+ if not self.product.strip():
+ raise ValueError("Firmware product is empty")
+ if not self.version_name.strip():
+ raise ValueError("Firmware version name is empty")
+ if not 0 <= self.version_code <= 0x7FFFFFFF:
+ raise ValueError(
+ "Firmware version code must be between 0 and 2147483647"
+ )
+ if self.transport not in SUPPORTED_TRANSPORTS:
+ raise ValueError("Unsupported firmware transport")
+ if (
+ self.base_address is not None
+ and not 0 <= self.base_address <= 0xFFFFFFFF
+ ):
+ raise ValueError("Firmware base address is outside the uint32 range")
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def safe_release_tag_part(value: str) -> str:
+ source = value.strip()
+ part = re.sub(r"[^A-Za-z0-9._-]+", "-", source).strip("-.")
+ digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:8]
+ if not part:
+ return digest
+ return part if part == source else f"{part}-{digest}"
+
+
+def firmware_release_tag(publication: FirmwarePublication) -> str:
+ return "firmware-%s-v%s" % (
+ safe_release_tag_part(publication.product),
+ safe_release_tag_part(publication.version_name),
+ )
+
+
+def firmware_release_entry(
+ publication: FirmwarePublication, image_url: str, sha256: str
+) -> dict:
+ result = {
+ "product": publication.product.strip(),
+ "versionCode": publication.version_code,
+ "versionName": publication.version_name.strip(),
+ "imageUrl": image_url,
+ "fileName": publication.path.name,
+ "sha256": sha256,
+ "transport": publication.transport,
+ "notes": publication.notes.strip(),
+ }
+ if publication.base_address is not None:
+ result["baseAddress"] = f"0x{publication.base_address:08X}"
+ return result
+
+
+def firmware_entry_identity(entry: dict) -> tuple[str, int, str]:
+ return (
+ str(entry.get("product", entry.get("device", ""))).strip().casefold(),
+ int(entry.get("versionCode", 0)),
+ str(entry.get("transport", "rs485")).strip().lower(),
+ )
+
+
+def update_firmware_manifest(manifest: dict, entry: dict) -> dict:
+ """Insert or replace one release without disturbing other manifest data."""
+ result = dict(manifest)
+ existing = manifest.get("firmware")
+ firmware = dict(existing) if isinstance(existing, dict) else {}
+ rows = firmware.get("releases") if isinstance(existing, dict) else existing
+ releases = (
+ [dict(row) for row in rows if isinstance(row, dict)]
+ if isinstance(rows, list)
+ else []
+ )
+ identity = firmware_entry_identity(entry)
+ releases = [
+ row for row in releases if firmware_entry_identity(row) != identity
+ ]
+ releases.append(dict(entry))
+ releases.sort(
+ key=lambda row: (
+ str(row.get("product", row.get("device", ""))).casefold(),
+ -int(row.get("versionCode", 0)),
+ str(row.get("transport", "rs485")),
+ )
+ )
+ previous_rows = firmware.get("releases")
+ changed = releases != previous_rows
+ firmware["catalogVersion"] = (
+ int(firmware.get("catalogVersion", 0)) + int(changed)
+ )
+ firmware["releases"] = releases
+ result["firmware"] = firmware
+
+ encoded = (json.dumps(result, ensure_ascii=False) + "\n").encode("utf-8")
+ if len(encoded) > MAX_MANIFEST_BYTES:
+ raise ValueError("Updated update.json exceeds the maximum size")
+ parse_firmware_catalog(encoded, "https://catalog.invalid/update.json")
+ return result
+
diff --git a/python/tests/test_firmware_publish.py b/python/tests/test_firmware_publish.py
new file mode 100644
index 0000000..a50b354
--- /dev/null
+++ b/python/tests/test_firmware_publish.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from setprotocol.firmware_publish import (
+ FirmwarePublication,
+ firmware_release_entry,
+ firmware_release_tag,
+ update_firmware_manifest,
+)
+
+
+class FirmwarePublishTests(unittest.TestCase):
+ def publication(self, path: Path, **overrides) -> FirmwarePublication:
+ fields = {
+ "path": path,
+ "product": "F103DS18",
+ "version_name": "1.1.0",
+ "version_code": 0x00010100,
+ "transport": "can",
+ "base_address": 0x08003000,
+ "notes": "Verified release",
+ }
+ fields.update(overrides)
+ return FirmwarePublication(**fields)
+
+ def test_publication_validates_file_and_metadata(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ image = Path(temporary) / "image.hex"
+ image.write_text(":00000001FF\n", encoding="ascii")
+ self.publication(image).validate()
+ with self.assertRaisesRegex(ValueError, "Unsupported"):
+ self.publication(image, transport="unknown").validate()
+
+ def test_entry_and_tag_are_deterministic(self) -> None:
+ publication = self.publication(Path("image.hex"))
+ entry = firmware_release_entry(
+ publication, "https://example.test/image.hex", "ab" * 32
+ )
+ self.assertEqual(
+ firmware_release_tag(publication), "firmware-F103DS18-v1.1.0"
+ )
+ self.assertEqual(entry["baseAddress"], "0x08003000")
+
+ def test_update_preserves_sections_and_replaces_same_release(self) -> None:
+ first = {
+ "product": "Device",
+ "versionCode": 7,
+ "versionName": "1.2.3",
+ "imageUrl": "https://example.test/old.bin",
+ "fileName": "old.bin",
+ "sha256": "11" * 32,
+ "transport": "rs485",
+ }
+ manifest = update_firmware_manifest(
+ {"windows": {"versionCode": 8}}, first
+ )
+ replacement = {
+ **first,
+ "imageUrl": "https://example.test/new.bin",
+ "fileName": "new.bin",
+ "sha256": "22" * 32,
+ }
+ updated = update_firmware_manifest(manifest, replacement)
+ self.assertEqual(updated["windows"], {"versionCode": 8})
+ self.assertEqual(len(updated["firmware"]["releases"]), 1)
+ self.assertEqual(
+ updated["firmware"]["releases"][0]["sha256"], "22" * 32
+ )
+ self.assertEqual(updated["firmware"]["catalogVersion"], 2)
+
+ def test_legacy_array_is_migrated_without_data_loss(self) -> None:
+ legacy = {
+ "product": "Legacy",
+ "versionCode": 1,
+ "versionName": "1.0",
+ "imageUrl": "https://example.test/legacy.bin",
+ "fileName": "legacy.bin",
+ "sha256": "33" * 32,
+ "transport": "rs485",
+ }
+ current = {**legacy, "product": "Current", "versionCode": 2}
+ updated = update_firmware_manifest({"firmware": [legacy]}, current)
+ self.assertCountEqual(
+ [row["product"] for row in updated["firmware"]["releases"]],
+ ["Legacy", "Current"],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
From 286e454464560ca02be5621094de15d3595c820e Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sat, 5 Sep 2026 03:16:16 +0300
Subject: [PATCH 08/14] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D1=82=D1=8C=20=D0=B2=D1=8B=D0=BF=D1=83=D1=81=D0=BA=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=BE=D1=88=D0=B8=D0=B2=D0=BE=D0=BA=20=D0=B8=D0=B7=20Kei?=
=?UTF-8?q?l=20=D0=B8=20CCS?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 7 +
tools/firmware-publish/PUBLISH_FIRMWARE.bat | 221 ++++++++++++++++++
tools/firmware-publish/README.md | 159 +++++++++++++
.../examples/ccs12-firmware-release.cmd | 15 ++
.../examples/keil-firmware-release.cmd | 13 ++
5 files changed, 415 insertions(+)
create mode 100644 tools/firmware-publish/PUBLISH_FIRMWARE.bat
create mode 100644 tools/firmware-publish/README.md
create mode 100644 tools/firmware-publish/examples/ccs12-firmware-release.cmd
create mode 100644 tools/firmware-publish/examples/keil-firmware-release.cmd
diff --git a/README.md b/README.md
index c6268bd..55dbe63 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@
templates/
c/ библиотеки на C99: заголовок, реализация, README, где есть — порт и тесты
python/ модули на чистом Python 3.9+, только stdlib
+ tools/ общие инструменты сборки и выпуска прошивок
```
Пошаговая раскладка нового проекта и выбор портов для STM32F103, STM32G431
@@ -48,6 +49,12 @@ templates/
| [`python/protocan`](python/protocan) | разбор ProtoCAN, транспортный кадр моста, кадр SETGUI, кодеки каталога | stdlib, Python 3.9+ |
| [`python/protocan/trends.py`](python/protocan/trends.py) | общие настройки графиков, ограниченная история, ctypes-декодер GAS/raw CAN | stdlib, опционально SETProtocol DLL/SO |
+### Инструменты
+
+| Инструмент | Что делает |
+|---|---|
+| [`tools/firmware-publish`](tools/firmware-publish) | единый BAT и конфигурации для проверки и публикации `.hex` Keil / `.bin` CCS 12 в каталоге SETGUI |
+
Общие тренды для Android GUI и SETGUI: [формат JSON, C99-ядро и адаптеры](c/set-protocol/docs/GUI_TRENDS.md).
Общие масштабирование и маркеры: [C99, JNI и Python/Qt](c/set-protocol/docs/GUI_PLOT.md).
diff --git a/tools/firmware-publish/PUBLISH_FIRMWARE.bat b/tools/firmware-publish/PUBLISH_FIRMWARE.bat
new file mode 100644
index 0000000..af08934
--- /dev/null
+++ b/tools/firmware-publish/PUBLISH_FIRMWARE.bat
@@ -0,0 +1,221 @@
+@echo off
+setlocal EnableExtensions DisableDelayedExpansion
+
+set "FW_TOOL_DIR=%~dp0"
+set "FW_MODE=--preflight"
+set "FW_CONFIG="
+set "FW_ASSUME_YES=0"
+set "FW_ALLOW_DIRTY=0"
+
+:parse_arguments
+if "%~1"=="" goto arguments_done
+if /i "%~1"=="--preflight" (
+ set "FW_MODE=--preflight"
+ shift
+ goto parse_arguments
+)
+if /i "%~1"=="--publish" (
+ set "FW_MODE=--publish"
+ shift
+ goto parse_arguments
+)
+if /i "%~1"=="--yes" (
+ set "FW_ASSUME_YES=1"
+ shift
+ goto parse_arguments
+)
+if /i "%~1"=="--allow-dirty" (
+ set "FW_ALLOW_DIRTY=1"
+ shift
+ goto parse_arguments
+)
+if /i "%~1"=="--config" (
+ if "%~2"=="" goto missing_config_value
+ set "FW_CONFIG=%~2"
+ shift
+ shift
+ goto parse_arguments
+)
+if /i "%~1"=="--help" goto usage
+if /i "%~1"=="-h" goto usage
+echo ERROR: unknown argument: %~1
+goto usage_error
+
+:missing_config_value
+echo ERROR: --config requires a path.
+goto usage_error
+
+:arguments_done
+if not defined FW_CONFIG (
+ if exist "%CD%\firmware-release.cmd" (
+ set "FW_CONFIG=%CD%\firmware-release.cmd"
+ ) else (
+ set "FW_CONFIG=%FW_TOOL_DIR%firmware-release.cmd"
+ )
+)
+for %%I in ("%FW_CONFIG%") do set "FW_CONFIG=%%~fI"
+if not exist "%FW_CONFIG%" (
+ echo ERROR: publication config not found:
+ echo %FW_CONFIG%
+ echo Copy one of the examples to the firmware project root as
+ echo firmware-release.cmd, then edit its values.
+ exit /b 2
+)
+
+for %%I in ("%FW_CONFIG%") do set "FW_CONFIG_DIR=%%~dpI"
+call "%FW_CONFIG%"
+if errorlevel 1 (
+ echo ERROR: publication config returned an error.
+ exit /b 2
+)
+
+if not defined FW_PRODUCT goto missing_FW_PRODUCT
+if not defined FW_VERSION goto missing_FW_VERSION
+if not defined FW_VERSION_CODE goto missing_FW_VERSION_CODE
+if not defined FW_TRANSPORTS goto missing_FW_TRANSPORTS
+if not defined FW_IMAGE goto missing_FW_IMAGE
+if not defined FW_PROJECT_ROOT set "FW_PROJECT_ROOT=%FW_CONFIG_DIR%"
+
+pushd "%FW_CONFIG_DIR%" >nul
+if errorlevel 1 (
+ echo ERROR: cannot enter config directory: %FW_CONFIG_DIR%
+ exit /b 2
+)
+for %%I in ("%FW_PROJECT_ROOT%") do set "FW_PROJECT_ROOT_ABS=%%~fI"
+for %%I in ("%FW_IMAGE%") do set "FW_IMAGE_ABS=%%~fI"
+if defined FW_SETGUI_ROOT for %%I in ("%FW_SETGUI_ROOT%") do set "FW_SETGUI_ROOT_ABS=%%~fI"
+popd
+
+if not defined FW_SETGUI_ROOT_ABS call :find_setgui
+if not defined FW_SETGUI_ROOT_ABS (
+ echo ERROR: SETGUI was not found.
+ echo Set FW_SETGUI_ROOT in %FW_CONFIG%
+ exit /b 2
+)
+set "FW_SETGUI_PUBLISHER=%FW_SETGUI_ROOT_ABS%\PUBLISH_FIRMWARE.bat"
+if not exist "%FW_SETGUI_PUBLISHER%" (
+ echo ERROR: SETGUI publisher not found:
+ echo %FW_SETGUI_PUBLISHER%
+ exit /b 2
+)
+if not exist "%FW_IMAGE_ABS%" (
+ echo ERROR: firmware image not found:
+ echo %FW_IMAGE_ABS%
+ exit /b 2
+)
+
+for %%I in ("%FW_IMAGE_ABS%") do set "FW_IMAGE_EXT=%%~xI"
+if /i not "%FW_IMAGE_EXT%"==".hex" if /i not "%FW_IMAGE_EXT%"==".bin" (
+ echo ERROR: FW_IMAGE must point to a .hex or .bin file, not %FW_IMAGE_EXT%.
+ echo Keil must create a HEX file; CCS must run C2000 Hex Utility.
+ exit /b 2
+)
+
+echo.
+echo Firmware publication
+echo Mode: %FW_MODE%
+echo Project: %FW_PROJECT_ROOT_ABS%
+echo Image: %FW_IMAGE_ABS%
+echo Product: %FW_PRODUCT%
+echo Version: %FW_VERSION% ^(%FW_VERSION_CODE%^)
+echo Transports: %FW_TRANSPORTS%
+if defined FW_BASE_ADDRESS echo Base address: %FW_BASE_ADDRESS%
+echo.
+
+if /i "%FW_MODE%"=="--publish" call :guard_worktree
+if errorlevel 1 exit /b %errorlevel%
+
+if /i "%FW_MODE%"=="--publish" if "%FW_ASSUME_YES%"=="0" (
+ echo This will upload the image and change the public firmware catalog.
+ choice /C YN /N /M "Continue [Y/N]? "
+ if errorlevel 2 (
+ echo Publication cancelled.
+ exit /b 2
+ )
+)
+
+for %%T in (%FW_TRANSPORTS:,= %) do (
+ call :publish_transport "%%~T"
+ if errorlevel 1 goto publication_failed
+)
+
+if /i "%FW_MODE%"=="--publish" (
+ echo.
+ echo Publication completed and verified for all transports.
+) else (
+ echo.
+ echo Preflight passed. Run with --publish only when this build is a release.
+)
+exit /b 0
+
+:publication_failed
+echo.
+echo ERROR: publication step failed; remaining transports were not processed.
+exit /b 1
+
+:publish_transport
+set "FW_TRANSPORT=%~1"
+if "%FW_TRANSPORT%"=="" exit /b 0
+echo ----- %FW_TRANSPORT% -----
+if defined FW_BASE_ADDRESS (
+ call "%FW_SETGUI_PUBLISHER%" %FW_MODE% --file "%FW_IMAGE_ABS%" --product "%FW_PRODUCT%" --version "%FW_VERSION%" --version-code "%FW_VERSION_CODE%" --transport "%FW_TRANSPORT%" --base-address "%FW_BASE_ADDRESS%" --notes "%FW_NOTES%"
+) else (
+ call "%FW_SETGUI_PUBLISHER%" %FW_MODE% --file "%FW_IMAGE_ABS%" --product "%FW_PRODUCT%" --version "%FW_VERSION%" --version-code "%FW_VERSION_CODE%" --transport "%FW_TRANSPORT%" --notes "%FW_NOTES%"
+)
+exit /b %errorlevel%
+
+:guard_worktree
+where git >nul 2>nul
+if errorlevel 1 (
+ echo WARNING: Git is unavailable; source cleanliness was not checked.
+ exit /b 0
+)
+git -C "%FW_PROJECT_ROOT_ABS%" rev-parse --is-inside-work-tree >nul 2>nul
+if errorlevel 1 (
+ echo WARNING: project is not a Git worktree; source cleanliness was not checked.
+ exit /b 0
+)
+git -C "%FW_PROJECT_ROOT_ABS%" diff --quiet HEAD -- >nul 2>nul
+if not errorlevel 1 exit /b 0
+if "%FW_ALLOW_DIRTY%"=="1" (
+ echo WARNING: publishing from a worktree with tracked changes.
+ exit /b 0
+)
+echo ERROR: tracked project files differ from HEAD.
+echo Commit the release sources or repeat with --allow-dirty deliberately.
+exit /b 3
+
+:find_setgui
+for %%I in ("%FW_CONFIG_DIR%..\SETGUI" "%FW_CONFIG_DIR%..\..\SETGUI" "%FW_CONFIG_DIR%..\..\..\SETGUI") do (
+ if exist "%%~fI\PUBLISH_FIRMWARE.bat" if not defined FW_SETGUI_ROOT_ABS set "FW_SETGUI_ROOT_ABS=%%~fI"
+)
+exit /b 0
+
+:missing_FW_PRODUCT
+echo ERROR: FW_PRODUCT is not set in %FW_CONFIG%
+exit /b 2
+:missing_FW_VERSION
+echo ERROR: FW_VERSION is not set in %FW_CONFIG%
+exit /b 2
+:missing_FW_VERSION_CODE
+echo ERROR: FW_VERSION_CODE is not set in %FW_CONFIG%
+exit /b 2
+:missing_FW_TRANSPORTS
+echo ERROR: FW_TRANSPORTS is not set in %FW_CONFIG%
+exit /b 2
+:missing_FW_IMAGE
+echo ERROR: FW_IMAGE is not set in %FW_CONFIG%
+exit /b 2
+
+:usage
+echo Usage:
+echo PUBLISH_FIRMWARE.bat [--config PATH] --preflight
+echo PUBLISH_FIRMWARE.bat [--config PATH] --publish [--yes] [--allow-dirty]
+echo.
+echo Default mode is --preflight. Default config is firmware-release.cmd
+echo in the current directory or next to this script.
+exit /b 0
+
+:usage_error
+call :usage
+exit /b 2
diff --git a/tools/firmware-publish/README.md b/tools/firmware-publish/README.md
new file mode 100644
index 0000000..9c27223
--- /dev/null
+++ b/tools/firmware-publish/README.md
@@ -0,0 +1,159 @@
+# Публикация прошивки из Keil и CCS 12
+
+Этот комплект связывает проект прошивки с каталогом SETGUI. IDE по-прежнему
+собирает штатный загрузочный файл, а `PUBLISH_FIRMWARE.bat`:
+
+1. читает метаданные выпуска из `firmware-release.cmd`;
+2. проверяет наличие и формат `.hex` или `.bin`;
+3. запускает локальную проверку SETGUI (`--preflight`) либо публикацию
+ (`--publish`);
+4. для каждого транспорта загружает образ в Gitea, скачивает его обратно,
+ сверяет SHA-256 и только после этого обновляет `update.json`.
+
+Пароль в проекте не хранится. Публикатор использует учётные данные Gitea,
+которые сохранены в SETGUI через окно «Версия и обновление».
+
+## Один раз на рабочем компьютере
+
+1. Соберите или запустите SETGUI и сохраните в нём логин и пароль (либо токен
+ вместо пароля) Gitea.
+2. Убедитесь, что у SETGUI создано Python-окружение `.venv`.
+3. Подключите `templates` как сабмодуль проекта либо используйте уже общий
+ checkout. Не делайте отдельные исправленные копии скрипта в каждом проекте.
+4. Скопируйте подходящий файл из [`examples`](examples) в корень проекта под
+ именем `firmware-release.cmd` и исправьте значения.
+
+`firmware-release.cmd` содержит только метаданные:
+
+```bat
+set "FW_PROJECT_ROOT=%~dp0"
+set "FW_SETGUI_ROOT=%~dp0..\SETGUI"
+set "FW_PRODUCT=F103DS18"
+set "FW_VERSION=1.1.0"
+set "FW_VERSION_CODE=0x00010100"
+set "FW_TRANSPORTS=can rs485"
+set "FW_BASE_ADDRESS=0x08003000"
+set "FW_IMAGE=mdk\build\ds18b20_f103.hex"
+set "FW_NOTES=Краткое описание выпуска"
+```
+
+Пути считаются относительно каталога `firmware-release.cmd`. Если SETGUI не
+лежит рядом с проектом, укажите абсолютный `FW_SETGUI_ROOT` или системную
+переменную с тем же именем.
+
+### Обязательные поля
+
+| Поле | Значение |
+|---|---|
+| `FW_PRODUCT` | Стабильный идентификатор изделия. Не меняйте регистр/написание между версиями. |
+| `FW_VERSION` | Читаемая версия SemVer, например `1.1.0`. |
+| `FW_VERSION_CODE` | Число для сравнения версий. Рекомендуется `(major << 16) + (minor << 8) + patch`: `1.1.0` = `0x00010100`. |
+| `FW_TRANSPORTS` | Один или несколько транспортов через пробел: `can`, `rs485`, `tms`. |
+| `FW_IMAGE` | Готовый файл `.hex` или `.bin`. `.axf` и `.out` публиковать нельзя. |
+
+`FW_BASE_ADDRESS` нужен для обычного бинарного образа STM32. Для Intel HEX
+адрес уже записан в файле, но поле каталога всё равно лучше заполнить адресом
+приложения. Для загрузочной таблицы TMS SCI8 оставьте поле пустым.
+
+## Keil MDK / Arm Compiler 6
+
+1. В **Options for Target → Output** включите **Create HEX File**.
+2. Укажите в `FW_IMAGE` реальный выходной файл, например
+ `mdk\build\ds18b20_f103.hex`.
+3. Запустите полную сборку и локальную проверку. В `KONOR_ds18b20`, где
+ сабмодуль `templates` подключён как `lib`, команда выглядит так:
+
+```bat
+call "lib\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight
+```
+
+В **Options for Target → User → After Build/Rebuild** можно добавить эту же
+команду с `--preflight`. Если Keil запускает её из каталога `mdk`, передайте
+явный путь к конфигурации, например `--config "..\firmware-release.cmd"`.
+
+Не ставьте `--publish` в post-build: иначе обычная сборка станет внешней
+операцией и сможет перезаписать опубликованную версию.
+
+## Code Composer Studio 12 / C2000
+
+SETGUI не преобразует `.out`. Для активной конфигурации **Debug и/или Release**
+включите **C2000 Hex Utility** и сформируйте загрузочный `.bin`. Для SCI8 boot
+TMS320F2812 используются параметры проекта:
+
+```text
+--binary
+--boot
+--sci8
+```
+
+Выход удобно складывать в `bin\${BuildArtifactFileBaseName}.bin`. Затем укажите
+этот путь в `FW_IMAGE`, `tms` в `FW_TRANSPORTS`, а `FW_BASE_ADDRESS` оставьте
+пустым.
+
+После Build выполните:
+
+```bat
+call "..\newProject\templates\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight
+```
+
+Команда выше соответствует текущей раскладке `SETGIT\BALZAM_ALL` и
+`SETGIT\newProject\templates`. Если `templates` подключён в сам проект как
+`lib\templates`, используйте `lib\templates\tools\firmware-publish\...`.
+
+При желании ту же команду можно добавить в **Project Properties → Build →
+Steps → Post-build steps**. В конфигурации CCS, где Hex Utility не включён,
+файл `.bin` не обновится — это особенно важно отдельно проверить для Release.
+
+## Выпуск
+
+Рабочая последовательность одинакова для обеих IDE:
+
+```bat
+rem 1. Собрать Release в IDE.
+
+rem 2. Проверить метаданные, размер, имя, SHA-256 и запись каталога без сети.
+call "lib\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight
+
+rem 3. Закоммитить исходники выпуска и опубликовать.
+call "lib\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --publish
+```
+
+Перед публикацией скрипт показывает все параметры, проверяет tracked-файлы
+Git и просит подтверждение. `--allow-dirty` осознанно разрешает публикацию из
+изменённого worktree, а `--yes` отключает только интерактивное подтверждение
+для доверенного CI:
+
+```bat
+call PUBLISH_FIRMWARE.bat --publish --yes --allow-dirty
+```
+
+Повторная публикация той же комбинации `product + versionCode + transport`
+заменяет запись каталога. Новый `versionCode` добавляет новую версию. Если при
+нескольких транспортах сеть оборвалась посередине, исправьте причину и повторите
+ту же команду: уже опубликованные записи будут безопасно заменены теми же
+данными.
+
+## Контроль после публикации
+
+Успешное завершение означает, что образ:
+
+- загружен как asset выпуска;
+- скачан обратно и совпал по SHA-256;
+- записан в `firmware.releases` файла `update.json`;
+- повторно прочитан и разобран тем же кодом, который использует SETGUI.
+
+После этого откройте в SETGUI **Версия и обновление → База прошивок → Обновить
+каталог** и проверьте изделие, версию и транспорт. Для окончательной проверки
+выполните загрузку на тестовое устройство именно тем транспортом, который
+указан в записи.
+
+## Частые ошибки
+
+| Сообщение | Что проверить |
+|---|---|
+| `firmware image not found` | Сборка завершилась успешно, `FW_IMAGE` задан относительно конфигурации, нужная конфигурация IDE создаёт `.hex/.bin`. |
+| `SETGUI was not found` | Исправьте `FW_SETGUI_ROOT`. |
+| `Missing build environment: .venv` | Создайте окружение SETGUI и установите зависимости проекта. |
+| Ошибка авторизации Gitea | Заново сохраните логин и токен в SETGUI; не записывайте токен в `.cmd`. |
+| `tracked project files differ from HEAD` | Закоммитьте точные исходники выпуска либо осознанно добавьте `--allow-dirty`. |
+| Версия не видна в SETGUI | Нажмите «Обновить каталог» и проверьте точное значение `FW_PRODUCT` и поддерживаемый выбранным устройством транспорт. |
diff --git a/tools/firmware-publish/examples/ccs12-firmware-release.cmd b/tools/firmware-publish/examples/ccs12-firmware-release.cmd
new file mode 100644
index 0000000..ef3ae21
--- /dev/null
+++ b/tools/firmware-publish/examples/ccs12-firmware-release.cmd
@@ -0,0 +1,15 @@
+@echo off
+rem Copy this file to the firmware repository root as firmware-release.cmd.
+
+set "FW_PROJECT_ROOT=%~dp0"
+rem This relative path matches SETGIT/BALZAM_ALL + SETGIT/newProject/SETGUI.
+rem Change it if the repositories are stored differently.
+set "FW_SETGUI_ROOT=%~dp0..\newProject\SETGUI"
+
+set "FW_PRODUCT=BALZAM-166"
+set "FW_VERSION=1.0.0"
+set "FW_VERSION_CODE=0x00010000"
+set "FW_TRANSPORTS=tms"
+set "FW_BASE_ADDRESS="
+set "FW_IMAGE=ccs12_8_proj\bin\balzam_166_ccs12_8.bin"
+set "FW_NOTES=TMS320F2812 SCI8 boot image"
diff --git a/tools/firmware-publish/examples/keil-firmware-release.cmd b/tools/firmware-publish/examples/keil-firmware-release.cmd
new file mode 100644
index 0000000..40b7a95
--- /dev/null
+++ b/tools/firmware-publish/examples/keil-firmware-release.cmd
@@ -0,0 +1,13 @@
+@echo off
+rem Copy this file to the firmware repository root as firmware-release.cmd.
+
+set "FW_PROJECT_ROOT=%~dp0"
+set "FW_SETGUI_ROOT=%~dp0..\SETGUI"
+
+set "FW_PRODUCT=F103DS18"
+set "FW_VERSION=1.1.0"
+set "FW_VERSION_CODE=0x00010100"
+set "FW_TRANSPORTS=can rs485"
+set "FW_BASE_ADDRESS=0x08003000"
+set "FW_IMAGE=mdk\build\ds18b20_f103.hex"
+set "FW_NOTES=DS18B20 firmware release"
From 59f847e900c17152f5f76ba355e1c829c17f24a1 Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sat, 5 Sep 2026 03:43:22 +0300
Subject: [PATCH 09/14] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D1=82=D1=8C=20=D0=BC=D0=BD=D0=BE=D0=B6=D0=B8=D1=82=D0=B5=D0=BB?=
=?UTF-8?q?=D1=8C=20=D0=B8=20IQ=20=D0=B2=20Python-=D1=82=D1=80=D0=B5=D0=BD?=
=?UTF-8?q?=D0=B4=D1=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
python/protocan/trends.py | 16 +++++++++++++---
python/tests/test_trends.py | 10 ++++++++++
2 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/python/protocan/trends.py b/python/protocan/trends.py
index 94c67fa..71b6f38 100644
--- a/python/protocan/trends.py
+++ b/python/protocan/trends.py
@@ -54,6 +54,8 @@ class TrendSignal:
deviceType: int = 7
device: int = 13
byteOffset: int = 0
+ multiplier: float = 1.0
+ iq: int = 0
extended: bool = True
@classmethod
@@ -72,9 +74,11 @@ class TrendSignal:
for field in ("id", "name", "source", "address", "color", "valueType"):
if type(getattr(self, field)) is not str:
raise ValueError(f"{field} must be a string")
- for field in ("order", "deviceType", "device", "byteOffset"):
+ for field in ("order", "deviceType", "device", "byteOffset", "iq"):
if type(getattr(self, field)) is not int:
raise ValueError(f"{field} must be an integer")
+ if type(self.multiplier) not in (int, float) or not math.isfinite(self.multiplier):
+ raise ValueError("multiplier must be a finite number")
if type(self.visible) is not bool or type(self.extended) is not bool:
raise ValueError("Visibility and CAN format must be boolean")
if not self.id.strip() or len(self.id) > 80 or not 1 <= self.order <= 9999:
@@ -98,6 +102,11 @@ class TrendSignal:
raise ValueError("Invalid ProtoCAN device")
if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6:
raise ValueError("Word offset must be 0..6")
+ if not 0 <= self.iq <= 30:
+ raise ValueError("IQ must be 0..30")
+
+ def display_value(self, raw_value: float) -> float:
+ return raw_value * self.multiplier / (1 << self.iq)
def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None:
ids = set()
@@ -132,14 +141,15 @@ def decode_settings(text: str) -> dict[str, list[TrendSignal]]:
raise ValueError("profiles must be an object")
result = {}
fields = set(TrendSignal.__dataclass_fields__)
+ required_fields = fields - {"multiplier", "iq"}
for profile, items in profiles.items():
if not isinstance(items, list) or len(items) > MAX_SIGNALS:
raise ValueError("Expected up to 64 signals")
signals = []
for item in items:
- if not isinstance(item, dict) or not fields <= item.keys():
+ if not isinstance(item, dict) or not required_fields <= item.keys():
raise ValueError("Missing signal fields")
- signals.append(TrendSignal(**{key: item[key] for key in fields}))
+ signals.append(TrendSignal(**{key: item[key] for key in fields if key in item}))
result[profile] = signals
validate_settings(result)
return result
diff --git a/python/tests/test_trends.py b/python/tests/test_trends.py
index 9a82963..3613823 100644
--- a/python/tests/test_trends.py
+++ b/python/tests/test_trends.py
@@ -14,6 +14,16 @@ FIXTURE = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/t
class TrendTests(unittest.TestCase):
+ def test_multiplier_and_iq_scale_display_and_old_json_defaults(self):
+ signal = TrendSignal("scaled", multiplier=2.5, iq=3)
+ self.assertEqual(10.0, signal.display_value(32.0))
+ signal.validate("TMS2812")
+ payload = json.loads(encode_settings({"TMS2812": [signal]}))
+ payload["profiles"]["TMS2812"][0].pop("multiplier")
+ payload["profiles"]["TMS2812"][0].pop("iq")
+ restored = decode_settings(json.dumps(payload))["TMS2812"][0]
+ self.assertEqual((1.0, 0), (restored.multiplier, restored.iq))
+
def test_shared_kotlin_fixture_and_round_trip(self):
settings = decode_settings(FIXTURE.read_text(encoding="utf-8"))
self.assertEqual(5, sum(map(len, settings.values())))
From 235d5d81a2239d33d25bcb046b4714365db78a8c Mon Sep 17 00:00:00 2001
From: Andrey
Date: Sat, 5 Sep 2026 11:37:03 +0300
Subject: [PATCH 10/14] Share PM67 upload protocol through C core
---
c/set-protocol/CMakeLists.txt | 5 +
c/set-protocol/include/pcan_abi.h | 15 +++
c/set-protocol/include/set_crc.h | 19 ++++
c/set-protocol/include/setprotocol.h | 1 +
c/set-protocol/include/tms2812.h | 49 ++++++++++
c/set-protocol/ports/android/Android.mk | 2 +
.../setcorp/setprotocol/NativeSetProtocol.kt | 5 +
.../ports/android/setprotocol_jni.c | 91 +++++++++++++++++++
c/set-protocol/src/pcan_abi.c | 35 +++++++
c/set-protocol/src/periph28335.c | 16 +---
c/set-protocol/src/set_crc.c | 18 ++++
c/set-protocol/src/tms2812.c | 78 ++++++++++++++++
c/set-protocol/tests/test_tms2812.c | 35 +++++++
c/set-protocol/tools/build_host.py | 2 +-
doc/CROSS_PLATFORM_AUDIT.md | 3 +-
python/protocan/native.py | 47 ++++++++++
python/protocan/tms2812.py | 60 ++++++++++++
17 files changed, 466 insertions(+), 15 deletions(-)
create mode 100644 c/set-protocol/include/set_crc.h
create mode 100644 c/set-protocol/include/tms2812.h
create mode 100644 c/set-protocol/src/set_crc.c
create mode 100644 c/set-protocol/src/tms2812.c
create mode 100644 c/set-protocol/tests/test_tms2812.c
create mode 100644 python/protocan/tms2812.py
diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt
index b0f027d..f757905 100644
--- a/c/set-protocol/CMakeLists.txt
+++ b/c/set-protocol/CMakeLists.txt
@@ -18,7 +18,9 @@ set(SETPROTOCOL_V2_SOURCES
# Совместимые ProtoCAN/SETGUI v1 форматы переходного периода.
set(SETPROTOCOL_LEGACY_SOURCES
src/balsam_can.c
+ src/set_crc.c
src/periph28335.c
+ src/tms2812.c
src/gui_catalog.c
src/gui_frame.c
src/pcan_abi.c
@@ -102,6 +104,9 @@ if(SETP_BUILD_TESTS)
add_executable(test_periph28335 tests/test_periph28335.c)
target_link_libraries(test_periph28335 PRIVATE setprotocol_static)
add_test(NAME shared_periph28335 COMMAND test_periph28335)
+ add_executable(test_tms2812 tests/test_tms2812.c)
+ target_link_libraries(test_tms2812 PRIVATE setprotocol_static)
+ add_test(NAME shared_tms2812 COMMAND test_tms2812)
add_executable(test_trends tests/test_trends.c)
target_link_libraries(test_trends PRIVATE setprotocol_static)
add_test(NAME shared_trends COMMAND test_trends)
diff --git a/c/set-protocol/include/pcan_abi.h b/c/set-protocol/include/pcan_abi.h
index 2205331..7413a84 100644
--- a/c/set-protocol/include/pcan_abi.h
+++ b/c/set-protocol/include/pcan_abi.h
@@ -101,6 +101,21 @@ PCAN_ABI_API const char *pcan_abi_periph28335_project_name(
PCAN_ABI_API const char *pcan_abi_periph28335_command_name(
size_t project_index, size_t command_index);
+/* PM67/TMS320F2812 legacy memory upload. */
+PCAN_ABI_API uint16_t pcan_abi_tms2812_crc16(
+ const uint8_t *data, size_t size);
+PCAN_ABI_API size_t pcan_abi_tms2812_build_upload(
+ uint8_t controller, uint32_t word_address, uint32_t byte_count,
+ uint8_t *output, size_t output_size);
+PCAN_ABI_API size_t pcan_abi_tms2812_expected_upload_size(
+ uint32_t byte_count);
+PCAN_ABI_API int pcan_abi_tms2812_validate_upload(
+ const uint8_t *data, size_t size, uint8_t controller,
+ uint32_t byte_count);
+PCAN_ABI_API int pcan_abi_tms2812_decode_upload(
+ const uint8_t *data, size_t size, uint8_t controller,
+ uint32_t byte_count, uint8_t *output, size_t output_size);
+
PCAN_ABI_API size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags,
uint32_t can_id,
const uint8_t *data, uint8_t dlc,
diff --git a/c/set-protocol/include/set_crc.h b/c/set-protocol/include/set_crc.h
new file mode 100644
index 0000000..efa9cf0
--- /dev/null
+++ b/c/set-protocol/include/set_crc.h
@@ -0,0 +1,19 @@
+/** @file set_crc.h @brief Shared checksums used by legacy SET controllers. */
+#ifndef SET_CRC_H
+#define SET_CRC_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** CRC-16/Modbus: reflected polynomial 0xA001, initial value 0xFFFF. */
+uint16_t set_crc16_modbus(const uint8_t *data, size_t size);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* SET_CRC_H */
diff --git a/c/set-protocol/include/setprotocol.h b/c/set-protocol/include/setprotocol.h
index 03520c3..73b6929 100644
--- a/c/set-protocol/include/setprotocol.h
+++ b/c/set-protocol/include/setprotocol.h
@@ -7,6 +7,7 @@
#include "balsam_can.h"
#include "periph28335.h"
+#include "tms2812.h"
/* Основной SET protocol v2. */
#include "set_protocol.h"
diff --git a/c/set-protocol/include/tms2812.h b/c/set-protocol/include/tms2812.h
new file mode 100644
index 0000000..d40ee56
--- /dev/null
+++ b/c/set-protocol/include/tms2812.h
@@ -0,0 +1,49 @@
+/**
+ * @file tms2812.h
+ * @brief Shared PM67/TMS320F2812 legacy upload protocol.
+ */
+#ifndef TMS2812_H
+#define TMS2812_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define TMS2812_CMD_UPLOAD 52U
+#define TMS2812_UPLOAD_REQUEST_SIZE 12U
+#define TMS2812_UPLOAD_RESPONSE_OVERHEAD 8U
+
+typedef enum {
+ TMS2812_OK = 0,
+ TMS2812_ERROR_ARGUMENT = -1,
+ TMS2812_ERROR_RANGE = -2,
+ TMS2812_ERROR_LENGTH = -3,
+ TMS2812_ERROR_CRC = -4,
+ TMS2812_ERROR_HEADER = -5,
+ TMS2812_ERROR_CAPACITY = -6
+} tms2812_status_t;
+
+uint16_t tms2812_crc16(const uint8_t *data, size_t size);
+
+size_t tms2812_build_upload_request(
+ uint8_t controller, uint32_t word_address, uint32_t byte_count,
+ uint8_t *output, size_t output_size);
+
+size_t tms2812_expected_upload_response_size(uint32_t byte_count);
+
+int tms2812_validate_upload_response(
+ const uint8_t *data, size_t size, uint8_t controller,
+ uint32_t byte_count);
+
+int tms2812_decode_upload_response(
+ const uint8_t *data, size_t size, uint8_t controller,
+ uint32_t byte_count, uint8_t *output, size_t output_size);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* TMS2812_H */
diff --git a/c/set-protocol/ports/android/Android.mk b/c/set-protocol/ports/android/Android.mk
index 9eceeb8..530093f 100644
--- a/c/set-protocol/ports/android/Android.mk
+++ b/c/set-protocol/ports/android/Android.mk
@@ -13,7 +13,9 @@ LOCAL_SRC_FILES := \
../../src/set_trends.c \
../../src/set_spectrum.c \
../../src/balsam_can.c \
+ ../../src/set_crc.c \
../../src/periph28335.c \
+ ../../src/tms2812.c \
../../src/gui_catalog.c \
../../src/gui_frame.c \
../../src/pcan_abi.c \
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
index 8aa7255..4e7b830 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
@@ -46,6 +46,11 @@ object NativeSetProtocol {
external fun nativePeriph28335ProjectCount(): Int
external fun nativePeriph28335ProjectName(projectIndex: Int): String?
external fun nativePeriph28335CommandName(projectIndex: Int, commandIndex: Int): String?
+ external fun nativeTms2812Crc16(input: ByteArray): Int
+ external fun nativeTms2812BuildUpload(controller: Int, wordAddress: Long, byteCount: Long): ByteArray?
+ external fun nativeTms2812ExpectedUploadSize(byteCount: Long): Int
+ external fun nativeTms2812ValidateUpload(input: ByteArray, controller: Int, byteCount: Int): Boolean
+ external fun nativeTms2812DecodeUpload(input: ByteArray, controller: Int, byteCount: Int): ByteArray?
external fun nativeEncodeFrame(
sequence: Int,
flags: Int,
diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c
index 7f916f7..d95566a 100644
--- a/c/set-protocol/ports/android/setprotocol_jni.c
+++ b/c/set-protocol/ports/android/setprotocol_jni.c
@@ -8,6 +8,97 @@
#include "set_spectrum.h"
#include "balsam_can.h"
#include "periph28335.h"
+#include "tms2812.h"
+
+JNIEXPORT jint JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812Crc16(
+ JNIEnv *env, jobject self, jbyteArray input)
+{
+ (void)self;
+ if (input == NULL) return 0;
+ jsize size = (*env)->GetArrayLength(env, input);
+ jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
+ if ((data == NULL) && (size != 0)) return 0;
+ uint16_t crc = tms2812_crc16((const uint8_t *)data, (size_t)size);
+ if (data != NULL) (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
+ return (jint)crc;
+}
+
+JNIEXPORT jbyteArray JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812BuildUpload(
+ JNIEnv *env, jobject self, jint controller, jlong word_address,
+ jlong byte_count)
+{
+ (void)self;
+ uint8_t output[TMS2812_UPLOAD_REQUEST_SIZE];
+ if (controller < 0 || controller > 255 || word_address < 0 ||
+ (uint64_t)word_address > UINT32_MAX || byte_count < 1 ||
+ (uint64_t)byte_count > UINT32_MAX) return NULL;
+ size_t written = tms2812_build_upload_request(
+ (uint8_t)controller, (uint32_t)word_address, (uint32_t)byte_count,
+ output, sizeof output);
+ if (written == 0U) return NULL;
+ jbyteArray result = (*env)->NewByteArray(env, (jsize)written);
+ if (result != NULL) (*env)->SetByteArrayRegion(
+ env, result, 0, (jsize)written, (const jbyte *)output);
+ return result;
+}
+
+JNIEXPORT jint JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812ExpectedUploadSize(
+ JNIEnv *env, jobject self, jlong byte_count)
+{
+ (void)env; (void)self;
+ if (byte_count < 1 || (uint64_t)byte_count > UINT32_MAX) return 0;
+ size_t size = tms2812_expected_upload_response_size((uint32_t)byte_count);
+ return size <= INT32_MAX ? (jint)size : 0;
+}
+
+JNIEXPORT jboolean JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812ValidateUpload(
+ JNIEnv *env, jobject self, jbyteArray input, jint controller,
+ jint byte_count)
+{
+ (void)self;
+ if (input == NULL || controller < 0 || controller > 255 || byte_count < 1)
+ return JNI_FALSE;
+ jsize size = (*env)->GetArrayLength(env, input);
+ jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
+ if (data == NULL) return JNI_FALSE;
+ int status = tms2812_validate_upload_response(
+ (const uint8_t *)data, (size_t)size, (uint8_t)controller,
+ (uint32_t)byte_count);
+ (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
+ return status == TMS2812_OK ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT jbyteArray JNICALL
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812DecodeUpload(
+ JNIEnv *env, jobject self, jbyteArray input, jint controller,
+ jint byte_count)
+{
+ (void)self;
+ if (input == NULL || controller < 0 || controller > 255 || byte_count < 1)
+ return NULL;
+ jsize size = (*env)->GetArrayLength(env, input);
+ jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
+ if (data == NULL) return NULL;
+ uint8_t *output = (uint8_t *)malloc((size_t)byte_count);
+ if (output == NULL) {
+ (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
+ return NULL;
+ }
+ int status = tms2812_decode_upload_response(
+ (const uint8_t *)data, (size_t)size, (uint8_t)controller,
+ (uint32_t)byte_count, output, (size_t)byte_count);
+ (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
+ if (status != TMS2812_OK) { free(output); return NULL; }
+ jbyteArray result = (*env)->NewByteArray(env, byte_count);
+ if (result != NULL) (*env)->SetByteArrayRegion(
+ env, result, 0, byte_count, (const jbyte *)output);
+ free(output);
+ return result;
+}
JNIEXPORT jint JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335Crc16(
diff --git a/c/set-protocol/src/pcan_abi.c b/c/set-protocol/src/pcan_abi.c
index adf58a1..800b840 100644
--- a/c/set-protocol/src/pcan_abi.c
+++ b/c/set-protocol/src/pcan_abi.c
@@ -5,6 +5,7 @@
#include "pcan_crc.h"
#include "balsam_can.h"
#include "periph28335.h"
+#include "tms2812.h"
#include "pcan_frame.h"
#include "pcan_id.h"
#include "gui_frame.h"
@@ -162,6 +163,40 @@ const char *pcan_abi_periph28335_command_name(
return periph28335_command_name(project_index, command_index);
}
+uint16_t pcan_abi_tms2812_crc16(const uint8_t *data, size_t size)
+{
+ return tms2812_crc16(data, size);
+}
+
+size_t pcan_abi_tms2812_build_upload(
+ uint8_t controller, uint32_t word_address, uint32_t byte_count,
+ uint8_t *output, size_t output_size)
+{
+ return tms2812_build_upload_request(
+ controller, word_address, byte_count, output, output_size);
+}
+
+size_t pcan_abi_tms2812_expected_upload_size(uint32_t byte_count)
+{
+ return tms2812_expected_upload_response_size(byte_count);
+}
+
+int pcan_abi_tms2812_validate_upload(
+ const uint8_t *data, size_t size, uint8_t controller,
+ uint32_t byte_count)
+{
+ return tms2812_validate_upload_response(
+ data, size, controller, byte_count);
+}
+
+int pcan_abi_tms2812_decode_upload(
+ const uint8_t *data, size_t size, uint8_t controller,
+ uint32_t byte_count, uint8_t *output, size_t output_size)
+{
+ return tms2812_decode_upload_response(
+ data, size, controller, byte_count, output, output_size);
+}
+
size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags,
uint32_t can_id, const uint8_t *data, uint8_t dlc,
uint8_t *output, size_t output_size)
diff --git a/c/set-protocol/src/periph28335.c b/c/set-protocol/src/periph28335.c
index dee241f..3641ba3 100644
--- a/c/set-protocol/src/periph28335.c
+++ b/c/set-protocol/src/periph28335.c
@@ -2,6 +2,8 @@
#include
+#include "set_crc.h"
+
typedef struct {
const char *project;
const char *commands[PERIPH28335_COMMAND_COUNT];
@@ -32,19 +34,7 @@ static uint16_t get_be16(const uint8_t *input)
uint16_t periph28335_crc16_modbus(const uint8_t *data, size_t size)
{
- uint16_t crc = 0xFFFFU;
- size_t index;
- uint8_t bit;
- if ((data == NULL) && (size != 0U)) return 0U;
- for (index = 0U; index < size; ++index) {
- crc ^= data[index];
- for (bit = 0U; bit < 8U; ++bit) {
- crc = (crc & 1U) != 0U
- ? (uint16_t)((crc >> 1) ^ 0xA001U)
- : (uint16_t)(crc >> 1);
- }
- }
- return crc;
+ return set_crc16_modbus(data, size);
}
size_t periph28335_append_crc(const uint8_t *payload, size_t payload_size,
diff --git a/c/set-protocol/src/set_crc.c b/c/set-protocol/src/set_crc.c
new file mode 100644
index 0000000..65a2792
--- /dev/null
+++ b/c/set-protocol/src/set_crc.c
@@ -0,0 +1,18 @@
+#include "set_crc.h"
+
+uint16_t set_crc16_modbus(const uint8_t *data, size_t size)
+{
+ uint16_t crc = 0xFFFFU;
+ size_t index;
+ uint8_t bit;
+ if ((data == NULL) && (size != 0U)) return 0U;
+ for (index = 0U; index < size; ++index) {
+ crc ^= data[index];
+ for (bit = 0U; bit < 8U; ++bit) {
+ crc = (crc & 1U) != 0U
+ ? (uint16_t)((crc >> 1) ^ 0xA001U)
+ : (uint16_t)(crc >> 1);
+ }
+ }
+ return crc;
+}
diff --git a/c/set-protocol/src/tms2812.c b/c/set-protocol/src/tms2812.c
new file mode 100644
index 0000000..333923f
--- /dev/null
+++ b/c/set-protocol/src/tms2812.c
@@ -0,0 +1,78 @@
+#include "tms2812.h"
+
+#include
+#include
+
+#include "set_crc.h"
+
+static void put_le32(uint8_t *output, uint32_t value)
+{
+ output[0] = (uint8_t)value;
+ output[1] = (uint8_t)(value >> 8);
+ output[2] = (uint8_t)(value >> 16);
+ output[3] = (uint8_t)(value >> 24);
+}
+
+uint16_t tms2812_crc16(const uint8_t *data, size_t size)
+{
+ return set_crc16_modbus(data, size);
+}
+
+size_t tms2812_build_upload_request(
+ uint8_t controller, uint32_t word_address, uint32_t byte_count,
+ uint8_t *output, size_t output_size)
+{
+ uint16_t crc;
+ if ((output == NULL) || (output_size < TMS2812_UPLOAD_REQUEST_SIZE) ||
+ (byte_count == 0U)) return 0U;
+ output[0] = controller;
+ output[1] = TMS2812_CMD_UPLOAD;
+ put_le32(&output[2], word_address);
+ put_le32(&output[6], byte_count);
+ crc = tms2812_crc16(output, 10U);
+ output[10] = (uint8_t)crc;
+ output[11] = (uint8_t)(crc >> 8);
+ return TMS2812_UPLOAD_REQUEST_SIZE;
+}
+
+size_t tms2812_expected_upload_response_size(uint32_t byte_count)
+{
+ if (byte_count == 0U ||
+ (uint64_t)byte_count + TMS2812_UPLOAD_RESPONSE_OVERHEAD > SIZE_MAX) {
+ return 0U;
+ }
+ return (size_t)byte_count + TMS2812_UPLOAD_RESPONSE_OVERHEAD;
+}
+
+int tms2812_validate_upload_response(
+ const uint8_t *data, size_t size, uint8_t controller,
+ uint32_t byte_count)
+{
+ size_t expected = tms2812_expected_upload_response_size(byte_count);
+ size_t crc_offset;
+ uint16_t expected_crc;
+ uint16_t actual_crc;
+ if (data == NULL) return TMS2812_ERROR_ARGUMENT;
+ if (expected == 0U) return TMS2812_ERROR_RANGE;
+ if (size != expected) return TMS2812_ERROR_LENGTH;
+ if ((data[0] != controller) || (data[1] != TMS2812_CMD_UPLOAD))
+ return TMS2812_ERROR_HEADER;
+ crc_offset = (size_t)byte_count + 2U;
+ expected_crc = tms2812_crc16(data, crc_offset);
+ actual_crc = (uint16_t)(data[crc_offset] |
+ ((uint16_t)data[crc_offset + 1U] << 8));
+ return actual_crc == expected_crc ? TMS2812_OK : TMS2812_ERROR_CRC;
+}
+
+int tms2812_decode_upload_response(
+ const uint8_t *data, size_t size, uint8_t controller,
+ uint32_t byte_count, uint8_t *output, size_t output_size)
+{
+ int status = tms2812_validate_upload_response(
+ data, size, controller, byte_count);
+ if (status != TMS2812_OK) return status;
+ if ((output == NULL) || (output_size < byte_count))
+ return TMS2812_ERROR_CAPACITY;
+ memcpy(output, &data[2], byte_count);
+ return TMS2812_OK;
+}
diff --git a/c/set-protocol/tests/test_tms2812.c b/c/set-protocol/tests/test_tms2812.c
new file mode 100644
index 0000000..50be35b
--- /dev/null
+++ b/c/set-protocol/tests/test_tms2812.c
@@ -0,0 +1,35 @@
+#include
+#include
+
+#include "tms2812.h"
+
+int main(void)
+{
+ uint8_t request[TMS2812_UPLOAD_REQUEST_SIZE];
+ static const uint8_t prefix[] = {
+ 0x05U, 0x34U, 0x78U, 0x56U, 0x34U, 0x12U,
+ 0x00U, 0x10U, 0x00U, 0x00U
+ };
+ uint8_t reply[4U + TMS2812_UPLOAD_RESPONSE_OVERHEAD];
+ uint8_t decoded[4];
+ uint16_t crc;
+
+ if (tms2812_build_upload_request(5U, 0x12345678UL, 0x1000U,
+ request, sizeof request) != sizeof request ||
+ memcmp(request, prefix, sizeof prefix) != 0) return 1;
+ reply[0] = 5U;
+ reply[1] = TMS2812_CMD_UPLOAD;
+ reply[2] = 0x10U; reply[3] = 0x20U; reply[4] = 0x30U; reply[5] = 0x40U;
+ crc = tms2812_crc16(reply, 6U);
+ reply[6] = (uint8_t)crc;
+ reply[7] = (uint8_t)(crc >> 8);
+ memset(&reply[8], 0, 4U);
+ if (tms2812_decode_upload_response(reply, sizeof reply, 5U, 4U,
+ decoded, sizeof decoded) != TMS2812_OK ||
+ memcmp(decoded, &reply[2], sizeof decoded) != 0) return 2;
+ reply[6] ^= 1U;
+ if (tms2812_validate_upload_response(reply, sizeof reply, 5U, 4U)
+ != TMS2812_ERROR_CRC) return 3;
+ puts("PM67/TMS320F2812 upload protocol tests passed");
+ return 0;
+}
diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py
index 4d6ed08..c586ddb 100644
--- a/c/set-protocol/tools/build_host.py
+++ b/c/set-protocol/tools/build_host.py
@@ -21,7 +21,7 @@ JNI_INCLUDES: list[Path] = []
SOURCES = [
ROOT / "src" / name for name in (
"set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c",
- "balsam_can.c", "periph28335.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c",
+ "balsam_can.c", "set_crc.c", "periph28335.c", "tms2812.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c",
"pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c",
"pcan_gas.c",
)
diff --git a/doc/CROSS_PLATFORM_AUDIT.md b/doc/CROSS_PLATFORM_AUDIT.md
index eaaba20..92bea7b 100644
--- a/doc/CROSS_PLATFORM_AUDIT.md
+++ b/doc/CROSS_PLATFORM_AUDIT.md
@@ -12,6 +12,7 @@
| CAN Бальзам | `c/set-protocol/src/balsam_can.c` |
| График, тренды и спектр | `c/set-protocol/src/set_plot.c`, `set_trends.c`, `set_spectrum.c` |
| ПМ35 / TMS320F28335: MODBUS-подобные запросы, CRC16/Modbus, ответы и каталог команд | `c/set-protocol/src/periph28335.c` |
+| ПМ67 / TMS320F2812: CMD_UPLOAD, CRC16/Modbus и проверка ответа | `c/set-protocol/src/tms2812.c` |
Python вызывает это ядро через `python/protocan/native.py`, Android — через
`ports/android/setprotocol_jni.c`. Файлы на Python и Kotlin являются портами и
@@ -24,7 +25,7 @@ Python вызывает это ядро через `python/protocan/native.py`,
| Приоритет | Android | SETGUI | Что вынести в C |
|---|---|---|---|
| 0 | `ports/android/.../CanBridgeProtocol.kt`, `GuiProtocol.kt` | Python transport wrappers | удалить оставшиеся алгоритмические fallback, так как codec/parser уже есть в C |
-| 1 | `core/Tms2812Protocol.kt` | `core/tms_flash.py`, `core/tms_terminal.py` | весь RS-протокол ПМ67/2812, CRC, команды и validators |
+| 1 | остальные команды `core/Tms2812Protocol.kt` | `core/tms_terminal.py` | оставшиеся команды и parser непрерывной телеметрии ПМ67/2812; CMD_UPLOAD уже перенесён |
| 2 | `core/Ds18b20Protocol.kt` | `core/ds18b20.py` | кодеки списков/данных/EEPROM, CRC8 и значения датчиков |
| 3 | `core/CanFirmwareProtocol.kt` | `core/can_firmware.py` | автомат CAN boot, статусы, окна блоков |
| 4 | `core/SlCanProtocol.kt` | `adapters/slcan_adapter.py` и CAN transport | ASCII codec/parser SLCAN; доступ к COM остаётся в портах |
diff --git a/python/protocan/native.py b/python/protocan/native.py
index b30f40f..4c2b131 100644
--- a/python/protocan/native.py
+++ b/python/protocan/native.py
@@ -177,6 +177,24 @@ class NativeProtocol:
ctypes.c_size_t, ctypes.c_size_t,
]
lib.pcan_abi_periph28335_command_name.restype = ctypes.c_char_p
+ lib.pcan_abi_tms2812_crc16.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
+ lib.pcan_abi_tms2812_crc16.restype = ctypes.c_uint16
+ lib.pcan_abi_tms2812_build_upload.argtypes = [
+ ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint32,
+ ctypes.c_void_p, ctypes.c_size_t,
+ ]
+ lib.pcan_abi_tms2812_build_upload.restype = ctypes.c_size_t
+ lib.pcan_abi_tms2812_expected_upload_size.argtypes = [ctypes.c_uint32]
+ lib.pcan_abi_tms2812_expected_upload_size.restype = ctypes.c_size_t
+ lib.pcan_abi_tms2812_validate_upload.argtypes = [
+ ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint8, ctypes.c_uint32,
+ ]
+ lib.pcan_abi_tms2812_validate_upload.restype = ctypes.c_int
+ lib.pcan_abi_tms2812_decode_upload.argtypes = [
+ ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint8, ctypes.c_uint32,
+ ctypes.c_void_p, ctypes.c_size_t,
+ ]
+ lib.pcan_abi_tms2812_decode_upload.restype = ctypes.c_int
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,
@@ -335,6 +353,35 @@ class NativeProtocol:
result[raw_name.decode("utf-8")] = tuple(commands)
return result
+ def tms2812_crc16(self, data: bytes) -> int:
+ return int(self.lib.pcan_abi_tms2812_crc16(
+ self._bytes_buffer(data), len(data)))
+
+ def tms2812_build_upload(self, controller: int, word_address: int,
+ byte_count: int) -> bytes:
+ output = (ctypes.c_uint8 * 12)()
+ size = int(self.lib.pcan_abi_tms2812_build_upload(
+ controller, word_address, byte_count, output, len(output)))
+ if size == 0:
+ raise ValueError("SETProtocol rejected PM67 upload request")
+ return bytes(output[:size])
+
+ def tms2812_expected_upload_size(self, byte_count: int) -> int:
+ return int(self.lib.pcan_abi_tms2812_expected_upload_size(byte_count))
+
+ def tms2812_validate_upload(self, data: bytes, controller: int,
+ byte_count: int) -> int:
+ return int(self.lib.pcan_abi_tms2812_validate_upload(
+ self._bytes_buffer(data), len(data), controller, byte_count))
+
+ def tms2812_decode_upload(self, data: bytes, controller: int,
+ byte_count: int) -> tuple[int, bytes]:
+ output = (ctypes.c_uint8 * byte_count)()
+ status = int(self.lib.pcan_abi_tms2812_decode_upload(
+ self._bytes_buffer(data), len(data), controller, byte_count,
+ output, len(output)))
+ return status, bytes(output) if status == 0 else b""
+
def encode(self, sequence: int, flags: int, can_id: int, data: bytes) -> bytes:
if len(data) > 8:
raise ValueError("DLC cannot exceed 8 bytes")
diff --git a/python/protocan/tms2812.py b/python/protocan/tms2812.py
new file mode 100644
index 0000000..8bc2dd0
--- /dev/null
+++ b/python/protocan/tms2812.py
@@ -0,0 +1,60 @@
+"""Thin Python adapter for the shared C99 PM67/TMS320F2812 protocol."""
+
+from __future__ import annotations
+
+from .native import get_native_protocol
+
+
+CMD_UPLOAD = 52
+UPLOAD_RESPONSE_OVERHEAD = 8
+
+
+def _core():
+ return get_native_protocol()
+
+
+def crc16_modbus(data: bytes, initial: int = 0xFFFF) -> int:
+ if initial != 0xFFFF:
+ raise ValueError("Произвольное начальное значение CRC не входит в протокол ПМ67")
+ return _core().tms2812_crc16(bytes(data))
+
+
+def build_upload_request(controller: int, word_address: int,
+ byte_count: int) -> bytes:
+ _range("адрес контроллера", controller, 0xFF)
+ _range("адрес слова", word_address, 0xFFFFFFFF)
+ if not 1 <= byte_count <= 0xFFFFFFFF:
+ raise ValueError("размер блока вне диапазона 1..0xFFFFFFFF")
+ return _core().tms2812_build_upload(controller, word_address, byte_count)
+
+
+def expected_upload_response_size(byte_count: int) -> int:
+ if not 1 <= byte_count <= 0xFFFFFFFF:
+ raise ValueError("размер блока вне диапазона 1..0xFFFFFFFF")
+ return _core().tms2812_expected_upload_size(byte_count)
+
+
+def decode_upload_reply(raw: bytes, controller: int, byte_count: int) -> bytes:
+ expected = expected_upload_response_size(byte_count)
+ if len(raw) != expected:
+ raise ValueError(
+ f"ответ CMD_UPLOAD: ожидалось {expected} байт, получено {len(raw)}"
+ )
+ status, data = _core().tms2812_decode_upload(
+ bytes(raw), controller, byte_count)
+ errors = {
+ -1: "неверные аргументы CMD_UPLOAD",
+ -2: "размер CMD_UPLOAD вне диапазона",
+ -3: "неверная длина ответа CMD_UPLOAD",
+ -4: "CRC ответа CMD_UPLOAD не совпадает",
+ -5: "ответ CMD_UPLOAD имеет неверный адрес или номер команды",
+ -6: "недостаточный буфер CMD_UPLOAD",
+ }
+ if status != 0:
+ raise ValueError(errors.get(status, f"ошибка ответа CMD_UPLOAD: {status}"))
+ return data
+
+
+def _range(name: str, value: int, maximum: int) -> None:
+ if not 0 <= value <= maximum:
+ raise ValueError(f"{name} вне диапазона 0..{maximum}")
From 0ce464499f37734f3643115664f33a67df27ee0f Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sun, 6 Sep 2026 00:11:25 +0300
Subject: [PATCH 11/14] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D0=B1=D1=89=D1=83=D1=8E=20=D0=BC=D0=BE?=
=?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D1=80?=
=?UTF-8?q?=D1=83=D0=BA=D1=82=D0=BE=D1=80=D0=B0=20ProtoCAN=20=D0=B4=D0=BB?=
=?UTF-8?q?=D1=8F=20Android?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../setflash/core/CanBridgeProtocol.kt | 21 +-----
.../setcorp/setflash/core/ProtoCanCatalog.kt | 67 +++++++++++++++++++
.../setflash/core/ProtoCanCatalogTest.kt | 21 ++++++
3 files changed, 90 insertions(+), 19 deletions(-)
create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/ProtoCanCatalog.kt
create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setflash/core/ProtoCanCatalogTest.kt
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/CanBridgeProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/CanBridgeProtocol.kt
index 599c6df..103f7fd 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/CanBridgeProtocol.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/CanBridgeProtocol.kt
@@ -265,31 +265,14 @@ data class ProtoCanId(
val messageType: Int,
val body: Int,
) {
- val messageTypeName: String get() = MESSAGE_TYPES[messageType] ?: "Reserved 0x${messageType.toString(16).uppercase()}"
+ val messageTypeName: String get() = ProtoCanMessageType.fromCode(messageType)?.title
+ ?: "Reserved 0x${messageType.toString(16).uppercase()}"
val deviceName: String? get() = DEVICE_NAMES[device]
fun summary(): String = "P$priority · PM$pm · Type $deviceType · Dev $device" +
(deviceName?.let { " ($it)" } ?: "") + " · $messageTypeName · Body 0x%04X".format(body)
companion object {
- private val MESSAGE_TYPES = mapOf(
- 0x0 to "Broadcast",
- 0x1 to "Discrete",
- 0x2 to "Analog",
- 0x3 to "General Address Space",
- 0x4 to "Modbus Coil",
- 0x5 to "Modbus Discrete",
- 0x6 to "Modbus Holding",
- 0x7 to "Modbus Input",
- 0x8 to "Error",
- 0x9 to "Boot Control",
- 0xA to "Boot Data A",
- 0xB to "Boot Data B",
- 0xC to "Boot Status",
- 0xD to "Boot Discovery",
- 0xE to "Settings",
- 0xF to "Pulse",
- )
private val DEVICE_NAMES = mapOf(
0xD to "DS_CONTROL",
0xE to "Android GUI",
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/ProtoCanCatalog.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/ProtoCanCatalog.kt
new file mode 100644
index 0000000..385865f
--- /dev/null
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/ProtoCanCatalog.kt
@@ -0,0 +1,67 @@
+package ru.setcorp.setflash.core
+
+/** Canonical ProtoCAN message-type registry shared by Android protocol clients. */
+enum class ProtoCanMessageType(val code: Int, val title: String) {
+ BROADCAST(0x0, "Broadcast"),
+ DISCRETE(0x1, "Discrete"),
+ ANALOG(0x2, "Analog"),
+ GENERAL_ADDRESS_SPACE(0x3, "General Address Space"),
+ MODBUS_COIL(0x4, "Modbus Coil"),
+ MODBUS_DISCRETE(0x5, "Modbus Discrete"),
+ MODBUS_HOLDING(0x6, "Modbus Holding"),
+ MODBUS_INPUT(0x7, "Modbus Input"),
+ ERROR(0x8, "Error"),
+ BOOT_CONTROL(0x9, "Boot Control"),
+ BOOT_DATA_A(0xA, "Boot Data A"),
+ BOOT_DATA_B(0xB, "Boot Data B"),
+ BOOT_STATUS(0xC, "Boot Status"),
+ BOOT_DISCOVERY(0xD, "Boot Discovery"),
+ SETTINGS(0xE, "Settings"),
+ PULSE(0xF, "Pulse");
+
+ companion object {
+ fun fromCode(code: Int): ProtoCanMessageType? = entries.firstOrNull { it.code == code }
+ }
+}
+
+enum class ProtoCanBroadcastType(val code: Int, val title: String) {
+ STATUS(0x0, "STATUS · запрос статуса"),
+ ONOFF(0x1, "ONOFF · вкл/выкл пульса"),
+ RESTART_DEVICE(0x2, "RESTARTDEVICE · перезапуск"),
+ RTC_SETUP(0x3, "RTCSETUP · установка RTC"),
+ END(0xFFF, "END · конец диапазона"),
+}
+
+enum class ProtoCanDiscreteType(val code: Int, val title: String) {
+ ACCIDENT(0x0, "ACCIDENT · авария"),
+ WARNING(0x1, "WARNING · предупреждение"),
+ CONTROL_SIGNALS(0x2, "CONTROL_SIGNALS · управление"),
+ FLAGS(0x3, "FLAGS · флаги"),
+ RESET(0x4, "RESET · сброс"),
+ CHANGE_MODE(0x5, "CHANGE_MODE · смена режима"),
+ REQUEST_PARAMETERS(0x6, "REQUEST_LIST_OF_PARAMETERS"),
+ END(0xF, "END · конец диапазона"),
+}
+
+enum class ProtoCanAnalogType(val code: Int, val title: String) {
+ UNIVERSAL(0x0, "UNIVERSAL · универсальный"),
+ SETTINGS(0x1, "SETTINGS · уставки"),
+ U(0x2, "U · напряжение"),
+ I(0x3, "I · ток"),
+ T(0x4, "T · температура"),
+ END(0xF, "END · конец диапазона"),
+}
+
+/** Portable MsgBody layouts matching `pcan_id.h` and SETGUI's frame builder. */
+object ProtoCanBody {
+ fun broadcast(type: Int, body: Int): Int = ((type and 0xFFF) shl 4) or (body and 0xF)
+
+ fun typed(type: Int, body: Int): Int = ((type and 0xF) shl 12) or (body and 0xFFF)
+
+ fun modbus(address: Int, registerCount: Int): Int =
+ ((address and 0xFFF) shl 4) or (registerCount and 0xF)
+
+ fun error(info: Int, code: Int): Int = ((info and 0xFF) shl 8) or (code and 0xFF)
+
+ fun settings(z: Int, y: Int): Int = ((z and 0xFF) shl 8) or (y and 0xFF)
+}
diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setflash/core/ProtoCanCatalogTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setflash/core/ProtoCanCatalogTest.kt
new file mode 100644
index 0000000..4b682d5
--- /dev/null
+++ b/c/set-protocol/ports/android/tests/ru/setcorp/setflash/core/ProtoCanCatalogTest.kt
@@ -0,0 +1,21 @@
+package ru.setcorp.setflash.core
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ProtoCanCatalogTest {
+ @Test
+ fun bodyLayoutsMatchSetGuiBuilder() {
+ assertEquals(0x1234, ProtoCanBody.broadcast(0x123, 0x4))
+ assertEquals(0x2001, ProtoCanBody.typed(ProtoCanAnalogType.U.code, 1))
+ assertEquals(0x1234, ProtoCanBody.modbus(0x123, 4))
+ assertEquals(0xABCD, ProtoCanBody.error(0xAB, 0xCD))
+ assertEquals(0xFF01, ProtoCanBody.settings(0xFF, 0x01))
+ }
+
+ @Test
+ fun messageRegistryCoversAllFourBitValues() {
+ assertEquals((0..0xF).toList(), ProtoCanMessageType.entries.map { it.code })
+ assertEquals("Settings", ProtoCanId.parse(0x17FE1234).messageTypeName)
+ }
+}
From 1511719bb97cdf93d4fd2fb9c04cf9a511ba28f2 Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sun, 6 Sep 2026 01:01:27 +0300
Subject: [PATCH 12/14] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80?=
=?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=20CAN=5FBal=5F2812?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
c/set-protocol/include/balsam_can.h | 10 +--
.../setprotocol/balsam/BalsamCanProtocol.kt | 71 ++++++++++++++++---
.../balsam/BalsamCanProtocolTest.kt | 18 +++++
3 files changed, 83 insertions(+), 16 deletions(-)
diff --git a/c/set-protocol/include/balsam_can.h b/c/set-protocol/include/balsam_can.h
index 95feacb..88cf866 100644
--- a/c/set-protocol/include/balsam_can.h
+++ b/c/set-protocol/include/balsam_can.h
@@ -1,8 +1,8 @@
/**
* @file balsam_can.h
- * @brief Legacy Balsam 167 extended-CAN register frames.
+ * @brief CAN_Bal_2812 extended-CAN register space.
*
- * The wire layout is taken from Balsam_167_periph/Source/Internal/ecan.c:
+ * The wire layout is used by BALZAM Src/balzam_7/CanSetupBalzam7.c:
* one big-endian address/mask word followed by three big-endian register words.
*/
#ifndef BALSAM_CAN_H
@@ -34,7 +34,7 @@ typedef struct {
uint16_t values[BALSAM_CAN_REGISTER_COUNT];
} balsam_can_frame_t;
-/** Return non-zero for the command/data/terminal IDs used by Balsam 167. */
+/** Return non-zero for the command/data/terminal IDs used by CAN_Bal_2812. */
int balsam_can_is_id(uint32_t can_id);
/**
@@ -44,11 +44,11 @@ int balsam_can_is_id(uint32_t can_id);
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. */
+/** Human-readable Russian name of a CAN_Bal_2812 device. */
const char *balsam_can_device_name(uint8_t device);
/**
- * Format the register name from the Balsam 167 data table into output.
+ * Format the register name from the CAN_Bal_2812 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,
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt
index e1fd17d..9d09908 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt
@@ -7,6 +7,15 @@ data class BalsamRegister(val address: Int, val value: Int, val name: String) {
val signedValue: Int get() = if (value < 0x8000) value else value - 0x10000
}
+data class BalsamCanNode(
+ val device: Int,
+ val name: String,
+ val commandId: Long,
+ val dataId: Long,
+)
+
+data class BalsamCanWireFrame(val canId: Long, val data: ByteArray)
+
data class BalsamFrame(
val canId: Long,
val device: Int,
@@ -20,18 +29,31 @@ data class BalsamFrame(
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"
+ return "CAN_Bal_2812 · $deviceName · $direction · $values"
}
}
-/** Shared parser for Balsam_167_periph eCAN frames. */
+/** Shared CAN_Bal_2812 register protocol used by the BALZAM F2812 firmware. */
object BalsamCanProtocol {
const val BASE_ID = 0x00BA_0000L
+ const val NODE_COUNT = 13
+ const val REGISTER_BANK_SIZE = 128
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
+ val nodes: List by lazy {
+ (1..NODE_COUNT).map { device ->
+ BalsamCanNode(
+ device = device,
+ name = deviceName(device),
+ commandId = BASE_ID + device - 1L,
+ dataId = BASE_ID + 0x0FL + device,
+ )
+ }
+ }
+
fun isLegacyId(canId: Long): Boolean {
val relative = (canId and 0x1FFF_FFFFL) - BASE_ID
return relative in 0L..12L || relative in 0x10L..0x1CL ||
@@ -62,17 +84,44 @@ object BalsamCanProtocol {
)
}
+ /** Encode one CAN_Bal_2812 register write/data frame with one to three words. */
+ fun encodeWrite(
+ device: Int,
+ fromDevice: Boolean,
+ startAddress: Int,
+ values: List,
+ ): BalsamCanWireFrame {
+ require(device in 1..NODE_COUNT) { "Номер узла должен быть в диапазоне 1..$NODE_COUNT" }
+ require(startAddress in 0 until REGISTER_BANK_SIZE) { "Адрес регистра должен быть в диапазоне 0..127" }
+ require(values.size in 1..3) { "Нужно от 1 до 3 слов данных" }
+ require(startAddress + values.size <= REGISTER_BANK_SIZE) { "Запись выходит за границу регистрового пространства" }
+ values.forEach { require(it in 0..0xFFFF) { "Значение должно быть в диапазоне 0..65535" } }
+
+ val mask = when (values.size) {
+ 1 -> 4
+ 2 -> 6
+ else -> 7
+ }
+ val padded = values + List(3 - values.size) { 0 }
+ val header = (mask shl 13) or startAddress
+ val data = listOf(header, padded[0], padded[1], padded[2])
+ .flatMap { listOf((it ushr 8).toByte(), it.toByte()) }
+ .toByteArray()
+ val node = nodes[device - 1]
+ return BalsamCanWireFrame(if (fromDevice) node.dataId else node.commandId, data)
+ }
+
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 · команда пульту"
+ TERMINAL_REQUEST_ID -> "CAN_Bal_2812 · запрос терминала"
+ TERMINAL_RESPONSE_ID -> "CAN_Bal_2812 · ответ терминалу"
+ PULT_REQUEST_ID -> "CAN_Bal_2812 · данные пульта"
+ PULT_RESPONSE_ID -> "CAN_Bal_2812 · команда пульту"
in (BASE_ID + 0x10L)..(BASE_ID + 0x1BL) ->
- "BALZAM legacy · данные · ${deviceName((canId - BASE_ID - 0x0FL).toInt())}"
+ "CAN_Bal_2812 · данные · ${deviceName((canId - BASE_ID - 0x0FL).toInt())}"
in BASE_ID..(BASE_ID + 0x0BL) ->
- "BALZAM legacy · команда · ${deviceName((canId - BASE_ID + 1L).toInt())}"
- else -> "BALZAM legacy · неизвестный ID"
+ "CAN_Bal_2812 · команда · ${deviceName((canId - BASE_ID + 1L).toInt())}"
+ else -> "CAN_Bal_2812 · неизвестный ID"
}
private fun isRegisterId(canId: Long): Boolean {
@@ -95,7 +144,7 @@ object BalsamCanProtocol {
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) {
+ fun deviceName(device: Int): String = if (NativeSetProtocol.available) {
NativeSetProtocol.nativeBalsamDeviceName(device)
} else listOf(
"Трансформатор 1", "Трансформатор 2", "Силовой блок 1", "Силовой блок 2",
@@ -103,7 +152,7 @@ object BalsamCanProtocol {
"Узел 12", "Терминал",
).getOrElse(device - 1) { "Неизвестный узел" }
- private fun registerName(device: Int, address: Int): String =
+ 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}"
diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt
index 305b93b..43eccdb 100644
--- a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt
+++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt
@@ -17,4 +17,22 @@ class BalsamCanProtocolTest {
assertEquals("Показания T° 1", frame.registers.first().displayName)
assertTrue(frame.summary().contains("Трансформатор 1"))
}
+
+ @Test
+ fun encodesControllerWriteForBal2812RegisterSpace() {
+ val wire = BalsamCanProtocol.encodeWrite(
+ device = 3,
+ fromDevice = false,
+ startAddress = 0x28,
+ values = listOf(0x1234, 0xFEDC),
+ )
+ assertEquals(0x00BA_0002L, wire.canId)
+ assertEquals(
+ listOf(0xC0, 0x28, 0x12, 0x34, 0xFE, 0xDC, 0x00, 0x00),
+ wire.data.map { it.toInt() and 0xFF },
+ )
+ val decoded = requireNotNull(BalsamCanProtocol.decode(wire.canId, wire.data))
+ assertEquals(false, decoded.fromDevice)
+ assertEquals(listOf(0x1234, 0xFEDC), decoded.registers.map { it.value })
+ }
}
From 9ad0322c715ef356eddaec2acc0a98227b3b503f Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sun, 6 Sep 2026 02:08:52 +0300
Subject: [PATCH 13/14] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D1=80=D1=82=20SETProtocol=20v2=20?=
=?UTF-8?q?=D0=B4=D0=BB=D1=8F=20TMS320F2812?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 3 +-
c/set-protocol/CMakeLists.txt | 7 +
c/set-protocol/PORTING.md | 5 +
c/set-protocol/ports/tms320f2812/README.md | 23 +
.../ports/tms320f2812/setp_tms2812_boot.c | 407 ++++++++++++++++++
.../ports/tms320f2812/setp_tms2812_boot.h | 74 ++++
c/set-protocol/tests/test_tms2812_boot.c | 225 ++++++++++
python/setprotocol/__init__.py | 1 +
python/setprotocol/can.py | 173 ++++++++
python/tests/test_setprotocol_can.py | 42 ++
10 files changed, 959 insertions(+), 1 deletion(-)
create mode 100644 c/set-protocol/ports/tms320f2812/README.md
create mode 100644 c/set-protocol/ports/tms320f2812/setp_tms2812_boot.c
create mode 100644 c/set-protocol/ports/tms320f2812/setp_tms2812_boot.h
create mode 100644 c/set-protocol/tests/test_tms2812_boot.c
create mode 100644 python/setprotocol/can.py
create mode 100644 python/tests/test_setprotocol_can.py
diff --git a/README.md b/README.md
index 55dbe63..2ce178c 100644
--- a/README.md
+++ b/README.md
@@ -34,8 +34,9 @@ templates/
| [`c/eeprom-ft24c256`](c/eeprom-ft24c256) | EEPROM 24Cxx по I²C с нарезкой записи по страницам | `stdint.h` | две I²C-транзакции, задержка |
| [`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`](c/set-protocol) | единое ядро SETProtocol: SET v2, совместимые ProtoCAN/GUI v1, GAS, телеметрия, firmware flow и стабильный host ABI | C99 | COM/SLCAN/SocketCAN/USB/Ethernet или callbacks — **Windows, Android, STM32F4 и TMS320F2812-порты в комплекте** |
| [`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/set-protocol/ports/tms320f2812`](c/set-protocol/ports/tms320f2812) | SETProtocol v2 firmware service по segmented classic CAN | `set_protocol`, `set_can`, `set_firmware` | CAN TX, Flash erase/write/read, optional signature policy и reboot |
| [`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 в комплекте** |
| [`c/rtc-service`](c/rtc-service) | RTC с резервированным backup-томом | `stdint.h` | доступ к RTC и backup-памяти — **порт K1921VK028 в комплекте** |
diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt
index f757905..f35443c 100644
--- a/c/set-protocol/CMakeLists.txt
+++ b/c/set-protocol/CMakeLists.txt
@@ -107,6 +107,13 @@ if(SETP_BUILD_TESTS)
add_executable(test_tms2812 tests/test_tms2812.c)
target_link_libraries(test_tms2812 PRIVATE setprotocol_static)
add_test(NAME shared_tms2812 COMMAND test_tms2812)
+ add_executable(test_tms2812_boot
+ tests/test_tms2812_boot.c
+ ports/tms320f2812/setp_tms2812_boot.c
+ )
+ target_include_directories(test_tms2812_boot PRIVATE ports/tms320f2812)
+ target_link_libraries(test_tms2812_boot PRIVATE setprotocol_static)
+ add_test(NAME setp_tms2812_boot COMMAND test_tms2812_boot)
add_executable(test_trends tests/test_trends.c)
target_link_libraries(test_trends PRIVATE setprotocol_static)
add_test(NAME shared_trends COMMAND test_trends)
diff --git a/c/set-protocol/PORTING.md b/c/set-protocol/PORTING.md
index 6373c96..8c1c206 100644
--- a/c/set-protocol/PORTING.md
+++ b/c/set-protocol/PORTING.md
@@ -43,6 +43,11 @@ multicast запрещены WRITE, FW_BEGIN и FW_ACTIVATE.
В проект устройства добавляются `ports/stm32-bxcan/protocan.c` и `src/pcan_id.c`,
а в include paths — `ports/stm32-bxcan` и `include`.
+Для TMS320F2812 доступен SETProtocol v2 firmware service
+[`ports/tms320f2812`](ports/tms320f2812/README.md). Он использует общую
+сегментацию `set_can`, собирается без TI headers и получает eCAN, Flash и reset
+через callbacks проекта платы.
+
Не вызывайте прикладной router из ISR. ISR принимает CAN-кадры в очередь,
сегментация собирается в главном цикле, и только полный SETP-кадр передаётся
parser/router. Сборка должна иметь timeout, контроль номера сегмента и одного
diff --git a/c/set-protocol/ports/tms320f2812/README.md b/c/set-protocol/ports/tms320f2812/README.md
new file mode 100644
index 0000000..6794ad0
--- /dev/null
+++ b/c/set-protocol/ports/tms320f2812/README.md
@@ -0,0 +1,23 @@
+# SETProtocol v2 firmware port for TMS320F2812
+
+`setp_tms2812_boot.c` implements the shared SETP v2 CAN firmware service for
+F2812 projects. It owns CAN reassembly, request/response framing, firmware state,
+idempotent blocks, CRC32 and SHA-256 verification. It does not include TI or
+board headers.
+
+The target application provides callbacks for classic-CAN transmission, Flash
+erase/program/read, optional signature authorization, and reset. Receive ISR
+code must only copy frames into a queue; call `setp_tms2812_boot_process()` from
+task context.
+
+Add these sources to a CCS project:
+
+- `c/set-protocol/src/set_protocol.c`
+- `c/set-protocol/src/set_can.c`
+- `c/set-protocol/src/set_firmware.c`
+- `c/set-protocol/ports/tms320f2812/setp_tms2812_boot.c`
+
+Add `c/set-protocol/include` and this directory to include paths. The port uses
+extended 29-bit SETP CAN identifiers and supports one firmware slot. A single
+slot has no power-loss rollback; production hardware should provide staging or
+A/B storage.
diff --git a/c/set-protocol/ports/tms320f2812/setp_tms2812_boot.c b/c/set-protocol/ports/tms320f2812/setp_tms2812_boot.c
new file mode 100644
index 0000000..fe89157
--- /dev/null
+++ b/c/set-protocol/ports/tms320f2812/setp_tms2812_boot.c
@@ -0,0 +1,407 @@
+#include "setp_tms2812_boot.h"
+
+#include
+
+#define SHA256_BLOCK_SIZE 64U
+
+typedef struct {
+ uint32_t state[8];
+ uint64_t bit_count;
+ uint8_t block[SHA256_BLOCK_SIZE];
+ uint16_t block_length;
+} sha256_ctx_t;
+
+static uint32_t rotr32(uint32_t value, uint8_t bits)
+{
+ return (value >> bits) | (value << (32U - bits));
+}
+
+static void sha256_transform(sha256_ctx_t *ctx, const uint8_t *block)
+{
+ static const uint32_t k[64] = {
+ 0x428A2F98UL, 0x71374491UL, 0xB5C0FBCFUL, 0xE9B5DBA5UL,
+ 0x3956C25BUL, 0x59F111F1UL, 0x923F82A4UL, 0xAB1C5ED5UL,
+ 0xD807AA98UL, 0x12835B01UL, 0x243185BEUL, 0x550C7DC3UL,
+ 0x72BE5D74UL, 0x80DEB1FEUL, 0x9BDC06A7UL, 0xC19BF174UL,
+ 0xE49B69C1UL, 0xEFBE4786UL, 0x0FC19DC6UL, 0x240CA1CCUL,
+ 0x2DE92C6FUL, 0x4A7484AAUL, 0x5CB0A9DCUL, 0x76F988DAUL,
+ 0x983E5152UL, 0xA831C66DUL, 0xB00327C8UL, 0xBF597FC7UL,
+ 0xC6E00BF3UL, 0xD5A79147UL, 0x06CA6351UL, 0x14292967UL,
+ 0x27B70A85UL, 0x2E1B2138UL, 0x4D2C6DFCUL, 0x53380D13UL,
+ 0x650A7354UL, 0x766A0ABBUL, 0x81C2C92EUL, 0x92722C85UL,
+ 0xA2BFE8A1UL, 0xA81A664BUL, 0xC24B8B70UL, 0xC76C51A3UL,
+ 0xD192E819UL, 0xD6990624UL, 0xF40E3585UL, 0x106AA070UL,
+ 0x19A4C116UL, 0x1E376C08UL, 0x2748774CUL, 0x34B0BCB5UL,
+ 0x391C0CB3UL, 0x4ED8AA4AUL, 0x5B9CCA4FUL, 0x682E6FF3UL,
+ 0x748F82EEUL, 0x78A5636FUL, 0x84C87814UL, 0x8CC70208UL,
+ 0x90BEFFFAUL, 0xA4506CEBUL, 0xBEF9A3F7UL, 0xC67178F2UL
+ };
+ uint32_t w[64];
+ uint32_t a, b, c, d, e, f, g, h, s0, s1, ch, maj, temp1, temp2;
+ uint16_t i;
+ for (i = 0U; i < 16U; i++) {
+ uint16_t p = (uint16_t)(i * 4U);
+ w[i] = ((uint32_t)block[p] << 24U)
+ | ((uint32_t)block[p + 1U] << 16U)
+ | ((uint32_t)block[p + 2U] << 8U)
+ | (uint32_t)block[p + 3U];
+ }
+ for (i = 16U; i < 64U; i++) {
+ s0 = rotr32(w[i - 15U], 7U) ^ rotr32(w[i - 15U], 18U)
+ ^ (w[i - 15U] >> 3U);
+ s1 = rotr32(w[i - 2U], 17U) ^ rotr32(w[i - 2U], 19U)
+ ^ (w[i - 2U] >> 10U);
+ w[i] = w[i - 16U] + s0 + w[i - 7U] + s1;
+ }
+ a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3];
+ e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7];
+ for (i = 0U; i < 64U; i++) {
+ s1 = rotr32(e, 6U) ^ rotr32(e, 11U) ^ rotr32(e, 25U);
+ ch = (e & f) ^ ((~e) & g);
+ temp1 = h + s1 + ch + k[i] + w[i];
+ s0 = rotr32(a, 2U) ^ rotr32(a, 13U) ^ rotr32(a, 22U);
+ maj = (a & b) ^ (a & c) ^ (b & c);
+ temp2 = s0 + maj;
+ h = g; g = f; f = e; e = d + temp1;
+ d = c; c = b; b = a; a = temp1 + temp2;
+ }
+ ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d;
+ ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h;
+}
+
+static void sha256_init(sha256_ctx_t *ctx)
+{
+ static const uint32_t initial[8] = {
+ 0x6A09E667UL, 0xBB67AE85UL, 0x3C6EF372UL, 0xA54FF53AUL,
+ 0x510E527FUL, 0x9B05688CUL, 0x1F83D9ABUL, 0x5BE0CD19UL
+ };
+ (void)memcpy(ctx->state, initial, sizeof(initial));
+ ctx->bit_count = 0U;
+ ctx->block_length = 0U;
+}
+
+static void sha256_update(sha256_ctx_t *ctx, const uint8_t *data, uint16_t length)
+{
+ uint16_t i;
+ for (i = 0U; i < length; i++) {
+ ctx->block[ctx->block_length++] = data[i];
+ ctx->bit_count += 8U;
+ if (ctx->block_length == SHA256_BLOCK_SIZE) {
+ sha256_transform(ctx, ctx->block);
+ ctx->block_length = 0U;
+ }
+ }
+}
+
+static void sha256_finish(sha256_ctx_t *ctx, uint8_t digest[SETP_SHA256_SIZE])
+{
+ uint16_t i;
+ uint64_t bits = ctx->bit_count;
+ ctx->block[ctx->block_length++] = 0x80U;
+ if (ctx->block_length > 56U) {
+ while (ctx->block_length < SHA256_BLOCK_SIZE) ctx->block[ctx->block_length++] = 0U;
+ sha256_transform(ctx, ctx->block);
+ ctx->block_length = 0U;
+ }
+ while (ctx->block_length < 56U) ctx->block[ctx->block_length++] = 0U;
+ for (i = 0U; i < 8U; i++) {
+ ctx->block[63U - i] = (uint8_t)(bits & 0xFFU);
+ bits >>= 8U;
+ }
+ sha256_transform(ctx, ctx->block);
+ for (i = 0U; i < 8U; i++) {
+ digest[i * 4U] = (uint8_t)(ctx->state[i] >> 24U);
+ digest[i * 4U + 1U] = (uint8_t)(ctx->state[i] >> 16U);
+ digest[i * 4U + 2U] = (uint8_t)(ctx->state[i] >> 8U);
+ digest[i * 4U + 3U] = (uint8_t)ctx->state[i];
+ }
+}
+
+static void boot_status(const setp_tms2812_boot_t *boot, setp_fw_status_t *status)
+{
+ status->state = boot->state;
+ status->active_slot = boot->config.active_slot;
+ status->max_block_size = boot->config.max_block_size;
+ status->next_offset = boot->next_offset;
+ status->image_size = boot->manifest.image_size;
+ status->last_error = boot->last_error;
+ status->flags = 0U;
+}
+
+static bool boot_send_response(setp_tms2812_boot_t *boot,
+ const setp_frame_t *request,
+ const setp_can_id_t *request_id,
+ uint16_t status,
+ const uint8_t *body, uint16_t body_length)
+{
+ setp_frame_t response;
+ setp_can_id_t response_id;
+ size_t packet_length;
+ if ((uint32_t)body_length + 2U > sizeof(boot->response_payload)) return false;
+ setp_put_u16(boot->response_payload, status);
+ if (body_length != 0U) (void)memcpy(&boot->response_payload[2], body, body_length);
+ response.flags = SETP_FLAG_RESPONSE;
+ if (status != SETP_STATUS_OK) response.flags |= SETP_FLAG_ERROR;
+ if ((request->flags & SETP_FLAG_PRIORITY) != 0U) response.flags |= SETP_FLAG_PRIORITY;
+ response.message_type = request->message_type;
+ response.source = boot->config.node_id;
+ response.destination = request->source;
+ response.sequence = request->sequence;
+ response.payload_length = (uint16_t)(body_length + 2U);
+ response.payload = boot->response_payload;
+ packet_length = setp_frame_encode(&response, boot->response_packet,
+ sizeof(boot->response_packet));
+ if (packet_length == 0U) return false;
+ response_id.destination = request_id->source;
+ response_id.source = boot->config.node_id;
+ response_id.priority = (response.flags & SETP_FLAG_PRIORITY) != 0U ? 1U : 0U;
+ response_id.channel = request_id->channel;
+ return setp_can_segment(boot->response_packet, (uint16_t)packet_length,
+ setp_can_id_pack(&response_id), boot->port.send_can,
+ boot->port_user);
+}
+
+static uint16_t boot_verify_image(setp_tms2812_boot_t *boot)
+{
+ uint8_t data[SETP_TMS2812_MAX_BLOCK_SIZE];
+ uint8_t digest[SETP_SHA256_SIZE];
+ sha256_ctx_t sha;
+ uint32_t crc = 0xFFFFFFFFUL;
+ uint32_t offset = 0U;
+ uint16_t i;
+ sha256_init(&sha);
+ while (offset < boot->manifest.image_size) {
+ uint32_t remaining = boot->manifest.image_size - offset;
+ uint16_t length = remaining > sizeof(data) ? (uint16_t)sizeof(data) : (uint16_t)remaining;
+ if (!boot->port.read_image(boot->port_user, offset, data, length)) {
+ return SETP_STATUS_INTERNAL;
+ }
+ sha256_update(&sha, data, length);
+ for (i = 0U; i < length; i++) {
+ uint8_t bit;
+ crc ^= data[i];
+ for (bit = 0U; bit < 8U; bit++)
+ crc = (crc >> 1U) ^ (((crc & 1U) != 0U) ? 0xEDB88320UL : 0U);
+ }
+ offset += length;
+ }
+ sha256_finish(&sha, digest);
+ crc ^= 0xFFFFFFFFUL;
+ if ((crc != boot->manifest.image_crc32)
+ || (memcmp(digest, boot->manifest.sha256, SETP_SHA256_SIZE) != 0)) {
+ return SETP_STATUS_VERIFY_FAILED;
+ }
+ return SETP_STATUS_OK;
+}
+
+static uint16_t boot_fw_begin(setp_tms2812_boot_t *boot, const setp_frame_t *request)
+{
+ setp_fw_begin_t value;
+ bool same_manifest;
+ if (!setp_fw_begin_decode(request->payload, request->payload_length, &value))
+ return SETP_STATUS_INVALID_LENGTH;
+ if ((value.image_size == 0U) || (value.image_size > boot->config.max_image_size)
+ || (value.slot != boot->config.active_slot)
+ || ((value.base_address != 0U)
+ && (value.base_address != boot->config.app_base_address))
+ || (value.block_size == 0U)
+ || (value.block_size > boot->config.max_block_size))
+ return SETP_STATUS_INVALID_ARGUMENT;
+ if (((value.flags & SETP_FW_FLAG_SIGNED) != 0U) || boot->config.require_signature) {
+ if ((boot->port.authorize == NULL)
+ || !boot->port.authorize(boot->port_user, &value))
+ return SETP_STATUS_AUTH_FAILED;
+ }
+ same_manifest = boot->state == SETP_FW_RECEIVING
+ && boot->manifest.image_size == value.image_size
+ && boot->manifest.image_crc32 == value.image_crc32
+ && boot->manifest.image_version == value.image_version
+ && memcmp(boot->manifest.sha256, value.sha256, SETP_SHA256_SIZE) == 0;
+ if (same_manifest && ((value.flags & SETP_FW_FLAG_RESUME) != 0U)) return SETP_STATUS_OK;
+ if (!boot->port.erase_image(boot->port_user, value.image_size))
+ return SETP_STATUS_INTERNAL;
+ boot->manifest = value;
+ boot->manifest.signature = NULL;
+ boot->manifest.signature_length = 0U;
+ boot->next_offset = 0U;
+ boot->state = SETP_FW_RECEIVING;
+ return SETP_STATUS_OK;
+}
+
+static uint16_t boot_fw_data(setp_tms2812_boot_t *boot, const setp_frame_t *request)
+{
+ setp_fw_data_t value;
+ uint8_t current[SETP_TMS2812_MAX_BLOCK_SIZE];
+ if (boot->state != SETP_FW_RECEIVING) return SETP_STATUS_WRONG_STATE;
+ if (!setp_fw_data_decode(request->payload, request->payload_length, &value))
+ return SETP_STATUS_CRC;
+ if ((value.data_length > boot->manifest.block_size)
+ || (value.data_length > boot->config.max_block_size)
+ || (value.offset > boot->manifest.image_size)
+ || ((uint32_t)value.data_length > boot->manifest.image_size - value.offset))
+ return SETP_STATUS_INVALID_ARGUMENT;
+ if (value.offset < boot->next_offset) {
+ if ((value.offset + value.data_length > boot->next_offset)
+ || !boot->port.read_image(boot->port_user, value.offset,
+ current, value.data_length)
+ || memcmp(current, value.data, value.data_length) != 0)
+ return SETP_STATUS_SEQUENCE;
+ return SETP_STATUS_OK;
+ }
+ if (value.offset != boot->next_offset) return SETP_STATUS_SEQUENCE;
+ if (!boot->port.write_image(boot->port_user, value.offset,
+ value.data, value.data_length))
+ return SETP_STATUS_INTERNAL;
+ boot->next_offset += value.data_length;
+ return SETP_STATUS_OK;
+}
+
+static uint16_t boot_fw_end(setp_tms2812_boot_t *boot, const setp_frame_t *request)
+{
+ setp_fw_end_t value;
+ uint16_t status;
+ if (boot->state != SETP_FW_RECEIVING) return SETP_STATUS_WRONG_STATE;
+ if (!setp_fw_end_decode(request->payload, request->payload_length, &value))
+ return SETP_STATUS_INVALID_LENGTH;
+ if ((boot->next_offset != boot->manifest.image_size)
+ || (value.image_size != boot->manifest.image_size)
+ || (value.image_crc32 != boot->manifest.image_crc32)
+ || (memcmp(value.sha256, boot->manifest.sha256, SETP_SHA256_SIZE) != 0))
+ return SETP_STATUS_VERIFY_FAILED;
+ boot->state = SETP_FW_VERIFYING;
+ status = boot_verify_image(boot);
+ boot->state = status == SETP_STATUS_OK ? SETP_FW_READY : SETP_FW_FAILED;
+ return status;
+}
+
+static bool boot_dispatch(setp_tms2812_boot_t *boot, const setp_frame_t *request,
+ const setp_can_id_t *request_id, uint32_t now_ms)
+{
+ uint8_t body[SETP_TMS2812_RESPONSE_PAYLOAD_SIZE - 2U];
+ uint16_t body_length = 0U;
+ uint16_t status = SETP_STATUS_OK;
+ uint8_t reboot = 0U;
+ setp_fw_status_t fw_status;
+ if ((request->flags & (SETP_FLAG_RESPONSE | SETP_FLAG_EVENT)) != 0U) return false;
+ if ((request->source > 0xFFU) || (request->destination != boot->config.node_id)) return false;
+ switch (request->message_type) {
+ case SETP_MSG_PING:
+ if (request->payload_length != 0U) status = SETP_STATUS_INVALID_LENGTH;
+ else { setp_put_u32(body, now_ms); body_length = 4U; }
+ break;
+ case SETP_MSG_DEVICE_INFO:
+ if (request->payload_length != 0U) status = SETP_STATUS_INVALID_LENGTH;
+ else {
+ setp_device_info_t info;
+ info.schema_version = SETP_DEVICE_INFO_SCHEMA_VERSION;
+ info.device_class = boot->config.device_class;
+ info.hardware_version = boot->config.hardware_version;
+ info.firmware_version = boot->config.firmware_version;
+ info.dictionary_version = boot->config.dictionary_version;
+ info.serial_number = boot->config.serial_number;
+ info.model_length = boot->config.model_length;
+ info.model = boot->config.model;
+ body_length = (uint16_t)setp_device_info_encode(&info, body, sizeof(body));
+ if (body_length == 0U) status = SETP_STATUS_INTERNAL;
+ }
+ break;
+ case SETP_MSG_CAPABILITIES:
+ if (request->payload_length != 0U) status = SETP_STATUS_INVALID_LENGTH;
+ else {
+ setp_capabilities_t caps;
+ caps.schema_version = SETP_CAPABILITIES_SCHEMA_VERSION;
+ caps.max_payload = (uint16_t)(SETP_FW_DATA_HEADER_SIZE + boot->config.max_block_size);
+ caps.interface_mask = SETP_IFACE_MASK(SETP_IFACE_CAN);
+ caps.feature_flags = SETP_FEATURE_FIRMWARE;
+ caps.max_read_items = 0U; caps.max_write_items = 0U;
+ caps.max_subscriptions = 0U; caps.max_publish_items = 0U;
+ body_length = (uint16_t)setp_capabilities_encode(&caps, body, sizeof(body));
+ if (body_length == 0U) status = SETP_STATUS_INTERNAL;
+ }
+ break;
+ case SETP_MSG_FW_BEGIN:
+ status = boot_fw_begin(boot, request);
+ setp_put_u32(body, boot->next_offset); body_length = 4U;
+ break;
+ case SETP_MSG_FW_DATA:
+ status = boot_fw_data(boot, request);
+ setp_put_u32(body, boot->next_offset); body_length = 4U;
+ break;
+ case SETP_MSG_FW_END:
+ status = boot_fw_end(boot, request);
+ setp_put_u32(body, boot->next_offset); body_length = 4U;
+ break;
+ case SETP_MSG_FW_ABORT:
+ if (request->payload_length != 0U) status = SETP_STATUS_INVALID_LENGTH;
+ else setp_tms2812_boot_abort(boot);
+ setp_put_u32(body, boot->next_offset); body_length = 4U;
+ break;
+ case SETP_MSG_FW_STATUS:
+ if (request->payload_length != 0U) status = SETP_STATUS_INVALID_LENGTH;
+ else {
+ boot_status(boot, &fw_status);
+ body_length = (uint16_t)setp_fw_status_encode(&fw_status, body, sizeof(body));
+ }
+ break;
+ case SETP_MSG_FW_ACTIVATE:
+ if (request->payload_length != 0U) status = SETP_STATUS_INVALID_LENGTH;
+ else if (boot->state != SETP_FW_READY) status = SETP_STATUS_WRONG_STATE;
+ else { boot->state = SETP_FW_ACTIVE; reboot = 1U; }
+ setp_put_u32(body, boot->next_offset); body_length = 4U;
+ break;
+ default:
+ status = SETP_STATUS_UNSUPPORTED;
+ break;
+ }
+ boot->last_error = status == SETP_STATUS_OK ? 0U : status;
+ if (!boot_send_response(boot, request, request_id, status, body, body_length)) return false;
+ if (reboot != 0U) boot->port.reboot(boot->port_user);
+ return true;
+}
+
+bool setp_tms2812_boot_init(setp_tms2812_boot_t *boot,
+ const setp_tms2812_boot_config_t *config,
+ const setp_tms2812_boot_port_t *port,
+ void *port_user)
+{
+ if ((boot == NULL) || (config == NULL) || (port == NULL)
+ || (config->model == NULL) || (config->model_length > SETP_DEVICE_MODEL_MAX)
+ || (config->max_image_size == 0U) || (config->max_block_size == 0U)
+ || (config->max_block_size > SETP_TMS2812_MAX_BLOCK_SIZE)
+ || (port->send_can == NULL) || (port->erase_image == NULL)
+ || (port->write_image == NULL) || (port->read_image == NULL)
+ || (port->reboot == NULL)) return false;
+ (void)memset(boot, 0, sizeof(*boot));
+ boot->config = *config;
+ boot->port = *port;
+ boot->port_user = port_user;
+ boot->state = SETP_FW_IDLE;
+ setp_can_rx_init(&boot->rx);
+ return true;
+}
+
+void setp_tms2812_boot_abort(setp_tms2812_boot_t *boot)
+{
+ if (boot == NULL) return;
+ boot->state = SETP_FW_IDLE;
+ boot->next_offset = 0U;
+ boot->last_error = 0U;
+ (void)memset(&boot->manifest, 0, sizeof(boot->manifest));
+}
+
+bool setp_tms2812_boot_process(setp_tms2812_boot_t *boot,
+ const setp_can_frame_t *frame,
+ uint32_t now_ms)
+{
+ setp_can_packet_t packet;
+ setp_can_rx_result_t result;
+ setp_can_id_t can_id;
+ setp_frame_t request;
+ if ((boot == NULL) || (frame == NULL)) return false;
+ result = setp_can_rx_feed(&boot->rx, frame, now_ms, &packet);
+ if (result != SETP_CAN_RX_COMPLETE) return result == SETP_CAN_RX_NONE;
+ if (!setp_can_id_unpack(packet.can_id, &can_id)
+ || (can_id.destination != boot->config.node_id)
+ || !setp_frame_decode_datagram(packet.data, packet.length, &request)) return false;
+ return boot_dispatch(boot, &request, &can_id, now_ms);
+}
diff --git a/c/set-protocol/ports/tms320f2812/setp_tms2812_boot.h b/c/set-protocol/ports/tms320f2812/setp_tms2812_boot.h
new file mode 100644
index 0000000..4c2c0c0
--- /dev/null
+++ b/c/set-protocol/ports/tms320f2812/setp_tms2812_boot.h
@@ -0,0 +1,74 @@
+#ifndef SETP_TMS2812_BOOT_H
+#define SETP_TMS2812_BOOT_H
+
+#include "set_can.h"
+#include "set_firmware.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#ifndef SETP_TMS2812_MAX_BLOCK_SIZE
+#define SETP_TMS2812_MAX_BLOCK_SIZE 64U
+#endif
+
+#define SETP_TMS2812_RESPONSE_PAYLOAD_SIZE \
+ (2U + SETP_DEVICE_INFO_FIXED_SIZE + SETP_DEVICE_MODEL_MAX)
+
+typedef struct {
+ uint8_t node_id;
+ uint16_t device_class;
+ uint32_t hardware_version;
+ uint32_t firmware_version;
+ uint32_t dictionary_version;
+ uint64_t serial_number;
+ const uint8_t *model;
+ uint8_t model_length;
+ uint32_t app_base_address;
+ uint32_t max_image_size;
+ uint16_t max_block_size;
+ uint8_t active_slot;
+ uint8_t require_signature;
+} setp_tms2812_boot_config_t;
+
+typedef struct {
+ setp_can_send_fn send_can;
+ bool (*erase_image)(void *user, uint32_t image_size);
+ bool (*write_image)(void *user, uint32_t offset,
+ const uint8_t *data, uint16_t length);
+ bool (*read_image)(void *user, uint32_t offset,
+ uint8_t *data, uint16_t length);
+ bool (*authorize)(void *user, const setp_fw_begin_t *manifest);
+ void (*reboot)(void *user);
+} setp_tms2812_boot_port_t;
+
+typedef struct {
+ setp_tms2812_boot_config_t config;
+ setp_tms2812_boot_port_t port;
+ void *port_user;
+ setp_can_rx_t rx;
+ setp_fw_begin_t manifest;
+ uint32_t next_offset;
+ uint16_t last_error;
+ uint8_t state;
+ uint8_t response_payload[SETP_TMS2812_RESPONSE_PAYLOAD_SIZE];
+ uint8_t response_packet[SETP_FRAME_MAX];
+} setp_tms2812_boot_t;
+
+bool setp_tms2812_boot_init(setp_tms2812_boot_t *boot,
+ const setp_tms2812_boot_config_t *config,
+ const setp_tms2812_boot_port_t *port,
+ void *port_user);
+
+/** Process one classic-CAN frame in task context, never from an ISR. */
+bool setp_tms2812_boot_process(setp_tms2812_boot_t *boot,
+ const setp_can_frame_t *frame,
+ uint32_t now_ms);
+
+void setp_tms2812_boot_abort(setp_tms2812_boot_t *boot);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* SETP_TMS2812_BOOT_H */
diff --git a/c/set-protocol/tests/test_tms2812_boot.c b/c/set-protocol/tests/test_tms2812_boot.c
new file mode 100644
index 0000000..f1065b0
--- /dev/null
+++ b/c/set-protocol/tests/test_tms2812_boot.c
@@ -0,0 +1,225 @@
+#include
+#include
+#include
+
+#include "setp_tms2812_boot.h"
+
+#define NODE_ID 13U
+#define IMAGE_SIZE 100U
+
+typedef struct {
+ uint8_t flash[512];
+ setp_can_rx_t response_rx;
+ setp_can_packet_t response_packet;
+ uint32_t now_ms;
+ unsigned int response_complete;
+ unsigned int erased;
+ unsigned int rebooted;
+} fake_t;
+
+typedef struct {
+ setp_tms2812_boot_t *boot;
+ fake_t *fake;
+} request_context_t;
+
+static bool fake_send_response(const setp_can_frame_t *frame, void *user)
+{
+ fake_t *fake = (fake_t *)user;
+ setp_can_rx_result_t result = setp_can_rx_feed(
+ &fake->response_rx, frame, fake->now_ms++, &fake->response_packet);
+ if (result == SETP_CAN_RX_COMPLETE) fake->response_complete++;
+ return result == SETP_CAN_RX_NONE || result == SETP_CAN_RX_COMPLETE;
+}
+
+static bool fake_erase(void *user, uint32_t image_size)
+{
+ fake_t *fake = (fake_t *)user;
+ if (image_size > sizeof(fake->flash)) return false;
+ (void)memset(fake->flash, 0xFF, sizeof(fake->flash));
+ fake->erased++;
+ return true;
+}
+
+static bool fake_write(void *user, uint32_t offset,
+ const uint8_t *data, uint16_t length)
+{
+ fake_t *fake = (fake_t *)user;
+ if (offset + length > sizeof(fake->flash)) return false;
+ (void)memcpy(&fake->flash[offset], data, length);
+ return true;
+}
+
+static bool fake_read(void *user, uint32_t offset,
+ uint8_t *data, uint16_t length)
+{
+ fake_t *fake = (fake_t *)user;
+ if (offset + length > sizeof(fake->flash)) return false;
+ (void)memcpy(data, &fake->flash[offset], length);
+ return true;
+}
+
+static void fake_reboot(void *user)
+{
+ ((fake_t *)user)->rebooted++;
+}
+
+static bool send_request_frame(const setp_can_frame_t *frame, void *user)
+{
+ request_context_t *context = (request_context_t *)user;
+ return setp_tms2812_boot_process(context->boot, frame,
+ context->fake->now_ms++);
+}
+
+static setp_frame_t transact(setp_tms2812_boot_t *boot, fake_t *fake,
+ uint16_t sequence, uint16_t type,
+ const uint8_t *payload, uint16_t payload_length)
+{
+ uint8_t packet[SETP_FRAME_MAX];
+ setp_frame_t request;
+ setp_frame_t response;
+ setp_can_id_t id;
+ request_context_t context;
+ size_t packet_length;
+ fake->response_complete = 0U;
+ setp_can_rx_init(&fake->response_rx);
+ request.flags = SETP_FLAG_ACK_REQUIRED | SETP_FLAG_PRIORITY;
+ request.message_type = type;
+ request.source = 0U;
+ request.destination = NODE_ID;
+ request.sequence = sequence;
+ request.payload_length = payload_length;
+ request.payload = payload;
+ packet_length = setp_frame_encode(&request, packet, sizeof(packet));
+ assert(packet_length != 0U);
+ id.destination = NODE_ID;
+ id.source = 0U;
+ id.priority = 1U;
+ id.channel = 1U;
+ context.boot = boot;
+ context.fake = fake;
+ assert(setp_can_segment(packet, (uint16_t)packet_length,
+ setp_can_id_pack(&id), send_request_frame, &context));
+ assert(fake->response_complete == 1U);
+ assert(setp_frame_decode_datagram(fake->response_packet.data,
+ fake->response_packet.length, &response));
+ assert(response.flags & SETP_FLAG_RESPONSE);
+ assert(response.message_type == type);
+ assert(response.sequence == sequence);
+ assert(response.source == NODE_ID);
+ assert(response.destination == 0U);
+ return response;
+}
+
+static void assert_ok(const setp_frame_t *response)
+{
+ assert(response->payload_length >= 2U);
+ assert(setp_get_u16(response->payload) == SETP_STATUS_OK);
+ assert((response->flags & SETP_FLAG_ERROR) == 0U);
+}
+
+int main(void)
+{
+ static const uint8_t model[] = "BALZAM-166";
+ static const uint8_t expected_sha[SETP_SHA256_SIZE] = {
+ 0xBC, 0xE0, 0xAF, 0xF1, 0x9C, 0xF5, 0xAA, 0x6A,
+ 0x74, 0x69, 0xA3, 0x0D, 0x61, 0xD0, 0x4E, 0x43,
+ 0x76, 0xE4, 0xBB, 0xF6, 0x38, 0x10, 0x52, 0xEE,
+ 0x9E, 0x7F, 0x33, 0x92, 0x5C, 0x95, 0x4D, 0x52
+ };
+ uint8_t image[IMAGE_SIZE];
+ uint8_t payload[SETP_MAX_PAYLOAD];
+ setp_tms2812_boot_t boot;
+ setp_tms2812_boot_config_t config;
+ setp_tms2812_boot_port_t port;
+ setp_fw_begin_t begin;
+ setp_fw_end_t end;
+ setp_fw_status_t status;
+ setp_frame_t response;
+ size_t length;
+ uint32_t offset;
+ uint16_t sequence = 1U;
+ fake_t fake;
+ unsigned int i;
+
+ (void)memset(&fake, 0, sizeof(fake));
+ (void)memset(&config, 0, sizeof(config));
+ (void)memset(&port, 0, sizeof(port));
+ for (i = 0U; i < IMAGE_SIZE; i++) image[i] = (uint8_t)i;
+ config.node_id = NODE_ID;
+ config.device_class = 0x0166U;
+ config.hardware_version = 1U;
+ config.firmware_version = 2U;
+ config.model = model;
+ config.model_length = (uint8_t)(sizeof(model) - 1U);
+ config.app_base_address = 0x00100000UL;
+ config.max_image_size = sizeof(fake.flash);
+ config.max_block_size = 32U;
+ config.active_slot = 0U;
+ port.send_can = fake_send_response;
+ port.erase_image = fake_erase;
+ port.write_image = fake_write;
+ port.read_image = fake_read;
+ port.reboot = fake_reboot;
+ assert(setp_tms2812_boot_init(&boot, &config, &port, &fake));
+
+ response = transact(&boot, &fake, sequence++, SETP_MSG_PING, NULL, 0U);
+ assert_ok(&response);
+ assert(response.payload_length == 6U);
+
+ (void)memset(&begin, 0, sizeof(begin));
+ begin.image_size = IMAGE_SIZE;
+ begin.image_crc32 = setp_crc32(image, sizeof(image));
+ begin.image_version = 0x01020304UL;
+ begin.base_address = config.app_base_address;
+ begin.slot = 0U;
+ begin.flags = SETP_FW_FLAG_RESUME | SETP_FW_FLAG_ERASE_SLOT;
+ begin.block_size = 32U;
+ (void)memcpy(begin.sha256, expected_sha, sizeof(expected_sha));
+ length = setp_fw_begin_encode(&begin, payload, sizeof(payload));
+ assert(length != 0U);
+ response = transact(&boot, &fake, sequence++, SETP_MSG_FW_BEGIN,
+ payload, (uint16_t)length);
+ assert_ok(&response);
+ assert(fake.erased == 1U);
+
+ for (offset = 0U; offset < IMAGE_SIZE;) {
+ uint16_t chunk = (uint16_t)(IMAGE_SIZE - offset);
+ if (chunk > begin.block_size) chunk = begin.block_size;
+ length = setp_fw_data_encode(offset, 0U, &image[offset], chunk,
+ payload, sizeof(payload));
+ response = transact(&boot, &fake, sequence++, SETP_MSG_FW_DATA,
+ payload, (uint16_t)length);
+ assert_ok(&response);
+ assert(setp_get_u32(&response.payload[2]) == offset + chunk);
+ if (offset == 0U) {
+ response = transact(&boot, &fake, sequence++, SETP_MSG_FW_DATA,
+ payload, (uint16_t)length);
+ assert_ok(&response);
+ assert(setp_get_u32(&response.payload[2]) == chunk);
+ }
+ offset += chunk;
+ }
+ assert(memcmp(fake.flash, image, sizeof(image)) == 0);
+
+ end.image_size = begin.image_size;
+ end.image_crc32 = begin.image_crc32;
+ (void)memcpy(end.sha256, expected_sha, sizeof(expected_sha));
+ length = setp_fw_end_encode(&end, payload, sizeof(payload));
+ response = transact(&boot, &fake, sequence++, SETP_MSG_FW_END,
+ payload, (uint16_t)length);
+ assert_ok(&response);
+ assert(boot.state == SETP_FW_READY);
+
+ response = transact(&boot, &fake, sequence++, SETP_MSG_FW_STATUS, NULL, 0U);
+ assert_ok(&response);
+ assert(setp_fw_status_decode(&response.payload[2],
+ (uint16_t)(response.payload_length - 2U), &status));
+ assert(status.state == SETP_FW_READY);
+ assert(status.next_offset == IMAGE_SIZE);
+
+ response = transact(&boot, &fake, sequence++, SETP_MSG_FW_ACTIVATE, NULL, 0U);
+ assert_ok(&response);
+ assert(fake.rebooted == 1U);
+ puts("SETProtocol v2 TMS320F2812 firmware port tests passed");
+ return 0;
+}
diff --git a/python/setprotocol/__init__.py b/python/setprotocol/__init__.py
index 8791acc..fc0d384 100644
--- a/python/setprotocol/__init__.py
+++ b/python/setprotocol/__init__.py
@@ -1,3 +1,4 @@
"""Cross-platform Python facade for the canonical SETProtocol core."""
from .core import * # noqa: F401,F403
+from .can import CanAddress, CanFrame, CanReassembler, segment
diff --git a/python/setprotocol/can.py b/python/setprotocol/can.py
new file mode 100644
index 0000000..30c0e6d
--- /dev/null
+++ b/python/setprotocol/can.py
@@ -0,0 +1,173 @@
+"""Classic-CAN segmentation for canonical SETProtocol v2 frames."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .core import (
+ CRC_SIZE,
+ FRAME_MAX,
+ HEADER_SIZE,
+ SOF,
+ FrameFlag,
+ SetProtocolError,
+)
+
+CAN_ID_MASK = 0x1FFFFFFF
+CAN_ID_PREFIX = 0x12
+CAN_ID_PREFIX_MASK = 0x1F000000
+PCI_FIRST = 0x10
+PCI_CONSECUTIVE = 0x20
+PCI_FLOW_CONTROL = 0x30
+PCI_TYPE_MASK = 0xF0
+PCI_VALUE_MASK = 0x0F
+FIRST_DATA_SIZE = 5
+CONSECUTIVE_DATA_SIZE = 7
+REASSEMBLY_TIMEOUT_MS = 500
+
+
+@dataclass(frozen=True)
+class CanAddress:
+ destination: int
+ source: int
+ priority: int = 0
+ channel: int = 0
+
+ def __post_init__(self) -> None:
+ if not 0 <= self.destination <= 0xFF:
+ raise SetProtocolError("CAN destination вне диапазона u8")
+ if not 0 <= self.source <= 0xFF:
+ raise SetProtocolError("CAN source вне диапазона u8")
+ if self.priority not in (0, 1):
+ raise SetProtocolError("CAN priority должен быть 0 или 1")
+ if not 0 <= self.channel <= 0x7F:
+ raise SetProtocolError("CAN channel вне диапазона 0..127")
+
+ def pack(self) -> int:
+ return (
+ (CAN_ID_PREFIX << 24)
+ | (self.destination << 16)
+ | (self.source << 8)
+ | (self.priority << 7)
+ | self.channel
+ )
+
+ @classmethod
+ def unpack(cls, can_id: int) -> "CanAddress":
+ if not 0 <= can_id <= CAN_ID_MASK:
+ raise SetProtocolError("CAN ID вне 29-битного диапазона")
+ if can_id & CAN_ID_PREFIX_MASK != CAN_ID_PREFIX << 24:
+ raise SetProtocolError("CAN ID не принадлежит SETProtocol v2")
+ return cls(
+ destination=(can_id >> 16) & 0xFF,
+ source=(can_id >> 8) & 0xFF,
+ priority=(can_id >> 7) & 1,
+ channel=can_id & 0x7F,
+ )
+
+
+@dataclass(frozen=True)
+class CanFrame:
+ can_id: int
+ data: bytes
+
+ def __post_init__(self) -> None:
+ CanAddress.unpack(self.can_id)
+ if not 1 <= len(self.data) <= 8:
+ raise SetProtocolError("classic CAN data должен содержать 1..8 байт")
+
+
+def segment(packet: bytes, address: CanAddress) -> list[CanFrame]:
+ """Split one complete encoded SETP frame into classic-CAN frames."""
+ if not HEADER_SIZE + CRC_SIZE <= len(packet) <= FRAME_MAX:
+ raise SetProtocolError("неверная длина SETP-пакета для CAN")
+ can_id = address.pack()
+ result = [
+ CanFrame(
+ can_id,
+ bytes((PCI_FIRST,))
+ + len(packet).to_bytes(2, "little")
+ + packet[:FIRST_DATA_SIZE],
+ )
+ ]
+ sequence = 1
+ for offset in range(FIRST_DATA_SIZE, len(packet), CONSECUTIVE_DATA_SIZE):
+ chunk = packet[offset : offset + CONSECUTIVE_DATA_SIZE]
+ result.append(CanFrame(can_id, bytes((PCI_CONSECUTIVE | sequence,)) + chunk))
+ sequence = (sequence + 1) & PCI_VALUE_MASK
+ return result
+
+
+class CanReassembler:
+ """Reassemble one SETP packet from one active source/channel."""
+
+ def __init__(self, timeout_ms: int = REASSEMBLY_TIMEOUT_MS) -> None:
+ if timeout_ms <= 0:
+ raise ValueError("timeout_ms должен быть положительным")
+ self.timeout_ms = timeout_ms
+ self.reset()
+
+ def reset(self) -> None:
+ self._buffer = bytearray()
+ self._expected_length = 0
+ self._can_id: int | None = None
+ self._deadline_ms = 0
+ self._next_sequence = 1
+
+ def feed(self, frame: CanFrame, now_ms: int) -> bytes | None:
+ if self._can_id is not None and now_ms >= self._deadline_ms:
+ self.reset()
+ raise SetProtocolError("таймаут сборки SETProtocol CAN")
+ pci = frame.data[0] & PCI_TYPE_MASK
+ if pci == PCI_FIRST:
+ if len(frame.data) != 8:
+ raise SetProtocolError("первый CAN-сегмент должен иметь DLC 8")
+ total = int.from_bytes(frame.data[1:3], "little")
+ if not HEADER_SIZE + CRC_SIZE <= total <= FRAME_MAX:
+ raise SetProtocolError("неверная полная длина SETP CAN")
+ self._buffer = bytearray(frame.data[3:])
+ self._expected_length = total
+ self._can_id = frame.can_id
+ self._deadline_ms = now_ms + self.timeout_ms
+ self._next_sequence = 1
+ return None
+ if pci == PCI_CONSECUTIVE:
+ sequence = frame.data[0] & PCI_VALUE_MASK
+ if (
+ self._can_id is None
+ or frame.can_id != self._can_id
+ or sequence != self._next_sequence
+ or len(frame.data) < 2
+ ):
+ self.reset()
+ raise SetProtocolError("нарушена последовательность SETP CAN")
+ remaining = self._expected_length - len(self._buffer)
+ chunk = frame.data[1:]
+ if len(chunk) > remaining:
+ self.reset()
+ raise SetProtocolError("CAN-сегмент длиннее остатка SETP-пакета")
+ self._buffer.extend(chunk)
+ self._next_sequence = (self._next_sequence + 1) & PCI_VALUE_MASK
+ self._deadline_ms = now_ms + self.timeout_ms
+ if len(self._buffer) != self._expected_length:
+ return None
+ packet = bytes(self._buffer)
+ can_id = self._can_id
+ self.reset()
+ assert can_id is not None
+ address = CanAddress.unpack(can_id)
+ if packet[:2] != SOF:
+ raise SetProtocolError("SETP CAN packet не содержит SOF")
+ source = int.from_bytes(packet[6:8], "little")
+ destination = int.from_bytes(packet[8:10], "little")
+ priority = int(bool(packet[3] & int(FrameFlag.PRIORITY)))
+ if (
+ source != address.source
+ or destination != address.destination
+ or priority != address.priority
+ ):
+ raise SetProtocolError("SETP header не совпадает с CAN ID")
+ return packet
+ if pci == PCI_FLOW_CONTROL:
+ return None
+ raise SetProtocolError("неизвестный тип SETP CAN-сегмента")
diff --git a/python/tests/test_setprotocol_can.py b/python/tests/test_setprotocol_can.py
new file mode 100644
index 0000000..e288286
--- /dev/null
+++ b/python/tests/test_setprotocol_can.py
@@ -0,0 +1,42 @@
+from setprotocol import Frame, FrameFlag, MessageType, build_frame, decode_datagram
+from setprotocol.can import CanAddress, CanFrame, CanReassembler, segment
+
+
+def test_can_id_roundtrip():
+ value = CanAddress(destination=13, source=0, priority=1, channel=7)
+ assert CanAddress.unpack(value.pack()) == value
+
+
+def test_segmented_setp_frame_roundtrip():
+ packet = build_frame(
+ Frame(
+ message_type=MessageType.FW_DATA,
+ sequence=0x1234,
+ payload=bytes(range(76)),
+ flags=FrameFlag.ACK_REQUIRED | FrameFlag.PRIORITY,
+ source=0,
+ destination=13,
+ )
+ )
+ frames = segment(packet, CanAddress(13, 0, priority=1, channel=1))
+ assert len(frames) == 14
+ reassembler = CanReassembler()
+ restored = None
+ for now_ms, frame in enumerate(frames):
+ restored = reassembler.feed(frame, now_ms)
+ assert restored == packet
+ assert decode_datagram(restored).payload == bytes(range(76))
+
+
+def test_reassembler_rejects_wrong_sequence():
+ packet = build_frame(Frame(MessageType.PING, 1, source=0, destination=13))
+ frames = segment(packet, CanAddress(13, 0))
+ reassembler = CanReassembler()
+ assert reassembler.feed(frames[0], 0) is None
+ damaged = CanFrame(frames[1].can_id, bytes((0x22,)) + frames[1].data[1:])
+ try:
+ reassembler.feed(damaged, 1)
+ except ValueError as error:
+ assert "последовательность" in str(error)
+ else:
+ raise AssertionError("wrong sequence must fail")
From b0f3731554c081f255808c80893238910e851716 Mon Sep 17 00:00:00 2001
From: Andrey Kruchinkin
Date: Sun, 6 Sep 2026 03:03:59 +0300
Subject: [PATCH 14/14] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D1=82=D1=8C=20HTML-=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87?=
=?UTF-8?q?=D0=BD=D0=B8=D0=BA=20CAN=20v1=20=D0=B8=20v2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
doc/CAN_FRAME_PARSE_V1_V2.md | 379 +++++++++++++++++++++++++
doc/build-setprotocol-html.ps1 | 36 ++-
doc/index.html | 2 +-
doc/setprotocol.html | 498 ++++++++++++++++++++++++++++++++-
4 files changed, 899 insertions(+), 16 deletions(-)
create mode 100644 doc/CAN_FRAME_PARSE_V1_V2.md
diff --git a/doc/CAN_FRAME_PARSE_V1_V2.md b/doc/CAN_FRAME_PARSE_V1_V2.md
new file mode 100644
index 0000000..d312288
--- /dev/null
+++ b/doc/CAN_FRAME_PARSE_V1_V2.md
@@ -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`, следующие 1–7 байт 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`.
diff --git a/doc/build-setprotocol-html.ps1 b/doc/build-setprotocol-html.ps1
index 6183b25..e783583 100644
--- a/doc/build-setprotocol-html.ps1
+++ b/doc/build-setprotocol-html.ps1
@@ -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 '', ''
+ $html = $html -replace '
', '
'
+ return $html
+}
+
+$html = Convert-DocumentationMarkdown -Path $sourcePath
$generatedBlock = @"
@@ -15,6 +28,15 @@ $html
"@
+$canFrameHtml = Convert-DocumentationMarkdown -Path $canFrameSourcePath
+$canFrameBlock = @"
+
+
+$canFrameHtml
+
+
+"@
+
$page = Get-Content -Raw -LiteralPath $outputPath -Encoding UTF8
$pattern = '(?s).*?'
if ($page -notmatch $pattern) {
@@ -26,6 +48,16 @@ $page = [regex]::Replace($page, $pattern, [System.Text.RegularExpressions.MatchE
$generatedBlock
}, 1)
+$canFramePattern = '(?s).*?'
+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)
diff --git a/doc/index.html b/doc/index.html
index c09ac93..c4861e4 100644
--- a/doc/index.html
+++ b/doc/index.html
@@ -17,7 +17,7 @@ git subtree pull --prefix lib/templates https://git.rd12.ru/Andrey/templates.git
eeprom-ft24c256 EEPROM 24Cxx с нарезкой записи по страницам.
I²C storage
Порт: I²C write, write-read и delay.
can-sensor Однокадровые SETCAN SETTINGS для 64-битных ROM датчиков.
CAN sensor
Порт: готовый bxCAN STM32F1 либо callbacks CAN TX/RX.
ds18b20 1-Wire, CRC8, SEARCH ROM и несколько термометров.
1-Wire порты F1/G4/F4
Порт: Init, DelayUs, Reset, WriteBit, ReadBit.
-SETProtocol Единое C99-ядро: SET v2, ProtoCAN/GUI v1, телеметрия, firmware flow, GAS и стабильный ABI.
UART/USB/CAN/Ethernet Windows + Android + STM32F4
Порт: COM, SLCAN, SocketCAN, JNI или callbacks MCU.
+SETProtocol Единое C99-ядро: SET v2, ProtoCAN/GUI v1, телеметрия, firmware flow, GAS и стабильный ABI.
UART/USB/CAN/Ethernet Windows + Android + STM32F4
Порт: COM, SLCAN, SocketCAN, JNI или callbacks MCU.
protocan-boot Прошивка по ProtoCAN: A/B-слоты, resume, CRC32, verify и rollback.
CAN firmware
Порт: CAN TX, Flash, metadata, verify, reboot.
rs485-boot SETGUI v1 firmware update: stream parser, CRC32 и resume.
RS-485 порты F103/G474
Порт: UART TX/RX, DE и Flash.
rtc-service RTC с резервным backup-томом и единым API времени.
RTC порт K1921VK028
Порт: RTC и backup storage.
diff --git a/doc/setprotocol.html b/doc/setprotocol.html
index 258e50f..f8e800e 100644
--- a/doc/setprotocol.html
+++ b/doc/setprotocol.html
@@ -14,7 +14,7 @@
.callout{margin:20px 0;padding:17px 19px;border-left:4px solid var(--mint);border-radius:0 12px 12px 0;background:var(--panel)}.callout.warn{border-left-color:var(--amber)}.callout.danger{border-left-color:var(--red)}
.flow{display:grid;grid-template-columns:1fr 44px 1fr 44px 1fr;align-items:stretch;margin:22px 0}.flow .node{display:grid;place-items:center;min-height:126px;padding:18px;border:1px solid var(--line);border-radius:15px;background:var(--panel);text-align:center}.flow .node b{display:block;color:var(--mint);font-size:17px}.flow .arrow{display:grid;place-items:center;color:var(--mint);font-size:28px}.core-node{box-shadow:inset 0 0 0 1px #4ddbb055,0 18px 60px var(--shadow)!important}
.wire{display:flex;flex-wrap:wrap;gap:4px;margin:15px 0}.byte{padding:9px 10px;border:1px solid var(--line);background:#07131b;color:#c9dcdf;font:13px Consolas,monospace}.byte:first-child{border-radius:9px 0 0 9px}.byte:last-child{border-radius:0 9px 9px 0}.byte.sof{border-color:#2b816b;color:var(--mint)}.byte.crc{border-color:#75612f;color:#ffd879}.byte.payload{flex:1;min-width:130px;text-align:center}
- table{width:100%;border-collapse:collapse;margin:17px 0}th,td{padding:11px 13px;border:1px solid var(--line);text-align:left;vertical-align:top}th{color:var(--mint);background:var(--panel2)}td{background:#0a1822c4}.table-wrap{overflow:auto}.status{white-space:nowrap}.copy{position:absolute;right:9px;top:9px;border:1px solid var(--line);border-radius:7px;padding:5px 8px;color:var(--muted);background:#0b1b27;cursor:pointer}.copy:hover{color:var(--text);border-color:var(--mint)}
+ table{width:100%;border-collapse:collapse;margin:17px 0}th,td{padding:11px 13px;border:1px solid var(--line);text-align:left;vertical-align:top}th{color:var(--mint);background:var(--panel2)}td{background:#0a1822c4}.table-wrap{max-width:100%;overflow:auto}.full-doc .table-wrap table{min-width:720px}.full-doc .table-wrap th,.full-doc .table-wrap td{overflow-wrap:normal;word-break:normal}.status{white-space:nowrap}.copy{position:absolute;right:9px;top:9px;border:1px solid var(--line);border-radius:7px;padding:5px 8px;color:var(--muted);background:#0b1b27;cursor:pointer}.copy:hover{color:var(--text);border-color:var(--mint)}
.steps{counter-reset:s;display:grid;gap:12px}.step{position:relative;padding:19px 20px 19px 68px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}.step:before{counter-increment:s;content:counter(s);position:absolute;left:19px;top:19px;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;color:#03100d;background:var(--mint);font-weight:900}
.full-doc{padding:27px}.full-doc h1{font-size:34px;letter-spacing:-.025em}.full-doc h2{margin-top:34px;padding-top:22px;border-top:1px solid var(--line);font-size:25px}.full-doc h3{margin-top:25px}.full-doc li{margin:5px 0}.full-doc blockquote{margin:16px 0;padding:2px 17px;border-left:4px solid var(--mint);color:var(--muted)}footer{padding:25px 0 45px;border-top:1px solid var(--line);color:var(--muted)}
@media(max-width:900px){.grid,.grid.two{grid-template-columns:repeat(2,1fr)}.flow{grid-template-columns:1fr}.flow .arrow{transform:rotate(90deg);height:42px}.flow .node{min-height:100px}}@media(max-width:620px){.grid,.grid.two{grid-template-columns:1fr}header{padding-top:42px}.full-doc{padding:18px}.wrap{width:min(100% - 22px,1200px)}th,td{padding:9px}}
@@ -34,6 +34,7 @@
ABI и память
Платформы
Сборка
+ CAN v1 / v2
Полный справочник
@@ -107,6 +108,477 @@ wire = core.encode(1, 1, 0x1234567, b"\xAA\xBB")
frames = core.parser().feed(wire)
+
+ Разбор CAN-кадров v1 и v2 Раздел генерируется из doc/CAN_FRAME_PARSE_V1_V2.md. В нём собраны wire-разметка, примеры и готовые Python-парсеры.
+
+
+Разбор 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
+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
+
+Формула:
+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 байт:
+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 содержит:
+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:
+CAN ID: 17D90001
+DLC: 0
+
+1.2. Python-парсер v1
+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
+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
+
+Формула:
+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, следующие 1–7 байт SETP
+
+
+0x30..0x32
+flow control
+3S, block_size, st_min_ms
+
+
+
+N — циклический номер сегмента 1..15,0..; следующий сегмент обязан иметь
+ожидаемый номер, тот же CAN ID и прийти до тайм-аута сборки 500 мс.
+2.3. Внутренний кадр SETProtocol v2
+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:
+Полный 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
+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.
+
+
+
+
+
+
Полный справочник Этот раздел генерируется из канонического c/set-protocol/docs/SETPROTOCOL.md. Редактировать нужно Markdown, затем запускать doc/build-setprotocol-html.ps1.
@@ -159,7 +631,7 @@ COM │ SLCAN │ SocketCAN │ USB CDC │ TCP │ STM32 UART/CAN
последовательного моста, а 500 kbit/s в CAN-настройках — bitrate самой CAN-шины.
Ядро не подменяет одно другим и не выбирает эти значения автоматически.
2. Состав исходников
-
+
Модуль
@@ -234,7 +706,7 @@ COM │ SLCAN │ SocketCAN │ USB CDC │ TCP │ STM32 UART/CAN
ABI компилятора C
-
+
Общая точка включения для C-кода — include/setprotocol.h.
Иностранные runtimes должны использовать include/setprotocol_abi.h, а не
повторять внутреннюю раскладку pcan_parser_t или gui_parser_t.
@@ -253,7 +725,7 @@ ABI v1 пока экспортирует функции pcan_abi_*:
кадра — 19 байт. Формат переносит один classic CAN 2.0 кадр через COM, USB CDC,
RS-232, RS-485 или TCP byte stream.
Флаги:
-
+
Бит
@@ -283,7 +755,7 @@ RS-232, RS-485 или TCP byte stream.
служебный кадр диагностики моста
-
+
3.2. GUI transport: A5 5A
A5 5A | VER | TYPE | SEQ[2] BE | SIZE[2] BE | PAYLOAD[0..512] | CRC32 LE
@@ -312,7 +784,7 @@ MSVC, GCC и Clang.
pcan_abi.h экспортирует простые числа, указатели и явно ограниченные буферы.
Текущая версия возвращается pcan_abi_version() и равна 1.
CAN bridge API
-
+
Функция
@@ -353,9 +825,9 @@ MSVC, GCC и Clang.
Получить frames/CRC/bad length/stray bytes
-
+
GUI API
-
+
Функция
@@ -380,7 +852,7 @@ MSVC, GCC и Clang.
Получить frames/CRC/version/length/stray bytes
-
+
Возврат 0 из encode означает неверные аргументы или недостаточный output
buffer. Parser API возвращает отрицательное значение при неверном context,
0 пока кадр не собран и 1 при готовом кадре.
@@ -404,7 +876,7 @@ pcan_abi_parser_init(storage, size);
буфер рассчитан на одного писателя и одного читателя (SPSC). Для нескольких
писателей синхронизацию обеспечивает порт/приложение.
7. Порты и адаптеры
-
+
Среда
@@ -449,7 +921,7 @@ pcan_abi_parser_init(storage, size);
ABI подходит, Swift wrapper пока не добавлен
-
+
Linux
Само ядро не содержит WinAPI, поэтому собирается GCC или Clang. Для SETGUI под
Linux остаются две отдельные задачи: упаковать libsetprotocol.so с приложением и
@@ -514,7 +986,7 @@ size_t written = pcan_abi_frame_encode(
Для firmware удобнее полный C API из protocan_transport.h: он даёт link,
callbacks, ring и GAS без FFI-обёртки.
10. Диагностика
-
+
Симптом
@@ -551,7 +1023,7 @@ callbacks, ring и GAS без FFI-обёртки.
COM baud не равен CAN bitrate; проверьте настройку самого адаптера
-
+
11. Совместимость и ограничения
ABI v1 изменяется только совместимым добавлением функций. Ломающее изменение