From 20f63bc389f20180251d2aab17c36e2c1155fca8 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 12:22:17 +0300 Subject: [PATCH 01/37] =?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=D0=B8=D0=B5=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=84=D0=B8=D0=BA=D0=B8,=20=D0=B4=D0=B5=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=20KONOR=20=D0=B8=20=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=20STM32=20bxCAN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- c/set-protocol/CMakeLists.txt | 19 + c/set-protocol/PORTING.md | 7 + c/set-protocol/README.md | 13 +- c/set-protocol/docs/GUI_PLOT.md | 80 + c/set-protocol/docs/GUI_SPECTRUM.md | 53 + c/set-protocol/docs/GUI_TRENDS.md | 78 + c/set-protocol/docs/legacy/GAS.md | 2 +- c/set-protocol/include/pcan_gas.h | 2 +- c/set-protocol/include/pcan_id.h | 2 +- c/set-protocol/include/set_plot.h | 35 + c/set-protocol/include/set_spectrum.h | 34 + c/set-protocol/include/set_trends.h | 54 + c/set-protocol/ports/android/Android.mk | 6 +- c/set-protocol/ports/android/README.md | 7 +- .../ru/setcorp/setflash/core/GuiProtocol.kt | 6 + .../setcorp/setprotocol/NativeSetProtocol.kt | 9 + .../setcorp/setprotocol/trends/GuiGasWatch.kt | 46 + .../setcorp/setprotocol/trends/NativePlot.kt | 32 + .../setprotocol/trends/PlotViewport.kt | 10 + .../setprotocol/trends/TrendDecoder.kt | 39 + .../setprotocol/trends/TrendMarkers.kt | 54 + .../setcorp/setprotocol/trends/TrendModels.kt | 156 ++ .../setprotocol/trends/TrendSettingsJson.kt | 100 + .../setprotocol/trends/TrendSpectrum.kt | 67 + c/set-protocol/ports/android/set_plot_jni.c | 22 + .../ports/android/setprotocol_jni.c | 89 + .../setprotocol/trends/GuiGasWatchTest.kt | 25 + .../setprotocol/trends/PlotContractTest.kt | 37 + .../setprotocol/trends/PlotViewportTest.kt | 39 + .../trends/TrendSettingsJsonTest.kt | 87 + c/set-protocol/ports/stm32-bxcan/README.md | 602 ++++++ c/set-protocol/ports/stm32-bxcan/protocan.c | 1667 +++++++++++++++++ c/set-protocol/ports/stm32-bxcan/protocan.h | 427 +++++ c/set-protocol/src/set_plot.c | 71 + c/set-protocol/src/set_spectrum.c | 126 ++ c/set-protocol/src/set_trends.c | 74 + c/set-protocol/tests/fixtures/plot-v1.json | 27 + c/set-protocol/tests/fixtures/trends-v1.json | 19 + c/set-protocol/tests/test_plot.c | 31 + c/set-protocol/tests/test_spectrum.c | 22 + c/set-protocol/tests/test_trends.c | 43 + c/set-protocol/tools/build_host.py | 18 +- doc/setcan/README.md | 10 +- python/protocan/plot.py | 159 ++ python/protocan/protocan.py | 26 + python/protocan/spectrum.py | 71 + python/protocan/trends.py | 192 ++ python/tests/test_plot.py | 50 + python/tests/test_spectrum.py | 117 ++ python/tests/test_trends.py | 87 + 51 files changed, 5040 insertions(+), 15 deletions(-) create mode 100644 c/set-protocol/docs/GUI_PLOT.md create mode 100644 c/set-protocol/docs/GUI_SPECTRUM.md create mode 100644 c/set-protocol/docs/GUI_TRENDS.md create mode 100644 c/set-protocol/include/set_plot.h create mode 100644 c/set-protocol/include/set_spectrum.h create mode 100644 c/set-protocol/include/set_trends.h create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendDecoder.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendModels.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSettingsJson.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt create mode 100644 c/set-protocol/ports/android/set_plot_jni.c create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/GuiGasWatchTest.kt create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/TrendSettingsJsonTest.kt create mode 100644 c/set-protocol/ports/stm32-bxcan/README.md create mode 100644 c/set-protocol/ports/stm32-bxcan/protocan.c create mode 100644 c/set-protocol/ports/stm32-bxcan/protocan.h create mode 100644 c/set-protocol/src/set_plot.c create mode 100644 c/set-protocol/src/set_spectrum.c create mode 100644 c/set-protocol/src/set_trends.c create mode 100644 c/set-protocol/tests/fixtures/plot-v1.json create mode 100644 c/set-protocol/tests/fixtures/trends-v1.json create mode 100644 c/set-protocol/tests/test_plot.c create mode 100644 c/set-protocol/tests/test_spectrum.c create mode 100644 c/set-protocol/tests/test_trends.c create mode 100644 python/protocan/plot.py create mode 100644 python/protocan/spectrum.py create mode 100644 python/protocan/trends.py create mode 100644 python/tests/test_plot.py create mode 100644 python/tests/test_spectrum.py create mode 100644 python/tests/test_trends.py diff --git a/README.md b/README.md index 9e98a9d..9f548cc 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ templates/ | [`c/can-sensor`](c/can-sensor) | однокадровые SETCAN SETTINGS для 64-битных ROM | ядро: `stdint.h`; порт F1: CMSIS | callbacks либо готовый bxCAN STM32F1 | | [`c/ds18b20`](c/ds18b20) | термометры DS18B20 поверх программной 1-Wire | `stdint.h` | Init, DelayUs, Reset, WriteBit, ReadBit — **порты STM32F103, STM32G431 и STM32G474 в комплекте** | | [`c/set-protocol`](c/set-protocol) | единое ядро SETProtocol: SET v2, совместимые ProtoCAN/GUI v1, GAS, телеметрия, firmware flow и стабильный host ABI | C99 | COM/SLCAN/SocketCAN/USB/Ethernet или callbacks — **Windows, Android и STM32F4-порты в комплекте** | +| [`c/set-protocol/ports/stm32-bxcan`](c/set-protocol/ports/stm32-bxcan) | порт прикладного ProtoCAN для STM32, бывший SETCAN; сохранён API `PROTOCAN_*` | STM32 HAL CAN/RTC/TIM + общее ядро `pcan_id` | classic bxCAN; настройки платы предоставляет прошивка | | [`c/protocan-boot`](c/protocan-boot) | адресная прошивка по ProtoCAN: A/B-слоты, сессия, CRC32, verify и rollback-контракт | C99 | CAN TX, erase/write Flash, boot metadata, проверка образа и reboot | | [`c/rs485-boot`](c/rs485-boot) | прошивка по RS-485 в формате SETGUI v1: потоковый parser, CRC32 и resume | C99 | UART TX/RX, DE, Flash — **порты STM32F103 и STM32G474VET в комплекте** | | [`c/rtc-service`](c/rtc-service) | RTC с резервированным backup-томом | `stdint.h` | доступ к RTC и backup-памяти — **порт K1921VK028 в комплекте** | @@ -44,6 +45,10 @@ 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 | + +Общие тренды для Android GUI и SETGUI: [формат JSON, C99-ядро и адаптеры](c/set-protocol/docs/GUI_TRENDS.md). +Общие масштабирование и маркеры: [C99, JNI и Python/Qt](c/set-protocol/docs/GUI_PLOT.md). Кодировщики `c/set-protocol` и `python/protocan` дают побайтово одинаковый результат — это зафиксировано эталонами в тестах на C. @@ -94,7 +99,6 @@ git subtree pull --prefix lib/templates https://git.rd12.ru/Andrey/templates.git | Код | Почему не переносится | Что можно вытащить | |---|---|---| -| `SETCAN/Src/protocan.c` | 59 вызовов `HAL_*`, привязка к `CAN_HandleTypeDef`, `RTC_HandleTypeDef`, `NVIC_SystemReset()` | разбиение длинных посылок на кадры и проверка даты — чистая логика, нужны два интерфейса: «отправить кадр» и «часы». Раскладка идентификатора уже вынесена в `pcan_id` | | `1921vk028/drivers/user/src/one_wire_user.c` | аппаратный 1-Wire `OWI_*` из `plib028`, мигание светодиодом прямо внутри опроса датчика | логика DS18B20 уже есть в `c/ds18b20` — переносится порт, а не алгоритм | | `1921vk028/drivers/GPIO`, `uart`, `optical`, `Interrupt` | целиком на регистрах `plib028` | переносить стоит не код, а приём: таблица описаний портов вместо `#define`, разбросанных по файлу | | `Drivers/` внутри прошивок | вендорный HAL и CMSIS | ничего, приходит со своим SDK | diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt index 338f9f2..27f19c6 100644 --- a/c/set-protocol/CMakeLists.txt +++ b/c/set-protocol/CMakeLists.txt @@ -10,6 +10,9 @@ set(SETPROTOCOL_V2_SOURCES src/set_can.c src/set_firmware.c src/set_telemetry.c + src/set_plot.c + src/set_trends.c + src/set_spectrum.c ) # Совместимые ProtoCAN/SETGUI v1 форматы переходного периода. @@ -32,6 +35,9 @@ set(SETPROTOCOL_SOURCES add_library(setprotocol_static STATIC ${SETPROTOCOL_SOURCES}) target_include_directories(setprotocol_static PUBLIC include) +if(NOT MSVC) + target_link_libraries(setprotocol_static PUBLIC m) +endif() # Совместимое имя цели для проектов, уже использовавших SET protocol v2. add_library(set_protocol ALIAS setprotocol_static) @@ -40,6 +46,9 @@ option(SETP_BUILD_SHARED "Build stable host ABI shared library" ON) if(SETP_BUILD_SHARED) add_library(setprotocol SHARED ${SETPROTOCOL_SOURCES}) target_include_directories(setprotocol PUBLIC include) + if(NOT MSVC) + target_link_libraries(setprotocol PRIVATE m) + endif() target_compile_definitions(setprotocol PRIVATE PCAN_ABI_BUILD_DLL) set_target_properties(setprotocol PROPERTIES C_VISIBILITY_PRESET hidden @@ -66,6 +75,10 @@ option(SETP_BUILD_TESTS "Build host tests" ON) if(SETP_BUILD_TESTS) enable_testing() + add_executable(test_plot tests/test_plot.c) + target_link_libraries(test_plot PRIVATE setprotocol_static) + add_test(NAME shared_plot COMMAND test_plot) + add_executable(test_set_protocol tests/test_set_protocol.c) target_link_libraries(test_set_protocol PRIVATE setprotocol_static) add_test(NAME set_protocol_v2 COMMAND test_set_protocol) @@ -81,4 +94,10 @@ if(SETP_BUILD_TESTS) add_executable(test_abi tests/test_abi.c) target_link_libraries(test_abi PRIVATE setprotocol_static) add_test(NAME stable_abi COMMAND test_abi) + add_executable(test_trends tests/test_trends.c) + target_link_libraries(test_trends PRIVATE setprotocol_static) + add_test(NAME shared_trends COMMAND test_trends) + add_executable(test_spectrum tests/test_spectrum.c) + target_link_libraries(test_spectrum PRIVATE setprotocol_static) + add_test(NAME shared_spectrum COMMAND test_spectrum) endif() diff --git a/c/set-protocol/PORTING.md b/c/set-protocol/PORTING.md index 14d6d4c..6373c96 100644 --- a/c/set-protocol/PORTING.md +++ b/c/set-protocol/PORTING.md @@ -36,6 +36,13 @@ multicast запрещены WRITE, FW_BEGIN и FW_ACTIVATE. ## CAN +Для существующего прикладного ProtoCAN доступен +[порт STM32 bxCAN](ports/stm32-bxcan/README.md), перенесённый из SETCAN. +Он сохраняет API `PROTOCAN_*`, использует HAL CAN/RTC/TIM и общий `pcan_id`. +Порт рассчитан на classic bxCAN; он не реализует сегментацию SETProtocol v2. +В проект устройства добавляются `ports/stm32-bxcan/protocan.c` и `src/pcan_id.c`, +а в include paths — `ports/stm32-bxcan` и `include`. + Не вызывайте прикладной router из ISR. ISR принимает CAN-кадры в очередь, сегментация собирается в главном цикле, и только полный SETP-кадр передаётся parser/router. Сборка должна иметь timeout, контроль номера сегмента и одного diff --git a/c/set-protocol/README.md b/c/set-protocol/README.md index 84ea25a..6bd2b4d 100644 --- a/c/set-protocol/README.md +++ b/c/set-protocol/README.md @@ -7,12 +7,20 @@ - совместимый ProtoCAN transport для существующих CAN-мостов; - SETGUI transport v1 для приборов переходного периода; - стабильный host ABI для Python/JNI/других FFI; -- GAS, кольцевые буферы, CRC и потоковые parser. +- GAS, кольцевые буферы, CRC и потоковые parser; +- host-анализ спектра: FFT, окна, фильтры и адаптеры JNI/ctypes. -Ядро не зависит от HAL, ОС или конкретного адаптера, не использует +Протокольное ядро не зависит от HAL, ОС или конкретного адаптера, не использует динамическую память и не открывает COM/CAN само. SLCAN, SocketCAN, USB CDC, TCP, UART/DMA и аппаратный CAN подключаются портами. +Отдельный host-модуль `set_spectrum.c` использует ограниченный временный буфер +до 2×16384 `double`; он не нужен для wire-протокола/MCU. +Контракт и использование из обоих GUI: [GUI_SPECTRUM.md](docs/GUI_SPECTRUM.md). + +Масштабирование, координаты и измерительные маркеры Android/SETGUI используют +общий `set_plot.c`: [границы модулей, ABI и проверки](docs/GUI_PLOT.md). + ## Структура | Каталог | Назначение | @@ -24,6 +32,7 @@ TCP, UART/DMA и аппаратный CAN подключаются портам | `src/` | общая реализация всех протокольных модулей | | `ports/android` | JNI и Kotlin facade | | `ports/stm32f4` | UART/DMA port для legacy byte stream | +| [`ports/stm32-bxcan`](ports/stm32-bxcan/README.md) | STM32 HAL bxCAN/RTC/TIM, прикладной ProtoCAN; бывший отдельный SETCAN | | `docs/SETPROTOCOL.md` | архитектура, ABI, память и переносимость | | `docs/legacy` | нормативные документы ProtoCAN/SETGUI v1 | | `PROTOCOL.md` | нормативный wire contract SET protocol v2 | diff --git a/c/set-protocol/docs/GUI_PLOT.md b/c/set-protocol/docs/GUI_PLOT.md new file mode 100644 index 0000000..d57233b --- /dev/null +++ b/c/set-protocol/docs/GUI_PLOT.md @@ -0,0 +1,80 @@ +# Общая работа с графиками Android и SETGUI + +Численная логика находится в `include/set_plot.h` и `src/set_plot.c`. +Это модуль C99 без Qt, Android, транспорта, динамической памяти и глобального +состояния. Он собирается в существующую библиотеку SETProtocol; отдельная DLL +для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 1`. + +| Общее в templates | Адаптер приложения | +|---|---| +| Нормализованная область просмотра, масштаб 1…128, ограничение панорамирования | Размер поля и отступы в пикселях | +| Независимый масштаб X/Y с сохранением координаты под пальцами | События Compose или Qt, колесо, кнопки | +| Выбор преобладающей оси по изменению расстояния между пальцами | Накопление движения до порога; фиксация выбранной оси до конца жеста | +| Перевод значения в долю экрана и обратно, инверсия Y | Canvas/QPainter и оформление шкал | +| Перемещение маркера от начальной координаты, ограничение видимой областью | Захват линии пальцем/мышью, редактор положения | +| Разность B−A, D−C и множитель единиц, шаги шкалы 1/2/5 | Подписи, цвета, миллисекунды/герцы/единицы сигнала | +| Модели маркеров и их размещение в Kotlin/Python-портах | Жизненный цикл экрана, очистка и выбор источника | + +Модель маркеров: A/B — координаты X и вертикальные линии во всю высоту поля; +C/D — координаты Y и горизонтальные линии. Они могут пересекаться: отрицательная +разность сохраняется. Начальные координаты — 1/3 и 2/3 диапазона. При изменении +масштаба уже установленные координаты сохраняются. «Маркеры в видимую область» +заново размещает их. Координаты и область просмотра времени/спектра независимы. + +`set_plot_eval()` принимает операцию из `enum set_plot_operation`, точное число +входных `double` и буфер вывода. Контракт каждой операции указан в заголовке. +Возвращает число записанных элементов, 0 — неправильные аргументы. Некорректный +коэффициент масштабирования оставляет корректный viewport неизменным. +NaN/Inf и вырожденные диапазоны не передаются дальше в отрисовку. +FRACTION/VALUE не обрезают координаты: это позволяет правильно отсекать линии +по границе поля, а не создавать ложные горизонтальные участки на его краю. +DRAG, напротив, ограничивает положение маркера видимым диапазоном. + +## Порты + +- Android/JVM: `ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt`, + `PlotViewport.kt`, `TrendMarkers.kt`; тонкий JNI — `ports/android/set_plot_jni.c`. + Android использует `System.loadLibrary("setprotocol")`. Для JVM-тестов можно + передать абсолютный путь DLL/SO системным свойством `setplot.library`. +- Python: `python/protocan/plot.py`: `PlotMath(library)`, `Viewport`, `Bounds`, + `Markers`. Приложение передаёт уже загруженную SETProtocol `ctypes.CDLL`. + Зависимости — стандартная библиотека; Qt в этот модуль не импортируется. + +Новые изменения не должны добавлять вторую реализацию численных операций +в Kotlin/Python или в приложение. Состояние и адаптация событий остаются в портах. +Добавление операции требует обновления C ABI, обоих портов и общих эталонов. + +## Интеграция приложений + +Android: `TrendGraph` использует общие viewport, bounds и маркеры, а Compose +рисует линии и панель. При измерениях время отображается в миллисекундах от +начала выбранного участка; сохранённые X-координаты включают абсолютное начало. + +SETGUI: `ui/plot_interaction.py` адаптирует общий модуль к Qt. Его используют +`SignalPlot` (ТМС и датчики), `TrendPlot` (логи) и `SpectrumPlot`. +Колесо по вертикали масштабирует Y, Shift+колесо и горизонтальное колесо — X; +сенсорный pinch выбирает ось по движению. Временные графики хранят метку приёма +каждого отсчёта. Пропущенные значения не растягивают остальные серии: +все сигналы имеют единую шкалу времени. При повторном построении ТМС из журнала +используются исходные метки приёма. Дискретные дорожки имеют общую X-шкалу, +а маркеры уровня Y относятся к аналоговому полю. + +Расчёт FFT не является частью этого модуля. Android вычисляет спектр через +`set_spectrum.c`; вкладка спектра SETGUI получает готовые уровни в дБмВ от +прибора. Общая интерактивная часть не меняет эти данные или единицы. + +## Проверка и сборка + +- CMake/CTest: `shared_plot` проверяет ABI, размеры буферов и координаты. +- `tests/fixtures/plot-v1.json` содержит общие численные эталоны. Их читают + `PlotContractTest` (фактический JNI) и `python/tests/test_plot.py` (ctypes). +- Host DLL/SO для Python: `python tools/build_host.py --output <путь>`. +- Для JVM: та же команда с `--java-home ` добавляет JNI-порт графиков. + Нужен C-компилятор (MSVC на Windows, cc/clang/gcc на Unix) и JDK с `jni.h`. +- Android-проект автоматически собирает host-библиотеку перед JVM-тестами; + интерпретатор Python можно задать свойством Gradle `setflash.python`. +- SETGUI проверяет события мыши, колеса и двух пальцев через QtTest без + физического прибора; численные тесты не требуют GUI. + +Исходники должны приходить из одного коммита подмодуля `templates` в обоих +проектах. Локальная замена файлов без обновления gitlink не закрепляет версию. diff --git a/c/set-protocol/docs/GUI_SPECTRUM.md b/c/set-protocol/docs/GUI_SPECTRUM.md new file mode 100644 index 0000000..f085763 --- /dev/null +++ b/c/set-protocol/docs/GUI_SPECTRUM.md @@ -0,0 +1,53 @@ +# Shared GUI spectrum + +`include/set_spectrum.h` / `src/set_spectrum.c` implement the same numerical +pipeline for desktop and Android. CMake, `tools/build_host.py`, and the Android +NDK build include it. The wire protocol is unchanged. The host-only analyzer +allocates at most two FFT arrays (2×N doubles), releasing them before return. + +The last N samples are used: the largest power of two within available data and +`max_size`, 16…16384. Timestamps are seconds; inputs are immutable. Fs is measured +from the selected endpoints. Non-monotonic/duplicate times and interval deviations +over 50% of the mean are rejected. Lesser jitter is linearly interpolated onto an +even grid (this can affect high-frequency amplitude; inspect returned jitter). + +Processing order: interpolation, optional mean subtraction, filter, periodic +window, forward FFT, one-sided peak amplitude. Windows: rectangular, Hann, +Hamming, Blackman, Flat Top. Amplitudes are divided by window sum, doubled except +DC and Nyquist. Output is not RMS/PSD/dB. Coherent gain correction is exact for +bin-centered tones away from DC/Nyquist; off-bin tones still have scalloping. + +Filters run before the window, on the unmodified sample copy. LP/HP are second +order Butterworth; band pass cascades HP and LP; notch uses Q=30. +Coefficients: [W3C Audio EQ Cookbook](https://www.w3.org/TR/audio-eq-cookbook/). +Initial state is the first sample's steady state, not a carried streaming state; +block-start transients may occur. Cutoffs must be strictly within (0, Fs/2). + +## Python + +```python +from protocan.spectrum import NativeSpectrum, Window, Filter + +# lib = an existing ctypes.CDLL from protocan.native.NativeProtocol +spectrum = NativeSpectrum(lib).analyze(times_seconds, values, + window=Window.HANN, filter=Filter.LOW_PASS, high_hz=100, + max_size=4096, remove_mean=True) +plot(spectrum.frequencies, spectrum.amplitudes) +``` + +Adapter errors are ValueError, not empty/misleading arrays. No NumPy/Qt dependency. + +## Android + +`SpectrumAnalyzer.analyze(List, SpectrumOptions)` calls JNI using +relative seconds from millisecond timestamps. `TrendSpectrum` contains N, Fs, +maximum relative jitter, amplitudes or a displayable error. Call on a worker +dispatcher. `PlotViewport` provides axis-independent zoom/pan, focus anchoring, +128× limit and bounds clamping without any Compose dependency. + +## Checks + +Build CMake with tests and run CTest in Debug (assertions enabled). Python +`tests/test_spectrum.py` runs against an explicitly supplied `SETPROTOCOL_LIBRARY`: +independent direct DFT, amplitude correction, DC/Nyquist, off-bin leakage, filter +bands, timing rejection/interpolation, bounded tail selection and immutable inputs. diff --git a/c/set-protocol/docs/GUI_TRENDS.md b/c/set-protocol/docs/GUI_TRENDS.md new file mode 100644 index 0000000..6a3819b --- /dev/null +++ b/c/set-protocol/docs/GUI_TRENDS.md @@ -0,0 +1,78 @@ +# Общие тренды для Android GUI и SETGUI + +Модуль задаёт совместимые настройки и числовые источники графиков. Он не +рисует экран, не открывает файлы или порты и не управляет прошивкой устройства. + +Слои: GUI → Kotlin/Python adapter → `set_trends.c` → уже разобранные кадры. +Транспорт, таймер опроса, выбор файла, хранение настроек и Canvas/Qt остаются +в приложении. У ядра нет callbacks, динамической памяти или глобального состояния. + +| Файл | Назначение и зависимости | +|---|---| +| `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 | +| `tests/fixtures/trends-v1.json` | Один образец для тестов обоих GUI и обмена настройками | + +## Формат файла + +UTF-8 JSON: `format = "setflash-trends"`, `version = 1`, `profiles` — словарь. +Ключи профилей стабильны: `TMS2812`, `SET_V1`, `CAN_BRIDGE`, `GS_USB_CAN`, +`SLCAN`, `CANGAROO_SLCAN`, `BALZAM_CAN`. Приложение явно сопоставляет своё +подключение с этим ключом; импорт не меняет подключение. + +Поля сигнала обязательны: `id`, `order`, `name`, `source`, `address`, `color`, +`visible`, `valueType`, `deviceType`, `device`, `byteOffset`, `extended`. +`id` уникален во всём файле. `order` — 1…9999, уникален внутри профиля. +`name` — непустое изменяемое имя (до 100 символов), `color` — `#RRGGBB`. +`visible` управляет только отрисовкой, не сбором. История и соединение в файл +не входят. Максимум 64 сигнала на профиль и 1 МиБ на файл. Неизвестные версии, +профили, источники и неверные значения отклоняются до замены настроек. + +| Профиль / источник | Адрес и значение | +|---|---| +| TMS2812 / TMS_MEMORY | 32-битный адрес слова, CMD_PEEK; контроллер из подключения | +| SET_V1 / SET_GAS | 16-битный GAS; GAS_WATCH_SET `0x12`, DATA `0x13` | +| SET_V1 / SET_SENSOR | ROM DS18B20, 16 HEX-цифр (дефисы допустимы), °C | +| Современные CAN / CAN_GAS | GAS 0…65535, DeviceType 0…7, DeviceID 0…15 | +| Все CAN / CAN_RAW | CAN ID, STD/EXT, byteOffset 0…6; слово Little-endian | + +Числовые адреса — десятичные либо HEX с `0x`. Регистры трактуются как +`UINT16` или `INT16`. Для RAW/CAN_GAS C-функция возвращает значение либо +`SET_TREND_NO_VALUE`, никогда не подставляет ноль вместо отсутствующего кадра. + +CAN — пассивный приём, без записи GAS и автоматической отправки запросов. +GAS принимает только входящие FROM_DEVICE; TX/RTR/ошибки исключены. +SET GUI оформляет подписку в порядке адресов. ACK должен подтвердить весь +список: при частичном принятии нельзя определить пропущенные адреса, поэтому +строить график по смещённым индексам запрещено. При паузе порт приложения +отправляет подписку с периодом 0, когда транспорт доступен и не занят прошивкой. + +## Быстрый старт SETGUI + +```python +from protocan.native import NativeProtocol +from protocan.trends import TrendSignal, NativeTrends, encode_settings + +core = NativeProtocol() # библиотека, пересобранная с set_trends.c +decoder = NativeTrends(core.lib) +signal = TrendSignal("current", source="CAN_GAS", address="0x1235") +text = encode_settings({"CAN_BRIDGE": [signal]}) +value = decoder.can_value(signal, 0x1FD31234, 1, bytes([1, 0, 254, 255])) +assert value == 65534.0 +# GUI сохраняет text выбранным способом и передаёт value своему графику. +``` + +Android подключает Kotlin-каталог напрямую через Gradle и C-файлы через NDK. +Приложение SETGUI может импортировать модуль через уже используемый путь +`third_party/templates/python`; в этом изменении его экран не изменяется. +После публикации новой ревизии templates оба проекта должны обновить сабмодуль +и пересобрать собственные native-библиотеки — вручную копировать реализации не нужно. + +## Проверки + +- CMake/CTest: `shared_trends`, плюс существующие protocol/ABI тесты. +- JVM: `TrendSettingsJsonTest`, `GuiGasWatchTest`; один JSON fixture с Python. +- Python: `PYTHONPATH=python python -m unittest discover -s python/tests`. + Для проверки настоящего C-декодера задайте `SETPROTOCOL_LIBRARY` путём DLL/SO. diff --git a/c/set-protocol/docs/legacy/GAS.md b/c/set-protocol/docs/legacy/GAS.md index b0f11bd..51207fc 100644 --- a/c/set-protocol/docs/legacy/GAS.md +++ b/c/set-protocol/docs/legacy/GAS.md @@ -48,7 +48,7 @@ uint16_t n = pcan_gas_read_block(&map, 0x0000, block, 4); Тип сообщения `PCAN_MSG_GAS` (`0b0011`). Адрес первого регистра лежит в `MsgBody`, данные — до 4 регистров подряд, младшим байтом вперёд. Это ровно то, что делает `PROTOCAN_SEND_GENERAL_ADDRESS_SPACE()` -в `SETCAN/Src/protocan.c`, поэтому обмен совместим с существующими +в `ports/stm32-bxcan/protocan.c` (бывший SETCAN), поэтому обмен совместим с существующими устройствами. | Кадр | DLC | Смысл | diff --git a/c/set-protocol/include/pcan_gas.h b/c/set-protocol/include/pcan_gas.h index aa59398..4f97dd8 100644 --- a/c/set-protocol/include/pcan_gas.h +++ b/c/set-protocol/include/pcan_gas.h @@ -10,7 +10,7 @@ * Транспорт ProtoCAN (MsgType = 0b0011) кладёт в MsgBody адрес первого * регистра, а в данные - до 4 регистров подряд, младшим байтом вперёд. * Это ровно то, что делает PROTOCAN_SEND_GENERAL_ADDRESS_SPACE() - * в SETCAN/Src/protocan.c, поэтому обмен совместим с существующими + * в ports/stm32-bxcan/protocan.c, поэтому обмен совместим с существующими * устройствами. * * Запрос на чтение кодируется кадром GAS с DLC = 0: в исходном коде diff --git a/c/set-protocol/include/pcan_id.h b/c/set-protocol/include/pcan_id.h index c52749f..29f5b10 100644 --- a/c/set-protocol/include/pcan_id.h +++ b/c/set-protocol/include/pcan_id.h @@ -2,7 +2,7 @@ * @file pcan_id.h * @brief Упаковка и разбор 29-битного идентификатора ProtoCAN. * - * Раскладка (соответствует ProtoCanId_t из SETCAN/Inc/protocan.h): + * Раскладка (ProtoCanId_t из ports/stm32-bxcan/protocan.h, бывший SETCAN): * * биты 28 Priority * 27 Route diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h new file mode 100644 index 0000000..c249bb3 --- /dev/null +++ b/c/set-protocol/include/set_plot.h @@ -0,0 +1,35 @@ +/** @file set_plot.h + * @brief Toolkit-independent plot interaction math shared by JNI and ctypes. + * Coordinates are doubles in the caller's units. No allocation or global state. + */ +#ifndef SET_PLOT_H +#define SET_PLOT_H +#include "pcan_abi.h" +#ifdef __cplusplus +extern "C" { +#endif + +enum set_plot_operation { + SET_PLOT_TRANSFORM = 0, /* x,y,w,h, zoomX,zoomY, panX,panY, focusX,focusY -> x,y,w,h */ + SET_PLOT_AXIS = 1, /* deltaX,deltaY,slop -> 0=none, 1=X, 2=Y */ + SET_PLOT_FRACTION = 2, /* value,low,high,inverted -> fraction (not clamped) */ + SET_PLOT_VALUE = 3, /* fraction,low,high,inverted -> value (not clamped) */ + SET_PLOT_DRAG = 4, /* initial,deltaPixels,length,low,high,inverted -> clamped value */ + SET_PLOT_TICK_STEP = 5, /* range,lengthPixels -> nice step */ + SET_PLOT_DELTA = 6 /* A,B,multiplier -> (B-A)*multiplier */ +}; + +/** Version of this 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 + * unchanged viewport. Zoom is restricted to 1..128, with focus anchoring. + * Screen fractions increase downwards; set inverted=1 for the Y value axis. + */ +PCAN_ABI_API size_t set_plot_eval(uint32_t operation, const double *input, + size_t count, double *output, size_t capacity); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/c/set-protocol/include/set_spectrum.h b/c/set-protocol/include/set_spectrum.h new file mode 100644 index 0000000..554c1a5 --- /dev/null +++ b/c/set-protocol/include/set_spectrum.h @@ -0,0 +1,34 @@ +/** Shared host-side FFT for Android/JNI and desktop/ctypes; no GUI dependencies. */ +#ifndef SET_SPECTRUM_H +#define SET_SPECTRUM_H +#include "pcan_abi.h" +#ifdef __cplusplus +extern "C" { +#endif + +#define SET_SPECTRUM_MAX 16384U +enum { SET_WINDOW_RECT, SET_WINDOW_HANN, SET_WINDOW_HAMMING, SET_WINDOW_BLACKMAN, SET_WINDOW_FLATTOP }; +enum { SET_FILTER_NONE, SET_FILTER_LOW_PASS, SET_FILTER_HIGH_PASS, SET_FILTER_BAND_PASS, SET_FILTER_NOTCH }; +enum { SET_SPECTRUM_OK, SET_SPECTRUM_SHORT, SET_SPECTRUM_INVALID, SET_SPECTRUM_TIMING, + SET_SPECTRUM_CUTOFF, SET_SPECTRUM_MEMORY }; + +/** Analyze the last power-of-two block (16 <= N <= max_size). + * times are seconds, strictly increasing; values must be finite. Fs is measured + * from timestamps, never taken from the requested acquisition rate. Interval + * deviations >50% of the mean are rejected; smaller jitter is linearly resampled. + * Filter: second-order Butterworth LP/HP, their cascade for band pass, notch Q=30. + * low_hz: HP/band lower cutoff or notch center; high_hz: LP/band upper cutoff. + * Each block starts the causal filters at steady state for its first sample. + * Processing order: resample, optional mean removal, filter, periodic window, FFT. + * Output: one-sided PEAK amplitude, normalized by window sum, DC/Nyquist not doubled. + * Caller supplies max_size/2+1 amplitudes; meta = {N, Fs, max relative jitter}. + * Inputs are immutable. Scratch memory is bounded by 2*N doubles, allocated here. + */ +PCAN_ABI_API int set_spectrum_analyze(const double *times, const double *values, size_t count, + size_t max_size, int window, int filter, double low_hz, double high_hz, int remove_mean, + double *amplitudes, size_t capacity, double *meta); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/c/set-protocol/include/set_trends.h b/c/set-protocol/include/set_trends.h new file mode 100644 index 0000000..1693d4a --- /dev/null +++ b/c/set-protocol/include/set_trends.h @@ -0,0 +1,54 @@ +/** @file set_trends.h + * @brief Shared GUI trend decoding and GUI v1 GAS subscription payloads. + * No transport, rendering, allocation or global state. JNI/ctypes call the + * same exported functions; flags use the existing AA55 bridge ABI. + */ +#ifndef SET_TRENDS_H +#define SET_TRENDS_H +#include "pcan_abi.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define SET_TREND_CAN_GAS 1U +#define SET_TREND_CAN_RAW 2U +#define SET_TREND_NO_VALUE INT32_MIN +#define SET_TREND_WATCH_MAX 64U + +/** Convert one wire word, without implementation-defined signed casts. */ +PCAN_ABI_API int32_t set_trend_word_value(uint16_t word, uint8_t is_signed); + +/** Return a decoded value or SET_TREND_NO_VALUE for a nonmatching/invalid frame. + * GAS requires Route=FROM_DEVICE, exact DeviceType/DeviceID, and an even DLC. + * TX echoes, RTR and error frames are always ignored. RAW offset is in bytes. + */ +PCAN_ABI_API int32_t set_trend_can_value( + uint8_t source, uint32_t address, uint8_t device_type, uint8_t device, + uint8_t byte_offset, uint8_t extended, uint8_t is_signed, + uint32_t can_id, uint8_t flags, const uint8_t *data, size_t size); + +/** Encode period:u16 + count:u16 + addresses[count]:u16, little-endian. + * A zero period/count is an unsubscribe. Returns 0 on invalid arguments. + */ +PCAN_ABI_API size_t set_trend_watch_request( + uint16_t period_ms, const uint16_t *addresses, size_t count, + uint8_t *output, size_t capacity); + +/** Accept only an exact acknowledgement of the entire ordered subscription. + * The device may omit unknown addresses. Partial acceptance must NOT be used: + * its acknowledgement has no address list, so index-to-address mapping is lost. + */ +PCAN_ABI_API int set_trend_watch_ack( + const uint8_t *payload, size_t size, uint16_t period_ms, size_t count); + +/** Decode timestamp:u32 + count:u16 + words. Returns count, or -1 on error. + * Timestamp is device-local; GUI plot clocks should use their own monotonic time. + */ +PCAN_ABI_API int set_trend_watch_values( + const uint8_t *payload, size_t size, uint16_t *words, size_t capacity); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/c/set-protocol/ports/android/Android.mk b/c/set-protocol/ports/android/Android.mk index fb9c90c..b9c7b81 100644 --- a/c/set-protocol/ports/android/Android.mk +++ b/c/set-protocol/ports/android/Android.mk @@ -8,6 +8,10 @@ LOCAL_SRC_FILES := \ ../../src/set_can.c \ ../../src/set_firmware.c \ ../../src/set_telemetry.c \ + ../../src/set_plot.c \ + set_plot_jni.c \ + ../../src/set_trends.c \ + ../../src/set_spectrum.c \ ../../src/gui_catalog.c \ ../../src/gui_frame.c \ ../../src/pcan_abi.c \ @@ -19,5 +23,5 @@ LOCAL_SRC_FILES := \ ../../src/pcan_gas.c \ setprotocol_jni.c LOCAL_CFLAGS := -std=c99 -Wall -Wextra -Wpedantic -fvisibility=hidden -LOCAL_LDLIBS := -llog +LOCAL_LDLIBS := -llog -lm include $(BUILD_SHARED_LIBRARY) diff --git a/c/set-protocol/ports/android/README.md b/c/set-protocol/ports/android/README.md index ee80107..65424f8 100644 --- a/c/set-protocol/ports/android/README.md +++ b/c/set-protocol/ports/android/README.md @@ -7,4 +7,9 @@ and include this `Android.mk` from the application NDK build. The JNI streaming parser returns fixed 15-byte records `SEQ | FLAGS | CAN_ID_LE | DLC | DATA[8]`. Dynamic allocation is confined to -the Android adapter; the portable core remains allocation free. +the Android adapter for protocol operations; the portable protocol core remains allocation free. + +`nativeSpectrum` calls the shared host DSP module `set_spectrum.c` (bounded heap +workspace, up to 2×16384 doubles). `trends/SpectrumAnalyzer` maps timestamps and +errors but does not duplicate FFT/filter math. Run it off the UI thread. +`trends/PlotViewport` is a toolkit-free normalized zoom/pan model. diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/GuiProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/GuiProtocol.kt index 1cd41d8..43faf5b 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/GuiProtocol.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/GuiProtocol.kt @@ -10,6 +10,9 @@ object MessageType { const val FIRMWARE_END = 0x0D const val FIRMWARE_ABORT = 0x0E const val FIRMWARE_STATUS = 0x0F + const val GAS_CATALOG = 0x11 + const val GAS_WATCH_SET = 0x12 + const val GAS_WATCH_DATA = 0x13 const val SENSOR_SCAN = 0x20 const val SENSOR_LIST = 0x21 const val SENSOR_READ = 0x22 @@ -33,6 +36,9 @@ object MessageType { FIRMWARE_END -> "FIRMWARE_END" FIRMWARE_ABORT -> "FIRMWARE_ABORT" FIRMWARE_STATUS -> "FIRMWARE_STATUS" + GAS_CATALOG -> "GAS_CATALOG" + GAS_WATCH_SET -> "GAS_WATCH_SET" + GAS_WATCH_DATA -> "GAS_WATCH_DATA" SENSOR_SCAN -> "SENSOR_SCAN" SENSOR_LIST -> "SENSOR_LIST" SENSOR_READ -> "SENSOR_READ" 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 a4accc8..40707b1 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 @@ -10,6 +10,15 @@ object NativeSetProtocol { } external fun nativeAbiVersion(): Int + /** {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? + 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 nativeTrendWatchValues(input: ByteArray): IntArray? 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 new file mode 100644 index 0000000..5a814ad --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt @@ -0,0 +1,46 @@ +package ru.setcorp.setprotocol.trends + +import ru.setcorp.setprotocol.NativeSetProtocol + +/** GUI v1 GAS contract, also exposed by python/protocan/gas_catalog.py. */ +object GuiGasWatch { + const val SET = 0x12 + const val DATA = 0x13 + const val PERIOD_MS = 1000 + + 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) } + } + } + + fun validateAck(payload: ByteArray, periodMs: Int, count: Int) { + require(payload.size == 4 && read16(payload, 0) == periodMs && read16(payload, 2) == 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) } + } + 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() + } +} 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 new file mode 100644 index 0000000..984a732 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt @@ -0,0 +1,32 @@ +package ru.setcorp.setprotocol.trends + +/** Shared C99 plot math, also used by the Python/Qt port. No UI dependency. */ +internal object NativePlot { + init { + val hostLibrary = System.getProperty("setplot.library") + if (hostLibrary != null) System.load(hostLibrary) else System.loadLibrary("setprotocol") + } + private external fun evaluate(operation: Int, input: DoubleArray): DoubleArray? + fun call(operation: Int, vararg input: Double): DoubleArray = + requireNotNull(evaluate(operation, input)) { "Invalid plot operation $operation" } +} + +enum class PlotAxis { X, Y } + +fun plotPinchAxis(deltaX: Double, deltaY: Double, slop: Double): PlotAxis? = + when (NativePlot.call(1, deltaX, deltaY, slop)[0].toInt()) { + 1 -> PlotAxis.X; 2 -> PlotAxis.Y; else -> null + } + +data class PlotBounds(val left: Double, val right: Double, val bottom: Double, val top: Double) { + fun fraction(value: Double, horizontal: Boolean): Double = NativePlot.call(2, value, + if (horizontal) bottom else left, if (horizontal) top else right, if (horizontal) 1.0 else 0.0)[0] + fun value(fraction: Double, horizontal: Boolean): Double = NativePlot.call(3, fraction, + if (horizontal) bottom else left, if (horizontal) top else right, if (horizontal) 1.0 else 0.0)[0] + fun drag(initial: Double, delta: Double, length: Double, horizontal: Boolean): Double = NativePlot.call(4, + initial, delta, length, if (horizontal) bottom else left, if (horizontal) top else right, + if (horizontal) 1.0 else 0.0)[0] +} + +fun plotTickStep(range: Double, pixels: Double): Double = NativePlot.call(5, range, pixels)[0] +fun plotDelta(a: Double, b: Double, multiplier: Double = 1.0): Double = NativePlot.call(6, a, b, multiplier)[0] diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt new file mode 100644 index 0000000..3f53a25 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt @@ -0,0 +1,10 @@ +package ru.setcorp.setprotocol.trends + +/** Normalized top-left viewport; independent of pixels, units, toolkit and samples. */ +data class PlotViewport(val x: Double = 0.0, val y: Double = 0.0, val width: Double = 1.0, val height: Double = 1.0) { + fun transform(zoomX: Double = 1.0, zoomY: Double = 1.0, panX: Double = 0.0, panY: Double = 0.0, + focusX: Double = 0.5, focusY: Double = 0.5): PlotViewport { + val result = NativePlot.call(0, x, y, width, height, zoomX, zoomY, panX, panY, focusX, focusY) + return PlotViewport(result[0], result[1], result[2], result[3]) + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendDecoder.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendDecoder.kt new file mode 100644 index 0000000..6173d96 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendDecoder.kt @@ -0,0 +1,39 @@ +package ru.setcorp.setprotocol.trends + +import ru.setcorp.setprotocol.NativeSetProtocol + +/** Shared JNI decoder with a JVM-only reference fallback for tests/source builds. */ +object TrendDecoder { + fun canValue(signal: TrendSignal, canId: Long, flags: Int, data: ByteArray): Double? { + if (NativeSetProtocol.available && signal.source in setOf(TrendSource.CAN_GAS, TrendSource.CAN_RAW)) { + val address = parseTrendAddress(signal.address, if (signal.source == TrendSource.CAN_GAS) 0xFFFF else 0x1FFF_FFFF) ?: return null + val value = NativeSetProtocol.nativeTrendCanValue( + if (signal.source == TrendSource.CAN_GAS) 1 else 2, address, signal.deviceType, signal.device, + signal.byteOffset, signal.extended, signal.valueType == TrendValueType.INT16, + canId, flags, data, + ) + return value.takeIf { it != Int.MIN_VALUE }?.toDouble() + } + if (flags and 0x0E != 0 || data.size !in 2..8) return null + val extended = flags and 1 != 0 + if (canId !in 0..(if (extended) 0x1FFF_FFFFL else 0x7FFL)) return null + val offset = when (signal.source) { + TrendSource.CAN_GAS -> { + if (!extended || data.size < 2 || data.size % 2 != 0) return null + if ((canId ushr 16) and 15L != 3L || (canId ushr 27) and 1L != 1L || + ((canId ushr 24) and 7L).toInt() != signal.deviceType || + ((canId ushr 20) and 15L).toInt() != signal.device) return null + val address = parseTrendAddress(signal.address, 0xFFFF)?.toInt() ?: return null + (address - (canId and 0xFFFF).toInt()) * 2 + } + TrendSource.CAN_RAW -> { + if (extended != signal.extended || canId != parseTrendAddress(signal.address, 0x1FFF_FFFF)) return null + signal.byteOffset + } + else -> return null + } + if (offset < 0 || offset + 2 > data.size) return null + val word = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) + return signal.valueType.decode(word) + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt new file mode 100644 index 0000000..e8f702c --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt @@ -0,0 +1,54 @@ +package ru.setcorp.setprotocol.trends + +import kotlin.math.abs + +enum class TrendMarker(val title: String, val horizontal: Boolean) { + A("A", false), B("B", false), C("C", false), D("D", false), + E("E", true), F("F", true), G("G", true), H("H", true) +} + +data class TrendMarkers( + val xEnabled: Boolean = true, + val yEnabled: Boolean = false, + val selected: TrendMarker = TrendMarker.A, + val a: Double? = null, + val b: Double? = null, + val c: Double? = null, + val d: Double? = null, + val e: Double? = null, + val f: Double? = null, + val g: Double? = null, + val h: Double? = null, +) { + fun value(marker: TrendMarker): Double? = when (marker) { + TrendMarker.A -> a; TrendMarker.B -> b; TrendMarker.C -> c; TrendMarker.D -> d + TrendMarker.E -> e; TrendMarker.F -> f; TrendMarker.G -> g; TrendMarker.H -> h + } + fun move(marker: TrendMarker, value: Double): TrendMarkers = when (marker) { + TrendMarker.A -> copy(a = value); TrendMarker.B -> copy(b = value) + TrendMarker.C -> copy(c = value); TrendMarker.D -> copy(d = value) + TrendMarker.E -> copy(e = value); TrendMarker.F -> copy(f = value) + TrendMarker.G -> copy(g = value); TrendMarker.H -> copy(h = value) + } + fun positioned(bounds: PlotBounds): TrendMarkers = copy( + a = a ?: bounds.value(0.2, false), b = b ?: bounds.value(0.4, false), + c = c ?: bounds.value(0.6, false), d = d ?: bounds.value(0.8, false), + e = e ?: bounds.value(0.2, true), f = f ?: bounds.value(0.4, true), + g = g ?: bounds.value(0.6, true), h = h ?: bounds.value(0.8, true)) + fun reset(bounds: PlotBounds): TrendMarkers = copy( + a = null, b = null, c = null, d = null, e = null, f = null, g = null, h = null).positioned(bounds) + fun fraction(marker: TrendMarker, bounds: PlotBounds): Double = bounds.fraction(requireNotNull(value(marker)), marker.horizontal) + fun drag(marker: TrendMarker, delta: Double, length: Double, bounds: PlotBounds): TrendMarkers = + move(marker, bounds.drag(requireNotNull(value(marker)), delta, length, marker.horizontal)).copy(selected = marker) + fun hit(x: Double, y: Double, width: Double, height: Double, radius: Double, bounds: PlotBounds): TrendMarker? { + if (x !in 0.0..width || y !in 0.0..height) return null + return TrendMarker.entries.filter { enabled(it) && value(it) != null }.mapNotNull { marker -> + val fraction = fraction(marker, bounds) + val distance = abs(if (marker.horizontal) y - fraction * height else x - fraction * width) + if (fraction in 0.0..1.0 && distance <= radius) marker to distance else null + }.sortedWith(compareBy> { it.second } + .thenBy { if (it.first == selected) 0 else 1 }).firstOrNull()?.first + } + fun enabled(marker: TrendMarker): Boolean = if (marker.horizontal) yEnabled else xEnabled +} + 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 new file mode 100644 index 0000000..c254742 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendModels.kt @@ -0,0 +1,156 @@ +package ru.setcorp.setprotocol.trends + +import java.util.UUID + +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"), + GS_USB_CAN("CANgaroo / gs_usb"), SLCAN("SKLab SLCAN"), CANGAROO_SLCAN("CANgaroo / SLCAN"), + BALZAM_CAN("Старый CAN BALZAM"), +} + +enum class TrendSource(val title: String) { + TMS_MEMORY("Память TMS · CMD_PEEK"), + CAN_GAS("Регистр GAS · ProtoCAN"), + CAN_RAW("Слово из CAN-кадра"), + SET_SENSOR("Температура DS18B20"), + SET_GAS("Регистр GAS · SET GUI"), +} + +enum class TrendValueType(val title: String) { + UINT16("UInt16 · 0…65535"), INT16("Int16 · −32768…32767"); + + fun decode(word: Int): Double = when (this) { + UINT16 -> word.toDouble() + INT16 -> word.toShort().toDouble() + } +} + +fun TrendProfile.trendSources(): List = when (this) { + TrendProfile.TMS2812 -> 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) +} + +/** Stable IDs keep samples attached to a signal when its name/order changes. */ +data class TrendSignal( + val id: String = UUID.randomUUID().toString(), + val order: Int = 1, + val name: String = "Тренд 1", + val source: TrendSource = TrendSource.TMS_MEMORY, + val address: String = "0x00000100", + val color: String = "#2F91FF", + val visible: Boolean = true, + val valueType: TrendValueType = TrendValueType.UINT16, + val deviceType: Int = 7, + val device: Int = 13, + val byteOffset: Int = 0, + val multiplier: Double = 1.0, + val iq: Int = 0, + val extended: Boolean = true, +) { + fun validationError(profile: TrendProfile): String? = when { + id.isBlank() || id.length > 80 -> "Неверный идентификатор тренда" + order !in 1..9999 -> "Номер должен быть от 1 до 9999" + name.isBlank() || name.length > 100 -> "Имя должно содержать от 1 до 100 символов" + address.length > 64 -> "Адрес слишком длинный" + source !in profile.trendSources() -> "Источник несовместим с протоколом ${profile.title}" + !color.matches(Regex("#[0-9a-fA-F]{6}")) -> "Цвет задаётся как #RRGGBB" + source == TrendSource.SET_SENSOR -> if (normalizeRom(address).matches(Regex("[0-9A-F]{16}"))) null + else "ROM датчика должен содержать 16 HEX-цифр" + source == TrendSource.TMS_MEMORY && parseTrendAddress(address, 0xFFFF_FFFFL) == null -> + "Адрес памяти: 0…0xFFFFFFFF" + source in setOf(TrendSource.CAN_GAS, TrendSource.SET_GAS) && parseTrendAddress(address, 0xFFFF) == null -> + "Адрес GAS: 0…0xFFFF" + source == TrendSource.CAN_GAS && (deviceType !in 0..7 || device !in 0..15) -> + "Тип устройства: 0…7, номер устройства: 0…15" + source == TrendSource.CAN_RAW && parseTrendAddress(address, if (extended) 0x1FFF_FFFF else 0x7FF) == null -> + "CAN ID вне диапазона ${if (extended) "29" else "11"} бит" + source == TrendSource.CAN_RAW && byteOffset !in 0..6 -> "Смещение слова: 0…6 байт" + !multiplier.isFinite() -> "Множитель должен быть конечным числом" + iq !in 0..30 -> "IQ должен быть в диапазоне 0…30" + else -> null + } + + fun displayValue(rawValue: Double): Double = + rawValue * multiplier / (1L shl iq).toDouble() + + /** Only source changes invalidate the historical series. */ + fun sameInput(other: TrendSignal): Boolean = + copy( + order = other.order, + name = other.name, + color = other.color, + visible = other.visible, + multiplier = other.multiplier, + iq = other.iq, + ) == other + + companion object { + val COLORS = listOf("#2F91FF", "#FFB547", "#52D6A4", "#F8798D", "#B79AFF", "#4AD9E8", "#E8DA68", "#E7ECF3") + fun new(profile: TrendProfile, existing: List): TrendSignal { + val order = (1..9999).first { number -> existing.none { it.order == number } } + val source = profile.trendSources().first() + return TrendSignal( + order = order, name = "Тренд $order", source = source, + address = when (source) { + TrendSource.TMS_MEMORY -> "0x00000100" + TrendSource.CAN_GAS -> "0x0000" + TrendSource.SET_GAS -> "0x0000" + TrendSource.CAN_RAW -> "0x00BA0010" + TrendSource.SET_SENSOR -> "" + }, + color = COLORS[(order - 1) % COLORS.size], + ) + } + } +} + +fun parseTrendAddress(value: String, max: Long): Long? { + val text = value.trim() + val hex = text.startsWith("0x", ignoreCase = true) + val digits = if (hex) text.drop(2) else text + if (digits.isEmpty() || !digits.all { if (hex) it in "0123456789abcdefABCDEF" else it in '0'..'9' }) return null + return digits.toLongOrNull(if (hex) 16 else 10)?.takeIf { it in 0..max } +} + +fun normalizeRom(value: String): String = value.trim().replace("-", "").replace(" ", "").uppercase() + +data class TrendPoint( + val timestampMs: Long, + val value: Double, + /** Nanoseconds inside [timestampMs], preserving spacing above 1 kHz without overflowing epoch nanoseconds. */ + val subMillisecondNanos: Int = 0, +) { + init { require(subMillisecondNanos in 0..999_999) } + fun nanosSince(other: TrendPoint): Long = + (timestampMs - other.timestampMs) * 1_000_000L + subMillisecondNanos - other.subMillisecondNanos +} + +data class TrendUiState( + val section: TrendSection = TrendSection.SIGNALS, + val settings: Map> = emptyMap(), + val history: Map> = emptyMap(), + val running: Boolean = false, + val status: String = "Добавьте сигналы и откройте вкладку «График»", + val fileBusy: Boolean = false, +) { + fun signals(profile: TrendProfile): List = settings[profile].orEmpty().sortedBy { it.order } + + fun append(values: Map, timestampMs: Long): TrendUiState { + if (!running) return this + val next = history.toMutableMap() + values.forEach { (id, value) -> + if (value.isFinite()) next[id] = (next[id].orEmpty() + TrendPoint(timestampMs, value)).takeLast(MAX_POINTS) + } + return copy(history = next) + } + + companion object { + const val MAX_SIGNALS = 64 + const val MAX_POINTS = 600 + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSettingsJson.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSettingsJson.kt new file mode 100644 index 0000000..763eb2c --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSettingsJson.kt @@ -0,0 +1,100 @@ +package ru.setcorp.setprotocol.trends + +import org.json.JSONArray +import org.json.JSONObject + +/** Versioned, human-readable configuration. Import is validated before replacing any state. */ +object TrendSettingsJson { + const val MAX_FILE_BYTES = 1024 * 1024 + + fun encode(settings: Map>): String { + validate(settings) + val profiles = JSONObject() + settings.forEach { (profile, signals) -> + profiles.put(profile.name, JSONArray().apply { + signals.sortedBy { it.order }.forEach { signal -> + put(JSONObject().apply { + put("id", signal.id) + put("order", signal.order) + put("name", signal.name) + put("source", signal.source.name) + put("address", signal.address) + put("color", signal.color) + put("visible", signal.visible) + put("valueType", signal.valueType.name) + put("deviceType", signal.deviceType) + put("device", signal.device) + put("byteOffset", signal.byteOffset) + put("multiplier", signal.multiplier) + put("iq", signal.iq) + put("extended", signal.extended) + }) + } + }) + } + return JSONObject().put("format", "setflash-trends").put("version", 1).put("profiles", profiles).toString(2) + } + + fun decode(text: String): Map> { + require(text.toByteArray(Charsets.UTF_8).size <= MAX_FILE_BYTES) { "Файл настроек больше 1 МБ" } + val root = JSONObject(text.removePrefix("\uFEFF")) + require(root.strictString("format") == "setflash-trends" && root.strictInt("version") == 1) { + "Неизвестный формат или версия настроек графиков" + } + val profiles = root.getJSONObject("profiles") + val result = profiles.keys().asSequence().associate { key -> + val profile = TrendProfile.entries.firstOrNull { it.name == key } + ?: error("Неизвестный протокол: $key") + val array = profiles.getJSONArray(key) + require(array.length() <= TrendUiState.MAX_SIGNALS) { "Не более 64 сигналов на протокол" } + profile to (0 until array.length()).map { index -> + val item = array.getJSONObject(index) + TrendSignal( + id = item.strictString("id"), order = item.strictInt("order"), name = item.strictString("name"), + source = TrendSource.valueOf(item.strictString("source")), address = item.strictString("address"), + color = item.strictString("color"), visible = item.strictBoolean("visible"), + valueType = TrendValueType.valueOf(item.strictString("valueType")), + deviceType = item.strictInt("deviceType"), device = item.strictInt("device"), + byteOffset = item.strictInt("byteOffset"), + multiplier = if (item.has("multiplier")) item.strictDouble("multiplier") else 1.0, + iq = if (item.has("iq")) item.strictInt("iq") else 0, + extended = item.strictBoolean("extended"), + ) + } + } + validate(result) + return result + } + + private fun JSONObject.strictString(key: String): String = + get(key).let { require(it is String) { "$key должен быть строкой" }; it } + + private fun JSONObject.strictBoolean(key: String): Boolean = + get(key).let { require(it is Boolean) { "$key должен быть логическим флагом" }; it } + + private fun JSONObject.strictInt(key: String): Int { + val value = get(key) + require(value is Int || value is Long) { "$key должен быть целым числом" } + val number = (value as Number).toLong() + require(number in Int.MIN_VALUE..Int.MAX_VALUE) { "$key вне диапазона" } + return number.toInt() + } + + private fun JSONObject.strictDouble(key: String): Double { + val value = get(key) + require(value is Number) { "$key должен быть числом" } + return value.toDouble().also { require(it.isFinite()) { "$key должен быть конечным числом" } } + } + + fun validate(settings: Map>) { + val allIds = settings.values.flatten().map { it.id } + require(allIds.distinct().size == allIds.size) { "Идентификаторы трендов не должны повторяться" } + settings.forEach { (profile, signals) -> + require(signals.size <= TrendUiState.MAX_SIGNALS) { "Не более 64 сигналов на протокол" } + require(signals.map { it.order }.distinct().size == signals.size) { "Номера трендов одного протокола должны различаться" } + signals.forEach { signal -> + require(signal.validationError(profile) == null) { "${signal.name}: ${signal.validationError(profile)}" } + } + } + } +} 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 new file mode 100644 index 0000000..5f02bd3 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt @@ -0,0 +1,67 @@ +package ru.setcorp.setprotocol.trends + +import ru.setcorp.setprotocol.NativeSetProtocol + +/** Enum order is the stable set_spectrum.h ABI; titles belong to this GUI adapter. */ +enum class SpectrumWindow(val title: String) { + RECT("Прямоугольное"), HANN("Hann"), HAMMING("Hamming"), BLACKMAN("Blackman"), FLATTOP("Flat Top"), +} +enum class SpectrumFilter(val title: String) { + NONE("Без фильтра"), LOW_PASS("ФНЧ"), HIGH_PASS("ФВЧ"), BAND_PASS("Полосовой"), NOTCH("Режекторный"), +} +data class SpectrumOptions( + val window: SpectrumWindow = SpectrumWindow.HANN, + val filter: SpectrumFilter = SpectrumFilter.NONE, + val lowHz: Double = 10.0, + val highHz: Double = 100.0, + val removeMean: Boolean = true, + val maxSize: Int = 4096, +) { + fun validate() { + require(maxSize in 16..16384 && maxSize and (maxSize - 1) == 0) { "Размер FFT: степень 2 от 16 до 16384" } + if (filter in listOf(SpectrumFilter.HIGH_PASS, SpectrumFilter.BAND_PASS, SpectrumFilter.NOTCH)) + require(lowHz.isFinite() && lowHz > 0) { "Частота должна быть больше 0" } + if (filter in listOf(SpectrumFilter.LOW_PASS, SpectrumFilter.BAND_PASS)) + require(highHz.isFinite() && highHz > 0) { "Частота должна быть больше 0" } + if (filter == SpectrumFilter.BAND_PASS) require(lowHz < highHz) { "Нижняя частота должна быть меньше верхней" } + } +} + +data class TrendSpectrum( + val size: Int = 0, val sampleRate: Double = 0.0, val jitter: Double = 0.0, + val amplitudes: List = emptyList(), val error: String? = null, +) { + val binHz: Double get() = if (size > 0) sampleRate / size else 0.0 +} + +/** Math is implemented once in C and used unchanged by JNI and ctypes. */ +object SpectrumAnalyzer { + fun analyze(points: List, options: SpectrumOptions): TrendSpectrum { + options.validate() + if (points.size < 16) return TrendSpectrum(error = "Нужно минимум 16 точек") + if (!NativeSetProtocol.available) return TrendSpectrum(error = "Модуль FFT недоступен") + val count = minOf(points.size, options.maxSize) + val start = points.size - count + val origin = points[start] + val times = DoubleArray(count) { points[start + it].nanosSince(origin) / 1_000_000_000.0 } + val values = DoubleArray(count) { points[start + it].value } + val output = NativeSetProtocol.nativeSpectrum(times, values, options.maxSize, + options.window.ordinal, options.filter.ordinal, options.lowHz, options.highHz, options.removeMean) + ?: return TrendSpectrum(error = "Не удалось вычислить FFT") + return decode(output) + } + + internal fun decode(output: DoubleArray): TrendSpectrum { + require(output.size >= 4) + val error = when (output[0].toInt()) { + 0 -> null + 1 -> "Нужно минимум 16 точек" + 3 -> "Разрывы или неравномерные метки времени: выберите другой участок" + 4 -> "Частота фильтра должна быть ниже Fs/2 = %.3f Гц".format(java.util.Locale.ROOT, output[2] / 2) + 5 -> "Недостаточно памяти для FFT" + else -> "Некорректные данные FFT" + } + return TrendSpectrum(output[1].toInt(), output[2], output[3], + if (error == null) output.drop(4) else emptyList(), error) + } +} diff --git a/c/set-protocol/ports/android/set_plot_jni.c b/c/set-protocol/ports/android/set_plot_jni.c new file mode 100644 index 0000000..9e60651 --- /dev/null +++ b/c/set-protocol/ports/android/set_plot_jni.c @@ -0,0 +1,22 @@ +#include +#include "set_plot.h" + +JNIEXPORT jdoubleArray JNICALL +Java_ru_setcorp_setprotocol_trends_NativePlot_evaluate(JNIEnv *env, jobject self, + jint operation, jdoubleArray input) { + double values[10], result[4]; + jsize n; + size_t count; + jdoubleArray output; + (void)self; + if (!input) return NULL; + n = (*env)->GetArrayLength(env, input); + if (n < 0 || n > 10) return NULL; + (*env)->GetDoubleArrayRegion(env, input, 0, n, values); + if ((*env)->ExceptionCheck(env)) return NULL; + count = set_plot_eval((uint32_t)operation, values, (size_t)n, result, 4); + if (!count) return NULL; + output = (*env)->NewDoubleArray(env, (jsize)count); + if (output) (*env)->SetDoubleArrayRegion(env, output, 0, (jsize)count, result); + return output; +} diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c index e48ac66..863c2e5 100644 --- a/c/set-protocol/ports/android/setprotocol_jni.c +++ b/c/set-protocol/ports/android/setprotocol_jni.c @@ -4,6 +4,95 @@ #include #include "setprotocol_abi.h" +#include "set_trends.h" +#include "set_spectrum.h" + +JNIEXPORT jdoubleArray JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeSpectrum( + JNIEnv *env, jobject self, jdoubleArray times, jdoubleArray values, jint max_size, + jint window, jint filter, jdouble low_hz, jdouble high_hz, jboolean remove_mean) +{ + (void)self; + if (times == NULL || values == NULL || max_size < 16 || max_size > (jint)SET_SPECTRUM_MAX || + (max_size & (max_size - 1)) != 0) return NULL; + jsize count = (*env)->GetArrayLength(env, times); + if ((*env)->GetArrayLength(env, values) != count) return NULL; + /* Only the analyzed tail crosses JNI; allocations cannot grow with file length. */ + jsize used = count < max_size ? count : max_size; + size_t bins = (size_t)max_size / 2 + 1; + double *buffer = (double *)calloc((size_t)used * 2 + bins + 4, sizeof(double)); + if (buffer == NULL) return NULL; + double *t = buffer, *v = t + used, *out = v + used; + (*env)->GetDoubleArrayRegion(env, times, count - used, used, t); + (*env)->GetDoubleArrayRegion(env, values, count - used, used, v); + if ((*env)->ExceptionCheck(env)) { free(buffer); return NULL; } + int status = set_spectrum_analyze(t, v, (size_t)used, (size_t)max_size, + window, filter, low_hz, high_hz, remove_mean, out + 4, bins, out + 1); + out[0] = (double)status; + jsize length = status == SET_SPECTRUM_OK ? (jsize)out[1] / 2 + 5 : 4; + jdoubleArray result = (*env)->NewDoubleArray(env, length); + if (result != NULL) (*env)->SetDoubleArrayRegion(env, result, 0, length, out); + free(buffer); + return result; +} + +JNIEXPORT jint JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendCanValue( + JNIEnv *env, jobject self, jint source, jlong address, jint device_type, + jint device, jint byte_offset, jboolean extended, jboolean is_signed, + jlong can_id, jint flags, jbyteArray input) +{ + (void)self; + jsize size = (*env)->GetArrayLength(env, input); + if (size < 2 || size > 8) return SET_TREND_NO_VALUE; + jbyte data[8]; + (*env)->GetByteArrayRegion(env, input, 0, size, data); + return set_trend_can_value((uint8_t)source, (uint32_t)address, + (uint8_t)device_type, (uint8_t)device, (uint8_t)byte_offset, + (uint8_t)extended, (uint8_t)is_signed, (uint32_t)can_id, + (uint8_t)flags, (const uint8_t *)data, (size_t)size); +} + +JNIEXPORT jbyteArray JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest( + JNIEnv *env, jobject self, jint period, jintArray input) +{ + (void)self; + jsize count = (*env)->GetArrayLength(env, input); + if (count > (jsize)SET_TREND_WATCH_MAX || period < 0 || period > 65535) return NULL; + jint values[SET_TREND_WATCH_MAX]; + uint16_t addresses[SET_TREND_WATCH_MAX]; + uint8_t output[4U + 2U * SET_TREND_WATCH_MAX]; + (*env)->GetIntArrayRegion(env, input, 0, count, values); + for (jsize i = 0; i < count; ++i) { + if (values[i] < 0 || values[i] > 65535) return NULL; + addresses[i] = (uint16_t)values[i]; + } + size_t size = set_trend_watch_request((uint16_t)period, addresses, (size_t)count, output, sizeof(output)); + if (size == 0U) return NULL; + jbyteArray result = (*env)->NewByteArray(env, (jsize)size); + if (result != NULL) (*env)->SetByteArrayRegion(env, result, 0, (jsize)size, (const jbyte *)output); + return result; +} + +JNIEXPORT jintArray JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues( + JNIEnv *env, jobject self, jbyteArray input) +{ + (void)self; + jsize size = (*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]; + jint values[SET_TREND_WATCH_MAX]; + (*env)->GetByteArrayRegion(env, input, 0, size, payload); + int count = set_trend_watch_values((const uint8_t *)payload, (size_t)size, words, SET_TREND_WATCH_MAX); + if (count < 0) return NULL; + for (int i = 0; i < count; ++i) values[i] = words[i]; + jintArray result = (*env)->NewIntArray(env, count); + if (result != NULL) (*env)->SetIntArrayRegion(env, result, 0, count, values); + return result; +} typedef struct { uint8_t *storage; diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/GuiGasWatchTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/GuiGasWatchTest.kt new file mode 100644 index 0000000..cfa281d --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/GuiGasWatchTest.kt @@ -0,0 +1,25 @@ +package ru.setcorp.setprotocol.trends + +import org.junit.Assert.* +import org.junit.Test + +class GuiGasWatchTest { + @Test fun requestMatchesPythonAndCContract() { + assertArrayEquals(byteArrayOf(-24, 3, 2, 0, 0x34, 0x12, -1, -1), GuiGasWatch.request(1000, listOf(0x1234, 0xFFFF))) + assertArrayEquals(byteArrayOf(0, 0, 0, 0), GuiGasWatch.request(0, emptyList())) + assertTrue(runCatching { GuiGasWatch.request(1, (0..64).toList()) }.isFailure) + } + + @Test fun partialAckMustNeverMislabelValues() { + GuiGasWatch.validateAck(byteArrayOf(-24, 3, 2, 0), 1000, 2) + assertTrue(runCatching { GuiGasWatch.validateAck(byteArrayOf(-24, 3, 1, 0), 1000, 2) }.isFailure) + assertTrue(runCatching { GuiGasWatch.validateAck(byteArrayOf(0, 0, 2, 0), 1000, 2) }.isFailure) + } + + @Test fun valuesRequireExactLengthAndCount() { + val payload = byteArrayOf(1, 2, 3, 4, 2, 0, 0x34, 0x12, -1, -1) + assertEquals(listOf(0x1234, 65535), GuiGasWatch.values(payload, 2)) + assertTrue(runCatching { GuiGasWatch.values(payload, 1) }.isFailure) + assertTrue(runCatching { GuiGasWatch.values(payload.dropLast(1).toByteArray(), 2) }.isFailure) + } +} diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt new file mode 100644 index 0000000..af427fa --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt @@ -0,0 +1,37 @@ +package ru.setcorp.setprotocol.trends + +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +class PlotContractTest { + @Test fun jniUsesTheSameNumericContractAsPython() { + val text = javaClass.getResourceAsStream("/plot-v1.json")!!.bufferedReader().use { it.readText() } + val cases = JSONObject(text).getJSONArray("cases") + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val input = case.getJSONArray("input") + val result = runCatching { NativePlot.call(case.getInt("op"), + *DoubleArray(input.length()) { input.getDouble(it) }) } + if (case.isNull("output")) assertTrue(case.getString("name"), result.isFailure) + else { + val expected = case.getJSONArray("output") + assertArrayEquals(case.getString("name"), DoubleArray(expected.length()) { expected.getDouble(it) }, + result.getOrThrow(), 1e-10) + } + } + } + + @Test fun markerCoordinatesSurviveZoomAndDragUsesGestureStart() { + val full = PlotBounds(1000.0, 2000.0, -10.0, 10.0) + val markers = TrendMarkers().positioned(full) + val zoomed = PlotBounds(1250.0, 1750.0, -5.0, 5.0) + assertEquals(markers, markers.positioned(zoomed)) + val dragged = markers.drag(TrendMarker.A, 50.0, 500.0, zoomed) + assertEquals(markers.a!! + 50, dragged.a!!, 1e-9) + assertEquals(markers.b, dragged.b) + assertEquals(TrendMarker.A, dragged.hit(0.0, 50.0, 500.0, 100.0, 20.0, zoomed)) + val crossed = markers.move(TrendMarker.A, 1900.0).move(TrendMarker.B, 1100.0) + assertEquals(-800.0, plotDelta(crossed.a!!, crossed.b!!), 0.0) + } +} 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 new file mode 100644 index 0000000..d9918d2 --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt @@ -0,0 +1,39 @@ +package ru.setcorp.setprotocol.trends + +import org.junit.Assert.* +import org.junit.Test + +class PlotViewportTest { + @Test fun zoomAnchorsToFingerAndAxesAreIndependent() { + val zoom = PlotViewport().transform(2.0, 1.0, focusX = 0.25) + assertEquals(0.125, zoom.x, 1e-12) + assertEquals(0.5, zoom.width, 1e-12) + assertEquals(1.0, zoom.height, 1e-12) + assertEquals(0.0, zoom.y, 1e-12) + } + @Test fun zoomAndPanClampToBoundsAndResetToFullView() { + val zoom = PlotViewport().transform(1e6, 1e6) + assertEquals(1.0 / 128, zoom.width, 1e-12) + val panned = zoom.transform(panX = 10000.0, panY = -10000.0) + assertEquals(0.0, panned.x, 1e-12) + assertEquals(1.0 - panned.height, panned.y, 1e-12) + assertEquals(PlotViewport(), panned.transform(1e-6, 1e-6)) + assertEquals(zoom, zoom.transform(Double.NaN, 1.0)) + assertEquals(zoom, zoom.transform(0.0, 1.0)) + } + @Test fun nativeResponseAndErrorsAreDecodedWithoutInventingSpectrum() { + val result = SpectrumAnalyzer.decode(doubleArrayOf(0.0, 16.0, 100.0, 0.0) + DoubleArray(9) { 2.0 }) + assertEquals(6.25, result.binHz, 0.0) + assertEquals(9, result.amplitudes.size) + val failed = SpectrumAnalyzer.decode(doubleArrayOf(4.0, 16.0, 100.0, 0.0)) + assertTrue(failed.error!!.contains("50.000")) + assertTrue(failed.amplitudes.isEmpty()) + } + @Test fun spectrumSettingsRejectInvalidBandsAndSizes() { + listOf(SpectrumOptions(maxSize = 30), SpectrumOptions(filter = SpectrumFilter.LOW_PASS, highHz = Double.NaN), + SpectrumOptions(filter = SpectrumFilter.BAND_PASS, lowHz = 100.0, highHz = 10.0)).forEach { + assertTrue(runCatching { it.validate() }.isFailure) + } + SpectrumOptions().validate() + } +} diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/TrendSettingsJsonTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/TrendSettingsJsonTest.kt new file mode 100644 index 0000000..3a1c6ec --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/TrendSettingsJsonTest.kt @@ -0,0 +1,87 @@ +package ru.setcorp.setprotocol.trends + +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +class TrendSettingsJsonTest { + @Test fun readsTheSameFixtureAsDesktopPython() { + val text = requireNotNull(javaClass.getResourceAsStream("/trends-v1.json")).bufferedReader().use { it.readText() } + val decoded = TrendSettingsJson.decode(text) + assertEquals(5, decoded.values.sumOf { it.size }) + assertEquals("Ток фазы А", decoded.getValue(TrendProfile.TMS2812).single().name) + assertEquals(TrendSource.SET_GAS, decoded.getValue(TrendProfile.SET_V1).first().source) + assertEquals(decoded, TrendSettingsJson.decode(TrendSettingsJson.encode(decoded))) + } + + @Test fun rejectsCoercionOfBooleanFractionAndStringNumbers() { + val json = TrendSettingsJson.encode(settings) + listOf("1", 1.5, true).forEach { value -> + val root = JSONObject(json) + root.getJSONObject("profiles").getJSONArray("TMS2812").getJSONObject(0).put("order", value) + expectInvalid { TrendSettingsJson.decode(root.toString()) } + } + expectInvalid { TrendSettingsJson.decode(JSONObject(json).put("version", 1.5).toString()) } + } + private val signal = TrendSignal(id = "tms-1", name = "Ток \"фаза А\"", color = "#FF1234", visible = false) + private val settings = mapOf(TrendProfile.TMS2812 to listOf(signal)) + + private fun expectInvalid(block: () -> Unit) { + assertTrue("Invalid settings must fail", runCatching(block).isFailure) + } + + @Test fun roundTripIncludesEveryFieldAndIndependentProtocols() { + val full = settings + mapOf( + TrendProfile.CAN_BRIDGE to listOf(TrendSignal(id = "gas", source = TrendSource.CAN_GAS, address = "0x0123", device = 3, deviceType = 2, valueType = TrendValueType.INT16)), + TrendProfile.BALZAM_CAN to listOf(TrendSignal(id = "raw", source = TrendSource.CAN_RAW, address = "0x321", extended = false, byteOffset = 6)), + TrendProfile.SET_V1 to listOf(TrendSignal(id = "sensor", source = TrendSource.SET_SENSOR, address = "28-01-02-03-04-05-06-07")), + ) + assertEquals(full, TrendSettingsJson.decode(TrendSettingsJson.encode(full))) + } + + @Test fun supportsEmptyConfigurationAndUtf8Bom() { + assertTrue(TrendSettingsJson.decode(TrendSettingsJson.encode(emptyMap())).isEmpty()) + assertEquals(settings, TrendSettingsJson.decode("\uFEFF" + TrendSettingsJson.encode(settings))) + } + + @Test fun encodingSortsByOrder() { + val unordered = mapOf(TrendProfile.TMS2812 to listOf(signal.copy(id = "b", order = 2), signal)) + assertEquals(listOf(1, 2), TrendSettingsJson.decode(TrendSettingsJson.encode(unordered)).getValue(TrendProfile.TMS2812).map { it.order }) + } + + @Test fun rejectsUnknownVersionFormatProfileAndMissingFields() { + val json = TrendSettingsJson.encode(settings) + expectInvalid { TrendSettingsJson.decode(JSONObject(json).put("version", 2).toString()) } + expectInvalid { TrendSettingsJson.decode(JSONObject(json).put("format", "other").toString()) } + expectInvalid { TrendSettingsJson.decode(json.replace("TMS2812", "UNKNOWN")) } + val missing = JSONObject(json) + missing.getJSONObject("profiles").getJSONArray("TMS2812").getJSONObject(0).remove("address") + expectInvalid { TrendSettingsJson.decode(missing.toString()) } + } + + @Test fun rejectsDuplicateOrderAndCrossProtocolIds() { + expectInvalid { TrendSettingsJson.encode(mapOf(TrendProfile.TMS2812 to listOf(signal, signal.copy(id = "another")))) } + expectInvalid { TrendSettingsJson.encode(settings + (TrendProfile.CAN_BRIDGE to listOf(signal.copy(source = TrendSource.CAN_GAS)))) } + } + + @Test fun importRunsValidationBeforeReturningAnySettings() { + val validJson = TrendSettingsJson.encode(settings) + listOf("address" to "0x100000000", "color" to "#BAD", "source" to "CAN_GAS", "name" to "").forEach { (field, value) -> + val bad = JSONObject(validJson) + bad.getJSONObject("profiles").getJSONArray("TMS2812").getJSONObject(0).put(field, value) + expectInvalid { TrendSettingsJson.decode(bad.toString()) } + } + assertEquals(settings, TrendSettingsJson.decode(validJson)) + } + + @Test fun enforcesSignalCountAndFileSizeLimits() { + expectInvalid { + TrendSettingsJson.encode(mapOf(TrendProfile.TMS2812 to (1..65).map { signal.copy(id = "$it", order = it) })) + } + expectInvalid { TrendSettingsJson.decode(" ".repeat(TrendSettingsJson.MAX_FILE_BYTES + 1)) } + } + + @Test fun malformedJsonIsNotAccepted() { + listOf("", "not JSON", "[]", "{\"format\":").forEach { text -> expectInvalid { TrendSettingsJson.decode(text) } } + } +} diff --git a/c/set-protocol/ports/stm32-bxcan/README.md b/c/set-protocol/ports/stm32-bxcan/README.md new file mode 100644 index 0000000..5f1dd42 --- /dev/null +++ b/c/set-protocol/ports/stm32-bxcan/README.md @@ -0,0 +1,602 @@ +# ProtoCAN: порт STM32 bxCAN (STM32 HAL) + +`stm32-bxcan` — порт прикладного ProtoCAN для STM32 HAL, перенесённый из +SETCAN. Модуль принимает +кадры, управляет HAL CAN/RTC/TIM и вызывает обработчики приложения. Формат +29-битного идентификатора и его переносимая реализация принадлежат общему +ядру [`c/set-protocol`](../../README.md) в этом же репозитории `templates`. + +Порт использует classic bxCAN (`HAL_CAN_*`), RTC и TIM. Для FDCAN на +STM32G4/H7 требуется другой аппаратный порт. Здесь нет `.ioc`, startup-файлов, +linker script или настроек тактирования: их предоставляет проект STM32CubeIDE. + +Схема подключения: приложение → `stm32-bxcan` → STM32 HAL → bxCAN/RTC/TIM. +Для упаковки и разбора CAN ID порт вызывает общее ядро `pcan_id`. +Старый API `PROTOCAN_*` и имена `protocan.c/.h` сохранены для существующих +прошивок. Отдельный репозиторий и вложенный сабмодуль SETCAN больше не нужны. + +Исходная версия: SETCAN `c8eec559785ab809469483dc58d3976ec9db340a`. +История SETCAN включена в историю `templates` отдельным родителем коммита +переноса; прежние пути можно посмотреть через +`git show c8eec55:Inc/protocan.h` и `git log c8eec55`. + +## Состав + +```text +c/set-protocol/ +├── include/pcan_id.h +├── src/pcan_id.c +└── ports/stm32-bxcan/ + ├── protocan.h # типы протокола, настройки устройства и публичный API + ├── protocan.c # приём, буферизация, разбор и отправка CAN-кадров + └── README.md +``` + +Модуль зависит от файлов `main.h` и `can.h`, сгенерированных STM32CubeMX, а также +от HAL-драйверов CAN, RTC и TIM. В сборку добавляются +`c/set-protocol/ports/stm32-bxcan/protocan.c` и `c/set-protocol/src/pcan_id.c`. +В include paths добавляются `c/set-protocol/ports/stm32-bxcan`, +`c/set-protocol/include` и каталог CubeMX с `main.h`/`can.h`. +Если прошивка уже собирает общее ядро SETProtocol, второй раз добавлять +`pcan_id.c` не нужно. HAL-порт не входит в host-сборку CMake общего ядра. + +| Файл | Зависимости | +|---|---| +| `protocan.h` | `main.h`, `can.h`, общий `pcan_id.h` | +| `protocan.c` | `protocan.h`, STM32 HAL CAN/RTC/TIM, CMSIS | +| `../../src/pcan_id.c` | переносимое ядро C99, `../../include` | + +## Документация протокола + +Каноническое описание теперь находится рядом с переносимой реализацией: + +- [Структура ProtoCAN](../../docs/legacy/PROTOCOL.md); +- [Прошивка по CAN](../../../protocan-boot/docs/BOOTLOADER.md); +- [Правила ведения ОАП](../../docs/legacy/OAP.md); +- [Машинные эталоны](../../tests/vectors/test-vectors.json); +- [Историческая HTML-документация SETCAN](../../../../doc/setcan/index.html). + +Файлы в `doc/setcan/protocan` сохранены как исторический снимок SETCAN и больше не +являются источником истины. + +## Формат расширенного CAN ID + +Протокол размещает служебные поля в 29-битном Extended ID: + +| Биты | Поле | Размер | Назначение | +|---:|---|---:|---| +| 28 | `Priority` | 1 | `0` — критический, `1` — стандартный приоритет | +| 27 | `Route` | 1 | `0` — от управляющего модуля, `1` — от устройства | +| 26..24 | `DeviceType` | 3 | тип устройства, `0..7` | +| 23..20 | `DeviceID` | 4 | номер устройства, `0..15` | +| 19..16 | `MsgType` | 4 | тип сообщения | +| 15..0 | `MsgBody` | 16 | тип команды, адрес, ID датчика или код ошибки | + +Типы сообщений: + +| Значение | Тип | +|---:|---| +| `0x0` | широковещательное (`BROADCAST`) | +| `0x1` | дискретное (`DISCRETE`) | +| `0x2` | аналоговое (`ANALOG`) | +| `0x3` | общее адресное пространство | +| `0x4` | Modbus Coil | +| `0x5` | Modbus Discrete | +| `0x6` | Modbus Holding | +| `0x7` | Modbus Input | +| `0x8` | ошибка | +| `0x9` | управление загрузчиком (`BOOT_CONTROL`, зарезервировано) | +| `0xA` | данные прошивки, слот A (`BOOT_DATA_A`, зарезервировано) | +| `0xB` | данные прошивки, слот B (`BOOT_DATA_B`, зарезервировано) | +| `0xC` | состояние загрузчика (`BOOT_STATUS`, зарезервировано) | +| `0xD` | обнаружение загрузчиков (`BOOT_DISCOVERY`, зарезервировано) | +| `0xE` | настройка привязки датчика (`SETTINGS`) | +| `0xF` | пульс присутствия устройства | + +Разметка `MsgBody` зависит от типа сообщения: + +- broadcast: младшие 4 бита — дополнительное поле, старшие 12 бит — команда; +- discrete: младшие 12 бит — данные/адрес, старшие 4 бита — подтип; +- analog: младшие 12 бит — ID датчика, старшие 4 бита — тип величины; +- Modbus: младшие 4 бита — количество регистров, старшие 12 бит — начальный адрес; +- SETTINGS: старший байт — номер сборки `Z`, младший байт — позиция `Y`; +- error: младший байт — код ошибки, старший байт — дополнительная информация. + +Для регистров `uint16_t` в CAN payload используется порядок байтов little-endian: сначала младший байт, затем старший. + +Пара `DeviceType/DeviceID` образует уникальный адрес прибора. Три бита +`DeviceType` задают 8 типов, четыре бита `DeviceID` — 16 экземпляров каждого +типа; всего на одной шине можно адресовать до 128 приборов. Загрузочные кадры +так же адресуются этой парой и не требуют изменения 29-битного CAN ID. + +> Разметка ID реализована C-битовыми полями. Она соответствует используемому STM32 GCC ABI, но не является переносимой между произвольными компиляторами без проверки фактического расположения битов. + +## Подключение к STM32-проекту + +### 1. Добавить файлы + +Скопировать или подключить к сборке: + +- `c/set-protocol/ports/stm32-bxcan/protocan.h`; +- `c/set-protocol/ports/stm32-bxcan/protocan.c`; +- `c/set-protocol/src/pcan_id.c` (если ещё не собирается в составе ядра). + +Каталоги `c/set-protocol/ports/stm32-bxcan` и `c/set-protocol/include` +добавить в include paths компилятора. + +### 2. Настроить устройство + +В `protocan.h` для прибора привязки DS18B20 заданы выбранные значения: + +```c +#define CURRENT_TYPE_DEVICE 0b111 /* DeviceType = 0x7 */ +#define CURRENT_ID_DEVICE 0b1111 /* DeviceID = 0xF */ +``` + +При необходимости изменить размер программного RX-буфера: + +```c +#define PROTOCAN_RX_BUFFER_SIZE 128 +``` + +Полезная ёмкость кольцевого буфера на один элемент меньше заданного размера, то есть при значении `128` в нём помещается 127 ожидающих кадров. + +### 3. Настроить CubeMX + +В проекте должны быть инициализированы: + +- CAN с поддержкой Extended ID; +- RTC; +- базовый таймер, задающий период отправки пульса. + +Модуль использует CAN FIFO0 и фильтры с номерами `0`, `1`, `2`. Адресованный фильтр принимает Route=0 (запрос от ПМ) для текущих `DeviceType/DeviceID`; отдельные фильтры принимают broadcast и pulse. В `PROTOCAN_CONFIG_FILTER()` также жёстко задано `SlaveStartFilterBank = 14`. Если приложение уже использует фильтры, номера и границу банков нужно согласовать. + +### 4. Инициализировать и запустить периферию + +Пример для `main.c`: + +```c +#include "protocan.h" + +int main(void) +{ + HAL_Init(); + SystemClock_Config(); + + MX_GPIO_Init(); + MX_CAN_Init(); + MX_RTC_Init(); + MX_TIM2_Init(); + + PROTOCAN_INIT_StatusTypeDef init_status = + PROTOCAN_INIT(&hcan, &hrtc, &htim2); + + if (init_status != PROTOCAN_INIT_OK) { + Error_Handler(); + } + + /* PROTOCAN_INIT() этого не делает. */ + if (HAL_CAN_Start(&hcan) != HAL_OK) { + Error_Handler(); + } + if (HAL_CAN_ActivateNotification( + &hcan, CAN_IT_RX_FIFO0_MSG_PENDING) != HAL_OK) { + Error_Handler(); + } + if (HAL_TIM_Base_Start_IT(&htim2) != HAL_OK) { + Error_Handler(); + } + + while (1) { + (void)PROTOCAN_ProcessAllRxMsgs(); + } +} +``` + +`PROTOCAN_INIT()` требует ненулевые указатели на CAN, RTC и TIM, настраивает CAN-фильтры и включает отправку пульса. Функцию следует вызвать после `MX_CAN_Init()`, `MX_RTC_Init()` и `MX_TIMx_Init()`, но до запуска CAN. + +### 5. Подключить callback’и HAL + +Если в HAL включены `USE_HAL_CAN_REGISTER_CALLBACKS == 1` и `USE_HAL_TIM_REGISTER_CALLBACKS == 1`, `PROTOCAN_INIT()` зарегистрирует callback’и автоматически. + +Если регистрация callback’ов отключена, добавить перенаправление из стандартных HAL-callback’ов: + +```c +void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan_ptr) +{ + if (hcan_ptr == &hcan) { + ProtoCanRxFifo0MsgPendingCallback(hcan_ptr); + } +} + +void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim_ptr) +{ + if (htim_ptr == &htim2) { + ProtoCanPulseCallback(htim_ptr); + } +} +``` + +Не следует одновременно регистрировать callback через HAL и вручную вызывать его из стандартного callback — иначе один кадр или событие таймера может быть обработано дважды. + +## Приём сообщений + +В прерывании `ProtoCanRxFifo0MsgPendingCallback()` модуль вычитывает все кадры из FIFO0: + +- стандартные CAN ID игнорируются; +- `PULSE` не занимает место в RX-буфере, а сразу обновляет внутреннюю таблицу присутствующих устройств; +- остальные Extended-кадры помещаются в кольцевой буфер; +- при переполнении новые кадры молча отбрасываются. + +Обработку прикладной логики следует выполнять вне прерывания одним из трёх способов: + +```c +PROTOCAN_ProcessSingleRxMsg(); /* обработать не более одного кадра */ +PROTOCAN_ProcessAllRxMsgs(); /* обработать все накопленные кадры */ +PROTOCAN_LoopProcessRxMsgs(); /* бесконечный блокирующий цикл */ +``` + +Для обычного `while (1)` удобнее `PROTOCAN_ProcessAllRxMsgs()`. Она возвращает `PROTOCAN_TIMEOUT`, если очередь была пуста, `PROTOCAN_OK` после успешной обработки или код первой ошибки. + +## Свои обработчики входящих команд + +Большинство обработчиков объявлены как `__weak`. Приложение может определить функцию с тем же именем и заменить демонстрационную реализацию своей логикой. + +Пример обработки запроса температуры: + +```c +PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogTSens(struct RXMsg msg) +{ + msgBodyAnalogType body = {0}; + body.Body = msg.eID.Fields.MsgBody; + + uint16_t sensor_id = body.Fields.SensorID; + /* Прочитать датчик sensor_id и сформировать нужный ответ. */ + + return PROTOCAN_OK; +} +``` + +Доступные точки переопределения находятся в `protocan.h`: + +- `ProtoCanMsgToBroadcast...()` — статус, включение/выключение, restart, RTC; +- `ProtoCanMsgToDiscrete...()` — аварии, предупреждения, флаги и команды; +- `ProtoCanMsgToAnalog...()` — универсальные данные, настройки, U/I/T; +- `ProtoCanMsgToSettings()` — GET/WRITE/CLEAR привязки ROM к локации; +- `ProtoCanMsgToGeneralAddressSpace()`; +- `ProtoCanMsgToModbus...()`; +- `PROTOCAN_RequestError()`. + +Стандартные слабые обработчики в основном являются демонстрационными: часть возвращает `PROTOCAN_OK` без действий, часть отправляет текстовые ответы вроде `TS0001` или `GAS-0001`. Для рабочего изделия их обычно нужно переопределить. + +## SETTINGS: привязка DS18B20 к локации + +Для прибора `DeviceType=0x7`, `DeviceID=0xF` используется `MsgType=0xE`: + +```text +Body = (Z << 8) | Y + +запрос от ПМ = 0x17FEZZYY +ответ прибора = 0x1FFEZZYY +``` + +| Запрос | Операция | +|---|---| +| `DLC=0` | `GET`: прочитать ROM локации | +| `DLC=8`, ROM не нулевой | `WRITE/REPLACE`: записать или заменить датчик | +| `DLC=8`, восемь нулей | `CLEAR`: очистить локацию | +| другой DLC или RTR | автоматический ответ `INVALID_DLC` | + +Успешный ответ имеет DLC=8 и содержит текущее состояние локации: ROM после +GET/WRITE либо восемь нулей после CLEAR. Ошибка сохраняет тот же Body, имеет +DLC=1 и передаёт `ProtoCanSettingsResultType` в `Data[0]`. + +`PROTOCAN_SettingsProcessing()` выполняет транспортный разбор и вызывает +слабую функцию `ProtoCanMsgToSettings()`. Приложение переопределяет её и +выполняет проверку family code/CRC8, поиск ROM на 1-Wire и запись EEPROM: + +```c +PROTOCAN_StatusTypeDef ProtoCanMsgToSettings( + const ProtoCanSettingsMsg_t *message) +{ + uint8_t current_rom[PROTOCAN_SETTINGS_ROM_SIZE] = {0}; + + switch (message->Operation) { + case PROTOCAN_SETTINGS_GET: + /* Загрузить ROM локации в current_rom; свободная локация = нули. */ + return PROTOCAN_SEND_SETTINGS_RESPONSE( + message->AssemblySerial, message->Position, current_rom); + + case PROTOCAN_SETTINGS_WRITE: + /* Проверить ROM и атомарно записать его в каталог/EEPROM. */ + return PROTOCAN_SEND_SETTINGS_RESPONSE( + message->AssemblySerial, message->Position, message->Rom); + + case PROTOCAN_SETTINGS_CLEAR: + /* Удалить локацию из каталога/EEPROM. */ + return PROTOCAN_SEND_SETTINGS_RESPONSE( + message->AssemblySerial, message->Position, current_rom); + + default: + return PROTOCAN_SEND_SETTINGS_ERROR( + message->AssemblySerial, message->Position, + PROTOCAN_SETTINGS_RESULT_INVALID_DLC); + } +} +``` + +Слабая реализация специально не подтверждает операцию: без прикладного +каталога библиотека не должна сообщать, что ROM был сохранён. + +## Отправка + +Публичная точка отправки — `PROTOCAN_SEND(id, data)`. Сначала заполняется общий Extended ID, затем соответствующая часть `ProtoCanData_t`. + +Пример отправки диапазона 16-битных регистров общего адресного пространства: + +```c +uint16_t registers[] = { 0x1234, 0x5678, 0x9ABC }; + +ProtoCanId_t id = {0}; +id.Fields.Priority = PROTOCAN_PRIORITY_STANDARD; +id.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; +id.Fields.DeviceType = CURRENT_TYPE_DEVICE; +id.Fields.DeviceID = CURRENT_ID_DEVICE; +id.Fields.MsgType = PROTOCAN_MSGTYPE_GENERAL_ADDRESS_SPACE; + +ProtoCanData_t tx = {0}; +tx.GeneralAddressSpaceData.RegStartAdr = 100; +tx.GeneralAddressSpaceData.Data = registers; +tx.GeneralAddressSpaceData.RegCount = 3; + +if (PROTOCAN_SEND(id, tx) != PROTOCAN_OK) { + /* Очередь CAN занята или произошла ошибка HAL. */ +} +``` + +Массив автоматически разбивается на кадры максимум по четыре регистра (8 байт) с увеличением адреса в `MsgBody`. + +Пример сообщения об ошибке без payload: + +```c +ProtoCanId_t id = {0}; +id.Fields.Priority = PROTOCAN_PRIORITY_CRITICAL; +id.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; +id.Fields.DeviceType = CURRENT_TYPE_DEVICE; +id.Fields.DeviceID = CURRENT_ID_DEVICE; +id.Fields.MsgType = PROTOCAN_MSGTYPE_ERROR; + +ProtoCanData_t tx = {0}; +tx.ErrorData.Code = 0x12; +tx.ErrorData.Info = 0x34; + +(void)PROTOCAN_SEND(id, tx); +``` + +Для `BROADCAST`, `DISCRETE` и `ANALOG` используется `tx.CoreData`; для Modbus-типов — `tx.ModbusData`, для `SETTINGS` — `tx.SettingsData`. Для успешных ответов и ошибок SETTINGS удобнее использовать готовые функции `PROTOCAN_SEND_SETTINGS_RESPONSE()` и `PROTOCAN_SEND_SETTINGS_ERROR()`. Если количество передаваемых элементов больше нуля, соответствующий указатель `Data` должен быть валиден до завершения вызова. + +## Пульс устройства + +Каждое прерывание переданного в `PROTOCAN_INIT()` таймера вызывает `ProtoCanPulseCallback()` и отправляет кадр: + +- `MsgType = PROTOCAN_MSGTYPE_PULSE`; +- `DLC = 1`; +- `Data[0]` — циклический счётчик `0..255`. + +Период пульса полностью определяется настройками таймера. Broadcast-команда `PROTOCAN_BROADCAST_ONOFF` в стандартной реализации переключает его отправку. + +Полученный пульс помечает удалённое устройство как активное и обнуляет `TimeFromLastPulse`. Увеличение этого времени и перевод устройства в offline в текущем модуле не реализованы — если это требуется, контроль таймаута нужно добавить в приложение. + +## Синхронизация RTC + +Broadcast-команда `PROTOCAN_BROADCAST_RTCSETUP` ожидает ровно 7 байт: + +| Индекс | Значение | +|---:|---| +| 0 | часы, `0..23` | +| 1 | минуты, `0..59` | +| 2 | секунды, `0..59` | +| 3 | год как смещение от 2000, `0..99` | +| 4 | месяц, `1..12` | +| 5 | число месяца | +| 6 | день недели в формате, ожидаемом данной прошивкой | + +Перед записью проверяются диапазоны и количество дней с учётом високосного года. + +## Прошивка приборов по CAN + +Ниже зафиксирован формат планируемого загрузочного сервиса. Значения +`MsgType=0x9..0xD` зарезервированы в протоколе, но обработчики загрузчика в +текущих `protocan.c/.h` ещё не реализованы. + +Загрузчик работает поверх classic CAN 2.0B с Extended ID. Управляющий модуль +использует `Route=0`, прибор отвечает с `Route=1`. Команды стирания и записи +всегда должны быть адресованы конкретной паре `DeviceType/DeviceID`; +широковещательный режим допустим только для обнаружения. + +### Карта загрузочных сообщений + +| `MsgType` | Имя | Назначение `MsgBody` | CAN payload | +|---:|---|---|---| +| `0x9` | `BOOT_CONTROL` | `SessionID:8 \| Command:8` | параметры команды | +| `0xA` | `BOOT_DATA_A` | номер 8-байтового блока слота A | 8 байт образа | +| `0xB` | `BOOT_DATA_B` | номер 8-байтового блока слота B | 8 байт образа | +| `0xC` | `BOOT_STATUS` | `SessionID:8 \| Command:8` | статус и прогресс | +| `0xD` | `BOOT_DISCOVERY` | подтип запроса/ответа | идентификация прибора | + +`MsgBody` в кадрах данных является не байтовым адресом, а номером блока: + +```c +block_offset = (uint32_t)MsgBody * 8U; + +if (MsgType == PROTOCAN_MSGTYPE_BOOT_DATA_A) { + address = SLOT_A_BASE + block_offset; +} else if (MsgType == PROTOCAN_MSGTYPE_BOOT_DATA_B) { + address = SLOT_B_BASE + block_offset; +} +``` + +Диапазон `MsgBody=0x0000..0xFFFF` адресует 65536 блоков: + +```text +65536 блоков * 8 байт = 524288 байт = 512 КиБ на слот +``` + +Таким образом, `BOOT_DATA_A` и `BOOT_DATA_B` адресуют два логических слота +по 512 КиБ, всего 1 МиБ пространства образов. Физические `SLOT_A_BASE` и +`SLOT_B_BASE` задаёт конкретный загрузчик. Если внутренняя Flash имеет ровно +1 МиБ, два полных слота в ней не поместятся вместе с загрузчиком и метаданными: +нужно уменьшить слоты, выбрать MCU с большей Flash или хранить staging-образ +во внешней памяти. + +### Кадр данных + +```text +Extended CAN ID + Priority = STANDARD + Route = FROM_PM + DeviceType = тип целевого прибора + DeviceID = экземпляр целевого прибора + MsgType = 0xA (слот A) или 0xB (слот B) + MsgBody = BlockIndex, 0x0000..0xFFFF + +DATA[0..7] = очередные 8 байт образа +``` + +Например, `MsgBody=0x0123` задаёт смещение `0x0123 * 8 = 0x0918` от +начала выбранного слота. Последний неполный блок дополняется значениями +`0xFF`; фактический размер передаётся командой `BEGIN_UPDATE`, поэтому CRC32 +считается только по байтам образа. + +### Управляющие команды + +В `BOOT_CONTROL` поле `MsgBody` имеет формат: + +```text +15........8 7.........0 + SessionID Command +``` + +Рекомендуемые команды: + +| Код | Команда | Назначение | +|---:|---|---| +| `0x01` | `IDENTIFY` | прочитать тип, аппаратную и программную версии | +| `0x02` | `ENTER_BOOT` | перейти из приложения в загрузчик | +| `0x03` | `BEGIN_IMAGE` | передать размер и CRC32 образа | +| `0x04` | `BEGIN_COMPAT` | передать тип, аппаратную и программную версии | +| `0x05` | `ERASE` | подготовить неактивный слот | +| `0x06` | `VERIFY` | проверить размер, CRC32 и подпись | +| `0x07` | `COMMIT` | назначить проверенный слот кандидатом на запуск | +| `0x08` | `CONFIRM` | подтвердить успешный запуск новой программы | +| `0x09` | `REBOOT` | перезагрузить прибор | +| `0x0A` | `ABORT` | отменить текущую сессию | +| `0x0B` | `QUERY_PROGRESS` | запросить слот и следующий ожидаемый блок | + +`BEGIN_IMAGE` содержит размер и CRC образа: + +```text +DATA[0..3] ImageSize, uint32 little-endian +DATA[4..7] ImageCRC32, uint32 little-endian +``` + +`BEGIN_COMPAT` содержит совместимость и версию: + +```text +DATA[0..1] ProductType, uint16 little-endian +DATA[2] минимальная HardwareRevision +DATA[3] максимальная HardwareRevision +DATA[4..7] FirmwareVersion, uint32 little-endian +``` + +Обе команды передаются с одним `SessionID`. До стирания Flash загрузчик обязан +получить обе части метаданных и проверить `ProductType`, аппаратную ревизию, +размер образа, границы выбранного слота и допустимость версии. + +### Ответ состояния + +`BOOT_STATUS` возвращает результат команды и точку продолжения: + +```text +MsgBody[15..8] = SessionID +MsgBody[7..0] = команда, на которую дан ответ + +DATA[0] Status +DATA[1] TargetSlot: 0 = A, 1 = B +DATA[2..3] NextBlock, uint16 little-endian +DATA[4..7] RunningCRC32, uint32 little-endian +``` + +Минимальный набор статусов: + +| Код | Статус | +|---:|---| +| `0x00` | `OK` | +| `0x01` | `BUSY` | +| `0x02` | `INVALID_COMMAND` | +| `0x03` | `WRONG_DEVICE` | +| `0x04` | `WRONG_HARDWARE` | +| `0x05` | `INVALID_SIZE` | +| `0x06` | `CRC_ERROR` | +| `0x07` | `FLASH_ERROR` | +| `0x08` | `SEQUENCE_ERROR` | +| `0x09` | `SIGNATURE_ERROR` | +| `0x0A` | `SESSION_ERROR` | +| `0x0B` | `VOLTAGE_ERROR` | + +`NextBlock` позволяет возобновить загрузку после разрыва связи. Для первой +реализации допустимо подтверждать каждый блок. Для рабочей скорости лучше +передавать окна по 16 кадров и подтверждать окно одним `BOOT_STATUS`; при +необходимости протокол статуса можно расширить битовой картой потерянных +блоков. + +### Выбор слота и безопасное обновление + +GUI не должен самостоятельно перезаписывать активный слот. После получения +`BEGIN_IMAGE` и `BEGIN_COMPAT` загрузчик выбирает неактивный слот и сообщает его в +`BOOT_STATUS`: + +```text +активен A -> принимать BOOT_DATA_B +активен B -> принимать BOOT_DATA_A +``` + +Рекомендуемый цикл обновления: + +1. Обнаружить прибор и сверить `DeviceType/DeviceID`, `ProductType`, UID и версии. +2. Выполнить адресную команду `ENTER_BOOT` и получить новый `SessionID`. +3. Передать `BEGIN_IMAGE` и `BEGIN_COMPAT`; загрузчик выберет неактивный слот. +4. Стереть выбранный слот и передать блоки `BOOT_DATA_A` или `BOOT_DATA_B`. +5. Выполнить `VERIFY`: проверить размер, CRC32 и цифровую подпись образа. +6. Выполнить `COMMIT` и перезагрузить устройство. +7. Новое приложение вызывает `CONFIRM` после успешной самопроверки. +8. При отсутствии подтверждения загрузчик возвращается к предыдущему слоту. + +CRC32 обнаруживает случайное повреждение, но не защищает от подмены. Для +серийных изделий образ следует подписывать, а открытый ключ проверки хранить +в неизменяемой части загрузчика. Сам загрузчик не должен обновляться обычными +командами `BOOT_DATA_A/B`. + +## Важные ограничения текущей реализации + +- Модуль рассчитан на classic CAN с payload до 8 байт, не на CAN FD. +- `PROTOCAN_INIT()` не запускает CAN/таймер и не включает CAN notification. +- Все три аппаратных фильтра и номера банков заданы внутри библиотеки. +- Фильтр адресованных сообщений рассчитан на устройство: он принимает Route=0 от ПМ. Для использования библиотеки на стороне ПМ потребуется отдельная конфигурация фильтров Route=1. +- Проверку ROM, физического датчика и EEPROM выполняет приложение в переопределённом `ProtoCanMsgToSettings()`. +- Таблица присутствующих устройств обновляется при пульсе, но автоматического offline-таймаута нет. +- Буфер переполнения не ведёт счётчик потерь и не сообщает ошибку приложению. +- У порта нет полного примера прошивки STM32; его сборку и проверку на плате выполняет проект устройства. Host-тесты общего ядра находятся в `../../tests`. +- Несколько внутренних функций отправки реализованы только в `protocan.c` и не входят в публичный заголовок; для прикладного кода следует использовать `PROTOCAN_SEND()`. + +## Краткий порядок запуска + +1. Подключить `templates` к проекту устройства. +2. Добавить `protocan.c/.h` и общий `pcan_id.c` в STM32-проект. +3. Задать `CURRENT_TYPE_DEVICE` и `CURRENT_ID_DEVICE`. +4. Настроить CAN, RTC и периодический TIM в CubeMX. +5. Вызвать `PROTOCAN_INIT()` после `MX_..._Init()`. +6. Запустить CAN, активировать `CAN_IT_RX_FIFO0_MSG_PENDING` и запустить таймер с прерыванием. +7. При необходимости подключить HAL-callback’и вручную. +8. Переопределить нужные `__weak`-обработчики. +9. Вызывать `PROTOCAN_ProcessAllRxMsgs()` в основном цикле. diff --git a/c/set-protocol/ports/stm32-bxcan/protocan.c b/c/set-protocol/ports/stm32-bxcan/protocan.c new file mode 100644 index 0000000..153320d --- /dev/null +++ b/c/set-protocol/ports/stm32-bxcan/protocan.c @@ -0,0 +1,1667 @@ +#include "protocan.h" + +ProtoCanDevice_t CurrentDevice; +ProtoCanDevice_t Device_on_the_Network[8][16]; +struct controlflags ControlFlags; + +CAN_HandleTypeDef *_HCAN = 0; +RTC_HandleTypeDef *_HRTC = 0; +TIM_HandleTypeDef *_HTIM = 0; + +/* CurrentStep - индекс следующего сообщения на обработку (пишет основной код). + LastStep - индекс последнего записанного сообщения (пишет ISR). + Оба индекса volatile: без этого компилятор вправе закэшировать значение в + регистре, т.к. в его модели исполнения прерывание в код не входит. + Сам массив rxMsg volatile НЕ объявлен - это ухудшило бы кодогенерацию и + мешало бы копированию структуры по значению. Порядок "заполнить слот, затем + опубликовать индекс" обеспечивается барьерами __DMB() в точках публикации и + чтения. */ +volatile uint16_t CurrentStep = 1; +volatile uint16_t LastStep = 0; +struct RXMsg rxMsg[PROTOCAN_RX_BUFFER_SIZE]; + +/** +* @brief Проверяет, является ли год високосным. +* В системе год хранится как смещение от 2000. Например, значение 24 соответствует 2024. +* Эта функция добавляет 2000 к значению и проверяет, високосный ли получившийся год. +* @param year Смещение от 2000, тип uint8_t (от 0 до 255). +* @return _Bool true, если год високосный; false — иначе. +*/ +_Bool IsLeapYear(uint8_t year) +{ + year += 2000; + return (year % 400 == 0) || ((year % 4 == 0) && (year % 100 != 0)); +} + +/** +* @brief Возвращает количество доступных сообщений в буфере приёма CAN. +* Используется кольцевой буфер. +* Возвращаемое значение — количество элементов между указателями LastStep и CurrentStep. +* @return uint16_t Количество доступных сообщений, ожидающих обработки, в буфере. +*/ +uint16_t AvailableCanRxMsg(void) +{ + /* Однократное чтение индекса, обновляемого из ISR. */ + uint16_t last = LastStep; + uint16_t current = CurrentStep; + /* Индекс прочитан до обращения к данным слота. */ + __DMB(); + return (uint16_t)(((unsigned)PROTOCAN_RX_BUFFER_SIZE + last - current + 1u) % PROTOCAN_RX_BUFFER_SIZE); +} + +void PROTOCAN_DEINIT(uint8_t stage) +{ + switch(stage) + { + case 3: + #if (USE_HAL_TIM_REGISTER_CALLBACKS == 1) + HAL_TIM_UnRegisterCallback(_HTIM, HAL_TIM_PERIOD_ELAPSED_CB_ID); + #endif + case 2: + case 1: + #if (USE_HAL_CAN_REGISTER_CALLBACKS == 1) + HAL_CAN_UnRegisterCallback(_HCAN, HAL_CAN_RX_FIFO0_MSG_PENDING_CB_ID); + #endif + } + return; +} + +/** +* @brief Инициализация переферии +* @details Инициализация указателей на HCAN, HRTC, установка фильтров CAN. +* @note Фильтры CAN описаны в разделе PROTOCAN_CAN_FILTERS(). +*/ +PROTOCAN_INIT_StatusTypeDef PROTOCAN_INIT(CAN_HandleTypeDef *tmp_hcan, RTC_HandleTypeDef *tmp_hrtc, TIM_HandleTypeDef *tmp_tim) +{ + unsigned initStage = 0; + if(tmp_hcan) + { + _HCAN = tmp_hcan; + #if (USE_HAL_CAN_REGISTER_CALLBACKS == 1) + HAL_StatusTypeDef CAN_RC_RESULT = HAL_CAN_RegisterCallback(_HCAN, HAL_CAN_RX_FIFO0_MSG_PENDING_CB_ID, ProtoCanRxFifo0MsgPendingCallback); + if(CAN_RC_RESULT != HAL_OK) + { + PROTOCAN_DEINIT(initStage); + return PROTOCAN_INIT_HCAN_ERROR; + } + #endif + } else { + PROTOCAN_DEINIT(initStage); + return PROTOCAN_INIT_HCAN_ERROR; + } + initStage++; + if(tmp_hrtc) + { + _HRTC = tmp_hrtc; + } + else + { + PROTOCAN_DEINIT(initStage); + return PROTOCAN_INIT_HRTC_ERROR; + } + initStage++; + if(tmp_tim) + { + _HTIM = tmp_tim; + #if (USE_HAL_TIM_REGISTER_CALLBACKS == 1) + HAL_StatusTypeDef TIM_RC_RESULT = HAL_TIM_RegisterCallback(_HTIM, HAL_TIM_PERIOD_ELAPSED_CB_ID, ProtoCanPulseCallback); + if(TIM_RC_RESULT != HAL_OK) + { + PROTOCAN_DEINIT(initStage); + return PROTOCAN_INIT_TIM_ERROR; + } + #endif + } + else + { + PROTOCAN_DEINIT(initStage); + return PROTOCAN_INIT_TIM_ERROR; + } + initStage++; + PROTOCAN_FILTERS(); + ControlFlags.IsPulse = 1; + return PROTOCAN_INIT_OK; +} + +/** + * @brief Бесконечный цикл обработки входящих CAN-сообщений. + * + * Эта функция постоянно проверяет наличие доступных сообщений в буфере CAN и + * вызывает соответствующие обработчики в зависимости от типа полученного сообщения. + * Обрабатываемые типы сообщений: + * - Аналоговые данные + * - Широковещательные сообщения + * - Дискретные данные + * - Общее адресное пространство + * - Модбас сообщения + * - Ошибки + * + * Обработка сообщений осуществляется внутри бесконечного цикла с использованием + * функции AvailableCanRxMsg() для определения наличия новых сообщений. + * + * В конце каждого обработанного сообщения индекс текущего элемента буфера инкрементируется + * с учетом размера буфера через операцию по модулю. + */ +void PROTOCAN_LoopProcessRxMsgs(void) +{ + while(1) + { + if(AvailableCanRxMsg()) + { + if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_ANALOG) + { + PROTOCAN_AnalogProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_BROADCAST) + { + PROTOCAN_BroadcastProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_DISCRETE) + { + PROTOCAN_DiscreticProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_GENERAL_ADDRESS_SPACE) + { + ProtoCanMsgToGeneralAddressSpace(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_SETTINGS) + { + PROTOCAN_SettingsProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_COIL || + rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE || + rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_HOLDING || + rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_INPUT) + { + PROTOCAN_ModbusProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_ERROR) + { + PROTOCAN_RequestError(rxMsg[CurrentStep]); + } + // Обновление индекса текущего сообщения с циклическим масштабированием + CurrentStep = (uint16_t)(CurrentStep + 1) % PROTOCAN_RX_BUFFER_SIZE; + } + } +} + +/** + * @brief Обрабатывает все доступные входящие CAN-сообщения. + * + * Эта функция последовательно обрабатывает все сообщения, доступные в буфере, + * вызывая соответствующие функции обработчики в зависимости от типа сообщения. + * После обработки каждого сообщения индекс текущего элемента буфера обновляется + * циклически. + * + * @return PROTOCAN_OK, если все сообщения были обработаны успешно. + * В противном случае возвращается код ошибки, полученный при обработке сообщения. + * + * Обработка происходит до тех пор, пока есть доступные сообщения, полученные функцией + * AvailableCanRxMsg(). + */ +PROTOCAN_StatusTypeDef PROTOCAN_ProcessAllRxMsgs(void) +{ + PROTOCAN_StatusTypeDef result = PROTOCAN_TIMEOUT; + while(AvailableCanRxMsg()) + { + if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_ANALOG) + { + result = PROTOCAN_AnalogProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_BROADCAST) + { + result = PROTOCAN_BroadcastProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_DISCRETE) + { + result = PROTOCAN_DiscreticProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_GENERAL_ADDRESS_SPACE) + { + result = ProtoCanMsgToGeneralAddressSpace(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_SETTINGS) + { + result = PROTOCAN_SettingsProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_COIL || + rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE || + rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_HOLDING || + rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_INPUT) + { + result = PROTOCAN_ModbusProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_ERROR) + { + result = PROTOCAN_RequestError(rxMsg[CurrentStep]); + } + CurrentStep = (uint16_t)(CurrentStep + 1) % PROTOCAN_RX_BUFFER_SIZE; + if(result != PROTOCAN_OK) + { + return result; + } + } + return result; +} + +/** + * @brief Обрабатывает одно доступное CAN-сообщение. + * + * Эта функция проверяет наличие доступных сообщений в буфере и, если таковое есть, + * выполняет обработку текущего сообщения в зависимости от его типа. После обработки + * индекс текущего сообщения обновляется циклически. + * + * @return Прошедший статус обработки: + * - PROTOCAN_OK, если сообщение успешно обработано, + * - PROTOCAN_TIMEOUT, если сообщений не было, + * - или код ошибки, возвращенный функцией обработки. + */ +PROTOCAN_StatusTypeDef PROTOCAN_ProcessSingleRxMsg(void) +{ + PROTOCAN_StatusTypeDef result = PROTOCAN_TIMEOUT; + if(AvailableCanRxMsg()) + { + if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_ANALOG) + { + result = PROTOCAN_AnalogProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_BROADCAST) + { + result = PROTOCAN_BroadcastProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_DISCRETE) + { + result = PROTOCAN_DiscreticProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_GENERAL_ADDRESS_SPACE) + { + result = ProtoCanMsgToGeneralAddressSpace(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_SETTINGS) + { + result = PROTOCAN_SettingsProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_COIL || + rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE || + rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_HOLDING || + rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_INPUT) + { + result = PROTOCAN_ModbusProcessing(rxMsg[CurrentStep]); + } + else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_ERROR) + { + result = PROTOCAN_RequestError(rxMsg[CurrentStep]); + } + CurrentStep = (uint16_t)(CurrentStep + 1) % PROTOCAN_RX_BUFFER_SIZE; + } + return result; +} + +/** +* @brief Функция обработки широковещательных запросов. +* @param struct RXMsg _rxMsg - структура для полученного сообщения. +* @details Функция, выполняющая команды, переданные в широковещательном формате с головного (master) устройства. Типы команд: Запрос статуса, запрос на включение или выключение, рестарт устройств, установка времени. +* @return HAL_StatusTypeDef Статус выполнения команды CAN (успех или ошибка). +*/ +PROTOCAN_StatusTypeDef PROTOCAN_BroadcastProcessing(struct RXMsg _rxMsg) +{ + msgBodyBroadcastType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + switch(msg.Fields.Type) + { + case PROTOCAN_BROADCAST_STATUS: + { + //Обработка запроса статуса устройства + return ProtoCanMsgToBroadcastStatus(_rxMsg); + } + case PROTOCAN_BROADCAST_ONOFF: + { + //Обработка запроса на вкл/выкл + ProtoCanMsgToBroadcastOnOff(_rxMsg); + break; + } + case PROTOCAN_BROADCAST_RESTARTDEVICE: + { + ProtoCanMsgToBroadcastRestart(_rxMsg); + break; + } + case PROTOCAN_BROADCAST_RTCSETUP: + { + //Обработка запроса на синхронизацию времени + //С головным устройством + ProtoCanMsgToBroadcastRtcSetup(_rxMsg); + break;; + } + default: + //RESERVE SENSOR TYPE. + return PROTOCAN_ERROR; + break; + } + return PROTOCAN_OK; +} + +/** +* @brief Формирует и отправляет широковещательное CAN-сообщение для ответа на запрос статуса. +* Устанавливает идентификатор, собирает текущие параметры времени и даты, и отправляет сообщение. +* @param _rxMsg Структура полученного по CAN запроса. +* @return HAL_StatusTypeDef Статус операции HAL, например HAL_OK при успехе. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToBroadcastStatus(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.DLC = 7; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + tmp_eID.Fields.DeviceType = CURRENT_TYPE_DEVICE; + tmp_eID.Fields.DeviceID = CURRENT_ID_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + RTC_TimeTypeDef sTime = {0}; + HAL_RTC_GetTime(_HRTC, &sTime, RTC_FORMAT_BIN); + data[0] = sTime.Hours; + data[1] = sTime.Minutes; + data[2] = sTime.Seconds; + RTC_DateTypeDef DateToUpdate = {0}; + HAL_RTC_GetDate(_HRTC, &DateToUpdate, RTC_FORMAT_BIN); + data[3] = DateToUpdate.Year; + data[4] = DateToUpdate.Month; + data[5] = DateToUpdate.Date; + data[6] = DateToUpdate.WeekDay; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** +* @brief Виртуальная слабая функция для переключения режима пульсации ON/OFF. +* Меняет состояние флага IsPulse в структуре ControlFlags. +* В данном случае — просто инвертирует его логическое значение. +* @param _rxMsg Структура полученного CAN-сообщения (Пока не используется в функции). +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToBroadcastOnOff(struct RXMsg _rxMsg) +{ + ControlFlags.IsPulse = !ControlFlags.IsPulse; + return PROTOCAN_OK; +} + +/** +* @brief __weak функция для обработки широковещательного сообщения перезапуска устройства. +* Проверяет длину сообщения, извлекает ID из данных и, если она совпадает с текущим устройством, +* вызывает системный перезапуск. +* @param _rxMsg Структура полученного CAN-сообщения с командой перезапуска. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToBroadcastRestart(struct RXMsg _rxMsg) +{ + if(_rxMsg.DLC == 0) + { + return PROTOCAN_ERROR; + } + msgBodyBroadcastType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + if(msg.Fields.Body == (CURRENT_ID_DEVICE / (_rxMsg.DLC*8))) + { + uint64_t page = 0; + for(uint32_t i = 0; i < _rxMsg.DLC; i++) + { + /* Приведение обязательно: Data[i] продвигается до int, и сдвиг на + 32..56 бит без него - неопределённое поведение. */ + page |= (uint64_t)_rxMsg.Data[i] << (i * 8u); + } + if((page >> CURRENT_ID_DEVICE) & 1u) + { + NVIC_SystemReset(); + } + } + return PROTOCAN_OK; +} + +/** +* @brief __weak функция для обработки запроса на синхронизацию RTC через широковещательное сообщение. +* Проверяет длину сообщения и корректность данных (время и дата). Если все проверки пройдены, +* вызывает функцию синхронизации RTC. +* @param _rxMsg Структура полученного CAN-сообщения с данными для установки времени и даты. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToBroadcastRtcSetup(struct RXMsg _rxMsg) +{ + /* Нужны все 7 полей: чч, мм, сс, год, месяц, число, день недели. + Прежняя проверка (DLC > 7) пропускала в том числе пустой кадр, и время + устанавливалось из неинициализированных байт. */ + if(_rxMsg.DLC != 7) + { + return PROTOCAN_ERROR; + } + else + { + static const uint8_t DaysCount_Normal[2][12] = {{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, + {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}}; + /* Месяц нумеруется с 1, индекс массива - с 0. */ + if( _rxMsg.Data[0] > 23 || + _rxMsg.Data[1] > 59 || + _rxMsg.Data[2] > 59 || + _rxMsg.Data[3] > 99 || + _rxMsg.Data[4] == 0 || _rxMsg.Data[4] > 12 || + _rxMsg.Data[5] == 0 || + _rxMsg.Data[5] > DaysCount_Normal[IsLeapYear(_rxMsg.Data[3])][_rxMsg.Data[4] - 1] || + _rxMsg.Data[6] > 6) + { + return PROTOCAN_ERROR; + } + else + { + PROTOCAN_RTC_SYNC(_rxMsg.Data); + } + } + return PROTOCAN_OK; +} + +/** +* @brief Функция обработки дискретных запросов. +* @param struct RXMsg _rxMsg - структура для полученного сообщения. +* @details Функция, формирующая и отправляющая ответ на запросы. Типы запросов: Аварии, Предупреждения, Управляющие сигналы, Флаги, Рестарт устройства, Изменение режима работы устройства, Запрос на устройство. +* @note Запрос на устройство. Головное (master) устройство запрашивает некоторое колличество параметров. В Data - 64 битовых адресса параметров, тип которых задаётся в Sensor ID. Имеется возможность запрашивать непоследовательные параметры. +* @return HAL_StatusTypeDef Статус выполнения команды CAN (успех или ошибка). +*/ +PROTOCAN_StatusTypeDef PROTOCAN_DiscreticProcessing(struct RXMsg _rxMsg) +{ + msgBodyDiscreteType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + switch(msg.Fields.Type) + { + case PROTOCAN_DISCRETE_ACCIDENT: + { + ProtoCanMsgToDiscreteAccident(_rxMsg); + break; + } + case PROTOCAN_DISCRETE_WARNING: + { + ProtoCanMsgToDiscreteWarning(_rxMsg); + break; + } + case PROTOCAN_DISCRETE_CONTROL_SIGNALS: + { + ProtoCanMsgToDiscreteControlSignals(_rxMsg); + break; + } + case PROTOCAN_DISCRETE_FLAGS: + { + ProtoCanMsgToDiscreteFlags(_rxMsg); + break; + } + case PROTOCAN_DISCRETE_RESET: + { + ProtoCanMsgToDiscreteReset(_rxMsg); + break; + } + case PROTOCAN_DISCRETE_CHANGE_MODE: + { + ProtoCanMsgToDiscreteChangeMode(_rxMsg); + break; + } + case PROTOCAN_DISCRETE_REQUEST_LIST_OF_PARAMETERS: + { + ProtoCanMsgToDiscreteRequestListOfParameters(_rxMsg); + break; + } + default: + //RESERVE SENSOR TYPE. + return PROTOCAN_ERROR; + break; + } + return PROTOCAN_OK; +} + +/** +* @brief Обработка запроса аварийных ситуаций. +* В текущей реализации — заглушка, ничего не выполняет. +* Можно реализовать отправку или обработку аварийных данных. +* @param _rxMsg Входящее сообщение с данными запроса. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteAccident(struct RXMsg _rxMsg) +{ + return PROTOCAN_OK; +} + +/** +* @brief Обработка запроса предупреждений. +* В текущей реализации — заглушка. +* Можно реализовать обработку или ответ на предупреждения. +* @param _rxMsg Входящее сообщение. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteWarning(struct RXMsg _rxMsg) +{ + return PROTOCAN_OK; +} + +/** +* @brief Обработка запроса управляющих сигналов. +* В текущей реализации — заглушка. +* Можно реализовать управление сигналами. +* @param _rxMsg Входящее сообщение. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteControlSignals(struct RXMsg _rxMsg) +{ + return PROTOCAN_OK; +} + +/** +* @brief Обработка запроса флагов. +* В текущей реализации — заглушка. +* Можно реализовать работу с флагами. +* @param _rxMsg Входящее сообщение. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteFlags(struct RXMsg _rxMsg) +{ + return PROTOCAN_OK; +} + +/** +* @brief Обработка рестарта системы по CAN-запросу. +* Выполняет программный рестарт системы. +* @param _rxMsg Входящее сообщение. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteReset(struct RXMsg _rxMsg) +{ + NVIC_SystemReset(); + return PROTOCAN_ERROR; +} + +/** +* @brief Обработка запроса на изменение режима. +* В текущей реализации — заглушка. +* Можно реализовать изменение режима работы. +* @param _rxMsg Входящее сообщение. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteChangeMode(struct RXMsg _rxMsg) +{ + return PROTOCAN_OK; +} + +/** +* @brief Обработка запроса на получение списка параметров. +* В текущей реализации — заглушка. +* Можно реализовать отправку списка параметров. +* @param _rxMsg Входящее сообщение. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteRequestListOfParameters(struct RXMsg _rxMsg) +{ + return PROTOCAN_OK; +} + +/** +* @brief Функция обработки аналоговых запросов. +* @param struct RXMsg _rxMsg - структура для полученного сообщения. +* @details Функция, сортирующая запросы по типам и вызывающая соответствующие функции. Типы запросов: Универсальный, Уставки, Напряжение, Ток, Температура. +* @return HAL_StatusTypeDef Статус выполнения команды CAN (успех или ошибка). +*/ +PROTOCAN_StatusTypeDef PROTOCAN_AnalogProcessing(struct RXMsg _rxMsg) +{ + msgBodyAnalogType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + switch (msg.Fields.Type) + { + case PROTOCAN_ANALOG_UNIVERSAL: + { + return ProtoCanMsgToAnalogUniversal(_rxMsg); + break; + } + case PROTOCAN_ANALOG_SETTINGS: + { + return ProtoCanMsgToAnalogSettings(_rxMsg); + break; + } + case PROTOCAN_ANALOG_U: + { + return ProtoCanMsgToAnalogUSens(_rxMsg); + break; + } + case PROTOCAN_ANALOG_I: + { + return ProtoCanMsgToAnalogISens(_rxMsg); + break; + } + case PROTOCAN_ANALOG_T: + { + return ProtoCanMsgToAnalogTSens(_rxMsg); + break; + } + default: + //RESERVE SENSOR TYPE + return PROTOCAN_ERROR; + break; + } + return PROTOCAN_ERROR; +} + +/** +* @brief Функция ответа на запрос данных со всех датчиков. +* Создает сообщение с определенными данными и отправляет его через CAN-шину. +* @param _rxMsg Структура входящего сообщения, используется для формирования ID. +* @return HAL_StatusTypeDef Статус выполнения операции HAL, например HAL_OK. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogUniversal(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 6; + data[0] = 'U'; + data[1] = 'N'; + data[2] = 'I'; + data[3] = 'V'; + data[4] = 'E'; + data[5] = 'R'; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** +* @brief Функция ответа на запрос уставок по CAN. +* Создает сообщение с определенными данными и отправляет его через CAN-шину. +* @param _rxMsg Структура входящего сообщения, используется только для формирования ID. +* @return HAL_StatusTypeDef Статус выполнения операции HAL, например HAL_OK. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogSettings(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 7; + data[0] = 'U'; + data[1] = 'S'; + data[2] = 'T'; + data[3] = 'A'; + data[4] = 'V'; + data[5] = 'K'; + data[6] = 'I'; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** +* @brief Функция ответа на запрос данных с датчиков напряжения. +* Создает сообщение с указанными параметрами и отправляет его через CAN-шину. +* @param _rxMsg Структура входящего сообщения, используемая для формирования идентификатора и данных. +* @return HAL_StatusTypeDef Статус выполнения операции HAL, например HAL_OK при успешной отправке. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogUSens(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 6; + msgBodyAnalogType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + data[0] = 'U'; + data[1] = 'S'; + data[2] = '0' + msg.Fields.SensorID / 1000; + data[3] = '0' + msg.Fields.SensorID / 100 % 10; + data[4] = '0' + msg.Fields.SensorID / 10 % 10; + data[5] = '0' + msg.Fields.SensorID % 10; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** +* @brief Функция ответа на запрос данных с датчиков тока. +* Создает сообщение с определенными параметрами и отправляет его через CAN-шину. +* @param _rxMsg Структура входящего сообщения, используется для формирования идентификатора и данных. +* @return HAL_StatusTypeDef Статус операции HAL, например HAL_OK при успешной отправке. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogISens(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 6; + msgBodyAnalogType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + data[0] = 'I'; + data[1] = 'S'; + data[2] = '0' + msg.Fields.SensorID / 1000; + data[3] = '0' + msg.Fields.SensorID / 100 % 10; + data[4] = '0' + msg.Fields.SensorID / 10 % 10; + data[5] = '0' + msg.Fields.SensorID % 10; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** +* @brief Функция ответа на запрос данных с датчиков температуры. +* Создает сообщение с определенным заголовком, формирует данные по ID сенсора и отправляет его через CAN. +* @param _rxMsg Структура полученного сообщения, которая используется для формирования идентификатора. +* @return HAL_StatusTypeDef Статус выполнения функции HAL, например HAL_OK при успехе. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogTSens(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 6; + msgBodyAnalogType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + data[0] = 'T'; + data[1] = 'S'; + data[2] = '0' + msg.Fields.SensorID / 1000; + data[3] = '0' + msg.Fields.SensorID / 100 % 10; + data[4] = '0' + msg.Fields.SensorID / 10 % 10; + data[5] = '0' + msg.Fields.SensorID % 10; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** + * @brief Отправляет сообщение по CAN-шине в соответствии с типом протокола. + * Эта функция формирует и отправляет сообщение через CAN-шину, основываясь на типе + * переданного ID и данных. Поддерживаются типы сообщений: широковещательные, дискретные + * и аналоговые. + * @param id Идентификатор сообщения, содержащий тип сообщения и другую информацию. + * @param coreData Структура данных, включающая тип, тело и данные для передачи. + * @return PROTOCAN_StatusTypeDef Статус операции. + * @details + * В зависимости от типа сообщения (панель broadcast, discrete или analog), + * формируется соответствующий пакет данных и отправляется на шину CAN. + * Для сообщений, превышающих длину 8 байт, передача выполняется пакетами. + * В случае ошибок при добавлении сообщения в очередь передачи, возвращается ошибка. + */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_CORE(const ProtoCanId_t id, const struct ProtoCanCoreData coreData) +{ + ProtoCanId_t localId = id; + CAN_TxHeaderTypeDef TxHeader; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.RTR = CAN_RTR_DATA; + TxHeader.TransmitGlobalTime = DISABLE; + uint32_t TxMailBox = 0; + uint8_t canData[8]; + switch(id.Fields.MsgType) + { + case PROTOCAN_MSGTYPE_BROADCAST: + { + msgBodyBroadcastType body; + body.Fields.Type = coreData.Type; + body.Fields.Body = coreData.Body; + localId.Fields.MsgBody = body.Body; + TxHeader.ExtId = PROTOCAN_IdPack(&localId); + TxHeader.DLC = 0; + if(HAL_CAN_AddTxMessage(_HCAN, &TxHeader, canData, &TxMailBox) != HAL_OK) + { + return PROTOCAN_ERROR; + } + break; + } + case PROTOCAN_MSGTYPE_DISCRETE: + { + msgBodyDiscreteType body; + body.Fields.Type = coreData.Type; + uint8_t regsRemaining = coreData.DataCount; + uint8_t currentIndex = 0; + while(regsRemaining > 0) + { + uint8_t regsInPacket = (regsRemaining > 8) ? 8 : regsRemaining; + body.Fields.Body = coreData.Body + currentIndex; + localId.Fields.MsgBody = body.Body; + TxHeader.ExtId = PROTOCAN_IdPack(&localId); + TxHeader.DLC = regsInPacket; + for(int i = 0; i < regsInPacket; i++) + { + canData[i] = coreData.Data[currentIndex + i]; + } + HAL_StatusTypeDef status = HAL_CAN_AddTxMessage(_HCAN, &TxHeader, canData, &TxMailBox); + if(status != HAL_OK) + { + return (PROTOCAN_StatusTypeDef)status; + } + regsRemaining -= regsInPacket; + currentIndex += regsInPacket; + } + break; + } + case PROTOCAN_MSGTYPE_ANALOG: + { + msgBodyAnalogType body; + body.Fields.Type = coreData.Type; + uint8_t regsRemaining = coreData.DataCount; + uint8_t currentIndex = 0; + while(regsRemaining > 0) + { + uint8_t regsInPacket = (regsRemaining > 8) ? 8 : regsRemaining; + body.Fields.SensorID = coreData.Body + currentIndex; + localId.Fields.MsgBody = body.Body; + TxHeader.ExtId = PROTOCAN_IdPack(&localId); + TxHeader.DLC = regsInPacket; + for(int i = 0; i < regsInPacket; i++) + { + canData[i] = coreData.Data[currentIndex + i]; + } + HAL_StatusTypeDef status = HAL_CAN_AddTxMessage(_HCAN, &TxHeader, canData, &TxMailBox); + if(status != HAL_OK) + { + return (PROTOCAN_StatusTypeDef)status; + } + regsRemaining -= regsInPacket; + currentIndex += regsInPacket; + } + break; + } + default: + { + return PROTOCAN_ERROR; + } + } + return PROTOCAN_OK; +} + +/** + * @brief Разбирает однокадровый запрос SETTINGS для текущего устройства. + * + * Body[15:8] содержит номер сборки, Body[7:0] — позицию. DLC=0 означает + * GET, DLC=8 с ненулевым ROM — WRITE/REPLACE, DLC=8 с нулями — CLEAR. + * Проверка физического датчика и сохранение EEPROM выполняются приложением в + * переопределённой функции ProtoCanMsgToSettings(). + */ +PROTOCAN_StatusTypeDef PROTOCAN_SettingsProcessing(struct RXMsg _rxMsg) +{ + ProtoCanSettingsMsg_t message; + uint8_t index; + uint8_t romIsZero = 1U; + + if((_rxMsg.eID.BitAll & PROTOCAN_SETTINGS_HEADER_MASK) + != PROTOCAN_SETTINGS_REQUEST_BASE_ID) + { + return PROTOCAN_ERROR; + } + + message.AssemblySerial = (uint8_t)((_rxMsg.eID.Fields.MsgBody >> 8U) & 0xFFU); + message.Position = (uint8_t)(_rxMsg.eID.Fields.MsgBody & 0xFFU); + message.Result = PROTOCAN_SETTINGS_RESULT_OK; + for(index = 0U; index < PROTOCAN_SETTINGS_ROM_SIZE; index++) + { + message.Rom[index] = 0U; + } + + if((_rxMsg.info.RTR != 0U) + || ((_rxMsg.DLC != 0U) && (_rxMsg.DLC != PROTOCAN_SETTINGS_ROM_SIZE))) + { + message.Operation = PROTOCAN_SETTINGS_INVALID; + message.Result = PROTOCAN_SETTINGS_RESULT_INVALID_DLC; + return PROTOCAN_SEND_SETTINGS_ERROR(message.AssemblySerial, message.Position, + message.Result); + } + if(_rxMsg.DLC == 0U) + { + message.Operation = PROTOCAN_SETTINGS_GET; + return ProtoCanMsgToSettings(&message); + } + + for(index = 0U; index < PROTOCAN_SETTINGS_ROM_SIZE; index++) + { + message.Rom[index] = _rxMsg.Data[index]; + if(message.Rom[index] != 0U) + { + romIsZero = 0U; + } + } + message.Operation = (romIsZero != 0U) ? PROTOCAN_SETTINGS_CLEAR + : PROTOCAN_SETTINGS_WRITE; + return ProtoCanMsgToSettings(&message); +} + +/** + * @brief Слабая точка подключения каталога датчиков приложения. + */ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToSettings( + const ProtoCanSettingsMsg_t *message) +{ + (void)message; + return PROTOCAN_ERROR; +} + +/** Отправляет один SETTINGS-кадр с Body из id и payload длиной 0..8. */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_SETTINGS( + ProtoCanId_t id, const struct ProtoCanSettingsData data) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0U; + uint8_t canData[PROTOCAN_SETTINGS_ROM_SIZE] = {0U}; + uint8_t index; + + if((id.Fields.MsgType != PROTOCAN_MSGTYPE_SETTINGS) + || (data.DataCount > PROTOCAN_SETTINGS_ROM_SIZE) + || ((data.DataCount != 0U) && (data.Data == 0))) + { + return PROTOCAN_ERROR; + } + for(index = 0U; index < data.DataCount; index++) + { + canData[index] = data.Data[index]; + } + TxHeader.ExtId = PROTOCAN_IdPack(&id); + TxHeader.IDE = CAN_ID_EXT; + TxHeader.RTR = CAN_RTR_DATA; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.DLC = data.DataCount; + if(HAL_CAN_AddTxMessage(_HCAN, &TxHeader, canData, &TxMailBox) != HAL_OK) + { + return PROTOCAN_ERROR; + } + return PROTOCAN_OK; +} + +/** Отправляет успешный ответ SETTINGS с текущим ROM целевой локации. */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_SETTINGS_RESPONSE(uint8_t assemblySerial, + uint8_t position, const uint8_t *rom) +{ + ProtoCanId_t id; + ProtoCanData_t data; + + if(rom == 0) + { + return PROTOCAN_ERROR; + } + id.BitAll = PROTOCAN_SETTINGS_RESPONSE_BASE_ID + | ((uint32_t)assemblySerial << 8U) | position; + data.SettingsData.Data = rom; + data.SettingsData.DataCount = PROTOCAN_SETTINGS_ROM_SIZE; + return PROTOCAN_SEND_SETTINGS(id, data.SettingsData); +} + +/** Отправляет однобайтовую ошибку SETTINGS с сохранением Body запроса. */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_SETTINGS_ERROR(uint8_t assemblySerial, + uint8_t position, ProtoCanSettingsResultType result) +{ + ProtoCanId_t id; + ProtoCanData_t data; + const uint8_t error = (uint8_t)result; + + if(result == PROTOCAN_SETTINGS_RESULT_OK) + { + return PROTOCAN_ERROR; + } + id.BitAll = PROTOCAN_SETTINGS_RESPONSE_BASE_ID + | ((uint32_t)assemblySerial << 8U) | position; + data.SettingsData.Data = &error; + data.SettingsData.DataCount = 1U; + return PROTOCAN_SEND_SETTINGS(id, data.SettingsData); +} + +/** +* @brief Обработка и ответ на запрос общего адресного пространства. +* Формирует и передает сообщение с данными о статусе "GAS-XXXX", где XXXX — значение из сообщения _rxMsg. +* @param _rxMsg Структура входящего сообщения, содержит идентификатор и тело сообщения. +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToGeneralAddressSpace(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 8; + data[0] = 'G'; + data[1] = 'A'; + data[2] = 'S'; + data[3] = '-'; + for(int i = 0; i < 4; i++) + { + unsigned sym = (_rxMsg.eID.Fields.MsgBody >> (12 - (i * 4))) & 0xF; + if(sym >= 10) + { + data[4 + i] = sym % 10 + 'A'; + } + else + { + data[4 + i] = sym + '0'; + } + } + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** + * @brief Отправляет произвольный диапазон регистров в пакетах по, максимум, 4 регистра за раз через CAN. + * Функция разбивает передаваемый массив регистров на пакеты по 4 или меньше и отправляет их по отдельности. + * Для каждого пакета формируется уникальный MsgBody, равный адресу первого регистра в пакете. + * @param priority Приоритет сообщения CAN. ProtoCan_Priority_TypeDef + * @param regStartAdr Адрес первого регистра из всего массива. + * @param data Указатель на массив регистров для отправки. + * @param regCount Общее количество регистров для отправки. + * @return Возвращает статус отправки: HAL_OK при успехе, или код ошибки HAL_StatusTypeDef при сбое. + */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_GENERAL_ADDRESS_SPACE(ProtoCanId_t id, uint16_t regStartAdr, uint16_t *data, uint8_t regCount) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t canData[8]; + + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = id.BitAll; + + uint8_t regsRemaining = regCount; + uint8_t currentIndex = 0; + + while(regsRemaining > 0) + { + uint8_t regsInPacket = (regsRemaining > 4) ? 4 : regsRemaining; + + tmp_eID.Fields.MsgBody = regStartAdr + currentIndex; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = regsInPacket*2; + + for(int i = 0; i < regsInPacket; i++) + { + canData[(i * 2)] = LowByteOfWord(data[currentIndex + i]); + canData[(i * 2) + 1] = HighByteOfWord(data[currentIndex + i]); + } + + HAL_StatusTypeDef status = HAL_CAN_AddTxMessage(_HCAN, &TxHeader, canData, &TxMailBox); + + if(status != HAL_OK) + { + return (PROTOCAN_StatusTypeDef)status; + } + + regsRemaining -= regsInPacket; + currentIndex += regsInPacket; + } + return (PROTOCAN_StatusTypeDef)HAL_OK; +} + +/** +* @brief Обработка запроса на основе типа Modbus. +* В зависимости от типа сообщения, вызывает соответствующую функцию +* для отправки Modbus-запроса по CAN-шине. +* @param _rxMsg Структура входящего сообщения RXMsg. +* @return HAL_StatusTypeDef Статус выполнения команды CAN (успех или ошибка). +*/ +PROTOCAN_StatusTypeDef PROTOCAN_ModbusProcessing(struct RXMsg _rxMsg) +{ + switch(_rxMsg.eID.Fields.MsgType) + { + case PROTOCAN_MSGTYPE_MODBUS_COIL: + { + return ProtoCanMsgToModbusCoil(_rxMsg); + break; + } + case PROTOCAN_MSGTYPE_MODBUS_DISCRETE: + { + return ProtoCanMsgToModbusDiscrete(_rxMsg); + break; + } + case PROTOCAN_MSGTYPE_MODBUS_HOLDING: + { + return ProtoCanMsgToModbusHolding(_rxMsg); + break; + } + case PROTOCAN_MSGTYPE_MODBUS_INPUT: + { + return ProtoCanMsgToModbusInput(_rxMsg); + break; + } + default: + //ERROR + return PROTOCAN_ERROR; + break; + } + return PROTOCAN_ERROR; +} + +/** +* @brief __weak Функция отправки ответа на запрос Coil Modbus по CAN. +* Формирует CAN ответ на запрос Coil Modbus и отправляет его. +* @param _rxMsg Структура входящего сообщения RXMsg. +* @return HAL_StatusTypeDef Статус выполнения команды CAN (успех или ошибка). +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToModbusCoil(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 8; + msgBodyModbusType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + data[0] = 'M'; + data[1] = 'C'; + data[2] = ' '; + data[3] = 'S'; + data[4] = msg.Fields.StrAdr; + data[5] = ' '; + data[6] = 'C'; + data[7] = msg.Fields.RegCount; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** +* @brief __weak Функция отправки ответа на запрос Discrete Modbus по CAN. +* Формирует CAN ответ на запрос Discrete Modbus и отправляет его. +* @param _rxMsg Структура входящего сообщения RXMsg. +* @return HAL_StatusTypeDef Статус выполнения команды CAN (успех или ошибка). +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToModbusDiscrete(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 8; + msgBodyModbusType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + data[0] = 'M'; + data[1] = 'D'; + data[2] = ' '; + data[3] = 'S'; + data[4] = msg.Fields.StrAdr; + data[5] = ' '; + data[6] = 'C'; + data[7] = msg.Fields.RegCount; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** +* @brief __weak Функция отправки ответа на запрос Holding Modbus по CAN. +* Формирует CAN ответ на запрос Holding Modbus и отправляет его. +* @param _rxMsg Структура входящего сообщения RXMsg. +* @return HAL_StatusTypeDef Статус выполнения команды CAN (успех или ошибка). +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToModbusHolding(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 8; + msgBodyModbusType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + data[0] = 'M'; + data[1] = 'H'; + data[2] = ' '; + data[3] = 'S'; + data[4] = msg.Fields.StrAdr; + data[5] = ' '; + data[6] = 'C'; + data[7] = msg.Fields.RegCount; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** +* @brief __weak Функция отправки ответа на запрос Input Modbus по CAN. +* Формирует CAN ответ на запрос Input Modbus и отправляет его. +* @param _rxMsg Структура входящего сообщения RXMsg. +* @return HAL_StatusTypeDef Статус выполнения команды CAN (успех или ошибка). +*/ +__weak PROTOCAN_StatusTypeDef ProtoCanMsgToModbusInput(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 8; + msgBodyModbusType msg; + msg.Body = _rxMsg.eID.Fields.MsgBody; + data[0] = 'M'; + data[1] = 'I'; + data[2] = ' '; + data[3] = 'S'; + data[4] = msg.Fields.StrAdr; + data[5] = ' '; + data[6] = 'C'; + data[7] = msg.Fields.RegCount; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** + * @brief Отправляет данные Modbus через CAN-шину в соответствии с типом сообщения. + * Эта функция формирует и отправляет Modbus-данные по CAN-шине, поддерживая + * различные типы сообщений: Coil, Discrete, Holding Register и Input Register. + * Для сообщений, превышающих 4 регистров, данные отправляются пакетами. + * @param id Идентификатор сообщения, содержащий тип сообщения и другую информацию. + * @param modbusData Структура с данными Modbus: адрес, количество регистров и сами данные. + * @return PROTOCAN_StatusTypeDef Статус операции. + * @details + * В зависимости от типа Modbus (coil/discrete или holding/input), формируется + * соответствующий CAN-пакет. Для coil/discrete отправляется один пакет, для holding/input — + * несколько, по 4 регистра в пакете. В случае ошибок при добавлении сообщения API HAL возвращается ошибка. + */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_MODBUS(const ProtoCanId_t id, const struct ProtoCanModbusData modbusData) +{ + ProtoCanId_t localId = id; + uint8_t msgType = localId.Fields.MsgType; + // Структура для отправки + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t canData[8]; + if(msgType == PROTOCAN_MSGTYPE_MODBUS_COIL || msgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE) + { + // Формируем body + msgBodyModbusType body; + body.Fields.StrAdr = modbusData.StrAdr; + body.Fields.RegCount = modbusData.RegCount; + // Устанавливаем коллизию ExtId с body.Body + localId.Fields.MsgBody = (body.Body & 0xFFFF); + // Обновляем ExtId + TxHeader.ExtId = PROTOCAN_IdPack(&localId); + // Остальные настройки + TxHeader.IDE = CAN_ID_EXT; + TxHeader.RTR = CAN_RTR_DATA; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.DLC = modbusData.RegCount % 8 + 1; + // Формируем CAN данные + canData[0] = LowByteOfWord(modbusData.Data[0]); + canData[1] = HighByteOfWord(modbusData.Data[0]); + if (HAL_CAN_AddTxMessage(_HCAN, &TxHeader, canData, &TxMailBox) != HAL_OK) + { + return PROTOCAN_ERROR; + } + } + else if(msgType == PROTOCAN_MSGTYPE_MODBUS_HOLDING || msgType == PROTOCAN_MSGTYPE_MODBUS_INPUT) + { + uint16_t* dataPtr = modbusData.Data; + unsigned totalRegs = modbusData.RegCount; + unsigned regsProcessed = 0; + unsigned startAddress = modbusData.StrAdr; + while (regsProcessed < totalRegs) + { + uint8_t regsInPacket = (totalRegs - regsProcessed) > 4 ? 4 : (totalRegs - regsProcessed); + // Формируем body + msgBodyModbusType body; + body.Fields.StrAdr = startAddress; + body.Fields.RegCount = regsInPacket; + // Обновляем ExtId + localId.Fields.MsgBody = (body.Body & 0xFFFF); + TxHeader.ExtId = PROTOCAN_IdPack(&localId); + TxHeader.IDE = CAN_ID_EXT; + TxHeader.RTR = CAN_RTR_DATA; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.DLC = regsInPacket * 2; + // Добавляем регистры + for (int i = 0; i < regsInPacket; i++) + { + canData[i * 2] = LowByteOfWord(dataPtr[regsProcessed + i]); + canData[i * 2 + 1] = HighByteOfWord(dataPtr[regsProcessed + i]); + } + // Отправляем сообщение + if(HAL_CAN_AddTxMessage(_HCAN, &TxHeader, canData, &TxMailBox) != HAL_OK) + { + return PROTOCAN_ERROR; + } + regsProcessed += regsInPacket; + startAddress += regsInPacket; + } + } + else + { + return PROTOCAN_ERROR; // Неверный тип сообщения + } + return PROTOCAN_OK; +} + +/** +* @brief __weak Функция отправки сообщения об ошибке. +* Посылает CAN сообщение с кодом ошибки. Используется, когда необходимо оповестить о неуспешной операции. +* @param _rxMsg Структура входящего сообщения RXMsg, данными которого формируется ответ-ошибка. +* @return HAL_StatusTypeDef Статус выполнения команды CAN. +*/ +__weak PROTOCAN_StatusTypeDef PROTOCAN_RequestError(struct RXMsg _rxMsg) +{ + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + ProtoCanId_t tmp_eID; + tmp_eID.BitAll = _rxMsg.eID.BitAll; + tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + tmp_eID.Fields.MsgType = PROTOCAN_MSGTYPE_ERROR; + msgBodyErrorType msg; + msg.Fields.ErrorCode = 0xFF; + msg.Fields.Info = 0; + tmp_eID.Fields.MsgBody = msg.Body; + TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID); + TxHeader.DLC = 0; + return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); +} + +/** + * @brief Отправляет сообщение об ошибке по CAN-шине. + * Эта функция формирует и отправляет сообщение об ошибке с кодом и информацией, + * объединёнными в поле MsgBody идентификатора сообщения, и передаёт его через CAN. + * @param id Идентификатор сообщения, содержащий базовую информацию. + * @param errorData Структура с данными ошибки: код и дополнительная информация. + * @return PROTOCAN_StatusTypeDef Статус операции. + * @details + * В качестве данных передаётся 2 байта: старшие биты — информация об ошибке, младшие — код ошибки. + * Размер данных в CANFrames устанавливается нулевым, так как все данные инкапсулированы в MsgBody. + */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_ERROR(const ProtoCanId_t id, const struct ProtoCanErrorData errorData) +{ + ProtoCanId_t localId = id; + // Структура для отправки + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + uint8_t canData[8]; + localId.Fields.MsgBody = ((errorData.Info << 8) | errorData.Code); + // Обновляем ExtId + TxHeader.ExtId = PROTOCAN_IdPack(&localId); + // Остальные настройки + TxHeader.IDE = CAN_ID_EXT; + TxHeader.RTR = CAN_RTR_DATA; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.DLC = 0; + if(HAL_CAN_AddTxMessage(_HCAN, &TxHeader, canData, &TxMailBox) != HAL_OK) + { + return PROTOCAN_ERROR; + } + return PROTOCAN_OK; +} + +/** +* @brief Записывает полученное сообщение CAN в буфер rxMsg. +* Копирует параметры расширенного ID, типы, длину данных и сам данные в указанный элемент буфера. +* @param tmp_eID Расширенный ID сообщения. +* @param tmp_IDE Тип идентификатора (например, CAN_ID_EXT). +* @param tmp_RTR Тип запроса (например, CAN_RTR_DATA). +* @param tmp_DLC Длина данных в сообщении. +* @param tmp_DATA Указатель на массив данных, полученных из CAN. +* @param tmp_LastStep Индекс позиции в буфере, куда записывать сообщение. +*/ +void TakeRxMsgToBuffer(ProtoCanId_t tmp_eID, uint32_t tmp_IDE, uint32_t tmp_RTR, uint32_t tmp_DLC, uint8_t *tmp_DATA, uint16_t tmp_LastStep) +{ + if(tmp_DLC > 8u) + { + tmp_DLC = 8u; + } + rxMsg[tmp_LastStep].eID.BitAll = tmp_eID.BitAll; + rxMsg[tmp_LastStep].info.EXT = (tmp_IDE == CAN_ID_EXT) ? 1U : 0U; + rxMsg[tmp_LastStep].info.RTR = (tmp_RTR == CAN_RTR_REMOTE) ? 1U : 0U; + rxMsg[tmp_LastStep].DLC = tmp_DLC; + for(uint32_t i = 0; i < tmp_DLC; i++) + { + rxMsg[tmp_LastStep].Data[i] = tmp_DATA[i]; + } + /* Слот заполнен - только теперь публикуем его. Без барьера компилятору + разрешено поднять запись LastStep выше записей в rxMsg, и обработчик + увидит наполовину заполненное сообщение. */ + __DMB(); + LastStep = tmp_LastStep; +} + +/** +* @brief Обработка сообщений, ожидающих в FIFO, по прерыванию HAL_CAN_RxFifo0MsgPendingCallback. +* Читает все сообщения из FIFO, проверяет их тип, обновляет статус устройств сети или сохраняет сообщение в буфер. +* @param hcan Указатель на структуру управления CAN-Hardware. +*/ +void ProtoCanRxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) +{ + //Обработка всех сообщений в FIFO + CAN_RxHeaderTypeDef RxHeader; + uint8_t RCAN_Data[8]; + while(HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, RCAN_Data) == HAL_OK) + { + //Расширенный ID + if(RxHeader.IDE == CAN_ID_EXT) + { + ProtoCanId_t ExtID_Of_RX_MSG; + PROTOCAN_IdUnpack(RxHeader.ExtId, &ExtID_Of_RX_MSG); + //Полученное сообщение - пульс устройств в сети. + //Место в буфере не занимает, поэтому проверяется до проверки переполнения. + if(ExtID_Of_RX_MSG.Fields.MsgType == PROTOCAN_MSGTYPE_PULSE) + { + Device_on_the_Network[ExtID_Of_RX_MSG.Fields.DeviceType][ExtID_Of_RX_MSG.Fields.DeviceID].Status = 1u; + Device_on_the_Network[ExtID_Of_RX_MSG.Fields.DeviceType][ExtID_Of_RX_MSG.Fields.DeviceID].TimeFromLastPulse = 0; + //continue, а не return: остаток FIFO нужно вычитать в этом же вызове, + //иначе прерывание по непустому FIFO сработает повторно. + continue; + } + //Полезная ёмкость буфера - (PROTOCAN_RX_BUFFER_SIZE - 1): при большем + //заполнении счётчик AvailableCanRxMsg() переполняется и даёт 0 ("пусто"). + if(AvailableCanRxMsg() >= (PROTOCAN_RX_BUFFER_SIZE - 1u)) + { + //Буфер переполнен, кадр отбрасывается. Кадр уже извлечён из + //аппаратного FIFO, поэтому продолжаем вычитывать остаток. + continue; + } + uint16_t tmp_LastStep = (uint16_t)((LastStep + 1u) % PROTOCAN_RX_BUFFER_SIZE); + TakeRxMsgToBuffer(ExtID_Of_RX_MSG, RxHeader.IDE, RxHeader.RTR, RxHeader.DLC, RCAN_Data, tmp_LastStep); + } + } +} + +/** +* @brief Функция отправки пульса устройства. +* @details Пульс устройства. Есть возможность отключить пульс по запросу. +*/ +void ProtoCanPulseCallback(TIM_HandleTypeDef *htim) +{ + if(ControlFlags.IsPulse) + { + static unsigned PulseStage = 0; + CAN_TxHeaderTypeDef TxHeader; + uint32_t TxMailBox = 0; + ProtoCanId_t currentID; + currentID.BitAll = 0; + currentID.Fields.Priority = PROTOCAN_PRIORITY_STANDARD; + currentID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE; + currentID.Fields.DeviceType = CURRENT_TYPE_DEVICE; + currentID.Fields.DeviceID = CURRENT_ID_DEVICE; + currentID.Fields.MsgType = PROTOCAN_MSGTYPE_PULSE; + currentID.Fields.MsgBody = 0; + TxHeader.ExtId = PROTOCAN_IdPack(¤tID); + uint8_t data[8]; + TxHeader.IDE = CAN_ID_EXT; + TxHeader.TransmitGlobalTime = DISABLE; + TxHeader.RTR = CAN_RTR_DATA; + TxHeader.DLC = 1; + PulseStage++; + if(PulseStage > 0xFF) + { + PulseStage = 0; + } + data[0] = PulseStage; + HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox); + } +} + +/** +* @brief Функция установки в RTC полученной даты/время из запроса. +* @param uint8_t *data - Байтовый массив, 7 элементов. [0] - Часы. [1] - Минуты. [2] - Секунды. [3] - Год. [4] - Месяц. [5] - Дата. [6] - День недели. +*/ +void PROTOCAN_RTC_SYNC(uint8_t *data) +{ + __HAL_RTC_WRITEPROTECTION_DISABLE(_HRTC); + RTC_TimeTypeDef sTime = {0}; + RTC_DateTypeDef DateToUpdate = {0}; + sTime.Hours = data[0]; + sTime.Minutes = data[1]; + sTime.Seconds = data[2]; + if(HAL_RTC_SetTime(_HRTC, &sTime, RTC_FORMAT_BIN) != HAL_OK) + { + Error_Handler(); + } + DateToUpdate.Year = data[3]; + DateToUpdate.Month = data[4]; + DateToUpdate.Date = data[5]; + DateToUpdate.WeekDay = data[6]; + if(HAL_RTC_SetDate(_HRTC, &DateToUpdate, RTC_FORMAT_BIN) != HAL_OK) + { + Error_Handler(); + } + __HAL_RTC_WRITEPROTECTION_ENABLE(_HRTC); +} + +/** +* @brief Настройка CAN-Фильтра. +* На основе переданных ID и маски настраивает фильтр CAN для приема только определённых сообщений. +* @param filterBank Номер фильтра (обычно 0..27). +* @param idFilter 32-битный идентификатор, используемый для фильтрации. +* @param idMask 32-битная маска, определяющая биты, проверяемые фильтром. +*/ +void PROTOCAN_CONFIG_FILTER(uint8_t filterBank, uint32_t idFilter, uint32_t idMask) +{ + CAN_FilterTypeDef canFilterConfig; + canFilterConfig.FilterBank = filterBank; + canFilterConfig.FilterMode = CAN_FILTERMODE_IDMASK; + canFilterConfig.FilterScale = CAN_FILTERSCALE_32BIT; + canFilterConfig.FilterFIFOAssignment = CAN_RX_FIFO0; + canFilterConfig.FilterActivation = ENABLE; + canFilterConfig.SlaveStartFilterBank = 14; + #define CAN_IDE_32 0b00000100 // Для 32-х битного масштаба + // Разбиваем 32-битный ID и маску для фильтрации на High и Low 16 бит + canFilterConfig.FilterIdHigh = (uint16_t)(((idFilter) >> 13)); // верхние 16 бит + canFilterConfig.FilterIdLow = (uint16_t)((((idFilter) << 3)) | CAN_IDE_32); // низкие 16 бит, + canFilterConfig.FilterMaskIdHigh = (uint16_t)(((idMask) >> 13)); + canFilterConfig.FilterMaskIdLow = (uint16_t)((((idMask) << 3)) | CAN_IDE_32); + if(HAL_CAN_ConfigFilter(_HCAN, &canFilterConfig) != HAL_OK) + { + Error_Handler(); + } +} + +/** +* @brief Конфигурирует три фильтра CAN для различных типов сообщений. +*/ +void PROTOCAN_FILTERS() +{ + #define BIT27 (1UL << 27) + #define BITS_20_23_MASK (0xFUL << 20) // 4 бита (20..23) + #define BITS_24_26_MASK (0x7UL << 24) // 3 бита (24..26) + + /* Адресованный запрос приходит от ПМ, поэтому Route (бит 27) равен нулю. */ + uint32_t filter1_id = (CURRENT_TYPE_DEVICE << 24) | (CURRENT_ID_DEVICE << 20); + uint32_t filter1_mask = BIT27 | BITS_24_26_MASK | BITS_20_23_MASK; // все проверяемые биты + + #define BITS_16_19_MASK (0xFUL << 16) + + uint32_t filter2_id = PROTOCAN_MSGTYPE_BROADCAST << 16; + uint32_t filter2_mask = BITS_16_19_MASK; + + uint32_t filter3_id = PROTOCAN_MSGTYPE_PULSE << 16; + uint32_t filter3_mask = BITS_16_19_MASK; + + // Первый фильтр: Route=0 (от ПМ), DeviceType и DeviceID текущего устройства. + PROTOCAN_CONFIG_FILTER(0, filter1_id, filter1_mask); + + // Второй фильтр: проверяем 16-19 биты, равны PROTOCAN_MSGTYPE_BROADCASTE + PROTOCAN_CONFIG_FILTER(1, filter2_id, filter2_mask); + + // Третий фильтр: проверяем 16-19 биты, равны PROTOCAN_MSGTYPE_PULSE + PROTOCAN_CONFIG_FILTER(2, filter3_id, filter3_mask); +} + +/** + * @brief Отправляет CAN-сообщение в зависимости от типа сообщения. + * Эта функция анализирует тип сообщения в идентификаторе и вызывает + * соответствующую функцию отправки. В текущей реализации поддерживается только + * тип PROTOCAN_MSGTYPE_GENERAL_ADDRESS_SPACE. + * @param id Идентификатор CAN-сообщения, содержащий информацию о типе. + * @param data Структура с данными, которые необходимо отправить. Зависит от типа сообщения. + * @return Статус выполнения операции. + */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND(ProtoCanId_t id, ProtoCanData_t data) +{ + switch(id.Fields.MsgType) + { + case PROTOCAN_MSGTYPE_BROADCAST: + case PROTOCAN_MSGTYPE_DISCRETE: + case PROTOCAN_MSGTYPE_ANALOG: + { + return PROTOCAN_SEND_CORE(id, data.CoreData); + break; + } + case PROTOCAN_MSGTYPE_GENERAL_ADDRESS_SPACE: + { + /* Если есть регистры для отправки, указатель data должен быть валиден. */ + if((data.GeneralAddressSpaceData.RegCount > 0U) && data.GeneralAddressSpaceData.Data == 0) + { + return PROTOCAN_ERROR; + } + return PROTOCAN_SEND_GENERAL_ADDRESS_SPACE(id, data.GeneralAddressSpaceData.RegStartAdr, + data.GeneralAddressSpaceData.Data, + data.GeneralAddressSpaceData.RegCount); + } + case PROTOCAN_MSGTYPE_MODBUS_COIL: + case PROTOCAN_MSGTYPE_MODBUS_DISCRETE: + case PROTOCAN_MSGTYPE_MODBUS_HOLDING: + case PROTOCAN_MSGTYPE_MODBUS_INPUT: + { + return PROTOCAN_SEND_MODBUS(id, data.ModbusData); + break; + } + case PROTOCAN_MSGTYPE_ERROR: + { + return PROTOCAN_SEND_ERROR(id, data.ErrorData); + break; + } + case PROTOCAN_MSGTYPE_SETTINGS: + { + return PROTOCAN_SEND_SETTINGS(id, data.SettingsData); + } + default: + return PROTOCAN_ERROR; + } + return PROTOCAN_ERROR; +} diff --git a/c/set-protocol/ports/stm32-bxcan/protocan.h b/c/set-protocol/ports/stm32-bxcan/protocan.h new file mode 100644 index 0000000..ea07e45 --- /dev/null +++ b/c/set-protocol/ports/stm32-bxcan/protocan.h @@ -0,0 +1,427 @@ +//тест + +#ifndef _protocan +#define _protocan +#include "main.h" +#include "can.h" +#include "pcan_id.h" + +#define _DEMO + +#define ONLINE 1; +#define OFFLINE 0; + +#define ID_MAIN_DEVICE 0x000 +#define CURRENT_TYPE_DEVICE 0b111 +#define CURRENT_ID_DEVICE 0b1111 + +#define PROTOCAN_RX_BUFFER_SIZE 128 + +#define SensorToModbusRegister(SensorType, SensorID) SensorType << 11 | SensorID +#define HighByteOfWord(WORD) (WORD>>8)&0xFF +#define LowByteOfWord(WORD) WORD&0xFF + +/** + * @brief Статус инициализации протокола CAN. + * + * Это перечисление описывает возможные статусы после попытки инициализации. + */ +typedef enum +{ + PROTOCAN_INIT_OK = 0x00U, /**< Инициализация прошла успешно. */ + PROTOCAN_INIT_HCAN_ERROR = 0x01U, /**< Ошибка при инициализации модуля CAN. */ + PROTOCAN_INIT_HRTC_ERROR = 0x02U, /**< Ошибка при инициализации модуля RTC. */ + PROTOCAN_INIT_TIM_ERROR = 0x04U /**< Ошибка при инициализации таймера. */ +} PROTOCAN_INIT_StatusTypeDef; + +/** + * @brief Статус выполнения операции протокола CAN. + * + * Это перечисление описывает возможные результаты выполнения функций протокола. + */ +typedef enum +{ + PROTOCAN_OK = 0x00U, /**< Операция прошла успешно. */ + PROTOCAN_ERROR = 0x01U, /**< Обнаружена ошибка во время выполнения. */ + PROTOCAN_BUSY = 0x02U, /**< Канал занят, выполнение невозможно в данный момент. */ + PROTOCAN_TIMEOUT = 0x03U /**< Истек таймаут ожидания. */ +} PROTOCAN_StatusTypeDef; + +/** + * @brief Уровень приоритета CAN-сообщения. + * + * Это перечисление определяет важность и приоритет обработки сообщения. + */ +typedef enum { + PROTOCAN_PRIORITY_CRITICAL = 0, /**< Критический приоритет, рекомендуется обработка без задержек. */ + PROTOCAN_PRIORITY_STANDARD = 1 /**< Стандартный приоритет, менее важный. */ +} ProtoCanPriorityType; + +/** + * @brief Маршрут CAN-сообщения. + * + * Это перечисление задает направление маршрута отправки сообщения. + */ +typedef enum { + PROTOCAN_ROUTE_FROM_PM = 0, /**< Сообщение отправляется от управляющего модуля (PM). */ + PROTOCAN_ROUTE_FROM_DEVICE = 1 /**< Сообщение отправляется от устройства (Device). */ +} ProtoCanRouteType; + +/** + * @brief Типы сообщений CAN. + * + * Перечисление определяет различные типы сообщений, используемые в протоколе. + */ +typedef enum { + PROTOCAN_MSGTYPE_BROADCAST = 0b0000, /**< Широковещательное сообщение. */ + PROTOCAN_MSGTYPE_DISCRETE = 0b0001, /**< Дискретное цифровое сообщение. */ + PROTOCAN_MSGTYPE_ANALOG = 0b0010, /**< Аналоговое сообщение. */ + PROTOCAN_MSGTYPE_GENERAL_ADDRESS_SPACE = 0b0011, /**< Сообщение общего адресного пространства. */ + PROTOCAN_MSGTYPE_MODBUS_COIL = 0b0100, /**< Сообщение Modbus - Coil. */ + PROTOCAN_MSGTYPE_MODBUS_DISCRETE = 0b0101, /**< Сообщение Modbus - Discrete. */ + PROTOCAN_MSGTYPE_MODBUS_HOLDING = 0b0110, /**< Сообщение Modbus - Holding. */ + PROTOCAN_MSGTYPE_MODBUS_INPUT = 0b0111, /**< Сообщение Modbus - Input. */ + PROTOCAN_MSGTYPE_ERROR = 0b1000, /**< Сообщение об ошибке. */ + PROTOCAN_MSGTYPE_BOOT_CONTROL = 0b1001, /**< Команды ProtoCAN Boot. */ + PROTOCAN_MSGTYPE_BOOT_DATA_A = 0b1010, /**< Блок образа для слота A. */ + PROTOCAN_MSGTYPE_BOOT_DATA_B = 0b1011, /**< Блок образа для слота B. */ + PROTOCAN_MSGTYPE_BOOT_STATUS = 0b1100, /**< Статус команды загрузчика. */ + PROTOCAN_MSGTYPE_BOOT_DISCOVERY = 0b1101, /**< Идентификация загрузчика/приложения. */ + PROTOCAN_MSGTYPE_SETTINGS = 0b1110, /**< Привязка датчика к локации. */ + PROTOCAN_MSGTYPE_PULSE = 0b1111, /**< Импульсное сообщение. */ +}ProtoCanMsgType; + +/** Длина ROM-кода 1-Wire и payload успешного ответа SETTINGS. */ +#define PROTOCAN_SETTINGS_ROM_SIZE 8U + +/** Фиксированные базы выбранного DeviceType=0x7, DeviceID=0xF, MsgType=0xE. */ +#define PROTOCAN_SETTINGS_REQUEST_BASE_ID 0x17FE0000UL +#define PROTOCAN_SETTINGS_RESPONSE_BASE_ID 0x1FFE0000UL +#define PROTOCAN_SETTINGS_HEADER_MASK 0x1FFF0000UL + +/** Операции SETTINGS, определяемые по направлению, DLC и payload. */ +typedef enum { + PROTOCAN_SETTINGS_GET = 0, + PROTOCAN_SETTINGS_WRITE = 1, + PROTOCAN_SETTINGS_CLEAR = 2, + PROTOCAN_SETTINGS_RESPONSE = 3, + PROTOCAN_SETTINGS_ERROR = 4, + PROTOCAN_SETTINGS_INVALID = 5 +} ProtoCanSettingsOperationType; + +/** Коды результата SETTINGS в ответе с DLC=1. */ +typedef enum { + PROTOCAN_SETTINGS_RESULT_OK = 0x00U, + PROTOCAN_SETTINGS_RESULT_INVALID_DLC = 0x01U, + PROTOCAN_SETTINGS_RESULT_INVALID_ROM_CRC = 0x02U, + PROTOCAN_SETTINGS_RESULT_NOT_FOUND = 0x03U, + PROTOCAN_SETTINGS_RESULT_ALREADY_ASSIGNED = 0x04U, + PROTOCAN_SETTINGS_RESULT_EEPROM_ERROR = 0x05U, + PROTOCAN_SETTINGS_RESULT_INVALID_LOCATION = 0x06U, + PROTOCAN_SETTINGS_RESULT_BUSY = 0x07U +} ProtoCanSettingsResultType; + +/** Разобранная команда SETTINGS с локацией и ROM. */ +typedef struct { + ProtoCanSettingsOperationType Operation; + uint8_t AssemblySerial; /**< Body[15:8], номер сборки Z. */ + uint8_t Position; /**< Body[7:0], позиция Y. */ + ProtoCanSettingsResultType Result; + uint8_t Rom[PROTOCAN_SETTINGS_ROM_SIZE]; +} ProtoCanSettingsMsg_t; + +/** + * @brief Типы широковещательных сообщений CAN. + * + * Перечисление задает различные типы широковещательных команд, используемых в протоколе. + */ +typedef enum { + PROTOCAN_BROADCAST_STATUS = 0b000000000000, /**< Статус устройства. */ + PROTOCAN_BROADCAST_ONOFF = 0b000000000001, /**< Включение/выключение устройства. */ + PROTOCAN_BROADCAST_RESTARTDEVICE = 0b000000000010, /**< Перезагрузка устройства. */ + PROTOCAN_BROADCAST_RTCSETUP = 0b000000000011, /**< Настройка RTC (часы реального времени). */ + PROTOCAN_BROADCAST_END = 0b111111111111 /**< Конец диапазона широковещательных сообщений. */ +}ProtoCanBroadcastType; + +/** + * @brief Структура и представление широковещательного сообщения CAN. + * + * Объединение позволяет обращаться к данным сообщения либо как к отдельным полям, либо как к целому 16-битному значению. + */ +typedef union{ + struct msgBody_Broadcast{ + unsigned Body:4; /**< Поле для дополнительных данных или кода задачи (4 бита). */ + ProtoCanBroadcastType Type:12; /**< Тип широковещательного сообщения (12 бит). */ + } Fields; /**< Структура с разбивкой по полям. */ + unsigned Body; /**< Общее 16-битное представление сообщения. */ +} msgBodyBroadcastType; + +/** + * @brief Типы дискретных сообщений CAN. + * + * Перечисление задает различные виды дискретных команд или событий. + */ +typedef enum { + PROTOCAN_DISCRETE_ACCIDENT = 0b0000, /**< Аварийное состояние. */ + PROTOCAN_DISCRETE_WARNING = 0b0001, /**< Предупредительный сигнал. */ + PROTOCAN_DISCRETE_CONTROL_SIGNALS = 0b0010, /**< Управляющие сигналы. */ + PROTOCAN_DISCRETE_FLAGS = 0b0011, /**< Флаги состояния. */ + PROTOCAN_DISCRETE_RESET = 0b0100, /**< Сброс устройства. */ + PROTOCAN_DISCRETE_CHANGE_MODE = 0b0101, /**< Смена режима работы. */ + PROTOCAN_DISCRETE_REQUEST_LIST_OF_PARAMETERS = 0b0110, /**< Запрос списка параметров. */ + PROTOCAN_DISCRETE_END = 0b1111 /**< Конец диапазона дискретных сообщений. */ +}ProtoCanDiscreteType; + +/** + * @brief Структура и представление дискретного сообщения CAN. + * + * Объединение позволяет обращаться к данным сообщения как к отдельным полям или как к целому 16-битному значению. + */ +typedef union{ + struct msgBody_Discrete{ + unsigned Body:12; /**< Поле данных или кода (12 бит). */ + ProtoCanDiscreteType Type:4; /**< Тип дискретного сообщения (4 бита). */ + } Fields; /**< Структура с разбивкой по полям. */ + unsigned Body; /**< Общее 16-битное представление сообщения. */ +} msgBodyDiscreteType; + +/** + * @brief Типы аналоговых сообщений CAN. + * + * Перечисление определяет различные типы аналоговых данных или команд. + */ +typedef enum { + PROTOCAN_ANALOG_UNIVERSAL = 0b0000, /**< Универсальный тип. */ + PROTOCAN_ANALOG_SETTINGS = 0b0001, /**< Настройки. */ + PROTOCAN_ANALOG_U = 0b0010, /**< Напряжение (U). */ + PROTOCAN_ANALOG_I = 0b0011, /**< Ток (I). */ + PROTOCAN_ANALOG_T = 0b0100, /**< Температура (T). */ + PROTOCAN_ANALOG_END = 0b1111 /**< Конец диапазона типов. */ +}ProtoCanAnalogType; + +/** + * @brief Структура и представление аналогового сообщения CAN. + * + * Объединение позволяет обращаться к данным сообщения как к отдельным полям или как к целому 16-битному значению. + */ +typedef union{ + struct msgBody_Analog{ + unsigned SensorID:12; /**< Идентификатор датчика (12 бит). */ + ProtoCanAnalogType Type:4; /**< Тип аналоговых данных (4 бита). */ + } Fields; /**< Структура с разбивкой по полям. */ + unsigned Body; /**< Общее 16-битное представление сообщения. */ +}msgBodyAnalogType; + +/** + * @brief Структура и представление сообщения Modbus CAN. + * + * Объединение позволяет обращаться к данным как к отдельным полям или как к целому 16-битному значению. + */ +typedef union{ + struct msgBody_Modbus{ + unsigned RegCount:4; /**< Количество регистров (4 бита). */ + unsigned StrAdr:12; /**< Адрес строки или регистра (12 бит). */ + } Fields; /**< Поля структуры сообщения Modbus. */ + unsigned Body; /**< Общее 16-битное представление сообщения. */ +} msgBodyModbusType; + +/** + * @brief Структура и представление сообщения об ошибке CAN. + * + * Объединение позволяет обращаться к данным как к отдельным полям или как к целому 16-битному значению. + */ +typedef union{ + struct msgBody_Error{ + unsigned ErrorCode:8; /**< Код ошибки (8 бит). */ + unsigned Info:8; /**< Дополнительная информация (8 бит). */ + } Fields; /**< Поля ошибки. */ + unsigned Body; /**< Общее 16-битное представление сообщения об ошибке. */ +} msgBodyErrorType; + +struct controlflags{ + unsigned IsPulse:1; + unsigned IsRtrMode:1; +}; + +/** + * @brief Поля идентификатора протокола CAN. + * + * Объединение позволяет обращаться к различным полям идентификатора, а также как к 29-битному целому числу. + */ +typedef union{ + struct protoCanIdFields{ + unsigned MsgBody:16; /**< Поле сообщения (16 бит). */ + ProtoCanMsgType MsgType:4; /**< Тип сообщения (4 бита). */ + unsigned DeviceID:4; /**< Идентификатор устройства (4 бита). */ + unsigned DeviceType:3; /**< Тип устройства (3 бита). */ + ProtoCanRouteType Route:1; /**< Маршрут (1 бит). */ + ProtoCanPriorityType Priority:1; /**< Приоритет (1 бит). */ + } Fields; /**< Структура с разбивкой по полям. */ + unsigned int BitAll:29; /**< Общее представление 29-битного идентификатора. */ +} ProtoCanId_t; + +/** + * Переходная граница legacy API -> переносимый формат линии. + * + * Прикладной код SETCAN пока может заполнять исторические Fields, но CAN ID + * всегда собирается и разбирается общим pcan_id без зависимости от порядка + * битовых полей конкретного компилятора. + */ +static inline uint32_t PROTOCAN_IdPack(const ProtoCanId_t *legacy) +{ + pcan_id_t shared; + shared.msg_body = (uint16_t)legacy->Fields.MsgBody; + shared.msg_type = (uint8_t)legacy->Fields.MsgType; + shared.device_id = (uint8_t)legacy->Fields.DeviceID; + shared.device_type = (uint8_t)legacy->Fields.DeviceType; + shared.route = (uint8_t)legacy->Fields.Route; + shared.priority = (uint8_t)legacy->Fields.Priority; + return pcan_id_pack(&shared); +} + +static inline void PROTOCAN_IdUnpack(uint32_t raw, ProtoCanId_t *legacy) +{ + pcan_id_t shared; + pcan_id_unpack(raw, &shared); + legacy->BitAll = 0U; + legacy->Fields.MsgBody = shared.msg_body; + legacy->Fields.MsgType = (ProtoCanMsgType)shared.msg_type; + legacy->Fields.DeviceID = shared.device_id; + legacy->Fields.DeviceType = shared.device_type; + legacy->Fields.Route = (ProtoCanRouteType)shared.route; + legacy->Fields.Priority = (ProtoCanPriorityType)shared.priority; +} + +/** + * @brief Структура для общего адресного пространства CAN. + * + * Используется для хранения данных о регистре и его содержимом. + */ +typedef struct{ + struct ProtoCanCoreData{ + uint16_t Type; + uint16_t Body; + uint8_t *Data; + unsigned DataCount; + } CoreData; + struct ProtoCanGeneralAddressSpaceData{ + uint16_t RegStartAdr; /**< Начальный адрес регистров. */ + uint16_t *Data; /**< Указатель на массив данных. */ + unsigned RegCount; /**< Количество регистров. */ + } GeneralAddressSpaceData; + struct ProtoCanModbusData{ + uint16_t StrAdr; + uint16_t *Data; + unsigned RegCount:4; + } ModbusData; + struct ProtoCanErrorData{ + uint16_t Info; + uint16_t Code; + } ErrorData; + struct ProtoCanSettingsData{ + const uint8_t *Data; + uint8_t DataCount; + } SettingsData; +} ProtoCanData_t; + +/** + * @brief Структура данных устройства CAN. + * + * Включает статус, идентификатор, таймер последнего импульса и шаг последнего импульса. + */ +typedef struct{ + unsigned Status:1; /**< Статус устройства (флаг). */ + ProtoCanId_t Id; /**< Идентификатор CAN. */ + unsigned TimeFromLastPulse; /**< Время с последнего импульса (мс). */ + unsigned LastPulseStep; /**< Шаг последнего импульса. */ +} ProtoCanDevice_t; + +/** + * @brief Структура для хранения сообщения RXCAN. + */ +struct RXMsg { + /** + * @brief Структура, содержащая флаги информации. + */ + struct INFO { + unsigned EXT : 1; /**< Бит расширенного идентификатора (EXT). */ + unsigned RTR : 1; /**< Бит запроса на передачу (RTR). */ + } info; /**< Информационные флаги сообщения. */ + ProtoCanId_t eID; /**< Идентификатор CAN-сообщения. */ + uint16_t DLC; /**< Длина данных (Data Length Code). */ + uint8_t Data[8]; /**< Массив данных (до 8 байт). */ +}; + +uint16_t AvailableCanRxMsg(void); + +PROTOCAN_INIT_StatusTypeDef PROTOCAN_INIT(CAN_HandleTypeDef *tmp_hcan, + RTC_HandleTypeDef *tmp_hrtc, + TIM_HandleTypeDef *tmp_tim); +void PROTOCAN_DEINIT(uint8_t stage); +void PROTOCAN_FILTERS(void); +void PROTOCAN_LoopProcessRxMsgs(void); +PROTOCAN_StatusTypeDef PROTOCAN_ProcessAllRxMsgs(void); +PROTOCAN_StatusTypeDef PROTOCAN_ProcessSingleRxMsg(void); +PROTOCAN_StatusTypeDef PROTOCAN_SEND(ProtoCanId_t id, ProtoCanData_t data); + +void ProtoCanPulseCallback(TIM_HandleTypeDef *htim); +void ProtoCanRxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan); + +PROTOCAN_StatusTypeDef PROTOCAN_BroadcastProcessing(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToBroadcastStatus(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToBroadcastOnOff(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToBroadcastRestart(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToBroadcastRtcSetup(struct RXMsg _rxMsg); + +PROTOCAN_StatusTypeDef PROTOCAN_DiscreticProcessing(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteAccident(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteWarning(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteControlSignals(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteFlags(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteReset(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteChangeMode(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToDiscreteRequestListOfParameters(struct RXMsg _rxMsg); + +PROTOCAN_StatusTypeDef PROTOCAN_AnalogProcessing(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogUniversal(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogSettings(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogUSens(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogISens(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToAnalogTSens(struct RXMsg _rxMsg); + +/** Разбирает запрос SETTINGS и вызывает прикладной обработчик. */ +PROTOCAN_StatusTypeDef PROTOCAN_SettingsProcessing(struct RXMsg _rxMsg); + +/** + * Прикладной обработчик GET/WRITE/CLEAR. Приложение должно переопределить + * слабую реализацию и после проверки датчика/EEPROM отправить ответ или ошибку. + */ +PROTOCAN_StatusTypeDef ProtoCanMsgToSettings(const ProtoCanSettingsMsg_t *message); + +/** Отправляет произвольный SETTINGS-кадр с уже заполненным Body. */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_SETTINGS(ProtoCanId_t id, + const struct ProtoCanSettingsData data); + +/** Отправляет успешный ответ 0x1FFEZZYY, DLC=8, с текущим ROM локации. */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_SETTINGS_RESPONSE(uint8_t assemblySerial, + uint8_t position, + const uint8_t *rom); + +/** Отправляет ошибку 0x1FFEZZYY, DLC=1, Data[0]=result. */ +PROTOCAN_StatusTypeDef PROTOCAN_SEND_SETTINGS_ERROR(uint8_t assemblySerial, + uint8_t position, + ProtoCanSettingsResultType result); + +PROTOCAN_StatusTypeDef ProtoCanMsgToGeneralAddressSpace(struct RXMsg _rxMsg); + +PROTOCAN_StatusTypeDef PROTOCAN_RequestError(struct RXMsg _rxMsg); + +PROTOCAN_StatusTypeDef PROTOCAN_ModbusProcessing(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToModbusCoil(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToModbusDiscrete(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToModbusHolding(struct RXMsg _rxMsg); +PROTOCAN_StatusTypeDef ProtoCanMsgToModbusInput(struct RXMsg _rxMsg); + +void PROTOCAN_RTC_SYNC(uint8_t *data); + +#endif diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c new file mode 100644 index 0000000..3f000e3 --- /dev/null +++ b/c/set-protocol/src/set_plot.c @@ -0,0 +1,71 @@ +#include "set_plot.h" +#include + +static double clamp(double v, double lo, double hi) { return fmin(hi, fmax(lo, v)); } +static int finite_values(const double *v, size_t n) { + size_t i; + for (i = 0; i < n; ++i) if (!isfinite(v[i])) return 0; + return 1; +} + +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}; + double span, fraction; + if (op > SET_PLOT_DELTA || !v || !out || n != sizes[op] || + cap < (op == SET_PLOT_TRANSFORM ? 4U : 1U)) return 0; + if (op == SET_PLOT_TRANSFORM) { + double w, h, fx, fy; + if (!finite_values(v, 4) || v[2] < 1.0/128 || v[2] > 1 || + v[3] < 1.0/128 || v[3] > 1 || v[0] < 0 || v[1] < 0 || + v[0] + v[2] > 1.000000000001 || v[1] + v[3] > 1.000000000001) return 0; + out[0] = v[0]; out[1] = v[1]; out[2] = v[2]; out[3] = v[3]; + if (!finite_values(v + 4, 6) || v[4] <= 0 || v[5] <= 0) return 4; + w = clamp(v[2] / v[4], 1.0/128, 1); + h = clamp(v[3] / v[5], 1.0/128, 1); + fx = clamp(v[8], 0, 1); fy = clamp(v[9], 0, 1); + out[0] = clamp(v[0] + (v[2] - w) * fx - v[6] * w, 0, 1 - w); + out[1] = clamp(v[1] + (v[3] - h) * fy - v[7] * h, 0, 1 - h); + out[2] = w; out[3] = h; + return 4; + } + if (!finite_values(v, n)) return 0; + switch (op) { + case SET_PLOT_AXIS: + if (v[2] < 0) return 0; + out[0] = fmax(fabs(v[0]), fabs(v[1])) <= v[2] ? 0 : + (fabs(v[1]) > fabs(v[0]) ? 2 : 1); + break; + case SET_PLOT_FRACTION: + case SET_PLOT_VALUE: + span = v[2] - v[1]; + if (!isfinite(span) || span <= 0 || (v[3] != 0 && v[3] != 1)) return 0; + if (op == SET_PLOT_FRACTION) { + fraction = (v[0] - v[1]) / span; + out[0] = v[3] ? 1 - fraction : fraction; + } else { + fraction = v[3] ? 1 - v[0] : v[0]; + out[0] = v[1] + fraction * span; + } + break; + case SET_PLOT_DRAG: + span = v[4] - v[3]; + if (!isfinite(span) || span <= 0 || v[2] <= 0 || (v[5] != 0 && v[5] != 1)) return 0; + out[0] = clamp(v[0] + (v[5] ? -1 : 1) * (v[1] / v[2]) * span, v[3], v[4]); + break; + case SET_PLOT_TICK_STEP: { + double raw, magnitude, scaled; + if (v[0] <= 0 || v[1] <= 0) return 0; + raw = v[0] / fmax(2, floor(v[1] / 100)); + magnitude = pow(10, floor(log10(raw))); + if (magnitude <= 0 || !isfinite(magnitude)) return 0; + scaled = raw / magnitude; + out[0] = (scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10) * magnitude; + break; + } + case SET_PLOT_DELTA: out[0] = (v[1] - v[0]) * v[2]; break; + 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 new file mode 100644 index 0000000..a73003e --- /dev/null +++ b/c/set-protocol/src/set_spectrum.c @@ -0,0 +1,126 @@ +#include "set_spectrum.h" +#include +#include + +#define PI 3.14159265358979323846 + +static double window_value(int window, size_t index, size_t n) +{ + double phase = 2.0 * PI * (double)index / (double)n; + switch (window) { + case SET_WINDOW_HANN: return 0.5 - 0.5 * cos(phase); + case SET_WINDOW_HAMMING: return 0.54 - 0.46 * cos(phase); + case SET_WINDOW_BLACKMAN: return 0.42 - 0.5 * cos(phase) + 0.08 * cos(2.0 * phase); + case SET_WINDOW_FLATTOP: return 0.21557895 - 0.41663158 * cos(phase) + + 0.277263158 * cos(2.0 * phase) - 0.083578947 * cos(3.0 * phase) + 0.006947368 * cos(4.0 * phase); + default: return 1.0; + } +} + +/* RBJ biquads, https://www.w3.org/TR/audio-eq-cookbook/ (LP/HP: Q=1/sqrt(2)). */ +static void filter_block(double *data, size_t n, double fs, double cutoff, int kind) +{ + double w = 2.0 * PI * cutoff / fs, c = cos(w); + double alpha = sin(w) / (2.0 * (kind == SET_FILTER_NOTCH ? 30.0 : sqrt(0.5))); + double a0 = 1.0 + alpha, a1 = -2.0 * c / a0, a2 = (1.0 - alpha) / a0; + double b0, b1, b2; + if (kind == SET_FILTER_LOW_PASS) { + b0 = (1.0 - c) / (2.0 * a0); b1 = 2.0 * b0; b2 = b0; + } else if (kind == SET_FILTER_HIGH_PASS) { + b0 = (1.0 + c) / (2.0 * a0); b1 = -2.0 * b0; b2 = b0; + } else { b0 = 1.0 / a0; b1 = -2.0 * c / a0; b2 = b0; } + double x1 = data[0], x2 = x1; + double y1 = kind == SET_FILTER_HIGH_PASS ? 0.0 : x1, y2 = y1; + for (size_t i = 0; i < n; ++i) { + double x = data[i], y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; + data[i] = y; x2 = x1; x1 = x; y2 = y1; y1 = y; + } +} + +static void fft(double *re, double *im, size_t n) +{ + for (size_t i = 1, j = 0; i < n; ++i) { + size_t bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { double tmp = re[i]; re[i] = re[j]; re[j] = tmp; } + } + for (size_t length = 2; length <= n; length <<= 1) { + double angle = -2.0 * PI / (double)length, wr = cos(angle), wi = sin(angle); + for (size_t start = 0; start < n; start += length) { + double tr = 1.0, ti = 0.0; + for (size_t k = 0; k < length / 2; ++k) { + size_t a = start + k, b = a + length / 2; + double br = re[b] * tr - im[b] * ti, bi = re[b] * ti + im[b] * tr; + re[b] = re[a] - br; im[b] = im[a] - bi; re[a] += br; im[a] += bi; + double next = tr * wr - ti * wi; + ti = tr * wi + ti * wr; tr = next; + } + } + } +} + +int set_spectrum_analyze(const double *times, const double *values, size_t count, + size_t max_size, int window, int filter, double low_hz, double high_hz, int remove_mean, + double *amplitudes, size_t capacity, double *meta) +{ + if (meta == NULL) return SET_SPECTRUM_INVALID; + meta[0] = meta[1] = meta[2] = 0.0; + if (times == NULL || values == NULL || amplitudes == NULL || max_size < 16 || + max_size > SET_SPECTRUM_MAX || (max_size & (max_size - 1)) != 0 || + window < SET_WINDOW_RECT || window > SET_WINDOW_FLATTOP || + filter < SET_FILTER_NONE || filter > SET_FILTER_NOTCH) return SET_SPECTRUM_INVALID; + if (count < 16) return SET_SPECTRUM_SHORT; + size_t n = 16; + while (n * 2 <= count && n * 2 <= max_size) n *= 2; + if (capacity < n / 2 + 1) return SET_SPECTRUM_INVALID; + times += count - n; values += count - n; + double dt = (times[n - 1] - times[0]) / (double)(n - 1); + if (!isfinite(dt) || dt <= 0.0) return SET_SPECTRUM_TIMING; + double fs = 1.0 / dt, jitter = 0.0; + if (!isfinite(fs)) return SET_SPECTRUM_TIMING; + for (size_t i = 0; i < n; ++i) { + if (!isfinite(values[i]) || !isfinite(times[i])) return SET_SPECTRUM_INVALID; + if (i > 0) { + double step = times[i] - times[i - 1]; + if (step <= 0.0) return SET_SPECTRUM_TIMING; + double relative = fabs(step / dt - 1.0); + if (relative > jitter) jitter = relative; + } + } + meta[0] = (double)n; meta[1] = fs; meta[2] = jitter; + if (jitter > 0.5) return SET_SPECTRUM_TIMING; + int need_low = filter == SET_FILTER_HIGH_PASS || filter == SET_FILTER_BAND_PASS || filter == SET_FILTER_NOTCH; + int need_high = filter == SET_FILTER_LOW_PASS || filter == SET_FILTER_BAND_PASS; + if ((need_low && (!isfinite(low_hz) || low_hz <= 0.0 || low_hz >= fs / 2.0)) || + (need_high && (!isfinite(high_hz) || high_hz <= 0.0 || high_hz >= fs / 2.0)) || + (filter == SET_FILTER_BAND_PASS && low_hz >= high_hz)) return SET_SPECTRUM_CUTOFF; + double *scratch = (double *)calloc(n * 2, sizeof(double)); + if (scratch == NULL) return SET_SPECTRUM_MEMORY; + double *re = scratch, *im = scratch + n, mean = 0.0; + size_t right = 1; + for (size_t i = 0; i < n; ++i) { + double time = times[0] + (double)i * dt; + while (right < n - 1 && times[right] < time) ++right; + double fraction = (time - times[right - 1]) / (times[right] - times[right - 1]); + fraction = fmax(0.0, fmin(1.0, fraction)); + re[i] = values[right - 1] * (1.0 - fraction) + values[right] * fraction; + mean += re[i] / (double)n; + } + if (remove_mean) for (size_t i = 0; i < n; ++i) re[i] -= mean; + if (filter == SET_FILTER_HIGH_PASS || filter == SET_FILTER_BAND_PASS) + filter_block(re, n, fs, low_hz, SET_FILTER_HIGH_PASS); + if (filter == SET_FILTER_LOW_PASS || filter == SET_FILTER_BAND_PASS) + filter_block(re, n, fs, high_hz, SET_FILTER_LOW_PASS); + if (filter == SET_FILTER_NOTCH) filter_block(re, n, fs, low_hz, SET_FILTER_NOTCH); + double gain = 0.0; + for (size_t i = 0; i < n; ++i) { double w = window_value(window, i, n); gain += w; re[i] *= w; } + fft(re, im, n); + int result = SET_SPECTRUM_OK; + for (size_t i = 0; i <= n / 2; ++i) { + amplitudes[i] = hypot(re[i], im[i]) / gain * ((i == 0 || i == n / 2) ? 1.0 : 2.0); + if (!isfinite(amplitudes[i])) result = SET_SPECTRUM_INVALID; + } + free(scratch); + return result; +} diff --git a/c/set-protocol/src/set_trends.c b/c/set-protocol/src/set_trends.c new file mode 100644 index 0000000..c1c2203 --- /dev/null +++ b/c/set-protocol/src/set_trends.c @@ -0,0 +1,74 @@ +#include "set_trends.h" +#include "pcan_id.h" + +static uint16_t get16(const uint8_t *p) +{ + return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); +} + +static void put16(uint8_t *p, uint16_t value) +{ + p[0] = (uint8_t)value; + p[1] = (uint8_t)(value >> 8); +} + +int32_t set_trend_word_value(uint16_t word, uint8_t is_signed) +{ + return (is_signed != 0U && word >= 0x8000U) ? (int32_t)word - 65536 : (int32_t)word; +} + +int32_t set_trend_can_value( + uint8_t source, uint32_t address, uint8_t device_type, uint8_t device, + uint8_t byte_offset, uint8_t extended, uint8_t is_signed, + uint32_t can_id, uint8_t flags, const uint8_t *data, size_t size) +{ + size_t offset; + const uint8_t ide = (uint8_t)(flags & 1U); + if (data == NULL || size < 2U || size > 8U || (flags & 0x0EU) != 0U || + can_id > (ide != 0U ? 0x1FFFFFFFUL : 0x7FFUL)) return SET_TREND_NO_VALUE; + if (source == SET_TREND_CAN_GAS) { + pcan_id_t id; + if (ide == 0U || (size & 1U) != 0U || address > 0xFFFFUL || + device_type > 7U || device > 15U) return SET_TREND_NO_VALUE; + pcan_id_unpack(can_id, &id); + if (id.msg_type != PCAN_MSG_GAS || id.route != PCAN_ROUTE_FROM_DEVICE || + id.device_type != device_type || id.device_id != device || + address < id.msg_body) return SET_TREND_NO_VALUE; + offset = (size_t)(address - id.msg_body) * 2U; + } else if (source == SET_TREND_CAN_RAW) { + if (ide != (extended != 0U ? 1U : 0U) || can_id != address || byte_offset > 6U) + return SET_TREND_NO_VALUE; + offset = byte_offset; + } else return SET_TREND_NO_VALUE; + if (offset + 2U > size) return SET_TREND_NO_VALUE; + return set_trend_word_value(get16(data + offset), is_signed); +} + +size_t set_trend_watch_request(uint16_t period_ms, const uint16_t *addresses, + size_t count, uint8_t *output, size_t capacity) +{ + size_t i; + if (count > SET_TREND_WATCH_MAX || output == NULL || capacity < 4U + count * 2U || + (count > 0U && addresses == NULL)) return 0U; + put16(output, period_ms); + put16(output + 2U, (uint16_t)count); + for (i = 0U; i < count; ++i) put16(output + 4U + i * 2U, addresses[i]); + return 4U + count * 2U; +} + +int set_trend_watch_ack(const uint8_t *payload, size_t size, uint16_t period_ms, size_t count) +{ + return payload != NULL && size == 4U && count <= SET_TREND_WATCH_MAX && + get16(payload) == period_ms && get16(payload + 2U) == count; +} + +int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words, size_t capacity) +{ + size_t i, count; + if (payload == NULL || size < 6U) return -1; + count = get16(payload + 4U); + 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); + return (int)count; +} diff --git a/c/set-protocol/tests/fixtures/plot-v1.json b/c/set-protocol/tests/fixtures/plot-v1.json new file mode 100644 index 0000000..3428ba4 --- /dev/null +++ b/c/set-protocol/tests/fixtures/plot-v1.json @@ -0,0 +1,27 @@ +{ + "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]}, + {"name":"pan_clamp","op":0,"input":[0.25,0.25,0.5,0.5,1,1,20,-20,0.5,0.5],"output":[0,0.5,0.5,0.5]}, + {"name":"zoom_limit","op":0,"input":[0,0,1,1,1000,1000,0,0,0.5,0.5],"output":[0.49609375,0.49609375,0.0078125,0.0078125]}, + {"name":"ignore_invalid_zoom","op":0,"input":[0.25,0.25,0.5,0.5,0,1,0,0,0.5,0.5],"output":[0.25,0.25,0.5,0.5]}, + {"name":"horizontal_spread","op":1,"input":[100,2,8],"output":[1]}, + {"name":"vertical_contract","op":1,"input":[2,-100,8],"output":[2]}, + {"name":"touch_jitter","op":1,"input":[3,-4,8],"output":[0]}, + {"name":"diagonal_tie_x","op":1,"input":[20,20,8],"output":[1]}, + {"name":"x_fraction","op":2,"input":[15,10,30,0],"output":[0.25]}, + {"name":"y_fraction","op":2,"input":[15,10,30,1],"output":[0.75]}, + {"name":"outside_view","op":2,"input":[40,10,30,0],"output":[1.5]}, + {"name":"y_coordinate","op":3,"input":[0.75,10,30,1],"output":[15]}, + {"name":"drag_x","op":4,"input":[15,50,200,10,30,0],"output":[20]}, + {"name":"drag_y","op":4,"input":[0,100,500,-10,10,1],"output":[-4]}, + {"name":"drag_clamp","op":4,"input":[15,500,200,10,30,0],"output":[30]}, + {"name":"ticks","op":5,"input":[100,800],"output":[20]}, + {"name":"negative_delta","op":6,"input":[10,0,1],"output":[-10]}, + {"name":"milliseconds","op":6,"input":[0,0.01,1000],"output":[10]}, + {"name":"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/c/set-protocol/tests/fixtures/trends-v1.json b/c/set-protocol/tests/fixtures/trends-v1.json new file mode 100644 index 0000000..2c061d0 --- /dev/null +++ b/c/set-protocol/tests/fixtures/trends-v1.json @@ -0,0 +1,19 @@ +{ + "format": "setflash-trends", + "version": 1, + "profiles": { + "TMS2812": [ + {"id":"tms-1","order":1,"name":"Ток фазы А","source":"TMS_MEMORY","address":"0x00000100","color":"#2F91FF","visible":true,"valueType":"INT16","deviceType":7,"device":13,"byteOffset":0,"extended":true} + ], + "SET_V1": [ + {"id":"set-gas-1","order":1,"name":"Регистр GAS","source":"SET_GAS","address":"0xFF00","color":"#52D6A4","visible":true,"valueType":"UINT16","deviceType":7,"device":13,"byteOffset":0,"extended":true}, + {"id":"sensor-1","order":2,"name":"Температура","source":"SET_SENSOR","address":"28-01-02-03-04-05-06-07","color":"#F8798D","visible":false,"valueType":"UINT16","deviceType":7,"device":13,"byteOffset":0,"extended":true} + ], + "CAN_BRIDGE": [ + {"id":"gas-1","order":1,"name":"GAS узла 13","source":"CAN_GAS","address":"0x1235","color":"#FFB547","visible":true,"valueType":"INT16","deviceType":7,"device":13,"byteOffset":0,"extended":true} + ], + "BALZAM_CAN": [ + {"id":"raw-1","order":3,"name":"Слово CAN","source":"CAN_RAW","address":"0x00BA0010","color":"#B79AFF","visible":true,"valueType":"UINT16","deviceType":7,"device":13,"byteOffset":6,"extended":true} + ] + } +} diff --git a/c/set-protocol/tests/test_plot.c b/c/set-protocol/tests/test_plot.c new file mode 100644 index 0000000..e606956 --- /dev/null +++ b/c/set-protocol/tests/test_plot.c @@ -0,0 +1,31 @@ +#include "set_plot.h" +#include +#include +#include + +int main(void) { + double output[5] = {0, 0, 0, 0, 12345}; + const double zoom[] = {0, 0, 1, 1, 2, 1, 0, 0, .25, .5}; + 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_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); + assert(set_plot_eval(SET_PLOT_TRANSFORM, zoom, 10, output, 3) == 0); + assert(set_plot_eval(SET_PLOT_TRANSFORM, zoom, 9, output, 4) == 0); + assert(set_plot_eval(999, zoom, 10, output, 4) == 0); + assert(set_plot_eval(SET_PLOT_FRACTION, NULL, 4, output, 4) == 0); + assert(set_plot_eval(SET_PLOT_FRACTION, y, 4, NULL, 4) == 0); + assert(set_plot_eval(SET_PLOT_AXIS, bad, 3, output, 4) == 0); + assert(set_plot_eval(SET_PLOT_FRACTION, y, 4, output, 4) == 1); + assert(output[0] == .75); + back[0] = output[0]; back[1] = y[1]; back[2] = y[2]; back[3] = y[3]; + assert(set_plot_eval(SET_PLOT_VALUE, back, 4, output, 4) == 1); + assert(output[0] == y[0]); + assert(set_plot_eval(SET_PLOT_DRAG, drag, 6, output, 4) == 1); + assert(output[0] == -4); + puts("shared plot: OK"); + return 0; +} diff --git a/c/set-protocol/tests/test_spectrum.c b/c/set-protocol/tests/test_spectrum.c new file mode 100644 index 0000000..0de2e94 --- /dev/null +++ b/c/set-protocol/tests/test_spectrum.c @@ -0,0 +1,22 @@ +#include "set_spectrum.h" +#include +#include +#include + +int main(void) +{ + double times[32], values[32], output[18], meta[3]; + for (size_t i = 0; i < 32; ++i) { times[i] = (double)i / 32; values[i] = sin(2 * 3.14159265358979323846 * 4 * times[i]); } + output[17] = 12345; + assert(set_spectrum_analyze(times, values, 32, 32, SET_WINDOW_HANN, 0, 0, 0, 1, output, 17, meta) == SET_SPECTRUM_OK); + assert(meta[0] == 32 && meta[1] == 32); + assert(fabs(output[4] - 1.0) < 1e-12 && output[17] == 12345); + assert(set_spectrum_analyze(times, values, 32, 32, 0, 0, 0, 0, 1, output, 16, meta) == SET_SPECTRUM_INVALID); + assert(set_spectrum_analyze(times, values, 15, 32, 0, 0, 0, 0, 1, output, 17, meta) == SET_SPECTRUM_SHORT); + assert(set_spectrum_analyze(times, values, 32, 32, 0, 99, 0, 0, 1, output, 17, meta) == SET_SPECTRUM_INVALID); + 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); + puts("shared spectrum: OK"); + return 0; +} diff --git a/c/set-protocol/tests/test_trends.c b/c/set-protocol/tests/test_trends.c new file mode 100644 index 0000000..97f10ee --- /dev/null +++ b/c/set-protocol/tests/test_trends.c @@ -0,0 +1,43 @@ +#include +#include +#include +#include "set_trends.h" + +int main(void) +{ + const uint8_t data[] = {1, 0, 254, 255}; + const uint32_t id = 0x1FD31234UL; /* priority 1, from device, type 7, dev 13, GAS */ + assert(set_trend_can_value(1, 0x1235, 7, 13, 0, 1, 0, id, 1, data, 4) == 65534); + assert(set_trend_can_value(1, 0x1235, 7, 13, 0, 1, 1, id, 1, data, 4) == -2); + assert(set_trend_can_value(1, 0x1233, 7, 13, 0, 1, 0, id, 1, data, 4) == SET_TREND_NO_VALUE); + assert(set_trend_can_value(1, 0x1236, 7, 13, 0, 1, 0, id, 1, data, 4) == SET_TREND_NO_VALUE); + assert(set_trend_can_value(1, 0x1235, 6, 13, 0, 1, 0, id, 1, data, 4) == SET_TREND_NO_VALUE); + assert(set_trend_can_value(1, 0x1235, 7, 12, 0, 1, 0, id, 1, data, 4) == SET_TREND_NO_VALUE); + assert(set_trend_can_value(1, 0x1235, 7, 13, 0, 1, 0, id ^ 0x08000000UL, 1, data, 4) == SET_TREND_NO_VALUE); + assert(set_trend_can_value(1, 0x1235, 7, 13, 0, 1, 0, id, 1, data, 3) == SET_TREND_NO_VALUE); + for (unsigned flag = 2; flag <= 8; flag <<= 1) { + assert(set_trend_can_value(1, 0x1235, 7, 13, 0, 1, 0, id, (uint8_t)(1 | flag), data, 4) == SET_TREND_NO_VALUE); + } + assert(set_trend_can_value(2, 0x321, 0, 0, 2, 0, 1, 0x321, 0, data, 4) == -2); + assert(set_trend_can_value(2, 0x321, 0, 0, 3, 0, 1, 0x321, 0, data, 4) == SET_TREND_NO_VALUE); + assert(set_trend_word_value(0x8000, 1) == -32768); + assert(set_trend_word_value(0xFFFF, 0) == 65535); + const uint16_t addresses[] = {0x1234, 0xFFFF}; + const uint8_t expected[] = {232, 3, 2, 0, 52, 18, 255, 255}; + uint8_t output[132]; + assert(set_trend_watch_request(1000, addresses, 2, output, sizeof(output)) == 8); + assert(memcmp(expected, output, 8) == 0); + assert(set_trend_watch_request(1000, addresses, 65, output, sizeof(output)) == 0); + assert(set_trend_watch_request(1000, NULL, 2, output, sizeof(output)) == 0); + assert(set_trend_watch_request(1000, addresses, 2, output, 7) == 0); + assert(set_trend_watch_ack(expected, 4, 1000, 2)); + 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]; + assert(set_trend_watch_values(packet, sizeof(packet), words, 64) == 2); + assert(words[0] == 0x1234 && words[1] == 0xFFFF); + 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"); + return 0; +} diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py index 07efb18..a978283 100644 --- a/c/set-protocol/tools/build_host.py +++ b/c/set-protocol/tools/build_host.py @@ -17,9 +17,10 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent INCLUDE = ROOT / "include" +JNI_INCLUDES: list[Path] = [] SOURCES = [ ROOT / "src" / name for name in ( - "set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", + "set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c", "pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c", "pcan_gas.c", @@ -51,9 +52,10 @@ def _build_msvc(output: Path, build_dir: Path) -> None: raise SystemExit("Visual Studio C++ tools were not found") quoted_sources = " ".join(f'"{source}"' for source in SOURCES) import_library = build_dir / "setprotocol.lib" + jni_flags = " ".join(f'/I"{path}"' for path in JNI_INCLUDES) command = ( f'call "{vcvars}" && cl /nologo /W4 /std:c11 ' - f'/DPCAN_ABI_BUILD_DLL /I"{INCLUDE}" /LD {quoted_sources} ' + f'/DPCAN_ABI_BUILD_DLL /I"{INCLUDE}" {jni_flags} /LD {quoted_sources} ' f'/Fe"{output}" /link /IMPLIB:"{import_library}"' ) # shell=True is intentional on Windows: ``call`` must run the batch file @@ -66,15 +68,23 @@ def _build_cc(output: Path, build_dir: Path) -> None: if compiler is None: raise SystemExit("C compiler was not found") command = [compiler, "-std=c99", "-Wall", "-Wextra", "-Wpedantic", - "-fPIC", "-shared", f"-I{INCLUDE}", *map(str, SOURCES), - "-o", str(output)] + "-fPIC", "-shared", f"-I{INCLUDE}", + *(f"-I{path}" for path in JNI_INCLUDES), *map(str, SOURCES), + "-o", str(output), "-lm"] subprocess.run(command, cwd=build_dir, check=True) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--java-home", type=Path, help="Include the plot JNI adapter for host JVM tests") args = parser.parse_args() + if args.java_home: + include = args.java_home.resolve() / "include" + 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") output = args.output.resolve() output.parent.mkdir(parents=True, exist_ok=True) build_dir = output.parent / ".setprotocol-build" diff --git a/doc/setcan/README.md b/doc/setcan/README.md index 0864e80..b50387b 100644 --- a/doc/setcan/README.md +++ b/doc/setcan/README.md @@ -10,12 +10,16 @@ |---|---| | Однокадровые SETTINGS и bxCAN STM32F1 | [`c/can-sensor`](../../c/can-sensor) | | Единый SET protocol v2 | [`c/set-protocol`](../../c/set-protocol) | -| Транспорт ProtoCAN | [`c/protocan-transport`](../../c/protocan-transport) | +| Транспорт ProtoCAN | [`c/set-protocol`](../../c/set-protocol) | +| STM32 HAL bxCAN/RTC/TIM, API `PROTOCAN_*` | [`c/set-protocol/ports/stm32-bxcan`](../../c/set-protocol/ports/stm32-bxcan/README.md) | | CAN-загрузчик | [`c/protocan-boot`](../../c/protocan-boot) | Файлы `Inc/protocan.h` и `Src/protocan.c`, упомянутые в историческом -[руководстве](index.html), больше не являются подключаемым исходным кодом. -В новых проектах используются нормализованные модули из таблицы выше. +[руководстве](index.html), перенесены в `c/set-protocol/ports/stm32-bxcan` +с сохранением имён и API. Общий `pcan_id` подключается из этого же `templates`. +Отдельный репозиторий SETCAN больше не требуется. Его история до `c8eec55` +включена в Git-историю `templates`; документация здесь сохранена без +перезаписи старым снимком. ## Документы diff --git a/python/protocan/plot.py b/python/protocan/plot.py new file mode 100644 index 0000000..127242b --- /dev/null +++ b/python/protocan/plot.py @@ -0,0 +1,159 @@ +"""Plot interaction port. All numerical operations use templates' set_plot.c. + +This module has no Qt dependency. The application supplies its SETProtocol CDLL. +Units, clocks, acquisition, colours and rendering belong to the application. +""" +from __future__ import annotations + +import ctypes +from dataclasses import dataclass, replace +from enum import IntEnum +from typing import Optional + + +class Axis(IntEnum): + X = 1 + Y = 2 + + +class Marker(IntEnum): + A = 0 + B = 1 + C = 2 + D = 3 + E = 4 + F = 5 + G = 6 + H = 7 + + @property + def horizontal(self) -> bool: + return self in (Marker.E, Marker.F, Marker.G, Marker.H) + + +class PlotMath: + """Typed operations over the versioned allocation-free C ABI.""" + def __init__(self, library: ctypes.CDLL) -> None: + 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: + 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] + library.set_plot_eval.restype = ctypes.c_size_t + + def call(self, operation: int, *values: float) -> tuple: + inputs = (ctypes.c_double * len(values))(*values) + output = (ctypes.c_double * 4)() + count = self.library.set_plot_eval(operation, inputs, len(values), output, 4) + if not count: + raise ValueError("Invalid plot operation %s" % operation) + return tuple(output[:count]) + + def pinch_axis(self, dx: float, dy: float, slop: float) -> Optional[Axis]: + value = int(self.call(1, dx, dy, slop)[0]) + return Axis(value) if value else None + + def tick_step(self, span: float, pixels: float) -> float: + return self.call(5, span, pixels)[0] + + def delta(self, a: float, b: float, multiplier: float = 1) -> float: + return self.call(6, a, b, multiplier)[0] + + +@dataclass(frozen=True) +class Viewport: + x: float = 0.0 + y: float = 0.0 + width: float = 1.0 + height: float = 1.0 + + def transform(self, core: PlotMath, zoom_x: float = 1, zoom_y: float = 1, + pan_x: float = 0, pan_y: float = 0, + focus_x: float = 0.5, focus_y: float = 0.5) -> "Viewport": + return Viewport(*core.call(0, self.x, self.y, self.width, self.height, + zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y)) + + +@dataclass(frozen=True) +class Bounds: + left: float + right: float + bottom: float + top: float + + 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] + + def value(self, core: PlotMath, fraction: float, horizontal: bool) -> float: + return core.call(3, fraction, self.bottom if horizontal else self.left, + self.top if horizontal else self.right, int(horizontal))[0] + + def visible(self, core: PlotMath, viewport: Viewport) -> "Bounds": + return Bounds(self.value(core, viewport.x, False), + self.value(core, viewport.x + viewport.width, False), + self.value(core, viewport.y + viewport.height, True), + self.value(core, viewport.y, True)) + + +@dataclass(frozen=True) +class Markers: + x_enabled: bool = True + y_enabled: bool = False + selected: Marker = Marker.A + a: Optional[float] = None + b: Optional[float] = None + c: Optional[float] = None + d: Optional[float] = None + e: Optional[float] = None + f: Optional[float] = None + g: Optional[float] = None + h: Optional[float] = None + + def value(self, marker: Marker) -> Optional[float]: + return getattr(self, marker.name.lower()) + + def enabled(self, marker: Marker) -> bool: + return self.y_enabled if marker.horizontal else self.x_enabled + + def move(self, marker: Marker, value: float) -> "Markers": + return replace(self, **{marker.name.lower(): value}) + + def positioned(self, core: PlotMath, bounds: Bounds) -> "Markers": + result = self + for marker, fraction in ((Marker.A, .2), (Marker.B, .4), (Marker.C, .6), (Marker.D, .8), + (Marker.E, .2), (Marker.F, .4), (Marker.G, .6), (Marker.H, .8)): + if result.value(marker) is None: + result = result.move(marker, bounds.value(core, fraction, marker.horizontal)) + return result + + def reset(self, core: PlotMath, bounds: Bounds) -> "Markers": + return replace(self, a=None, b=None, c=None, d=None, e=None, f=None, g=None, h=None).positioned(core, bounds) + + def fraction(self, core: PlotMath, marker: Marker, bounds: Bounds) -> float: + value = self.value(marker) + if value is None: + raise ValueError("Marker has no position") + return bounds.fraction(core, value, marker.horizontal) + + def drag(self, core: PlotMath, marker: Marker, delta: float, length: float, bounds: Bounds) -> "Markers": + value = core.call(4, self.value(marker), delta, length, + bounds.bottom if marker.horizontal else bounds.left, + bounds.top if marker.horizontal else bounds.right, int(marker.horizontal))[0] + return replace(self.move(marker, value), selected=marker) + + def hit(self, core: PlotMath, x: float, y: float, width: float, height: float, + radius: float, bounds: Bounds) -> Optional[Marker]: + if not (0 <= x <= width and 0 <= y <= height): + return None + candidates = [] + for marker in Marker: + if not self.enabled(marker) or self.value(marker) is None: + continue + fraction = self.fraction(core, marker, bounds) + distance = abs(y - fraction * height if marker.horizontal else x - fraction * width) + if 0 <= fraction <= 1 and distance <= radius: + candidates.append((distance, marker != self.selected, marker)) + return min(candidates)[2] if candidates else None diff --git a/python/protocan/protocan.py b/python/protocan/protocan.py index 94a7264..8a2789e 100644 --- a/python/protocan/protocan.py +++ b/python/protocan/protocan.py @@ -66,6 +66,7 @@ class SettingsResult(IntEnum): EEPROM_ERROR = 0x05 INVALID_LOCATION = 0x06 BUSY = 0x07 + INVALID_VALUE = 0x08 class BroadcastType(IntEnum): @@ -145,6 +146,7 @@ SETTINGS_RESULT_RU = { SettingsResult.EEPROM_ERROR: "ошибка EEPROM", SettingsResult.INVALID_LOCATION: "неверная локация", SettingsResult.BUSY: "устройство занято", + SettingsResult.INVALID_VALUE: "значение настройки вне диапазона", } BROADCAST_RU = { @@ -680,6 +682,30 @@ def _decode_settings(res: Decoded) -> None: (" SETTINGS.Position Y", "%d (0x%02X)" % (position, position)), ] + # Зарезервированная локация прибора KONOR: runtime-настройки не являются + # ROM датчика, хотя используют тот же однокадровый SETTINGS-транспорт. + if assembly == 0xFF and position == 0x01: + if res.dlc == 0 and res.id.pm == Route.FROM_PM: + res.summary = "SETTINGS RUNTIME GET" + return + if res.dlc == 8: + pulse_seconds = res.data[0] + led_hz = int.from_bytes(res.data[1:3], "little") + res.fields += [ + (" SETTINGS.Pulse period", + "%d s" % pulse_seconds if pulse_seconds else "выключен"), + (" SETTINGS.LED frequency", "%d Hz" % led_hz), + ] + if led_hz > 500: + res.warnings.append("Частота LED должна быть 0..500 Гц") + if any(res.data[3:]): + res.warnings.append("Резерв SETTINGS Data[3:8] должен быть нулевым") + direction = "WRITE" if res.id.pm == Route.FROM_PM else "RESPONSE" + period_text = "%d s" % pulse_seconds if pulse_seconds else "выключен" + res.summary = ("SETTINGS RUNTIME %s: PULSE %s, LED %d Hz" + % (direction, period_text, led_hz)) + return + if res.id.pm == Route.FROM_PM: if res.dlc == 0: operation = "GET" diff --git a/python/protocan/spectrum.py b/python/protocan/spectrum.py new file mode 100644 index 0000000..1ce87ec --- /dev/null +++ b/python/protocan/spectrum.py @@ -0,0 +1,71 @@ +"""Shared FFT adapter for desktop GUIs; no numpy/Qt dependency or duplicated DSP. + +Pass NativeProtocol.lib (rebuilt with set_spectrum.c). Timestamp inputs are seconds. +See c/set-protocol/include/set_spectrum.h for filtering and normalization semantics. +""" +from __future__ import annotations +import ctypes +from dataclasses import dataclass +from enum import IntEnum + + +class Window(IntEnum): + RECT = 0 + HANN = 1 + HAMMING = 2 + BLACKMAN = 3 + FLATTOP = 4 + + +class Filter(IntEnum): + NONE = 0 + LOW_PASS = 1 + HIGH_PASS = 2 + BAND_PASS = 3 + NOTCH = 4 + + +@dataclass(frozen=True) +class Spectrum: + size: int + sample_rate: float + jitter: float + amplitudes: tuple[float, ...] + + @property + def frequencies(self) -> tuple[float, ...]: + return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes))) + + +class NativeSpectrum: + def __init__(self, library: ctypes.CDLL): + self.lib = library + self._analyze = library.set_spectrum_analyze + pointer = ctypes.POINTER(ctypes.c_double) + self._analyze.argtypes = [pointer, pointer, ctypes.c_size_t, ctypes.c_size_t, + 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 + + 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: + if type(max_size) is not int or not 16 <= max_size <= 16384 or max_size & (max_size - 1): + raise ValueError("FFT size must be a power of two in 16..16384") + if len(times) != len(values): + raise ValueError("Timestamp and value counts differ") + count = min(len(times), max_size) + origin = times[len(times) - count] if count else 0.0 + t = (ctypes.c_double * count)(*(value - origin for value in times[-count:])) if count else (ctypes.c_double * 0)() + v = (ctypes.c_double * count)(*values[-count:]) if count else (ctypes.c_double * 0)() + capacity = max_size // 2 + 1 + output, meta = (ctypes.c_double * capacity)(), (ctypes.c_double * 3)() + status = self._analyze(t, v, count, max_size, int(window), int(filter), + low_hz, high_hz, remove_mean, output, capacity, meta) + if status: + message = {1: "At least 16 samples are required", 2: "Invalid spectrum input", + 3: "Gaps, duplicate or irregular timestamps (>50% interval deviation)", + 4: f"Filter frequencies must be between 0 and Fs/2 ({meta[1] / 2:g} Hz)", + 5: "Cannot allocate FFT workspace"} + raise ValueError(message.get(status, "FFT failed")) + n = int(meta[0]) + return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1])) diff --git a/python/protocan/trends.py b/python/protocan/trends.py new file mode 100644 index 0000000..22e2daa --- /dev/null +++ b/python/protocan/trends.py @@ -0,0 +1,192 @@ +"""Portable trend configuration shared by SETGUI and Android (no Qt/Android). + +Numeric CAN decoding uses set_trends.c through NativeTrends. JSON adapters +on Python/Kotlin implement the same documented version-1 interchange contract. +""" +from __future__ import annotations + +import ctypes +import json +import math +import re +import uuid +from collections import deque +from dataclasses import asdict, dataclass +from typing import Mapping + +MAX_SIGNALS = 64 +MAX_POINTS = 600 +MAX_FILE_BYTES = 1024 * 1024 +PROFILE_SOURCES = { + "TMS2812": ("TMS_MEMORY",), + "SET_V1": ("SET_GAS", "SET_SENSOR"), + "BALZAM_CAN": ("CAN_RAW",), + **{name: ("CAN_GAS", "CAN_RAW") for name in + ("CAN_BRIDGE", "GS_USB_CAN", "SLCAN", "CANGAROO_SLCAN")}, +} + + +def parse_address(value: str, maximum: int) -> int: + text = value.strip() + if not re.fullmatch(r"(?:0[xX][0-9a-fA-F]+|[0-9]+)", text): + raise ValueError("Address must be decimal or explicit 0x hexadecimal") + number = int(text[2:], 16) if text.lower().startswith("0x") else int(text, 10) + if not 0 <= number <= maximum: + raise ValueError("Address out of range") + return number + + +def normalize_rom(value: str) -> str: + return value.strip().replace("-", "").replace(" ", "").upper() + + +@dataclass(frozen=True) +class TrendSignal: + # Field spellings intentionally match the JSON/Kotlin contract. + id: str + order: int = 1 + name: str = "Тренд 1" + source: str = "TMS_MEMORY" + address: str = "0x00000100" + color: str = "#2F91FF" + visible: bool = True + valueType: str = "UINT16" + deviceType: int = 7 + device: int = 13 + byteOffset: int = 0 + extended: bool = True + + @classmethod + def new(cls, profile: str, existing: tuple = ()) -> "TrendSignal": + order = next(number for number in range(1, 10000) + if all(item.order != number for item in existing)) + source = PROFILE_SOURCES[profile][0] + address = {"TMS_MEMORY": "0x00000100", "SET_GAS": "0x0000", + "CAN_GAS": "0x0000", "CAN_RAW": "0x00BA0010"}[source] + colors = ("#2F91FF", "#FFB547", "#52D6A4", "#F8798D", + "#B79AFF", "#4AD9E8", "#E8DA68", "#E7ECF3") + return cls(str(uuid.uuid4()), order, f"Тренд {order}", source, + address, colors[(order - 1) % len(colors)]) + + def validate(self, profile: str) -> None: + 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"): + if type(getattr(self, field)) is not int: + raise ValueError(f"{field} must be an integer") + 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: + raise ValueError("Invalid ID/order") + if not self.name.strip() or len(self.name) > 100 or len(self.address) > 64: + raise ValueError("Invalid name/address length") + if self.source not in PROFILE_SOURCES.get(profile, ()): + raise ValueError("Source does not match connection protocol") + if not re.fullmatch(r"#[0-9a-fA-F]{6}", self.color): + raise ValueError("Color must be #RRGGBB") + if self.valueType not in ("UINT16", "INT16"): + raise ValueError("Unsupported word type") + if self.source == "SET_SENSOR": + if not re.fullmatch(r"[0-9A-F]{16}", normalize_rom(self.address)): + raise ValueError("ROM must contain 16 hexadecimal digits") + else: + maximum = {"TMS_MEMORY": 0xFFFFFFFF, "CAN_GAS": 0xFFFF, + "SET_GAS": 0xFFFF, "CAN_RAW": 0x1FFFFFFF if self.extended else 0x7FF}[self.source] + parse_address(self.address, maximum) + if self.source == "CAN_GAS" and not (0 <= self.deviceType <= 7 and 0 <= self.device <= 15): + raise ValueError("Invalid ProtoCAN device") + 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(): + if profile not in PROFILE_SOURCES or len(signals) > MAX_SIGNALS: + raise ValueError("Unknown profile or too many signals") + orders = set() + for signal in signals: + signal.validate(profile) + if signal.id in ids or signal.order in orders: + raise ValueError("Duplicate trend ID or order") + ids.add(signal.id) + orders.add(signal.order) + + +def encode_settings(settings: Mapping[str, list[TrendSignal]]) -> str: + validate_settings(settings) + return json.dumps({"format": "setflash-trends", "version": 1, "profiles": { + profile: [asdict(signal) for signal in sorted(signals, key=lambda item: item.order)] + for profile, signals in settings.items()}}, ensure_ascii=False, indent=2) + + +def decode_settings(text: str) -> dict[str, list[TrendSignal]]: + if len(text.encode("utf-8")) > MAX_FILE_BYTES: + raise ValueError("Trend configuration exceeds 1 MiB") + root = json.loads(text.removeprefix("\ufeff")) + if not isinstance(root, dict) or root.get("format") != "setflash-trends" or type(root.get("version")) is not int or root["version"] != 1: + raise ValueError("Unknown trend configuration format/version") + profiles = root.get("profiles") + if not isinstance(profiles, dict): + raise ValueError("profiles must be an object") + result = {} + fields = set(TrendSignal.__dataclass_fields__) + 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(): + raise ValueError("Missing signal fields") + signals.append(TrendSignal(**{key: item[key] for key in fields})) + result[profile] = signals + validate_settings(result) + return result + + +class TrendHistory: + """Bounded per-signal monotonic-time history; visibility belongs to the renderer.""" + def __init__(self) -> None: + self.series: dict[str, deque] = {} + + def append(self, values: Mapping[str, float], timestamp_ms: int) -> None: + for key, value in values.items(): + if math.isfinite(value): + self.series.setdefault(key, deque(maxlen=MAX_POINTS)).append((timestamp_ms, value)) + + +class NativeTrends: + """Thin ctypes port: accepts NativeProtocol.lib or any loaded SETProtocol CDLL. + + Existing older DLLs keep working for other protocol functions. This optional + feature reports a clear error until the shared library is rebuilt. + """ + def __init__(self, library: ctypes.CDLL) -> None: + self.lib = library + try: + self.decode = library.set_trend_can_value + except AttributeError as error: + raise RuntimeError("Rebuild SETProtocol with set_trends.c to use trends") from error + self.decode.argtypes = [ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint8, + 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 + + def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None: + if signal.source not in ("CAN_GAS", "CAN_RAW"): + return None + signal.validate("CAN_BRIDGE") + if not 0 <= can_id <= 0x1FFFFFFF or not 0 <= flags <= 255 or len(data) > 8: + return None + payload = (ctypes.c_uint8 * len(data)).from_buffer_copy(data) + value = self.decode(1 if signal.source == "CAN_GAS" else 2, + parse_address(signal.address, 0x1FFFFFFF), signal.deviceType, signal.device, + signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags, + payload, len(data)) + return None if value == -2147483648 else float(value) diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py new file mode 100644 index 0000000..b229824 --- /dev/null +++ b/python/tests/test_plot.py @@ -0,0 +1,50 @@ +"""Cross-port contract: Python/ctypes and Kotlin/JNI consume the same fixtures.""" +import ctypes +import json +import math +import os +from pathlib import Path +import unittest + +from protocan.plot import Bounds, Marker, Markers, PlotMath, Viewport + + +class PlotTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.core = PlotMath(ctypes.CDLL(os.environ["SETPROTOCOL_LIBRARY"])) + + def test_shared_numeric_fixtures(self): + path = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/plot-v1.json" + for case in json.loads(path.read_text())["cases"]: + with self.subTest(case=case["name"]): + if case["output"] is None: + with self.assertRaises(ValueError): + self.core.call(case["op"], *case["input"]) + else: + actual = self.core.call(case["op"], *case["input"]) + self.assertEqual(len(actual), len(case["output"])) + for a, b in zip(actual, case["output"]): + self.assertAlmostEqual(a, b, places=10) + + def test_markers_stay_in_data_coordinates_and_cross(self): + bounds = Bounds(1000, 2000, -10, 10) + markers = Markers().positioned(self.core, bounds) + zoomed = bounds.visible(self.core, Viewport(.25, .25, .5, .5)) + self.assertEqual(markers, markers.positioned(self.core, zoomed)) + moved = markers.drag(self.core, Marker.A, 50, 500, zoomed) + self.assertAlmostEqual(moved.a, markers.a + 50) + self.assertEqual(markers.b, moved.b) + self.assertEqual(Marker.A, moved.hit(self.core, 0, 50, 500, 100, 20, zoomed)) + crossed = markers.move(Marker.A, 1900).move(Marker.B, 1100) + self.assertEqual(-800, self.core.delta(crossed.a, crossed.b)) + + def test_invalid_numeric_inputs_do_not_escape_to_painter(self): + for value in (math.nan, math.inf, -math.inf): + self.assertEqual(Viewport(), Viewport().transform(self.core, zoom_x=value)) + with self.assertRaises(ValueError): + self.core.pinch_axis(value, 10, 8) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_spectrum.py b/python/tests/test_spectrum.py new file mode 100644 index 0000000..febddd0 --- /dev/null +++ b/python/tests/test_spectrum.py @@ -0,0 +1,117 @@ +import cmath +import ctypes +import math +import os +import unittest + +from protocan.spectrum import Filter, NativeSpectrum, Window + + +@unittest.skipUnless(os.environ.get("SETPROTOCOL_LIBRARY"), "Set SETPROTOCOL_LIBRARY to the built C library") +class SpectrumTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.core = NativeSpectrum(ctypes.CDLL(os.environ["SETPROTOCOL_LIBRARY"])) + + def sample(self, n=4096, fs=1024, frequencies=(64,), amplitude=1): + times = [i / fs for i in range(n)] + return times, [amplitude * sum(math.sin(2 * math.pi * f * t) for f in frequencies) for t in times] + + def test_fft_matches_independent_direct_dft(self): + times, _ = self.sample(32) + values = [math.sin(i * 1.37) + 0.1 * i for i in range(32)] + result = self.core.analyze(times, values, window=Window.RECT, remove_mean=False) + for k, amplitude in enumerate(result.amplitudes): + direct = abs(sum(v * cmath.exp(-2j * math.pi * k * i / 32) for i, v in enumerate(values))) / 32 + if k not in (0, 16): + direct *= 2 + self.assertAlmostEqual(direct, amplitude, places=11) + + def test_all_windows_preserve_bin_centered_peak_amplitude(self): + times, values = self.sample(amplitude=3.25) + for window in Window: + with self.subTest(window=window): + result = self.core.analyze(times, values, window=window) + 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) + + def test_dc_and_nyquist_are_not_doubled(self): + times, _ = self.sample() + result = self.core.analyze(times, [2.5] * len(times), remove_mean=False, window=Window.RECT) + self.assertAlmostEqual(2.5, result.amplitudes[0], places=10) + result = self.core.analyze(times, [3 * (-1) ** i for i in range(len(times))], window=Window.RECT) + self.assertAlmostEqual(3, result.amplitudes[-1], places=10) + result = self.core.analyze(times, [2.5] * len(times)) + self.assertLess(max(result.amplitudes), 1e-12) + + def test_windows_suppress_far_leakage_and_flattop_recovers_off_bin_amplitude(self): + times, values = self.sample(frequencies=(64.13,)) + rect = self.core.analyze(times, values, window=Window.RECT) + hann = self.core.analyze(times, values, window=Window.HANN) + flat = self.core.analyze(times, values, window=Window.FLATTOP) + self.assertLess(hann.amplitudes[400], rect.amplitudes[400] / 100) + self.assertAlmostEqual(1, max(flat.amplitudes), delta=0.002) + + def test_filters_attenuate_expected_bands(self): + times, values = self.sample(frequencies=(16, 64, 256)) + low = self.core.analyze(times, values, filter=Filter.LOW_PASS, high_hz=64) + high = self.core.analyze(times, values, filter=Filter.HIGH_PASS, low_hz=64) + band = self.core.analyze(times, values, filter=Filter.BAND_PASS, low_hz=32, high_hz=128) + notch = self.core.analyze(times, values, filter=Filter.NOTCH, low_hz=64) + at = lambda result, hz: result.amplitudes[int(hz / (result.sample_rate / result.size))] + self.assertGreater(at(low, 16), 0.99) + self.assertLess(at(low, 256), 0.05) + self.assertAlmostEqual(1 / math.sqrt(2), at(low, 64), delta=0.001) + self.assertLess(at(high, 16), 0.07) + self.assertGreater(at(high, 256), 0.99) + self.assertGreater(at(band, 64), 0.93) + self.assertLess(at(band, 16), 0.25) + self.assertLess(at(band, 256), 0.2) + self.assertLess(at(notch, 64), 0.02) + self.assertGreater(at(notch, 16), 0.99) + + def test_timestamp_rate_not_requested_rate_and_jitter_interpolation(self): + n, fs = 1024, 200 + times = [(i + (0.05 if i % 2 else 0)) / fs for i in range(n)] + values = [2 * math.sin(2 * math.pi * (16 * fs / n) * t) for t in times] + result = self.core.analyze(times, values) + self.assertAlmostEqual((n - 1) / (times[-1] - times[0]), result.sample_rate) + self.assertGreater(result.jitter, 0.04) + self.assertAlmostEqual(2, result.amplitudes[16], delta=0.005) + + def test_rejects_gaps_duplicates_bad_values_and_cutoffs(self): + times, values = self.sample(128) + for broken in ([0.0] * 128, times[:64] + [t + 1 for t in times[64:]], list(reversed(times))): + with self.assertRaisesRegex(ValueError, "timestamps"): + self.core.analyze(broken, values) + for value in (float("nan"), float("inf")): + with self.assertRaises(ValueError): + self.core.analyze(times, values[:-1] + [value]) + for cutoff in (0, -1, 512, 1000, float("nan")): + with self.assertRaisesRegex(ValueError, "Filter frequencies"): + self.core.analyze(times, values, filter=Filter.LOW_PASS, high_hz=cutoff) + with self.assertRaises(ValueError): + self.core.analyze(times, values, filter=Filter.BAND_PASS, low_hz=100, high_hz=50) + + def test_size_limits_tail_selection_and_inputs_unchanged(self): + times, values = self.sample(1000) + original = values[:] + result = self.core.analyze(times, values) + self.assertEqual(512, result.size) + self.assertEqual(original, values) + self.assertEqual(256, self.core.analyze(times, values, max_size=256).size) + for n in (0, 1, 15): + with self.assertRaisesRegex(ValueError, "16 samples"): + self.core.analyze(times[:n], values[:n]) + for size in (0, 15, 1000, 32768): + with self.assertRaises(ValueError): + self.core.analyze(times, values, max_size=size) + with self.assertRaises(ValueError): + self.core.analyze(times[:-1], values) + times, values = self.sample(20000) + self.assertEqual(16384, self.core.analyze(times, values, max_size=16384).size) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_trends.py b/python/tests/test_trends.py new file mode 100644 index 0000000..b18474b --- /dev/null +++ b/python/tests/test_trends.py @@ -0,0 +1,87 @@ +import ctypes +import json +import os +import unittest +from dataclasses import replace +from pathlib import Path + +from protocan.trends import ( + MAX_FILE_BYTES, MAX_POINTS, NativeTrends, TrendHistory, TrendSignal, + decode_settings, encode_settings, parse_address, +) + +FIXTURE = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/trends-v1.json" + + +class TrendTests(unittest.TestCase): + 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()))) + self.assertEqual("Ток фазы А", settings["TMS2812"][0].name) + self.assertEqual(settings, decode_settings(encode_settings(settings))) + self.assertEqual(settings, decode_settings("\ufeff" + encode_settings(settings))) + + def test_profile_specific_defaults(self): + self.assertEqual("SET_GAS", TrendSignal.new("SET_V1").source) + self.assertEqual("CAN_RAW", TrendSignal.new("BALZAM_CAN").source) + self.assertEqual("CAN_GAS", TrendSignal.new("SLCAN").source) + + def test_invalid_import_and_types(self): + fixture = FIXTURE.read_text(encoding="utf-8") + for field, value in (("order", "1"), ("order", 1.5), ("order", True), + ("visible", "true"), ("color", "red"), + ("source", "CAN_GAS"), ("address", "0x100000000")): + with self.subTest(field=field, value=value), self.assertRaises(ValueError): + data = json.loads(fixture) + data["profiles"]["TMS2812"][0][field] = value + decode_settings(json.dumps(data)) + for version in (2, "1", 1.5, True): + with self.assertRaises(ValueError): + data = json.loads(fixture) + data["version"] = version + decode_settings(json.dumps(data)) + with self.assertRaises(ValueError): + decode_settings(" " * (MAX_FILE_BYTES + 1)) + + def test_duplicates_and_limits(self): + signal = TrendSignal("one") + for signals in ([signal, replace(signal, id="two")], + [signal, replace(signal, order=2)], + [replace(signal, id=str(i), order=i + 1) for i in range(65)]): + with self.assertRaises(ValueError): + encode_settings({"TMS2812": signals}) + + def test_addresses_and_signedness(self): + self.assertEqual(255, parse_address("0xFF", 255)) + self.assertEqual(100, parse_address("100", 100)) + 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() + for i in range(MAX_POINTS + 10): + history.append({"a": float(i), "bad": float("nan")}, i) + self.assertEqual(MAX_POINTS, len(history.series["a"])) + self.assertEqual((10, 10.0), history.series["a"][0]) + self.assertNotIn("bad", history.series) + + @unittest.skipUnless(os.environ.get("SETPROTOCOL_LIBRARY"), "Host DLL not supplied") + def test_actual_shared_c_core(self): + core = NativeTrends(ctypes.CDLL(os.environ["SETPROTOCOL_LIBRARY"])) + signal = TrendSignal("gas", source="CAN_GAS", address="0x1235", valueType="INT16") + frame_id = 0x1FD31234 + data = bytes([1, 0, 254, 255]) + self.assertEqual(-2.0, core.can_value(signal, frame_id, 1, data)) + self.assertEqual(65534.0, core.can_value(replace(signal, valueType="UINT16"), frame_id, 1, data)) + self.assertIsNone(core.can_value(replace(signal, device=12), frame_id, 1, data)) + for flag in (2, 4, 8): + self.assertIsNone(core.can_value(signal, frame_id, 1 | flag, data)) + 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)) + + +if __name__ == "__main__": + unittest.main() From 31a111ee4672250da5457b66592016d37d4c7ced Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 12:22:21 +0300 Subject: [PATCH 02/37] =?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=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D1=8B=D0=B9=20=D0=B4=D1=80=D0=B0=D0=B9=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=20parallel=20NAND?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + c/parallel-nand/CMakeLists.txt | 26 +++ c/parallel-nand/README.md | 88 ++++++++ c/parallel-nand/docs/EMPTY_BOARD_PINOUT.md | 81 ++++++++ c/parallel-nand/docs/MEMORY_GAS.md | 177 ++++++++++++++++ c/parallel-nand/include/parallel_nand.h | 68 +++++++ c/parallel-nand/include/parallel_nand_gas.h | 84 ++++++++ .../include/parallel_nand_pcan_gas.h | 11 + c/parallel-nand/include/parallel_nand_port.h | 28 +++ c/parallel-nand/ports/stm32f407ve/README.md | 26 +++ .../parallel_nand_port_stm32f407ve.c | 93 +++++++++ .../parallel_nand_port_stm32f407ve.h | 8 + c/parallel-nand/ports/stm32g474ce/README.md | 21 ++ .../parallel_nand_port_stm32g474ce.c | 134 ++++++++++++ .../parallel_nand_port_stm32g474ce.h | 8 + c/parallel-nand/ports/tms320f2812/README.md | 21 ++ .../parallel_nand_port_tms320f2812.c | 65 ++++++ .../parallel_nand_port_tms320f2812.h | 8 + c/parallel-nand/src/parallel_nand.c | 191 ++++++++++++++++++ c/parallel-nand/src/parallel_nand_gas.c | 151 ++++++++++++++ c/parallel-nand/src/parallel_nand_pcan_gas.c | 43 ++++ c/parallel-nand/tests/test_parallel_nand.c | 97 +++++++++ 22 files changed, 1430 insertions(+) create mode 100644 c/parallel-nand/CMakeLists.txt create mode 100644 c/parallel-nand/README.md create mode 100644 c/parallel-nand/docs/EMPTY_BOARD_PINOUT.md create mode 100644 c/parallel-nand/docs/MEMORY_GAS.md create mode 100644 c/parallel-nand/include/parallel_nand.h create mode 100644 c/parallel-nand/include/parallel_nand_gas.h create mode 100644 c/parallel-nand/include/parallel_nand_pcan_gas.h create mode 100644 c/parallel-nand/include/parallel_nand_port.h create mode 100644 c/parallel-nand/ports/stm32f407ve/README.md create mode 100644 c/parallel-nand/ports/stm32f407ve/parallel_nand_port_stm32f407ve.c create mode 100644 c/parallel-nand/ports/stm32f407ve/parallel_nand_port_stm32f407ve.h create mode 100644 c/parallel-nand/ports/stm32g474ce/README.md create mode 100644 c/parallel-nand/ports/stm32g474ce/parallel_nand_port_stm32g474ce.c create mode 100644 c/parallel-nand/ports/stm32g474ce/parallel_nand_port_stm32g474ce.h create mode 100644 c/parallel-nand/ports/tms320f2812/README.md create mode 100644 c/parallel-nand/ports/tms320f2812/parallel_nand_port_tms320f2812.c create mode 100644 c/parallel-nand/ports/tms320f2812/parallel_nand_port_tms320f2812.h create mode 100644 c/parallel-nand/src/parallel_nand.c create mode 100644 c/parallel-nand/src/parallel_nand_gas.c create mode 100644 c/parallel-nand/src/parallel_nand_pcan_gas.c create mode 100644 c/parallel-nand/tests/test_parallel_nand.c diff --git a/README.md b/README.md index 9f548cc..c6268bd 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ templates/ | [`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 в комплекте** | | [`c/firmware-info`](c/firmware-info) | SemVer, build stamp и git build ID работающего образа | C99 | config-порты STM32F1/F4/G4 и К1921ВК028; упаковка для Modbus/SETGUI | +| [`c/parallel-nand`](c/parallel-nand) | parallel NAND x8: raw main/OOB, bad-block marker и банковое окно GAS для SETGUI | C99, опционально `set-protocol/pcan_gas` | **порты TMS320F2812, STM32F407VE и STM32G474CEU6 в комплекте** | ### Python diff --git a/c/parallel-nand/CMakeLists.txt b/c/parallel-nand/CMakeLists.txt new file mode 100644 index 0000000..0960b91 --- /dev/null +++ b/c/parallel-nand/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.16) +project(parallel_nand C) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) + +add_library(parallel_nand + src/parallel_nand.c + src/parallel_nand_gas.c +) +target_include_directories(parallel_nand PUBLIC + include +) + +add_library(parallel_nand_pcan_gas src/parallel_nand_pcan_gas.c) +target_include_directories(parallel_nand_pcan_gas PUBLIC + include + ../set-protocol/include +) +target_link_libraries(parallel_nand_pcan_gas PUBLIC parallel_nand) + +add_executable(test_parallel_nand tests/test_parallel_nand.c) +target_link_libraries(test_parallel_nand PRIVATE parallel_nand) + +enable_testing() +add_test(NAME parallel_nand_core COMMAND test_parallel_nand) diff --git a/c/parallel-nand/README.md b/c/parallel-nand/README.md new file mode 100644 index 0000000..ebc4a27 --- /dev/null +++ b/c/parallel-nand/README.md @@ -0,0 +1,88 @@ +# parallel-nand + +Переносимый C99-драйвер асинхронной parallel NAND x8 и окно управления через +общее адресное пространство GAS. Первый профиль — Micron `MT29F1G08ABADA`: +2048+64 байта, 64 страницы в блоке, 1024 блока. + +Подготовлены аппаратные порты: + +- `TMS320F2812` — XINTF Zone 6; +- `STM32F407VET6` — аппаратный FSMC NAND Bank 2; +- `STM32G474CEU6` — GPIO bit-bang для корпуса UFQFPN-48. + +Ядро не зависит от HAL и транспорта GUI. STM32 HAL используется только в +конкретных портах. `parallel_nand_gas` реализует переносимую 16-битную карту +GAS и компилируется в том числе TI C28x, где `char` имеет 16 бит. + +## Подключение + +В проект добавить: + +```text +c/parallel-nand/src/parallel_nand.c +c/parallel-nand/src/parallel_nand_gas.c +``` + +Пути заголовков: + +```text +c/parallel-nand/include +c/parallel-nand/ports/<нужный-порт> +``` + +И один аппаратный порт, например: + +```text +c/parallel-nand/ports/stm32g474ce/parallel_nand_port_stm32g474ce.c +``` + +Минимальная инициализация: + +```c +static parallel_nand_t nand; +static parallel_nand_gas_t nand_gas; + +void memory_init(void) +{ + int rc = parallel_nand_init(&nand, + parallel_nand_port_stm32g474ce(), + ¶llel_nand_mt29f1g08_geometry); + if (rc != PNAND_OK) { + /* Оставить диагностику доступной GUI и сообщить ошибку платы. */ + } + + parallel_nand_gas_init(&nand_gas, &nand, PNAND_GAS_DEFAULT_BASE); +} +``` + +Диспетчер общего адресного пространства вызывает +`parallel_nand_gas_read()`/`parallel_nand_gas_write()` для адресов +`0xE000..0xE51F`. Для STM32 с `c/set-protocol/pcan_gas` дополнительно включить +`src/parallel_nand_pcan_gas.c`, создать `pcan_gas_region_t` и вызвать +`parallel_nand_pcan_gas_region_init()`. Этот адаптер намеренно не нужен TMS. +Для полного дампа предпочтительны пакетные `READ_REGISTERS/WRITE_REGISTERS`, +а classic CAN GAS оставлен для совместимости и коротких диагностических чтений. + +Доступ GUI и алгоритм полного дампа описаны в +[docs/MEMORY_GAS.md](docs/MEMORY_GAS.md). Электрические соединения — в README +соответствующего порта и [docs/EMPTY_BOARD_PINOUT.md](docs/EMPTY_BOARD_PINOUT.md). + +## Текущие границы + +- Реализовано безопасное чтение, RESET, READ ID, STATUS и bad-block marker. +- PROGRAM/ERASE намеренно отсутствуют; ядро включает аппаратную защиту `WP#`. +- ECC пока не исправляет данные. GUI получает физические main/OOB и должен + помечать дамп как raw. +- В полном дампе bad-блоки не пропускаются: сохраняется физический порядок. +- Для другой NAND нужно передать другую `parallel_nand_geometry_t` и проверить + команды/маркер по её datasheet. + +## Проверка первого запуска + +1. Запустить `parallel_nand_init()`. +2. Через GAS проверить `0xE000 = 0x4E44`. +3. Записать `2` в `0xE004` (`READ_ID`). +4. Проверить `0xE00B = 0x002C`, `0xE00C = 0x00F1`. +5. Записать номер страницы в `0xE005/0xE006`, затем `3` в `0xE004`. +6. Дождаться `READY | PAGE_VALID` в `0xE002`. +7. Прочитать main из `0xE100..0xE4FF`, OOB из `0xE500..0xE51F`. diff --git a/c/parallel-nand/docs/EMPTY_BOARD_PINOUT.md b/c/parallel-nand/docs/EMPTY_BOARD_PINOUT.md new file mode 100644 index 0000000..5094b1f --- /dev/null +++ b/c/parallel-nand/docs/EMPTY_BOARD_PINOUT.md @@ -0,0 +1,81 @@ +# Подключение parallel NAND на пустой плате + +Документ рассчитан на асинхронную NAND x8 `MT29F1G08ABADA` 3,3 В в корпусе +TSOP-48 (суффикс `WP`). На второй фотографии NAND — длинная микросхема справа +с маркировкой `29F1G08ABADA`. Микросхема Micron `D9MDK` сверху — DDR2 SDRAM, +не NAND. На первой фотографии `AM29LV800BT` — parallel NOR, а не NAND. + +Уточнение по ёмкости с фотографий: + +- `K6R4008V1D` — SRAM 4 Mbit (512 KiB), не Flash; +- `AM29LV800BT` — parallel NOR 8 Mbit (1 MiB); +- `MT29F1G08ABADA` — parallel NAND 1 Gbit, то есть 128 MiB main area. + +Поэтому текущий NAND-профиль рассчитан на 1-Gbit чип со второй платы. Для +другой плотности меняется `parallel_nand_geometry_t`; GAS и GUI-команды +остаются теми же. + +Не переносите номера выводов TSOP-48 на вариант `H4` VFBGA-63. Для BGA нужно +использовать ball map конкретного полного part number. + +## TSOP-48, x8 + +| NAND | Вывод | Назначение | +|---|---:|---| +| `R/B#` | 7 | open-drain Ready/Busy, подтяжка 4,7–10 кОм к 3,3 В | +| `RE#` | 8 | строб чтения от МК | +| `CE#` | 9 | выбор кристалла от МК | +| `VCC` | 12, 37 | питание 3,3 В | +| `VSS` | 13, 36 | земля | +| `CLE` | 16 | фиксация команды | +| `ALE` | 17 | фиксация адреса | +| `WE#` | 18 | строб команды/адреса от МК | +| `WP#` | 19 | для read-only 10 кОм на GND; для записи — GPIO и pull-down | +| `I/O0` | 29 | двунаправленная шина, бит 0 | +| `I/O1` | 30 | двунаправленная шина, бит 1 | +| `I/O2` | 31 | двунаправленная шина, бит 2 | +| `I/O3` | 32 | двунаправленная шина, бит 3 | +| `I/O4` | 41 | двунаправленная шина, бит 4 | +| `I/O5` | 42 | двунаправленная шина, бит 5 | +| `I/O6` | 43 | двунаправленная шина, бит 6 | +| `I/O7` | 44 | двунаправленная шина, бит 7 | +| `VCC1` | 34, 39 | соединить с 3,3 В для совместимости корпуса/ONFI | +| `VSS1` | 25, 48 | соединить с GND для совместимости корпуса/ONFI | +| `DNU` | 38, 47 | оставить неподключёнными | +| `NC` | остальные | не подключать | + +У каждого VCC/VCC1 поставить 100 нФ на ближайший VSS/VSS1; рядом с NAND — +общий 1–4,7 мкФ. Сигналы не должны превышать питание NAND. Для трасс длиннее +нескольких сантиметров предусмотреть последовательные резисторы 22–47 Ом у +источника на `WE#`, `RE#`, `CLE`, `ALE` и при необходимости на данных. + +## Соединение с МК + +| NAND | TMS320F2812 | STM32F407VET6 | STM32G474CEU6 | +|---|---|---|---| +| `I/O0..7` | `XD0..XD7` | `PD14,PD15,PD0,PD1,PE7..PE10` | `PB0..PB7` | +| `CLE` | `XA0` | `PD11/FSMC_A16` | `PA0` | +| `ALE` | `XA1` | `PD12/FSMC_A17` | `PA1` | +| `WE#` | `XWE` | `PD5/FSMC_NWE` | `PA2` | +| `RE#` | `XRD` | `PD4/FSMC_NOE` | `PA3` | +| `CE#` | `XZCS6AND7` | `PD7/FSMC_NE2` | `PA4` | +| `WP#` | GND через 10 кОм | GND через 10 кОм | `PA5` и pull-down 10 кОм | +| `R/B#` | `GPIOE0` | `PD6/FSMC_NWAIT` | `PA6` | + +Подробные номера выводов корпуса МК и ограничения находятся в README каждого +каталога `ports/`. Перед изготовлением платы нужно сверить выбранный корпус и +полный part number NAND с актуальным datasheet. + +## Первый запуск + +1. Не устанавливать NAND и проверить 3,3 В, отсутствие КЗ и уровни `CE#/WE#/RE#`. +2. Установить NAND, оставить `WP#` в нуле и выполнить RESET `FFh`. +3. Прочитать ID `90h`: ожидаемые первые байты для профиля — `2C F1`. +4. Считать одну страницу два раза и сравнить main/OOB побайтно. +5. Только после устойчивого чтения запускать полный дамп через GAS. + +## Источники для проверки footprint + +- [Micron: каталог SLC NAND](https://www.micron.com/products/storage/nand-flash/slc-nand/part-catalog) +- [MT29F1G08ABADA: signal assignments и геометрия](https://helpdesk.trx.pl/pliki/SERWER/Micron-MT29F1G08ABADAWP-IT%20D-datasheet.pdf) +- [Micron FBGA part decoder](https://in.micron.com/sales-support/design-tools/fbga-parts-decoder) diff --git a/c/parallel-nand/docs/MEMORY_GAS.md b/c/parallel-nand/docs/MEMORY_GAS.md new file mode 100644 index 0000000..b760002 --- /dev/null +++ b/c/parallel-nand/docs/MEMORY_GAS.md @@ -0,0 +1,177 @@ +# Работа с parallel NAND через GAS + +## Архитектура + +Внешняя NAND значительно больше общего адресного пространства GAS. Например, +`MT29F1G08ABADA` содержит 128 МиБ main area, а GAS имеет только 65536 +16-битных регистров. Поэтому NAND представлена не линейным диапазоном, а +банковым окном одной физической страницы. + +```text +SETGUI + │ GUI protocol v1: READ_REGISTERS / WRITE_REGISTERS + ▼ +карта GAS 0x0000..0xFFFF + │ 0xE000: управление, 0xE100: main, 0xE500: OOB + ▼ +parallel_nand_gas + ▼ +общее ядро parallel_nand + ├─ TMS320F2812: XINTF + ├─ STM32F407VE: FSMC + └─ STM32G474CE: GPIO +``` + +GUI работает одинаково со всеми МК. Тип процессора влияет только на выбранный +файл из `ports/`. + +Встроенный проект должен направлять весь диапазон `0xE000..0xE51F` в +`parallel_nand_gas_read()` и `parallel_nand_gas_write()`. Их аргументы имеют +тип `unsigned int`: на C28x это нативное 16-битное слово, а на ARM функции +явно оставляют только младшие 16 бит. Так карта и wire-формат совпадают без +предположения, что C `char` всегда восьмибитный. + +Для STM32, использующего карту `pcan_gas`, готов адаптер: + +```c +#include "parallel_nand_pcan_gas.h" + +static pcan_gas_region_t nand_region; +parallel_nand_pcan_gas_region_init(&nand_region, &nand_gas); +``` + +Для TMS320F2812 переносимый обработчик вызывается прямо из существующего GAS +диспетчера: + +```c +if (address >= nand_gas.base && + address < nand_gas.base + PNAND_GAS_REGION_REGS) { + return parallel_nand_gas_read(&nand_gas, address, value); +} +``` + +Если тип результата диспетчера отличается, статусы `PNAND_GAS_OK`, +`PNAND_GAS_NO_REG`, `PNAND_GAS_READ_ONLY` и `PNAND_GAS_REJECTED` +преобразуются в его локальные статусы так же, как это сделано в +`parallel_nand_pcan_gas.c`. + +## Транспорт до GUI + +Карта GAS не привязана к физическому каналу. Рекомендуемый путь для полного +дампа — используемые SETGUI сообщения `WRITE_REGISTERS (0x0A)` и +`READ_REGISTERS (0x09)`: они позволяют передавать крупные блоки поверх USB, +UART, RS-485 или Ethernet. Адаптер `pcan_gas` нужен, когда теми же регистрами +обмениваются по classic CAN; там один кадр несёт только четыре регистра, и +полный 128-MiB дамп будет значительно медленнее. + +Устройство не должно собирать весь дамп в RAM. Буферизуется только одна +страница. Из-за переносимости на C28x каждый octet хранится в `unsigned short`, +поэтому статические буферы GAS занимают примерно 4,2 KiB RAM и на TMS, и на +ARM. + +## Карта GAS + +Базовый адрес по умолчанию — `0xE000`. Его можно изменить при вызове +`parallel_nand_gas_init()`; все смещения ниже останутся прежними. + +| Адрес | Доступ | Название | Значение | +|---:|:---:|---|---| +| `E000` | R | `SIGNATURE` | `0x4E44` (`ND`) | +| `E001` | R | `VERSION` | версия окна, сейчас 1 | +| `E002` | R | `STATUS` | состояние операции | +| `E003` | R | `ERROR` | код `PNAND_*`, 0 = нет ошибки | +| `E004` | W | `COMMAND` | команда окна | +| `E005` | R/W | `PAGE_LO` | младшие 16 бит физической страницы | +| `E006` | R/W | `PAGE_HI` | старшие 16 бит физической страницы | +| `E007` | R | `PAGE_MAIN` | main bytes, для MT29 = 2048 | +| `E008` | R | `PAGE_OOB` | OOB bytes, для MT29 = 64 | +| `E009` | R | `PAGES_PER_BLOCK` | для MT29 = 64 | +| `E00A` | R | `BLOCKS` | для MT29 = 1024 | +| `E00B` | R | `MANUFACTURER_ID` | ожидается `0x2C` | +| `E00C` | R | `DEVICE_ID` | ожидается `0xF1` | +| `E00D` | R | `BAD_BLOCK` | 1 для физически плохого блока | +| `E00E` | R | `GENERATION_LO` | счётчик загруженных страниц, low | +| `E00F` | R | `GENERATION_HI` | счётчик загруженных страниц, high | +| `E100..E4FF` | R | `PAGE_DATA` | 1024 регистра main, два байта LE | +| `E500..E51F` | R | `PAGE_OOB` | 32 регистра OOB, два байта LE | + +Между управляющими регистрами и окнами данных есть зарезервированные адреса. +Чтение блока через них не выполняется: каждый запрос должен начинаться внутри +существующего участка. + +## STATUS + +| Бит | Маска | Смысл | +|---:|---:|---| +| 0 | `0x0001` | `READY` | +| 1 | `0x0002` | `BUSY` | +| 2 | `0x0004` | `PAGE_VALID` | +| 3 | `0x0008` | `BAD_BLOCK` | +| 15 | `0x8000` | `ERROR`, подробность в `E003` | + +GUI читает окно данных только при `READY=1`, `PAGE_VALID=1`, `ERROR=0`. + +## COMMAND + +| Значение | Команда | Результат | +|---:|---|---| +| 1 | `RESET` | сброс NAND и ожидание Ready | +| 2 | `READ_ID` | обновляет `E00B/E00C` | +| 3 | `READ_PAGE` | загружает main/OOB выбранной страницы | + +Запись номера страницы и запуск — две отдельные операции. Сначала GUI пишет +`PAGE_LO/PAGE_HI`, затем записывает `READ_PAGE` в `COMMAND`. Нельзя отправлять +один блок, начинающийся с `COMMAND`: команда выполнилась бы до обновления номера +страницы. + +## Последовательность полного дампа + +1. Прочитать `E000..E00F`, проверить подпись, версию и геометрию. +2. Выполнить `READ_ID`; для микросхемы с фотографии проверить `2C F1`. +3. Рассчитать `page_count = PAGES_PER_BLOCK * BLOCKS`. +4. Для каждой физической страницы от 0 до `page_count - 1`: + - записать номер страницы в `E005/E006`; + - записать `3` в `E004`; + - опрашивать `E002`, пока не установлен `READY`; + - проверить `ERROR`, `BAD_BLOCK` и изменение `GENERATION`; + - прочитать `E100..E4FF`; + - прочитать `E500..E51F`; + - сразу дописать данные и метаданные в файлы. +5. Не пропускать bad-блоки: иначе файл потеряет соответствие физическим + страницам микросхемы. + +GUI protocol v1 допускает payload до 512 байт. Ответ `READ_REGISTERS` содержит +четырёхбайтовый заголовок диапазона, поэтому за один запрос следует читать не +более 254 GAS-регистров. Страница main читается пятью запросами: четыре раза по +254 регистра и последний раз 8 регистров. OOB читается одним запросом на 32 +регистра. + +## Возобновление + +GUI хранит номер последней полностью записанной страницы. После разрыва связи +он повторно проверяет подпись/ID/геометрию и продолжает со следующей страницы. +Страница считается готовой только после записи main, OOB и записи её статуса в +метаданные. + +Рекомендуемые выходные файлы: + +- `dump.bin` — 2048 байт main каждой физической страницы; +- `dump.oob` — 64 байта OOB каждой физической страницы; +- `dump.json` — ID, геометрия, bad-блоки, ошибки и последняя готовая страница. + +Для `MT29F1G08ABADA` размеры завершённого дампа: `dump.bin` = 134217728 байт, +`dump.oob` = 4194304 байта. + +## ECC + +Текущая версия шаблона отдаёт raw main/OOB и не заявляет исправление ошибок. +Статус bad-блока определяется по первому байту OOB первых двух страниц блока. +Перед использованием дампа как рабочего образа нужно добавить ECC-политику для +точного чипа и формата OOB. Само окно GAS при этом менять не требуется: можно +добавить новые флаги состояния в свободные биты `STATUS`. + +## PROGRAM/ERASE + +Запись и стирание не включены намеренно. Они требуют отдельного разрешения, +проверки ECC, защиты загрузочных блоков и двухфазного подтверждения через GAS. +До добавления этих функций `WP#` удерживается активным аппаратным портом. diff --git a/c/parallel-nand/include/parallel_nand.h b/c/parallel-nand/include/parallel_nand.h new file mode 100644 index 0000000..8773ede --- /dev/null +++ b/c/parallel-nand/include/parallel_nand.h @@ -0,0 +1,68 @@ +#ifndef PARALLEL_NAND_H +#define PARALLEL_NAND_H + +#include "parallel_nand_port.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define PNAND_MT29F1G08_PAGE_MAIN 2048UL +#define PNAND_MT29F1G08_PAGE_OOB 64UL +#define PNAND_MT29F1G08_PAGES_PER_BLOCK 64UL +#define PNAND_MT29F1G08_BLOCKS 1024UL +#define PNAND_MT29F1G08_COLUMN_CYCLES 2U +#define PNAND_MT29F1G08_ROW_CYCLES 2U +#define PNAND_MT29F1G08_PAGE_TOTAL \ + (PNAND_MT29F1G08_PAGE_MAIN + PNAND_MT29F1G08_PAGE_OOB) + +typedef enum { + PNAND_OK = 0, + PNAND_ERROR_ARGUMENT = -1, + PNAND_ERROR_PORT = -2, + PNAND_ERROR_TIMEOUT = -3, + PNAND_ERROR_RANGE = -4, + PNAND_ERROR_BUFFER = -5 +} parallel_nand_result_t; + +typedef struct { + unsigned long page_main_size; + unsigned long page_oob_size; + unsigned long pages_per_block; + unsigned long block_count; + unsigned int column_cycles; + unsigned int row_cycles; + unsigned long ready_timeout_us; +} parallel_nand_geometry_t; + +typedef struct { + const parallel_nand_port_t *port; + parallel_nand_geometry_t geometry; +} parallel_nand_t; + +extern const parallel_nand_geometry_t parallel_nand_mt29f1g08_geometry; + +int parallel_nand_init(parallel_nand_t *nand, + const parallel_nand_port_t *port, + const parallel_nand_geometry_t *geometry); +int parallel_nand_reset(parallel_nand_t *nand); +int parallel_nand_read_status(parallel_nand_t *nand, pnand_octet_t *status); +int parallel_nand_read_id(parallel_nand_t *nand, pnand_octet_t address, + pnand_octet_t *id, unsigned int count); +int parallel_nand_read_page(parallel_nand_t *nand, unsigned long page, + pnand_octet_t *main_data, + unsigned long main_capacity, + pnand_octet_t *oob, + unsigned long oob_capacity); +int parallel_nand_read_page_slice(parallel_nand_t *nand, unsigned long page, + unsigned long column, pnand_octet_t *out, + unsigned long count); +int parallel_nand_is_bad_block(parallel_nand_t *nand, unsigned long block, + int *is_bad); +unsigned long parallel_nand_page_count(const parallel_nand_t *nand); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/c/parallel-nand/include/parallel_nand_gas.h b/c/parallel-nand/include/parallel_nand_gas.h new file mode 100644 index 0000000..50238cc --- /dev/null +++ b/c/parallel-nand/include/parallel_nand_gas.h @@ -0,0 +1,84 @@ +#ifndef PARALLEL_NAND_GAS_H +#define PARALLEL_NAND_GAS_H + +#include "parallel_nand.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define PNAND_GAS_DEFAULT_BASE 0xE000U +#define PNAND_GAS_REGION_REGS 0x0520U +#define PNAND_GAS_DATA_OFFSET 0x0100U +#define PNAND_GAS_DATA_REGS 1024U +#define PNAND_GAS_OOB_OFFSET 0x0500U +#define PNAND_GAS_OOB_REGS 32U + +enum { + PNAND_GAS_REG_SIGNATURE = 0x0000, + PNAND_GAS_REG_VERSION = 0x0001, + PNAND_GAS_REG_STATUS = 0x0002, + PNAND_GAS_REG_ERROR = 0x0003, + PNAND_GAS_REG_COMMAND = 0x0004, + PNAND_GAS_REG_PAGE_LO = 0x0005, + PNAND_GAS_REG_PAGE_HI = 0x0006, + PNAND_GAS_REG_PAGE_MAIN = 0x0007, + PNAND_GAS_REG_PAGE_OOB = 0x0008, + PNAND_GAS_REG_PAGES_PER_BLOCK = 0x0009, + PNAND_GAS_REG_BLOCKS = 0x000A, + PNAND_GAS_REG_MANUFACTURER_ID = 0x000B, + PNAND_GAS_REG_DEVICE_ID = 0x000C, + PNAND_GAS_REG_BAD_BLOCK = 0x000D, + PNAND_GAS_REG_GENERATION_LO = 0x000E, + PNAND_GAS_REG_GENERATION_HI = 0x000F +}; + +enum { + PNAND_GAS_CMD_NONE = 0, + PNAND_GAS_CMD_RESET = 1, + PNAND_GAS_CMD_READ_ID = 2, + PNAND_GAS_CMD_READ_PAGE = 3 +}; + +#define PNAND_GAS_STATUS_READY 0x0001U +#define PNAND_GAS_STATUS_BUSY 0x0002U +#define PNAND_GAS_STATUS_PAGE_VALID 0x0004U +#define PNAND_GAS_STATUS_BAD_BLOCK 0x0008U +#define PNAND_GAS_STATUS_ERROR 0x8000U + +typedef struct { + parallel_nand_t *nand; + unsigned int base; + unsigned int status; + int error; + unsigned long selected_page; + unsigned long generation; + unsigned long cached_block; + int cached_bad_block; + int block_cache_valid; + pnand_octet_t id[8]; + pnand_octet_t main_data[PNAND_MT29F1G08_PAGE_MAIN]; + pnand_octet_t oob[PNAND_MT29F1G08_PAGE_OOB]; +} parallel_nand_gas_t; + +void parallel_nand_gas_init(parallel_nand_gas_t *gas, + parallel_nand_t *nand, unsigned int base); + +/* Portable GAS callbacks. Values and addresses are exactly 16 bits on wire. */ +typedef enum { + PNAND_GAS_OK = 0, + PNAND_GAS_NO_REG, + PNAND_GAS_READ_ONLY, + PNAND_GAS_REJECTED +} parallel_nand_gas_result_t; + +parallel_nand_gas_result_t parallel_nand_gas_read( + parallel_nand_gas_t *gas, unsigned int address, unsigned int *value); +parallel_nand_gas_result_t parallel_nand_gas_write( + parallel_nand_gas_t *gas, unsigned int address, unsigned int value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/c/parallel-nand/include/parallel_nand_pcan_gas.h b/c/parallel-nand/include/parallel_nand_pcan_gas.h new file mode 100644 index 0000000..939cd71 --- /dev/null +++ b/c/parallel-nand/include/parallel_nand_pcan_gas.h @@ -0,0 +1,11 @@ +#ifndef PARALLEL_NAND_PCAN_GAS_H +#define PARALLEL_NAND_PCAN_GAS_H + +#include "parallel_nand_gas.h" +#include "pcan_gas.h" + +/* Adapter for targets where c/set-protocol pcan_gas is available. */ +void parallel_nand_pcan_gas_region_init(pcan_gas_region_t *region, + parallel_nand_gas_t *gas); + +#endif diff --git a/c/parallel-nand/include/parallel_nand_port.h b/c/parallel-nand/include/parallel_nand_port.h new file mode 100644 index 0000000..a9e73c1 --- /dev/null +++ b/c/parallel-nand/include/parallel_nand_port.h @@ -0,0 +1,28 @@ +#ifndef PARALLEL_NAND_PORT_H +#define PARALLEL_NAND_PORT_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Один octet хранится в целом типе: это важно для C28x с 16-битным char. */ +typedef unsigned short pnand_octet_t; + +typedef struct { + int (*init)(void); + void (*select)(void); + void (*deselect)(void); + void (*write_command)(pnand_octet_t value); + void (*write_address)(pnand_octet_t value); + void (*write_data)(pnand_octet_t value); + pnand_octet_t (*read_data)(void); + int (*is_ready)(void); + void (*set_write_protect)(int enabled); + void (*delay_us)(unsigned long us); +} parallel_nand_port_t; + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/c/parallel-nand/ports/stm32f407ve/README.md b/c/parallel-nand/ports/stm32f407ve/README.md new file mode 100644 index 0000000..0decb57 --- /dev/null +++ b/c/parallel-nand/ports/stm32f407ve/README.md @@ -0,0 +1,26 @@ +# Порт STM32F407VET6 + +Используется аппаратный FSMC NAND Bank 2, шина x8. + +| NAND | STM32F407VE | LQFP-100 | +|---|---|---:| +| `I/O0` | `PD14/FSMC_D0` | 61 | +| `I/O1` | `PD15/FSMC_D1` | 62 | +| `I/O2` | `PD0/FSMC_D2` | 81 | +| `I/O3` | `PD1/FSMC_D3` | 82 | +| `I/O4` | `PE7/FSMC_D4` | 38 | +| `I/O5` | `PE8/FSMC_D5` | 39 | +| `I/O6` | `PE9/FSMC_D6` | 40 | +| `I/O7` | `PE10/FSMC_D7` | 41 | +| `RE#` | `PD4/FSMC_NOE` | 85 | +| `WE#` | `PD5/FSMC_NWE` | 86 | +| `R/B#` | `PD6/FSMC_NWAIT` | 87 | +| `CE#` | `PD7/FSMC_NE2` | 88 | +| `CLE` | `PD11/FSMC_A16` | 58 | +| `ALE` | `PD12/FSMC_A17` | 59 | + +Порт использует STM32Cube HAL. В `stm32f4xx_hal_conf.h` включить +`HAL_NAND_MODULE_ENABLED`, добавить `stm32f4xx_hal_nand.c` и +`stm32f4xx_ll_fsmc.c`, включить FSMC и не создавать второй +`NAND_HandleTypeDef` для Bank 2. Начальные тайминги +консервативны; финальные значения подтвердить осциллографом. diff --git a/c/parallel-nand/ports/stm32f407ve/parallel_nand_port_stm32f407ve.c b/c/parallel-nand/ports/stm32f407ve/parallel_nand_port_stm32f407ve.c new file mode 100644 index 0000000..50290c2 --- /dev/null +++ b/c/parallel-nand/ports/stm32f407ve/parallel_nand_port_stm32f407ve.c @@ -0,0 +1,93 @@ +#include "parallel_nand_port_stm32f407ve.h" +#include "stm32f4xx_hal.h" +#include "stm32f4xx_hal_nand.h" + +#define PNAND_F407_BANK2_BASE 0x70000000UL +#define PNAND_F407_CMD_AREA (1UL << 16) +#define PNAND_F407_ADDR_AREA (1UL << 17) + +static NAND_HandleTypeDef hnand; + +static int f407_init(void) +{ + GPIO_InitTypeDef io; + FSMC_NAND_PCC_TimingTypeDef common; + FSMC_NAND_PCC_TimingTypeDef attribute; + + __HAL_RCC_GPIOD_CLK_ENABLE(); + __HAL_RCC_GPIOE_CLK_ENABLE(); + __HAL_RCC_FSMC_CLK_ENABLE(); + + io.Mode = GPIO_MODE_AF_PP; + io.Pull = GPIO_NOPULL; + io.Speed = GPIO_SPEED_FREQ_VERY_HIGH; + io.Alternate = GPIO_AF12_FSMC; + io.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5 | + GPIO_PIN_6 | GPIO_PIN_7 | GPIO_PIN_11 | GPIO_PIN_12 | + GPIO_PIN_14 | GPIO_PIN_15; + HAL_GPIO_Init(GPIOD, &io); + io.Pin = GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10; + HAL_GPIO_Init(GPIOE, &io); + + hnand.Instance = FSMC_NAND_DEVICE; + hnand.Init.NandBank = FSMC_NAND_BANK2; + hnand.Init.Waitfeature = FSMC_NAND_PCC_WAIT_FEATURE_DISABLE; + hnand.Init.MemoryDataWidth = FSMC_NAND_PCC_MEM_BUS_WIDTH_8; + hnand.Init.EccComputation = FSMC_NAND_ECC_DISABLE; + hnand.Init.ECCPageSize = FSMC_NAND_ECC_PAGE_SIZE_2048BYTE; + hnand.Init.TCLRSetupTime = 2U; + hnand.Init.TARSetupTime = 2U; + + common.SetupTime = 2U; + common.WaitSetupTime = 5U; + common.HoldSetupTime = 3U; + common.HiZSetupTime = 2U; + attribute = common; + return HAL_NAND_Init(&hnand, &common, &attribute) == HAL_OK ? 0 : -1; +} + +static void f407_select(void) { /* FSMC_NCE2 активируется доступом. */ } +static void f407_deselect(void) { } +static void f407_write_command(pnand_octet_t value) +{ + *(__IO uint8_t *)(PNAND_F407_BANK2_BASE | PNAND_F407_CMD_AREA) = + (uint8_t)value; +} +static void f407_write_address(pnand_octet_t value) +{ + *(__IO uint8_t *)(PNAND_F407_BANK2_BASE | PNAND_F407_ADDR_AREA) = + (uint8_t)value; +} +static void f407_write_data(pnand_octet_t value) +{ + *(__IO uint8_t *)PNAND_F407_BANK2_BASE = (uint8_t)value; +} +static pnand_octet_t f407_read_data(void) +{ + return *(__IO uint8_t *)PNAND_F407_BANK2_BASE; +} +static int f407_is_ready(void) +{ + return HAL_GPIO_ReadPin(GPIOD, GPIO_PIN_6) == GPIO_PIN_SET; +} +static void f407_set_wp(int enabled) +{ + /* WP# аппаратный в базовой схеме. */ + (void)enabled; +} +static void f407_delay_us(unsigned long us) +{ + uint32_t cycles = (SystemCoreClock / 3000000UL) * (uint32_t)us; + while (cycles-- != 0U) __NOP(); +} + +static const parallel_nand_port_t f407_port = { + f407_init, f407_select, f407_deselect, + f407_write_command, f407_write_address, f407_write_data, + f407_read_data, f407_is_ready, f407_set_wp, f407_delay_us +}; + +const parallel_nand_port_t *parallel_nand_port_stm32f407ve(void) +{ + return &f407_port; +} diff --git a/c/parallel-nand/ports/stm32f407ve/parallel_nand_port_stm32f407ve.h b/c/parallel-nand/ports/stm32f407ve/parallel_nand_port_stm32f407ve.h new file mode 100644 index 0000000..36c1e8d --- /dev/null +++ b/c/parallel-nand/ports/stm32f407ve/parallel_nand_port_stm32f407ve.h @@ -0,0 +1,8 @@ +#ifndef PARALLEL_NAND_PORT_STM32F407VE_H +#define PARALLEL_NAND_PORT_STM32F407VE_H + +#include "parallel_nand_port.h" + +const parallel_nand_port_t *parallel_nand_port_stm32f407ve(void); + +#endif diff --git a/c/parallel-nand/ports/stm32g474ce/README.md b/c/parallel-nand/ports/stm32g474ce/README.md new file mode 100644 index 0000000..24e07e6 --- /dev/null +++ b/c/parallel-nand/ports/stm32g474ce/README.md @@ -0,0 +1,21 @@ +# Порт STM32G474CEU6 + +У UFQFPN-48 нет выведенного FSMC, поэтому используется GPIO bit-bang. + +| NAND | STM32G474CEU6 | UFQFPN-48 | +|---|---|---:| +| `I/O0..I/O7` | `PB0..PB7` | 17, 18, 19, 41, 42, 43, 44, 45 | +| `CLE` | `PA0` | 8 | +| `ALE` | `PA1` | 9 | +| `WE#` | `PA2` | 10 | +| `RE#` | `PA3` | 11 | +| `CE#` | `PA4` | 12 | +| `WP#` | `PA5` | 13 | +| `R/B#` | `PA6` | 14 | + +`R/B#` требует внешней подтяжки 4,7–10 кОм к 3,3 В. Порт использует STM32Cube +HAL только для инициализации, а циклы NAND выполняет прямым доступом к GPIO. + +Выбранные `PB3/PB4` нельзя одновременно использовать для trace/JTAG; SWD на +`PA13/PA14` остаётся доступен. Если эти пины нужны проекту, измените таблицу и +операции шины: текущая оптимизация рассчитана на непрерывные `PB0..PB7`. diff --git a/c/parallel-nand/ports/stm32g474ce/parallel_nand_port_stm32g474ce.c b/c/parallel-nand/ports/stm32g474ce/parallel_nand_port_stm32g474ce.c new file mode 100644 index 0000000..3f18b64 --- /dev/null +++ b/c/parallel-nand/ports/stm32g474ce/parallel_nand_port_stm32g474ce.c @@ -0,0 +1,134 @@ +#include "parallel_nand_port_stm32g474ce.h" +#include "stm32g4xx_hal.h" + +/* Пустая плата STM32G474CEU6: PB0..PB7 образуют непрерывную x8 шину. */ +#define DATA_MASK 0x00FFU +#define CLE_PIN GPIO_PIN_0 +#define ALE_PIN GPIO_PIN_1 +#define WE_PIN GPIO_PIN_2 +#define RE_PIN GPIO_PIN_3 +#define CE_PIN GPIO_PIN_4 +#define WP_PIN GPIO_PIN_5 +#define RB_PIN GPIO_PIN_6 + +static int data_is_output; + +static void data_input(void) +{ + if (data_is_output) { + GPIOB->MODER &= ~0x0000FFFFUL; + data_is_output = 0; + } +} + +static void data_output(void) +{ + if (!data_is_output) { + GPIOB->MODER = (GPIOB->MODER & ~0x0000FFFFUL) | 0x00005555UL; + data_is_output = 1; + } +} + +static void short_delay(void) +{ + __NOP(); __NOP(); __NOP(); __NOP(); + __NOP(); __NOP(); __NOP(); __NOP(); +} + +static int g474_init(void) +{ + GPIO_InitTypeDef io; + __HAL_RCC_GPIOA_CLK_ENABLE(); + __HAL_RCC_GPIOB_CLK_ENABLE(); + + io.Pin = DATA_MASK; + io.Mode = GPIO_MODE_OUTPUT_PP; + io.Pull = GPIO_NOPULL; + io.Speed = GPIO_SPEED_FREQ_VERY_HIGH; + HAL_GPIO_Init(GPIOB, &io); + data_is_output = 1; + data_input(); + + /* Set inactive levels before enabling the pins as outputs. */ + GPIOA->BSRR = WE_PIN | RE_PIN | CE_PIN | + ((uint32_t)(CLE_PIN | ALE_PIN | WP_PIN) << 16); + io.Pin = CLE_PIN | ALE_PIN | WE_PIN | RE_PIN | CE_PIN | WP_PIN; + io.Mode = GPIO_MODE_OUTPUT_PP; + io.Pull = GPIO_NOPULL; + io.Speed = GPIO_SPEED_FREQ_VERY_HIGH; + HAL_GPIO_Init(GPIOA, &io); + io.Pin = RB_PIN; + io.Mode = GPIO_MODE_INPUT; + io.Pull = GPIO_NOPULL; + HAL_GPIO_Init(GPIOA, &io); + return 0; +} + +static void g474_select(void) { GPIOA->BSRR = (uint32_t)CE_PIN << 16; } +static void g474_deselect(void) { GPIOA->BSRR = CE_PIN; } + +static void write_cycle(pnand_octet_t value) +{ + data_output(); + GPIOB->BSRR = (uint32_t)DATA_MASK << 16; + GPIOB->BSRR = (uint32_t)value & DATA_MASK; + short_delay(); + GPIOA->BSRR = (uint32_t)WE_PIN << 16; + short_delay(); + GPIOA->BSRR = WE_PIN; + short_delay(); +} + +static void g474_write_command(pnand_octet_t value) +{ + GPIOA->BSRR = CLE_PIN | ((uint32_t)ALE_PIN << 16); + write_cycle(value); + GPIOA->BSRR = (uint32_t)CLE_PIN << 16; +} + +static void g474_write_address(pnand_octet_t value) +{ + GPIOA->BSRR = ALE_PIN | ((uint32_t)CLE_PIN << 16); + write_cycle(value); + GPIOA->BSRR = (uint32_t)ALE_PIN << 16; +} + +static void g474_write_data(pnand_octet_t value) +{ + GPIOA->BSRR = ((uint32_t)(CLE_PIN | ALE_PIN) << 16); + write_cycle(value); +} + +static pnand_octet_t g474_read_data(void) +{ + pnand_octet_t value; + data_input(); + GPIOA->BSRR = ((uint32_t)(CLE_PIN | ALE_PIN | RE_PIN) << 16); + short_delay(); + value = (pnand_octet_t)(GPIOB->IDR & DATA_MASK); + GPIOA->BSRR = RE_PIN; + short_delay(); + return value; +} + +static int g474_is_ready(void) { return (GPIOA->IDR & RB_PIN) != 0U; } +static void g474_set_wp(int enabled) +{ + GPIOA->BSRR = enabled ? ((uint32_t)WP_PIN << 16) : WP_PIN; +} +static void g474_delay_us(unsigned long us) +{ + uint32_t cycles = (SystemCoreClock / 3000000UL) * (uint32_t)us; + while (cycles-- != 0U) __NOP(); +} + +static const parallel_nand_port_t g474_port = { + g474_init, g474_select, g474_deselect, + g474_write_command, g474_write_address, g474_write_data, + g474_read_data, g474_is_ready, g474_set_wp, g474_delay_us +}; + +const parallel_nand_port_t *parallel_nand_port_stm32g474ce(void) +{ + return &g474_port; +} diff --git a/c/parallel-nand/ports/stm32g474ce/parallel_nand_port_stm32g474ce.h b/c/parallel-nand/ports/stm32g474ce/parallel_nand_port_stm32g474ce.h new file mode 100644 index 0000000..409e52d --- /dev/null +++ b/c/parallel-nand/ports/stm32g474ce/parallel_nand_port_stm32g474ce.h @@ -0,0 +1,8 @@ +#ifndef PARALLEL_NAND_PORT_STM32G474CE_H +#define PARALLEL_NAND_PORT_STM32G474CE_H + +#include "parallel_nand_port.h" + +const parallel_nand_port_t *parallel_nand_port_stm32g474ce(void); + +#endif diff --git a/c/parallel-nand/ports/tms320f2812/README.md b/c/parallel-nand/ports/tms320f2812/README.md new file mode 100644 index 0000000..7a33632 --- /dev/null +++ b/c/parallel-nand/ports/tms320f2812/README.md @@ -0,0 +1,21 @@ +# Порт TMS320F2812 + +Порт использует XINTF Zone 6: + +- `XD0..XD7` — NAND `I/O0..I/O7`; +- `XA0` — `CLE`; +- `XA1` — `ALE`; +- `XWE` — `WE#`; +- `XRD` — `RE#`; +- `XZCS6AND7` — `CE#`; +- `GPIOE0/XINT1` — `R/B#` с внешней подтяжкой 4,7–10 кОм. + +Адреса C28x задаются в 16-битных словах: data `0x100000`, command +`0x100001`, address `0x100002`. Используется только младший байт XINTF. + +`WP#` в базовом варианте аппаратный: для режима только чтения соединить с GND +через 10 кОм; если позже понадобится PROGRAM/ERASE, заменить на GPIO и реализовать +`tms_set_wp()`. + +Задержка в порте приблизительная. После выбора частоты CPU проверить импульсы +осциллографом и при необходимости изменить `PNAND_TMS_CPU_MHZ`. diff --git a/c/parallel-nand/ports/tms320f2812/parallel_nand_port_tms320f2812.c b/c/parallel-nand/ports/tms320f2812/parallel_nand_port_tms320f2812.c new file mode 100644 index 0000000..b1dda6d --- /dev/null +++ b/c/parallel-nand/ports/tms320f2812/parallel_nand_port_tms320f2812.c @@ -0,0 +1,65 @@ +#include "parallel_nand_port_tms320f2812.h" +#include "DSP281x_Device.h" + +/* C28x addresses are 16-bit word addresses. XA0/XA1 drive CLE/ALE. */ +#define PNAND_TMS_DATA_REG (*(volatile unsigned int *)0x100000UL) +#define PNAND_TMS_CMD_REG (*(volatile unsigned int *)0x100001UL) +#define PNAND_TMS_ADDR_REG (*(volatile unsigned int *)0x100002UL) + +#ifndef PNAND_TMS_CPU_MHZ +#define PNAND_TMS_CPU_MHZ 150UL +#endif + +static void tms_delay_us(unsigned long us) +{ + volatile unsigned long loops = us * (PNAND_TMS_CPU_MHZ / 5UL); + while (loops-- != 0UL) asm(" NOP"); +} + +static int tms_init(void) +{ + EALLOW; + GpioMuxRegs.GPEMUX.bit.XINT1_XBIO_GPIOE0 = 0; + GpioMuxRegs.GPEDIR.bit.GPIOE0 = 0; + + XintfRegs.XINTCNF2.bit.XTIMCLK = 1; + XintfRegs.XINTCNF2.bit.WRBUFF = 0; + XintfRegs.XINTCNF2.bit.CLKOFF = 0; + XintfRegs.XINTCNF2.bit.CLKMODE = 1; + XintfRegs.XTIMING6.bit.XWRLEAD = 3; + XintfRegs.XTIMING6.bit.XWRACTIVE = 7; + XintfRegs.XTIMING6.bit.XWRTRAIL = 3; + XintfRegs.XTIMING6.bit.XRDLEAD = 3; + XintfRegs.XTIMING6.bit.XRDACTIVE = 7; + XintfRegs.XTIMING6.bit.XRDTRAIL = 3; + XintfRegs.XTIMING6.bit.USEREADY = 0; + XintfRegs.XTIMING6.bit.READYMODE = 0; + XintfRegs.XTIMING6.bit.XSIZE = 3; + EDIS; + asm(" RPT #7 || NOP"); + return 0; +} + +static void tms_select(void) { /* XZCS6AND7 формируется каждым доступом. */ } +static void tms_deselect(void) { } +static void tms_write_command(pnand_octet_t v) { PNAND_TMS_CMD_REG = v & 0xFFU; } +static void tms_write_address(pnand_octet_t v) { PNAND_TMS_ADDR_REG = v & 0xFFU; } +static void tms_write_data(pnand_octet_t v) { PNAND_TMS_DATA_REG = v & 0xFFU; } +static pnand_octet_t tms_read_data(void) { return PNAND_TMS_DATA_REG & 0xFFU; } +static int tms_is_ready(void) { return GpioDataRegs.GPEDAT.bit.GPIOE0 != 0; } +static void tms_set_wp(int enabled) +{ + /* Шаблон платы фиксирует WP# аппаратно. См. README порта. */ + (void)enabled; +} + +static const parallel_nand_port_t tms_port = { + tms_init, tms_select, tms_deselect, + tms_write_command, tms_write_address, tms_write_data, + tms_read_data, tms_is_ready, tms_set_wp, tms_delay_us +}; + +const parallel_nand_port_t *parallel_nand_port_tms320f2812(void) +{ + return &tms_port; +} diff --git a/c/parallel-nand/ports/tms320f2812/parallel_nand_port_tms320f2812.h b/c/parallel-nand/ports/tms320f2812/parallel_nand_port_tms320f2812.h new file mode 100644 index 0000000..ecddb38 --- /dev/null +++ b/c/parallel-nand/ports/tms320f2812/parallel_nand_port_tms320f2812.h @@ -0,0 +1,8 @@ +#ifndef PARALLEL_NAND_PORT_TMS320F2812_H +#define PARALLEL_NAND_PORT_TMS320F2812_H + +#include "parallel_nand_port.h" + +const parallel_nand_port_t *parallel_nand_port_tms320f2812(void); + +#endif diff --git a/c/parallel-nand/src/parallel_nand.c b/c/parallel-nand/src/parallel_nand.c new file mode 100644 index 0000000..62158c4 --- /dev/null +++ b/c/parallel-nand/src/parallel_nand.c @@ -0,0 +1,191 @@ +#include "parallel_nand.h" + +#define PNAND_CMD_READ_1 0x00U +#define PNAND_CMD_READ_2 0x30U +#define PNAND_CMD_READ_ID 0x90U +#define PNAND_CMD_READ_STATUS 0x70U +#define PNAND_CMD_RESET 0xFFU + +const parallel_nand_geometry_t parallel_nand_mt29f1g08_geometry = { + PNAND_MT29F1G08_PAGE_MAIN, + PNAND_MT29F1G08_PAGE_OOB, + PNAND_MT29F1G08_PAGES_PER_BLOCK, + PNAND_MT29F1G08_BLOCKS, + PNAND_MT29F1G08_COLUMN_CYCLES, + PNAND_MT29F1G08_ROW_CYCLES, + 1000000UL +}; + +static int port_valid(const parallel_nand_port_t *port) +{ + return port != 0 && port->init != 0 && port->select != 0 && + port->deselect != 0 && port->write_command != 0 && + port->write_address != 0 && port->read_data != 0 && + port->is_ready != 0 && port->set_write_protect != 0 && + port->delay_us != 0; +} + +static int geometry_valid(const parallel_nand_geometry_t *g) +{ + return g != 0 && g->page_main_size != 0UL && + g->pages_per_block != 0UL && g->block_count != 0UL && + g->column_cycles != 0U && g->row_cycles != 0U && + g->ready_timeout_us != 0UL; +} + +unsigned long parallel_nand_page_count(const parallel_nand_t *nand) +{ + if (nand == 0) return 0UL; + return nand->geometry.pages_per_block * nand->geometry.block_count; +} + +static int wait_ready(parallel_nand_t *nand) +{ + unsigned long elapsed = 0UL; + while (!nand->port->is_ready()) { + if (elapsed >= nand->geometry.ready_timeout_us) + return PNAND_ERROR_TIMEOUT; + nand->port->delay_us(1UL); + elapsed++; + } + return PNAND_OK; +} + +int parallel_nand_reset(parallel_nand_t *nand) +{ + if (nand == 0 || !port_valid(nand->port)) return PNAND_ERROR_ARGUMENT; + nand->port->select(); + nand->port->write_command(PNAND_CMD_RESET); + nand->port->deselect(); + return wait_ready(nand); +} + +int parallel_nand_init(parallel_nand_t *nand, + const parallel_nand_port_t *port, + const parallel_nand_geometry_t *geometry) +{ + if (nand == 0 || !port_valid(port) || !geometry_valid(geometry)) + return PNAND_ERROR_ARGUMENT; + nand->port = port; + nand->geometry = *geometry; + if (port->init() != 0) return PNAND_ERROR_PORT; + port->deselect(); + /* Ядро пока только читает: аппаратно запрещаем PROGRAM/ERASE. */ + port->set_write_protect(1); + return parallel_nand_reset(nand); +} + +int parallel_nand_read_status(parallel_nand_t *nand, pnand_octet_t *status) +{ + if (nand == 0 || status == 0) return PNAND_ERROR_ARGUMENT; + nand->port->select(); + nand->port->write_command(PNAND_CMD_READ_STATUS); + *status = nand->port->read_data() & 0xFFU; + nand->port->deselect(); + return PNAND_OK; +} + +int parallel_nand_read_id(parallel_nand_t *nand, pnand_octet_t address, + pnand_octet_t *id, unsigned int count) +{ + unsigned int i; + if (nand == 0 || id == 0 || count == 0U) return PNAND_ERROR_ARGUMENT; + nand->port->select(); + nand->port->write_command(PNAND_CMD_READ_ID); + nand->port->write_address(address & 0xFFU); + for (i = 0U; i < count; i++) id[i] = nand->port->read_data() & 0xFFU; + nand->port->deselect(); + return PNAND_OK; +} + +static int begin_page_read(parallel_nand_t *nand, unsigned long page, + unsigned long column) +{ + unsigned int i; + unsigned long value; + unsigned long total = nand->geometry.page_main_size + + nand->geometry.page_oob_size; + if (page >= parallel_nand_page_count(nand) || column >= total) + return PNAND_ERROR_RANGE; + + nand->port->select(); + nand->port->write_command(PNAND_CMD_READ_1); + value = column; + for (i = 0U; i < nand->geometry.column_cycles; i++) { + nand->port->write_address((pnand_octet_t)(value & 0xFFUL)); + value >>= 8; + } + value = page; + for (i = 0U; i < nand->geometry.row_cycles; i++) { + nand->port->write_address((pnand_octet_t)(value & 0xFFUL)); + value >>= 8; + } + nand->port->write_command(PNAND_CMD_READ_2); + nand->port->deselect(); + return wait_ready(nand); +} + +int parallel_nand_read_page_slice(parallel_nand_t *nand, unsigned long page, + unsigned long column, pnand_octet_t *out, + unsigned long count) +{ + unsigned long i; + unsigned long total; + int result; + if (nand == 0 || out == 0 || count == 0UL) return PNAND_ERROR_ARGUMENT; + total = nand->geometry.page_main_size + nand->geometry.page_oob_size; + if (column >= total || count > total - column) return PNAND_ERROR_RANGE; + result = begin_page_read(nand, page, column); + if (result != PNAND_OK) return result; + nand->port->select(); + for (i = 0UL; i < count; i++) out[i] = nand->port->read_data() & 0xFFU; + nand->port->deselect(); + return PNAND_OK; +} + +int parallel_nand_read_page(parallel_nand_t *nand, unsigned long page, + pnand_octet_t *main_data, + unsigned long main_capacity, + pnand_octet_t *oob, + unsigned long oob_capacity) +{ + unsigned long i; + int result; + if (nand == 0 || main_data == 0 || oob == 0) + return PNAND_ERROR_ARGUMENT; + if (main_capacity < nand->geometry.page_main_size || + oob_capacity < nand->geometry.page_oob_size) + return PNAND_ERROR_BUFFER; + result = begin_page_read(nand, page, 0UL); + if (result != PNAND_OK) return result; + nand->port->select(); + for (i = 0UL; i < nand->geometry.page_main_size; i++) + main_data[i] = nand->port->read_data() & 0xFFU; + for (i = 0UL; i < nand->geometry.page_oob_size; i++) + oob[i] = nand->port->read_data() & 0xFFU; + nand->port->deselect(); + return PNAND_OK; +} + +int parallel_nand_is_bad_block(parallel_nand_t *nand, unsigned long block, + int *is_bad) +{ + unsigned long first_page; + pnand_octet_t marker; + int result; + if (nand == 0 || is_bad == 0) return PNAND_ERROR_ARGUMENT; + if (block >= nand->geometry.block_count) return PNAND_ERROR_RANGE; + first_page = block * nand->geometry.pages_per_block; + result = parallel_nand_read_page_slice(nand, first_page, + nand->geometry.page_main_size, &marker, 1UL); + if (result != PNAND_OK) return result; + if ((marker & 0xFFU) != 0xFFU) { + *is_bad = 1; + return PNAND_OK; + } + result = parallel_nand_read_page_slice(nand, first_page + 1UL, + nand->geometry.page_main_size, &marker, 1UL); + if (result != PNAND_OK) return result; + *is_bad = ((marker & 0xFFU) != 0xFFU); + return PNAND_OK; +} diff --git a/c/parallel-nand/src/parallel_nand_gas.c b/c/parallel-nand/src/parallel_nand_gas.c new file mode 100644 index 0000000..461374b --- /dev/null +++ b/c/parallel-nand/src/parallel_nand_gas.c @@ -0,0 +1,151 @@ +#include "parallel_nand_gas.h" + +static unsigned int pack_pair(const pnand_octet_t *data, unsigned int index) +{ + return (unsigned int)((data[index * 2U] & 0xFFU) | + ((data[index * 2U + 1U] & 0xFFU) << 8)); +} + +parallel_nand_gas_result_t parallel_nand_gas_read( + parallel_nand_gas_t *gas, unsigned int address, unsigned int *value) +{ + unsigned int offset; + const parallel_nand_geometry_t *g; + if (gas == 0 || value == 0 || address < gas->base) + return PNAND_GAS_REJECTED; + g = &gas->nand->geometry; + offset = address - gas->base; + if (offset >= PNAND_GAS_REGION_REGS) return PNAND_GAS_NO_REG; + if (offset >= PNAND_GAS_DATA_OFFSET && + offset < PNAND_GAS_DATA_OFFSET + PNAND_GAS_DATA_REGS) { + if ((gas->status & PNAND_GAS_STATUS_PAGE_VALID) == 0U) + return PNAND_GAS_REJECTED; + *value = pack_pair(gas->main_data, + offset - PNAND_GAS_DATA_OFFSET); + return PNAND_GAS_OK; + } + if (offset >= PNAND_GAS_OOB_OFFSET && + offset < PNAND_GAS_OOB_OFFSET + PNAND_GAS_OOB_REGS) { + if ((gas->status & PNAND_GAS_STATUS_PAGE_VALID) == 0U) + return PNAND_GAS_REJECTED; + *value = pack_pair(gas->oob, + offset - PNAND_GAS_OOB_OFFSET); + return PNAND_GAS_OK; + } + switch (offset) { + case PNAND_GAS_REG_SIGNATURE: *value = 0x4E44U; break; /* ND */ + case PNAND_GAS_REG_VERSION: *value = 0x0001U; break; + case PNAND_GAS_REG_STATUS: *value = gas->status; break; + case PNAND_GAS_REG_ERROR: *value = (unsigned int)gas->error & 0xFFFFU; break; + case PNAND_GAS_REG_COMMAND: *value = PNAND_GAS_CMD_NONE; break; + case PNAND_GAS_REG_PAGE_LO: *value = (unsigned int)gas->selected_page & 0xFFFFU; break; + case PNAND_GAS_REG_PAGE_HI: *value = (unsigned int)(gas->selected_page >> 16) & 0xFFFFU; break; + case PNAND_GAS_REG_PAGE_MAIN: *value = (unsigned int)g->page_main_size; break; + case PNAND_GAS_REG_PAGE_OOB: *value = (unsigned int)g->page_oob_size; break; + case PNAND_GAS_REG_PAGES_PER_BLOCK: *value = (unsigned int)g->pages_per_block; break; + case PNAND_GAS_REG_BLOCKS: *value = (unsigned int)g->block_count; break; + case PNAND_GAS_REG_MANUFACTURER_ID: *value = (unsigned int)gas->id[0]; break; + case PNAND_GAS_REG_DEVICE_ID: *value = (unsigned int)gas->id[1]; break; + case PNAND_GAS_REG_BAD_BLOCK: + *value = (gas->status & PNAND_GAS_STATUS_BAD_BLOCK) != 0U; break; + case PNAND_GAS_REG_GENERATION_LO: *value = (unsigned int)gas->generation & 0xFFFFU; break; + case PNAND_GAS_REG_GENERATION_HI: *value = (unsigned int)(gas->generation >> 16) & 0xFFFFU; break; + default: return PNAND_GAS_NO_REG; + } + *value &= 0xFFFFU; + return PNAND_GAS_OK; +} + +static void finish(parallel_nand_gas_t *gas, int result) +{ + gas->error = result; + gas->status &= (0xFFFFU ^ PNAND_GAS_STATUS_BUSY); + gas->status |= PNAND_GAS_STATUS_READY; + if (result != PNAND_OK) gas->status |= PNAND_GAS_STATUS_ERROR; +} + +static parallel_nand_gas_result_t run_command(parallel_nand_gas_t *gas, + unsigned int command) +{ + int result = PNAND_OK; + int bad = 0; + gas->status = PNAND_GAS_STATUS_BUSY; + if (command == PNAND_GAS_CMD_RESET) { + result = parallel_nand_reset(gas->nand); + } else if (command == PNAND_GAS_CMD_READ_ID) { + result = parallel_nand_read_id(gas->nand, 0U, gas->id, 8U); + } else if (command == PNAND_GAS_CMD_READ_PAGE) { + unsigned long block; + if (gas->selected_page >= parallel_nand_page_count(gas->nand)) { + finish(gas, PNAND_ERROR_RANGE); + return PNAND_GAS_REJECTED; + } + result = parallel_nand_read_page(gas->nand, gas->selected_page, + gas->main_data, PNAND_MT29F1G08_PAGE_MAIN, + gas->oob, PNAND_MT29F1G08_PAGE_OOB); + if (result == PNAND_OK) { + gas->status |= PNAND_GAS_STATUS_PAGE_VALID; + gas->generation++; + block = gas->selected_page / + gas->nand->geometry.pages_per_block; + if (!gas->block_cache_valid || gas->cached_block != block) { + result = parallel_nand_is_bad_block(gas->nand, block, &bad); + if (result == PNAND_OK) { + gas->cached_block = block; + gas->cached_bad_block = bad; + gas->block_cache_valid = 1; + } + } else { + bad = gas->cached_bad_block; + } + if (result == PNAND_OK && bad) + gas->status |= PNAND_GAS_STATUS_BAD_BLOCK; + } + } else { + gas->status = PNAND_GAS_STATUS_READY; + return PNAND_GAS_REJECTED; + } + finish(gas, result); + return result == PNAND_OK ? PNAND_GAS_OK : PNAND_GAS_REJECTED; +} + +parallel_nand_gas_result_t parallel_nand_gas_write( + parallel_nand_gas_t *gas, unsigned int address, unsigned int value) +{ + unsigned int offset; + if (gas == 0 || address < gas->base) return PNAND_GAS_REJECTED; + offset = address - gas->base; + if (offset >= PNAND_GAS_REGION_REGS) return PNAND_GAS_NO_REG; + if ((gas->status & PNAND_GAS_STATUS_BUSY) != 0U) + return PNAND_GAS_REJECTED; + value &= 0xFFFFU; + switch (offset) { + case PNAND_GAS_REG_COMMAND: return run_command(gas, value); + case PNAND_GAS_REG_PAGE_LO: + gas->selected_page = (gas->selected_page & 0xFFFF0000UL) | value; + gas->status &= (0xFFFFU ^ PNAND_GAS_STATUS_PAGE_VALID); + return PNAND_GAS_OK; + case PNAND_GAS_REG_PAGE_HI: + gas->selected_page = (gas->selected_page & 0x0000FFFFUL) | + ((unsigned long)value << 16); + gas->status &= (0xFFFFU ^ PNAND_GAS_STATUS_PAGE_VALID); + return PNAND_GAS_OK; + default: return PNAND_GAS_READ_ONLY; + } +} + +void parallel_nand_gas_init(parallel_nand_gas_t *gas, + parallel_nand_t *nand, unsigned int base) +{ + unsigned int i; + gas->nand = nand; + gas->base = base; + gas->status = PNAND_GAS_STATUS_READY; + gas->error = PNAND_OK; + gas->selected_page = 0UL; + gas->generation = 0UL; + gas->cached_block = 0UL; + gas->cached_bad_block = 0; + gas->block_cache_valid = 0; + for (i = 0U; i < 8U; i++) gas->id[i] = 0U; +} diff --git a/c/parallel-nand/src/parallel_nand_pcan_gas.c b/c/parallel-nand/src/parallel_nand_pcan_gas.c new file mode 100644 index 0000000..9e7f6a8 --- /dev/null +++ b/c/parallel-nand/src/parallel_nand_pcan_gas.c @@ -0,0 +1,43 @@ +#include "parallel_nand_pcan_gas.h" + +static pcan_gas_status_t convert(parallel_nand_gas_result_t result) +{ + switch (result) { + case PNAND_GAS_OK: return PCAN_GAS_OK; + case PNAND_GAS_NO_REG: return PCAN_GAS_NO_REG; + case PNAND_GAS_READ_ONLY: return PCAN_GAS_READ_ONLY; + default: return PCAN_GAS_REJECTED; + } +} + +static pcan_gas_status_t read_reg(const pcan_gas_region_t *region, + uint16_t offset, uint16_t *value) +{ + parallel_nand_gas_t *gas = (parallel_nand_gas_t *)region->user; + unsigned int portable_value; + parallel_nand_gas_result_t result = parallel_nand_gas_read( + gas, (unsigned int)region->base + offset, &portable_value); + if (result == PNAND_GAS_OK) *value = (uint16_t)portable_value; + return convert(result); +} + +static pcan_gas_status_t write_reg(const pcan_gas_region_t *region, + uint16_t offset, uint16_t value) +{ + parallel_nand_gas_t *gas = (parallel_nand_gas_t *)region->user; + return convert(parallel_nand_gas_write( + gas, (unsigned int)region->base + offset, value)); +} + +void parallel_nand_pcan_gas_region_init(pcan_gas_region_t *region, + parallel_nand_gas_t *gas) +{ + region->base = (uint16_t)gas->base; + region->count = PNAND_GAS_REGION_REGS; + region->storage = 0; + region->read = read_reg; + region->write = write_reg; + region->flags = 0U; + region->user = gas; + region->name = "parallel-nand"; +} diff --git a/c/parallel-nand/tests/test_parallel_nand.c b/c/parallel-nand/tests/test_parallel_nand.c new file mode 100644 index 0000000..572f129 --- /dev/null +++ b/c/parallel-nand/tests/test_parallel_nand.c @@ -0,0 +1,97 @@ +#include +#include +#include +#include "parallel_nand.h" +#include "parallel_nand_gas.h" + +typedef struct { char kind; unsigned int value; } event_t; +static event_t events[64]; +static unsigned int event_count; +static pnand_octet_t input_data[32]; +static unsigned int input_count; +static unsigned int input_pos; + +static void record(char kind, unsigned int value) +{ + events[event_count].kind = kind; + events[event_count].value = value; + event_count++; +} +static int fake_init(void) { return 0; } +static void fake_select(void) { record('S', 0); } +static void fake_deselect(void) { record('D', 0); } +static void fake_cmd(pnand_octet_t v) { record('C', v); } +static void fake_addr(pnand_octet_t v) { record('A', v); } +static void fake_write(pnand_octet_t v) { record('W', v); } +static pnand_octet_t fake_read(void) +{ + return input_pos < input_count ? input_data[input_pos++] : 0xFFU; +} +static int fake_ready(void) { return 1; } +static void fake_wp(int enabled) { record('P', (unsigned int)enabled); } +static void fake_delay(unsigned long us) { (void)us; } + +static const parallel_nand_port_t fake_port = { + fake_init, fake_select, fake_deselect, fake_cmd, fake_addr, fake_write, + fake_read, fake_ready, fake_wp, fake_delay +}; + +static void clear_events(void) { event_count = 0U; } + +int main(void) +{ + parallel_nand_t nand; + pnand_octet_t id[2]; + pnand_octet_t bytes[2]; + parallel_nand_gas_t gas; + unsigned int reg; + + assert(parallel_nand_init(&nand, &fake_port, + ¶llel_nand_mt29f1g08_geometry) == PNAND_OK); + assert(events[0].kind == 'D'); + assert(events[1].kind == 'P' && events[1].value == 1U); + + clear_events(); + input_data[0] = 0x2CU; + input_data[1] = 0xF1U; + input_count = 2U; + input_pos = 0U; + assert(parallel_nand_read_id(&nand, 0U, id, 2U) == PNAND_OK); + assert(id[0] == 0x2CU && id[1] == 0xF1U); + assert(events[1].kind == 'C' && events[1].value == 0x90U); + assert(events[2].kind == 'A' && events[2].value == 0U); + + clear_events(); + input_data[0] = 0x12U; + input_data[1] = 0x34U; + input_count = 2U; + input_pos = 0U; + assert(parallel_nand_read_page_slice(&nand, 0x1234UL, 0x56UL, + bytes, 2UL) == PNAND_OK); + assert(bytes[0] == 0x12U && bytes[1] == 0x34U); + assert(events[1].kind == 'C' && events[1].value == 0x00U); + assert(events[2].kind == 'A' && events[2].value == 0x56U); + assert(events[3].kind == 'A' && events[3].value == 0x00U); + assert(events[4].kind == 'A' && events[4].value == 0x34U); + assert(events[5].kind == 'A' && events[5].value == 0x12U); + assert(events[6].kind == 'C' && events[6].value == 0x30U); + + parallel_nand_gas_init(&gas, &nand, PNAND_GAS_DEFAULT_BASE); + assert(parallel_nand_gas_read(&gas, 0xE000U, ®) == PNAND_GAS_OK); + assert(reg == 0x4E44U); + assert(parallel_nand_gas_write(&gas, 0xE005U, 0x1234U) == + PNAND_GAS_OK); + assert(parallel_nand_gas_write(&gas, 0xE006U, 0x0001U) == + PNAND_GAS_OK); + assert(gas.selected_page == 0x00011234UL); + gas.main_data[0] = 0x34U; + gas.main_data[1] = 0x12U; + gas.status |= PNAND_GAS_STATUS_PAGE_VALID; + assert(parallel_nand_gas_read(&gas, 0xE100U, ®) == PNAND_GAS_OK); + assert(reg == 0x1234U); + assert(parallel_nand_gas_write(&gas, 0xE000U, 0U) == + PNAND_GAS_READ_ONLY); + + puts("parallel_nand: OK"); + return 0; +} From 5caca07ad4d039592840fec385e0e99019b5f29e Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 12:23:07 +0300 Subject: [PATCH 03/37] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8=20=D1=82=D1=80=D0=B5=D0=BD=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B2=20Python=203.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/protocan/trends.py | 3 ++- python/tests/test_plot.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/python/protocan/trends.py b/python/protocan/trends.py index 22e2daa..e047e1b 100644 --- a/python/protocan/trends.py +++ b/python/protocan/trends.py @@ -129,7 +129,8 @@ def encode_settings(settings: Mapping[str, list[TrendSignal]]) -> str: def decode_settings(text: str) -> dict[str, list[TrendSignal]]: if len(text.encode("utf-8")) > MAX_FILE_BYTES: raise ValueError("Trend configuration exceeds 1 MiB") - root = json.loads(text.removeprefix("\ufeff")) + # Python 3.8 is used by the Windows 7 port; remove at most one BOM. + root = json.loads(text[1:] if text.startswith("\ufeff") else text) if not isinstance(root, dict) or root.get("format") != "setflash-trends" or type(root.get("version")) is not int or root["version"] != 1: raise ValueError("Unknown trend configuration format/version") profiles = root.get("profiles") diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index b229824..4470200 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -9,6 +9,7 @@ import unittest from protocan.plot import Bounds, Marker, Markers, PlotMath, Viewport +@unittest.skipUnless(os.environ.get("SETPROTOCOL_LIBRARY"), "Set SETPROTOCOL_LIBRARY to the built C library") class PlotTests(unittest.TestCase): @classmethod def setUpClass(cls): From 0c635ce1be4a9c210f7cd72662ee9b6cfcddde53 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 12:30:37 +0300 Subject: [PATCH 04/37] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B2=D0=BE=D0=BA=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- c/set-protocol/ports/android/README.md | 6 + .../setprotocol/update/FirmwareCatalog.kt | 170 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt diff --git a/c/set-protocol/ports/android/README.md b/c/set-protocol/ports/android/README.md index 65424f8..73616aa 100644 --- a/c/set-protocol/ports/android/README.md +++ b/c/set-protocol/ports/android/README.md @@ -13,3 +13,9 @@ the Android adapter for protocol operations; the portable protocol core remains workspace, up to 2×16384 doubles). `trends/SpectrumAnalyzer` maps timestamps and errors but does not duplicate FFT/filter math. Run it off the UI thread. `trends/PlotViewport` is a toolkit-free normalized zoom/pan model. + +`kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt` is a UI-independent +firmware release client. It reads the optional `firmware.releases` array from +the shared `update.json`, accepts only HTTPS assets, limits their size and +returns image bytes only after SHA-256 verification. Applications provide +their own UI and pass optional HTTP Basic credentials. diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt new file mode 100644 index 0000000..ffdb56c --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt @@ -0,0 +1,170 @@ +package ru.setcorp.setprotocol.update + +import android.util.Base64 +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +/** Учётные данные HTTP Basic для закрытого каталога релизов. */ +data class FirmwareCatalogCredentials(val login: String, val password: String) + +/** Одна проверенная запись секции `firmware.releases` общего update.json. */ +data class FirmwareRelease( + val product: String, + val versionName: String, + val versionCode: Long, + val imageUrl: String, + val fileName: String, + val sha256: String, + val notes: String, + val transport: String, + val baseAddress: Long?, +) + +/** + * Переиспользуемый клиент базы прошивок для Android-приложений SET. + * + * Не зависит от UI: читает общий manifest, валидирует записи, загружает образ + * только по HTTPS и возвращает байты после обязательной проверки SHA-256. + */ +class FirmwareCatalogClient(private val userAgent: String) { + fun readCatalog(manifestUrl: String, credentials: FirmwareCatalogCredentials?): List { + val connection = openChecked(URL(manifestUrl), "application/json", credentials) + val text = try { + connection.inputStream.bufferedReader().use { reader -> + reader.readText().also { + require(it.length <= MAX_MANIFEST_CHARS) { "Файл каталога прошивок слишком большой" } + } + } + } finally { + connection.disconnect() + } + return parse(manifestUrl, text) + } + + fun download( + release: FirmwareRelease, + credentials: FirmwareCatalogCredentials?, + onProgress: (Float) -> Unit = {}, + ): ByteArray { + val connection = openChecked(URL(release.imageUrl), "application/octet-stream", credentials) + val total = connection.contentLengthLong + require(total < 0 || total <= MAX_FIRMWARE_BYTES) { "Файл прошивки превышает допустимый размер" } + return try { + val bytes = connection.inputStream.use { input -> + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var received = 0L + while (true) { + val count = input.read(buffer) + if (count < 0) break + received += count + require(received <= MAX_FIRMWARE_BYTES) { "Файл прошивки превышает допустимый размер" } + output.write(buffer, 0, count) + if (total > 0) onProgress((received.toFloat() / total).coerceIn(0f, 1f)) + } + output.toByteArray() + } + require(bytes.isNotEmpty()) { "Сервер вернул пустой файл прошивки" } + val actual = MessageDigest.getInstance("SHA-256").digest(bytes).toHex() + require(actual.equals(release.sha256, ignoreCase = true)) { + "SHA-256 загруженной прошивки не совпадает с каталогом" + } + onProgress(1f) + bytes + } finally { + connection.disconnect() + } + } + + private fun openChecked( + initial: URL, + accept: String, + credentials: FirmwareCatalogCredentials?, + ): HttpURLConnection { + var current = initial.requireHttps() + val credentialHost = current.host + for (redirect in 0..MAX_REDIRECTS) { + val connection = current.openConnection() as HttpURLConnection + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.instanceFollowRedirects = false + connection.setRequestProperty("Accept", accept) + connection.setRequestProperty("User-Agent", userAgent) + if (credentials != null && current.host.equals(credentialHost, ignoreCase = true)) { + val basic = Base64.encodeToString( + "${credentials.login}:${credentials.password}".toByteArray(), Base64.NO_WRAP, + ) + connection.setRequestProperty("Authorization", "Basic $basic") + } + val code = connection.responseCode + if (code in REDIRECT_CODES) { + val location = connection.getHeaderField("Location") + ?: run { connection.disconnect(); error("Перенаправление без адреса") } + connection.disconnect() + require(redirect < MAX_REDIRECTS) { "Слишком много перенаправлений" } + current = URL(current, location).requireHttps() + continue + } + if (code !in 200..299) { + connection.disconnect() + error("Сервер каталога прошивок вернул HTTP $code") + } + return connection + } + error("Слишком много перенаправлений") + } + + companion object { + fun parse(manifestUrl: String, text: String): List { + require(text.length <= MAX_MANIFEST_CHARS) { "Файл каталога прошивок слишком большой" } + val root = JSONObject(text) + val firmware = root.optJSONObject("firmware") + ?: error("В update.json не опубликован каталог прошивок") + val rows = firmware.optJSONArray("releases") + ?: error("В каталоге отсутствует массив releases") + return buildList { + for (index in 0 until rows.length()) { + val row = rows.getJSONObject(index) + val product = row.optString("product", row.optString("device")).trim() + val versionName = row.optString("versionName", row.optString("version")).trim() + val versionCode = row.optLong("versionCode", -1) + val imageUrl = URL(URL(manifestUrl), row.getString("imageUrl")).toString() + URL(imageUrl).requireHttps() + val sha256 = row.getString("sha256").trim().lowercase() + val fileName = row.optString("fileName", URL(imageUrl).path.substringAfterLast('/')).trim() + val transport = row.optString("transport", "rs485").trim().lowercase() + require(product.isNotEmpty() && versionName.isNotEmpty() && versionCode >= 0) { + "Не заполнены обязательные поля прошивки №${index + 1}" + } + require(sha256.matches(Regex("[0-9a-f]{64}"))) { "Некорректный SHA-256 прошивки №${index + 1}" } + require(fileName.matches(Regex("[A-Za-zА-Яа-яЁё0-9._ -]+\\.(bin|hex)", RegexOption.IGNORE_CASE))) { + "Некорректное имя файла прошивки №${index + 1}" + } + require(transport in setOf("rs485", "can", "stm32")) { "Некорректный канал прошивки №${index + 1}" } + val baseAddress = row.opt("baseAddress")?.takeUnless { it == JSONObject.NULL }?.toString()?.let(::parseAddress) + add(FirmwareRelease( + product, versionName, versionCode, imageUrl, fileName, sha256, + row.optString("notes").trim(), transport, baseAddress, + )) + } + }.sortedWith(compareBy { it.product.lowercase() }.thenByDescending { it.versionCode }) + } + + private fun parseAddress(value: String): Long = + if (value.startsWith("0x", ignoreCase = true)) value.drop(2).toLong(16) else value.toLong() + + private fun URL.requireHttps(): URL = also { + require(protocol.equals("https", ignoreCase = true)) { "Обновления разрешены только по HTTPS" } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + private const val CONNECT_TIMEOUT_MS = 15_000 + private const val READ_TIMEOUT_MS = 60_000 + private const val MAX_REDIRECTS = 5 + private const val MAX_MANIFEST_CHARS = 128 * 1024 + private const val MAX_FIRMWARE_BYTES = 128L * 1024 * 1024 + private val REDIRECT_CODES = setOf(301, 302, 303, 307, 308) + } +} From 6392d3674124b5d8ac0edd0e36be2c1ce93762ff Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 12:31:42 +0300 Subject: [PATCH 05/37] =?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=D0=B8=D0=B9=20Python-=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=D0=B0=20=D0=BF=D1=80=D0=BE=D1=88=D0=B8=D0=B2=D0=BE?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/setprotocol/firmware_catalog.py | 75 ++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 python/setprotocol/firmware_catalog.py diff --git a/python/setprotocol/firmware_catalog.py b/python/setprotocol/firmware_catalog.py new file mode 100644 index 0000000..2b1c2e9 --- /dev/null +++ b/python/setprotocol/firmware_catalog.py @@ -0,0 +1,75 @@ +"""Shared firmware release catalog contract from ``update.json``.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urljoin, urlparse + +MAX_MANIFEST_BYTES = 128 * 1024 +SUPPORTED_TRANSPORTS = frozenset({"rs485", "can", "stm32"}) + + +@dataclass(frozen=True) +class FirmwareRelease: + product: str + version: str + version_code: int + image_url: str + sha256: str + file_name: str + notes: str = "" + transport: str = "rs485" + base_address: int | None = None + + +def parse_firmware_catalog(data: bytes, manifest_url: str) -> list[FirmwareRelease]: + """Parse and validate the optional ``firmware.releases`` section.""" + if len(data) > MAX_MANIFEST_BYTES: + raise ValueError("Файл каталога прошивок слишком большой") + try: + root = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError("Некорректный JSON каталога прошивок") from error + firmware = root.get("firmware") if isinstance(root, dict) else None + rows = firmware.get("releases") if isinstance(firmware, dict) else firmware + if not isinstance(rows, list): + raise ValueError("В update.json не опубликован каталог прошивок") + releases: list[FirmwareRelease] = [] + for index, row in enumerate(rows, 1): + if not isinstance(row, dict): + raise ValueError(f"Некорректная запись прошивки №{index}") + product = str(row.get("product", row.get("device", ""))).strip() + version = str(row.get("versionName", row.get("version", ""))).strip() + raw_url = str(row.get("imageUrl", row.get("url", ""))).strip() + sha256 = str(row.get("sha256", "")).strip().lower() + try: + version_code = int(row.get("versionCode", 0)) + except (TypeError, ValueError) as error: + raise ValueError(f"Некорректная версия прошивки №{index}") from error + if not product or not version or version_code < 0 or not raw_url: + raise ValueError(f"Не заполнены обязательные поля прошивки №{index}") + if len(sha256) != 64 or any(char not in "0123456789abcdef" for char in sha256): + raise ValueError(f"Некорректный SHA-256 прошивки №{index}") + image_url = urljoin(manifest_url, raw_url) + parsed = urlparse(image_url) + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise ValueError("Обновления разрешены только по HTTPS") + file_name = str(row.get("fileName", Path(parsed.path).name)).strip() + if not re.fullmatch(r"[A-Za-zА-Яа-яЁё0-9._ -]+\.(?:bin|hex)", file_name, re.I): + raise ValueError(f"Некорректное имя файла прошивки №{index}") + base_raw = row.get("baseAddress") + try: + base_address = int(str(base_raw), 0) if base_raw is not None else None + except ValueError as error: + raise ValueError(f"Некорректный адрес прошивки №{index}") from error + transport = str(row.get("transport", "rs485")).strip().lower() + if transport not in SUPPORTED_TRANSPORTS: + raise ValueError(f"Некорректный канал прошивки №{index}") + releases.append(FirmwareRelease( + product, version, version_code, image_url, sha256, file_name, + str(row.get("notes", "")).strip(), transport, base_address, + )) + return sorted(releases, key=lambda item: (item.product.casefold(), -item.version_code)) From 2bb9fea90a1c5962ef59000583952e54307628a6 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 12:36:56 +0300 Subject: [PATCH 06/37] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D1=8E=D1=8E=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B2=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=80=D0=BA=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt index e8f702c..a319035 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt @@ -51,4 +51,3 @@ data class TrendMarkers( } fun enabled(marker: TrendMarker): Boolean = if (marker.horizontal) yEnabled else xEnabled } - From 70b2ee3cad8a0cdd61ec74427de3d9e73adf17df Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 12:56:16 +0300 Subject: [PATCH 07/37] feat(plot): configure one or two marker pairs per axis --- .../ru/setcorp/setprotocol/trends/TrendMarkers.kt | 12 +++++++++++- .../setcorp/setprotocol/trends/PlotContractTest.kt | 3 +++ python/protocan/plot.py | 14 +++++++++++++- python/tests/test_plot.py | 4 ++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt index a319035..57dabf6 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt @@ -10,6 +10,8 @@ enum class TrendMarker(val title: String, val horizontal: Boolean) { data class TrendMarkers( val xEnabled: Boolean = true, val yEnabled: Boolean = false, + val xPairs: Int = 1, + val yPairs: Int = 1, val selected: TrendMarker = TrendMarker.A, val a: Double? = null, val b: Double? = null, @@ -20,6 +22,9 @@ data class TrendMarkers( val g: Double? = null, val h: Double? = null, ) { + init { + require(xPairs in 1..2 && yPairs in 1..2) { "Количество пар маркеров должно быть от 1 до 2" } + } fun value(marker: TrendMarker): Double? = when (marker) { TrendMarker.A -> a; TrendMarker.B -> b; TrendMarker.C -> c; TrendMarker.D -> d TrendMarker.E -> e; TrendMarker.F -> f; TrendMarker.G -> g; TrendMarker.H -> h @@ -49,5 +54,10 @@ data class TrendMarkers( }.sortedWith(compareBy> { it.second } .thenBy { if (it.first == selected) 0 else 1 }).firstOrNull()?.first } - fun enabled(marker: TrendMarker): Boolean = if (marker.horizontal) yEnabled else xEnabled + fun enabled(marker: TrendMarker): Boolean = when (marker) { + TrendMarker.A, TrendMarker.B -> xEnabled + TrendMarker.C, TrendMarker.D -> xEnabled && xPairs >= 2 + TrendMarker.E, TrendMarker.F -> yEnabled + TrendMarker.G, TrendMarker.H -> yEnabled && yPairs >= 2 + } } diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt index af427fa..803820a 100644 --- a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt @@ -25,6 +25,9 @@ class PlotContractTest { @Test fun markerCoordinatesSurviveZoomAndDragUsesGestureStart() { val full = PlotBounds(1000.0, 2000.0, -10.0, 10.0) val markers = TrendMarkers().positioned(full) + assertTrue(markers.enabled(TrendMarker.A)) + assertFalse(markers.enabled(TrendMarker.C)) + assertTrue(markers.copy(xPairs = 2).enabled(TrendMarker.C)) val zoomed = PlotBounds(1250.0, 1750.0, -5.0, 5.0) assertEquals(markers, markers.positioned(zoomed)) val dragged = markers.drag(TrendMarker.A, 50.0, 500.0, zoomed) diff --git a/python/protocan/plot.py b/python/protocan/plot.py index 127242b..9661827 100644 --- a/python/protocan/plot.py +++ b/python/protocan/plot.py @@ -102,6 +102,8 @@ class Bounds: class Markers: x_enabled: bool = True y_enabled: bool = False + x_pairs: int = 1 + y_pairs: int = 1 selected: Marker = Marker.A a: Optional[float] = None b: Optional[float] = None @@ -112,11 +114,21 @@ class Markers: g: Optional[float] = None h: Optional[float] = None + def __post_init__(self) -> None: + if self.x_pairs not in (1, 2) or self.y_pairs not in (1, 2): + raise ValueError("Marker pair count must be 1 or 2") + def value(self, marker: Marker) -> Optional[float]: return getattr(self, marker.name.lower()) def enabled(self, marker: Marker) -> bool: - return self.y_enabled if marker.horizontal else self.x_enabled + if marker in (Marker.A, Marker.B): + return self.x_enabled + if marker in (Marker.C, Marker.D): + return self.x_enabled and self.x_pairs >= 2 + if marker in (Marker.E, Marker.F): + return self.y_enabled + return self.y_enabled and self.y_pairs >= 2 def move(self, marker: Marker, value: float) -> "Markers": return replace(self, **{marker.name.lower(): value}) diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index 4470200..9fe8640 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -5,6 +5,7 @@ import math import os from pathlib import Path import unittest +from dataclasses import replace from protocan.plot import Bounds, Marker, Markers, PlotMath, Viewport @@ -31,6 +32,9 @@ class PlotTests(unittest.TestCase): def test_markers_stay_in_data_coordinates_and_cross(self): bounds = Bounds(1000, 2000, -10, 10) markers = Markers().positioned(self.core, bounds) + self.assertTrue(markers.enabled(Marker.A)) + self.assertFalse(markers.enabled(Marker.C)) + self.assertTrue(replace(markers, x_pairs=2).enabled(Marker.C)) zoomed = bounds.visible(self.core, Viewport(.25, .25, .5, .5)) self.assertEqual(markers, markers.positioned(self.core, zoomed)) moved = markers.drag(self.core, Marker.A, 50, 500, zoomed) From b7f00acb24e8ed3b4d8b159124be7d323f037709 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 13:50:20 +0300 Subject: [PATCH 08/37] feat(plot): lock axes and calculate marker levels in dB --- c/set-protocol/include/set_plot.h | 3 ++- .../ru/setcorp/setprotocol/trends/NativePlot.kt | 2 ++ .../ru/setcorp/setprotocol/trends/PlotViewport.kt | 6 ++++-- .../ru/setcorp/setprotocol/trends/PlotContractTest.kt | 8 ++++++++ c/set-protocol/src/set_plot.c | 8 ++++++-- c/set-protocol/tests/fixtures/plot-v1.json | 3 +++ python/protocan/plot.py | 11 ++++++++++- python/tests/test_plot.py | 7 +++++++ 8 files changed, 42 insertions(+), 6 deletions(-) diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h index c249bb3..2ae6847 100644 --- a/c/set-protocol/include/set_plot.h +++ b/c/set-protocol/include/set_plot.h @@ -16,7 +16,8 @@ enum set_plot_operation { SET_PLOT_VALUE = 3, /* fraction,low,high,inverted -> value (not clamped) */ SET_PLOT_DRAG = 4, /* initial,deltaPixels,length,low,high,inverted -> clamped value */ SET_PLOT_TICK_STEP = 5, /* range,lengthPixels -> nice step */ - SET_PLOT_DELTA = 6 /* A,B,multiplier -> (B-A)*multiplier */ + SET_PLOT_DELTA = 6, /* A,B,multiplier -> (B-A)*multiplier */ + SET_PLOT_DB_DELTA = 7 /* A,B -> 20*log10(abs(B/A)); zero is invalid */ }; /** Version of this plot ABI, independently of the transport ABI. */ 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 984a732..92f264b 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 @@ -30,3 +30,5 @@ data class PlotBounds(val left: Double, val right: Double, val bottom: Double, v fun plotTickStep(range: Double, pixels: Double): Double = NativePlot.call(5, range, pixels)[0] fun plotDelta(a: Double, b: Double, multiplier: Double = 1.0): Double = NativePlot.call(6, a, b, multiplier)[0] +fun plotDbDelta(a: Double, b: Double): Double? = + runCatching { NativePlot.call(7, a, b)[0] }.getOrNull() diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt index 3f53a25..7ec4442 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt @@ -1,10 +1,12 @@ package ru.setcorp.setprotocol.trends /** Normalized top-left viewport; independent of pixels, units, toolkit and samples. */ -data class PlotViewport(val x: Double = 0.0, val y: Double = 0.0, val width: Double = 1.0, val height: Double = 1.0) { +data class PlotViewport(val x: Double = 0.0, val y: Double = 0.0, val width: Double = 1.0, val height: Double = 1.0, + val locked: Boolean = false) { fun transform(zoomX: Double = 1.0, zoomY: Double = 1.0, panX: Double = 0.0, panY: Double = 0.0, focusX: Double = 0.5, focusY: Double = 0.5): PlotViewport { + if (locked) return this val result = NativePlot.call(0, x, y, width, height, zoomX, zoomY, panX, panY, focusX, focusY) - return PlotViewport(result[0], result[1], result[2], result[3]) + return PlotViewport(result[0], result[1], result[2], result[3], locked) } } diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt index 803820a..024b85b 100644 --- a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt @@ -37,4 +37,12 @@ class PlotContractTest { val crossed = markers.move(TrendMarker.A, 1900.0).move(TrendMarker.B, 1100.0) assertEquals(-800.0, plotDelta(crossed.a!!, crossed.b!!), 0.0) } + + @Test fun lockedViewportIgnoresZoomAndPanAndDbUsesAmplitudeRatio() { + val locked = PlotViewport(locked = true) + assertEquals(locked, locked.transform(zoomX = 2.0, panY = 0.2)) + assertEquals(20.0, plotDbDelta(1.0, 10.0)!!, 1e-10) + assertEquals(-20.0, plotDbDelta(10.0, 1.0)!!, 1e-10) + assertNull(plotDbDelta(0.0, 1.0)) + } } diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c index 3f000e3..66ed13c 100644 --- a/c/set-protocol/src/set_plot.c +++ b/c/set-protocol/src/set_plot.c @@ -11,9 +11,9 @@ static int finite_values(const double *v, size_t n) { uint32_t set_plot_abi_version(void) { return 1U; } size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t cap) { - static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3}; + static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3, 2}; double span, fraction; - if (op > SET_PLOT_DELTA || !v || !out || n != sizes[op] || + if (op > SET_PLOT_DB_DELTA || !v || !out || n != sizes[op] || cap < (op == SET_PLOT_TRANSFORM ? 4U : 1U)) return 0; if (op == SET_PLOT_TRANSFORM) { double w, h, fx, fy; @@ -65,6 +65,10 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t break; } case SET_PLOT_DELTA: out[0] = (v[1] - v[0]) * v[2]; break; + case SET_PLOT_DB_DELTA: + if (v[0] == 0 || v[1] == 0) return 0; + out[0] = 20 * log10(fabs(v[1] / v[0])); + break; default: return 0; } return isfinite(out[0]) ? 1 : 0; diff --git a/c/set-protocol/tests/fixtures/plot-v1.json b/c/set-protocol/tests/fixtures/plot-v1.json index 3428ba4..1e8715d 100644 --- a/c/set-protocol/tests/fixtures/plot-v1.json +++ b/c/set-protocol/tests/fixtures/plot-v1.json @@ -20,6 +20,9 @@ {"name":"ticks","op":5,"input":[100,800],"output":[20]}, {"name":"negative_delta","op":6,"input":[10,0,1],"output":[-10]}, {"name":"milliseconds","op":6,"input":[0,0.01,1000],"output":[10]}, + {"name":"db_gain","op":7,"input":[1,10],"output":[20]}, + {"name":"db_attenuation","op":7,"input":[10,1],"output":[-20]}, + {"name":"db_zero_reference","op":7,"input":[0,1],"output":null}, {"name":"zero_range","op":2,"input":[1,1,1,0],"output":null}, {"name":"zero_pixels","op":4,"input":[0,1,0,0,1,0],"output":null}, {"name":"bad_viewport","op":0,"input":[0,0,0,1,2,1,0,0,0.5,0.5],"output":null} diff --git a/python/protocan/plot.py b/python/protocan/plot.py index 9661827..67248f5 100644 --- a/python/protocan/plot.py +++ b/python/protocan/plot.py @@ -61,6 +61,12 @@ class PlotMath: def delta(self, a: float, b: float, multiplier: float = 1) -> float: return self.call(6, a, b, multiplier)[0] + def db_delta(self, a: float, b: float) -> Optional[float]: + try: + return self.call(7, a, b)[0] + except ValueError: + return None + @dataclass(frozen=True) class Viewport: @@ -68,12 +74,15 @@ class Viewport: y: float = 0.0 width: float = 1.0 height: float = 1.0 + locked: bool = False def transform(self, core: PlotMath, zoom_x: float = 1, zoom_y: float = 1, pan_x: float = 0, pan_y: float = 0, focus_x: float = 0.5, focus_y: float = 0.5) -> "Viewport": + if self.locked: + return self return Viewport(*core.call(0, self.x, self.y, self.width, self.height, - zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y)) + zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y), locked=self.locked) @dataclass(frozen=True) diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index 9fe8640..a2ba188 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -50,6 +50,13 @@ class PlotTests(unittest.TestCase): with self.assertRaises(ValueError): self.core.pinch_axis(value, 10, 8) + def test_locked_viewport_and_decibel_delta(self): + locked = Viewport(locked=True) + self.assertEqual(locked, locked.transform(self.core, zoom_x=2, pan_y=.2)) + self.assertAlmostEqual(20, self.core.db_delta(1, 10)) + self.assertAlmostEqual(-20, self.core.db_delta(10, 1)) + self.assertIsNone(self.core.db_delta(0, 1)) + if __name__ == "__main__": unittest.main() From 1f49ba49bb22e8d6fa8e15e8d240ce5dee3a3c0d Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 18:19:07 +0300 Subject: [PATCH 09/37] =?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=D0=B8=D0=B9=20API=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20CAN=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- c/set-protocol/CMakeLists.txt | 4 + c/set-protocol/include/balsam_can.h | 61 +++++ c/set-protocol/include/pcan_abi.h | 15 ++ c/set-protocol/include/setprotocol.h | 2 + c/set-protocol/ports/android/Android.mk | 1 + c/set-protocol/ports/android/README.md | 5 + .../setcorp/setprotocol/NativeSetProtocol.kt | 3 + .../setprotocol/balsam/BalsamCanProtocol.kt | 116 +++++++++ .../legacycan/LegacyCanTerminal.kt | 230 ++++++++++++++++++ .../ports/android/setprotocol_jni.c | 47 ++++ .../balsam/BalsamCanProtocolTest.kt | 20 ++ .../legacycan/LegacyCanTerminalTest.kt | 49 ++++ c/set-protocol/src/balsam_can.c | 182 ++++++++++++++ c/set-protocol/src/pcan_abi.c | 28 +++ c/set-protocol/tests/test_balsam_can.c | 30 +++ c/set-protocol/tools/build_host.py | 2 +- python/protocan/__init__.py | 2 + python/protocan/balsam.py | 98 ++++++++ python/protocan/native.py | 42 ++++ python/protocan/protocan.py | 5 + 20 files changed, 941 insertions(+), 1 deletion(-) create mode 100644 c/set-protocol/include/balsam_can.h create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt create mode 100644 c/set-protocol/src/balsam_can.c create mode 100644 c/set-protocol/tests/test_balsam_can.c create mode 100644 python/protocan/balsam.py diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt index 27f19c6..30dc23a 100644 --- a/c/set-protocol/CMakeLists.txt +++ b/c/set-protocol/CMakeLists.txt @@ -17,6 +17,7 @@ set(SETPROTOCOL_V2_SOURCES # Совместимые ProtoCAN/SETGUI v1 форматы переходного периода. set(SETPROTOCOL_LEGACY_SOURCES + src/balsam_can.c src/gui_catalog.c src/gui_frame.c src/pcan_abi.c @@ -94,6 +95,9 @@ if(SETP_BUILD_TESTS) add_executable(test_abi tests/test_abi.c) target_link_libraries(test_abi PRIVATE setprotocol_static) add_test(NAME stable_abi COMMAND test_abi) + add_executable(test_balsam_can tests/test_balsam_can.c) + target_link_libraries(test_balsam_can PRIVATE setprotocol_static) + add_test(NAME legacy_balsam_can COMMAND test_balsam_can) add_executable(test_trends tests/test_trends.c) target_link_libraries(test_trends PRIVATE setprotocol_static) add_test(NAME shared_trends COMMAND test_trends) diff --git a/c/set-protocol/include/balsam_can.h b/c/set-protocol/include/balsam_can.h new file mode 100644 index 0000000..95feacb --- /dev/null +++ b/c/set-protocol/include/balsam_can.h @@ -0,0 +1,61 @@ +/** + * @file balsam_can.h + * @brief Legacy Balsam 167 extended-CAN register frames. + * + * The wire layout is taken from Balsam_167_periph/Source/Internal/ecan.c: + * one big-endian address/mask word followed by three big-endian register words. + */ +#ifndef BALSAM_CAN_H +#define BALSAM_CAN_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define BALSAM_CAN_BASE_ID 0x00BA0000UL +#define BALSAM_CAN_NODE_COUNT 13U +#define BALSAM_CAN_DATA_OFFSET 0x10U +#define BALSAM_CAN_DLC 8U +#define BALSAM_CAN_REGISTER_COUNT 3U + +typedef enum { + BALSAM_CAN_TO_NODE = 0, + BALSAM_CAN_FROM_NODE = 1 +} balsam_can_direction_t; + +typedef struct { + uint8_t device; + uint8_t direction; + uint8_t present_mask; + uint16_t start_address; + uint16_t values[BALSAM_CAN_REGISTER_COUNT]; +} balsam_can_frame_t; + +/** Return non-zero for the command/data/terminal IDs used by Balsam 167. */ +int balsam_can_is_id(uint32_t can_id); + +/** + * Decode an 8-byte Balsam frame. Returns 1 on success, 0 for another CAN ID, + * -1 for invalid arguments and -2 for a Balsam ID with a non-8-byte payload. + */ +int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size, + balsam_can_frame_t *output); + +/** Human-readable Russian name of a Balsam device. */ +const char *balsam_can_device_name(uint8_t device); + +/** + * Format the register name from the Balsam 167 data table into output. + * Returns the required length (excluding NUL); an empty string means unknown. + */ +size_t balsam_can_register_name(uint8_t device, uint16_t address, + char *output, size_t output_size); + +#ifdef __cplusplus +} +#endif + +#endif /* BALSAM_CAN_H */ diff --git a/c/set-protocol/include/pcan_abi.h b/c/set-protocol/include/pcan_abi.h index 5e1c23a..4ce3b03 100644 --- a/c/set-protocol/include/pcan_abi.h +++ b/c/set-protocol/include/pcan_abi.h @@ -45,6 +45,14 @@ typedef struct { uint8_t payload[PCAN_ABI_GUI_PAYLOAD_MAX]; } pcan_abi_gui_frame_t; +typedef struct { + uint8_t device; + uint8_t direction; + uint8_t present_mask; + uint16_t start_address; + uint16_t values[3]; +} pcan_abi_balsam_frame_t; + PCAN_ABI_API uint32_t pcan_abi_version(void); PCAN_ABI_API uint32_t pcan_abi_id_pack(uint8_t priority, uint8_t route, @@ -58,6 +66,13 @@ PCAN_ABI_API void pcan_abi_id_unpack(uint32_t raw, uint8_t *priority, PCAN_ABI_API uint16_t pcan_abi_crc16(const uint8_t *data, size_t size); +PCAN_ABI_API int pcan_abi_balsam_decode(uint32_t can_id, + const uint8_t *data, size_t size, + pcan_abi_balsam_frame_t *output); +PCAN_ABI_API const char *pcan_abi_balsam_device_name(uint8_t device); +PCAN_ABI_API size_t pcan_abi_balsam_register_name( + uint8_t device, uint16_t address, char *output, size_t output_size); + PCAN_ABI_API size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags, uint32_t can_id, const uint8_t *data, uint8_t dlc, diff --git a/c/set-protocol/include/setprotocol.h b/c/set-protocol/include/setprotocol.h index 3cf2a3c..a680583 100644 --- a/c/set-protocol/include/setprotocol.h +++ b/c/set-protocol/include/setprotocol.h @@ -5,6 +5,8 @@ #ifndef SETPROTOCOL_H #define SETPROTOCOL_H +#include "balsam_can.h" + /* Основной SET protocol v2. */ #include "set_protocol.h" #include "set_can.h" diff --git a/c/set-protocol/ports/android/Android.mk b/c/set-protocol/ports/android/Android.mk index b9c7b81..e25efce 100644 --- a/c/set-protocol/ports/android/Android.mk +++ b/c/set-protocol/ports/android/Android.mk @@ -12,6 +12,7 @@ LOCAL_SRC_FILES := \ set_plot_jni.c \ ../../src/set_trends.c \ ../../src/set_spectrum.c \ + ../../src/balsam_can.c \ ../../src/gui_catalog.c \ ../../src/gui_frame.c \ ../../src/pcan_abi.c \ diff --git a/c/set-protocol/ports/android/README.md b/c/set-protocol/ports/android/README.md index 73616aa..7ba353c 100644 --- a/c/set-protocol/ports/android/README.md +++ b/c/set-protocol/ports/android/README.md @@ -14,6 +14,11 @@ workspace, up to 2×16384 doubles). `trends/SpectrumAnalyzer` maps timestamps an errors but does not duplicate FFT/filter math. Run it off the UI thread. `trends/PlotViewport` is a toolkit-free normalized zoom/pan model. +`legacycan/LegacyCanTerminal.kt` contains the two historical CAN_terminal wire +formats, the complete Projects.ini node/command catalog, and shared codecs for +register writes and command frames. Android UI code must use this module instead +of reproducing the Delphi byte rotation or CAN-ID routing rules. + `kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt` is a UI-independent firmware release client. It reads the optional `firmware.releases` array from the shared `update.json`, accepts only HTTPS assets, limits their size and diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt index 40707b1..5e45407 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt @@ -29,6 +29,9 @@ object NativeSetProtocol { ): Long external fun nativeUnpackId(raw: Long): IntArray? external fun nativeCrc16(input: ByteArray): Int + external fun nativeBalsamDecode(canId: Long, input: ByteArray): IntArray? + external fun nativeBalsamDeviceName(device: Int): String + external fun nativeBalsamRegisterName(device: Int, address: Int): String external fun nativeEncodeFrame( sequence: Int, flags: Int, diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt new file mode 100644 index 0000000..e1fd17d --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt @@ -0,0 +1,116 @@ +package ru.setcorp.setprotocol.balsam + +import ru.setcorp.setprotocol.NativeSetProtocol + +data class BalsamRegister(val address: Int, val value: Int, val name: String) { + val displayName: String get() = name.ifBlank { "R%04X".format(address) } + val signedValue: Int get() = if (value < 0x8000) value else value - 0x10000 +} + +data class BalsamFrame( + val canId: Long, + val device: Int, + val deviceName: String, + val fromDevice: Boolean, + val startAddress: Int, + val presentMask: Int, + val registers: List, +) { + fun summary(): String { + val direction = if (fromDevice) "данные" else "команда" + val values = registers.joinToString { "${it.displayName}=0x%04X (%d)".format(it.value, it.signedValue) } + .ifEmpty { "нет отмеченных регистров" } + return "BALZAM · $deviceName · $direction · $values" + } +} + +/** Shared parser for Balsam_167_periph eCAN frames. */ +object BalsamCanProtocol { + const val BASE_ID = 0x00BA_0000L + const val TERMINAL_REQUEST_ID = 0x00BA_001CL + const val TERMINAL_RESPONSE_ID = 0x00BA_000CL + const val PULT_REQUEST_ID = 0x0074_5019L + const val PULT_RESPONSE_ID = 0x0074_5009L + + fun isLegacyId(canId: Long): Boolean { + val relative = (canId and 0x1FFF_FFFFL) - BASE_ID + return relative in 0L..12L || relative in 0x10L..0x1CL || + canId == PULT_REQUEST_ID || canId == PULT_RESPONSE_ID + } + + fun decode(canId: Long, data: ByteArray): BalsamFrame? { + if (!isRegisterId(canId) || data.size != 8) return null + val native = if (NativeSetProtocol.available) { + NativeSetProtocol.nativeBalsamDecode(canId, data) + } else null + val words = native ?: fallbackDecode(canId, data) + val device = words[0] + val mask = words[2] + val start = words[3] + val registers = (0..2).filter { mask and (4 shr it) != 0 }.map { index -> + val address = start + index + BalsamRegister(address, words[4 + index], registerName(device, address)) + } + return BalsamFrame( + canId and 0x1FFF_FFFFL, + device, + deviceName(device), + words[1] == 1, + start, + mask, + registers, + ) + } + + fun summary(canId: Long, data: ByteArray? = null): String = + data?.let { decode(canId, it)?.summary() } ?: when (canId) { + TERMINAL_REQUEST_ID -> "BALZAM legacy · запрос терминала" + TERMINAL_RESPONSE_ID -> "BALZAM legacy · ответ терминалу" + PULT_REQUEST_ID -> "BALZAM legacy · данные пульта" + PULT_RESPONSE_ID -> "BALZAM legacy · команда пульту" + in (BASE_ID + 0x10L)..(BASE_ID + 0x1BL) -> + "BALZAM legacy · данные · ${deviceName((canId - BASE_ID - 0x0FL).toInt())}" + in BASE_ID..(BASE_ID + 0x0BL) -> + "BALZAM legacy · команда · ${deviceName((canId - BASE_ID + 1L).toInt())}" + else -> "BALZAM legacy · неизвестный ID" + } + + private fun isRegisterId(canId: Long): Boolean { + val relative = (canId and 0x1FFF_FFFFL) - BASE_ID + return relative in 0L..12L || relative in 0x10L..0x1CL + } + + private fun fallbackDecode(canId: Long, data: ByteArray): IntArray { + val relative = (canId and 0x1FFF_FFFFL) - BASE_ID + val header = u16be(data, 0) + return intArrayOf( + ((relative and 0x0F) + 1).toInt(), + if (relative >= 0x10) 1 else 0, + (header ushr 13) and 7, + header and 0x1FFF, + u16be(data, 2), u16be(data, 4), u16be(data, 6), + ) + } + + private fun u16be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + + private fun deviceName(device: Int): String = if (NativeSetProtocol.available) { + NativeSetProtocol.nativeBalsamDeviceName(device) + } else listOf( + "Трансформатор 1", "Трансформатор 2", "Силовой блок 1", "Силовой блок 2", + "УМП 1", "УМП 2", "Двигатель", "ВЭП", "Задатчик", "Узел 10", "Узел 11", + "Узел 12", "Терминал", + ).getOrElse(device - 1) { "Неизвестный узел" } + + private fun registerName(device: Int, address: Int): String = + if (NativeSetProtocol.available) NativeSetProtocol.nativeBalsamRegisterName(device, address) + else when { + device in 1..2 && address in 0x18..0x2B -> "Показания T° ${address - 0x17}" + device in 3..4 && address in 0x18..0x27 -> "Показания T° ${address - 0x17}" + device == 7 && address in 0x18..0x1F -> "Показания T° ${address - 0x17}" + address == 0x17 -> "Состояние джамперов" + address == 0x7F -> "Команды" + else -> "" + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt new file mode 100644 index 0000000..0a3cf26 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt @@ -0,0 +1,230 @@ +package ru.setcorp.setprotocol.legacycan + +/** Wire formats implemented by the historical CAN_terminal application. */ +enum class LegacyCanFormat { + /** Address and a three-bit presence mask are carried in DATA[4..5]. */ + ROTATING_THREE_WORDS, + + /** Register address is carried in CAN ID[6:0], followed by up to four words. */ + ADDRESS_IN_IDENTIFIER, +} + +enum class LegacyCanSource { TO_DEVICE, FROM_DEVICE } + +data class LegacyCanPacket( + val address: Int, + val mask: Int, + val values: List, + val source: LegacyCanSource, +) { + val presentValues: List> + get() = values.mapIndexedNotNull { index, value -> + if (formatUses(index)) address + index to value else null + } + + private fun formatUses(index: Int): Boolean = mask == 0xFF || mask and (4 shr index) != 0 +} + +data class LegacyCanWireFrame(val canId: Long, val data: ByteArray) + +data class LegacyCanRegisterValue( + val address: Int, + val value: Int = 0, + val source: LegacyCanSource? = null, + val revision: Long = 0, +) + +data class LegacyCanNode( + val index: Int, + val rsAddress: Int, + val canAddress: Int, + val rxId: Long, + val txId: Long, + val name: String, +) + +data class LegacyCanProject( + val name: String, + val format: LegacyCanFormat, + val baseId: Long, + val idOffset: Long, + val nodes: List, + val commandNames: List, +) { + fun nodeFor(canId: Long): LegacyCanNode? { + val normalized = LegacyCanTerminalProtocol.routingId(format, canId) + return nodes.firstOrNull { it.rxId == normalized || it.txId == normalized } + } +} + +/** + * Shared, UI-independent codec for the two protocols found in CAN_terminal.pas. + * Values are unsigned 16-bit words; callers can interpret them as signed with + * [signedWord]. + */ +object LegacyCanTerminalProtocol { + fun emptyRegisterBank(): List = + List(128) { LegacyCanRegisterValue(it) } + + fun applyPacket( + bank: List, + packet: LegacyCanPacket, + revision: Long, + ): List { + require(bank.size == 128) { "Банк должен содержать 128 регистров" } + val updates = packet.presentValues.toMap() + return bank.map { current -> + updates[current.address]?.let { value -> + current.copy(value = value, source = packet.source, revision = revision) + } ?: current + } + } + + fun routingId(format: LegacyCanFormat, canId: Long): Long = when (format) { + LegacyCanFormat.ROTATING_THREE_WORDS -> canId and 0x1FFF_FFFFL + LegacyCanFormat.ADDRESS_IN_IDENTIFIER -> canId and 0x1FF0_0000L + } + + fun decode( + format: LegacyCanFormat, + node: LegacyCanNode, + canId: Long, + data: ByteArray, + ): LegacyCanPacket? { + val route = routingId(format, canId) + val source = when (route) { + node.txId -> LegacyCanSource.TO_DEVICE + node.rxId -> LegacyCanSource.FROM_DEVICE + else -> return null + } + return when (format) { + LegacyCanFormat.ROTATING_THREE_WORDS -> { + if (data.size != 8) return null + val mask = (u8(data[4]) ushr 5) and 7 + val address = ((u8(data[4]) and 0x1F) shl 8) or u8(data[5]) + LegacyCanPacket(address, mask, listOf(u16be(data, 6), u16be(data, 0), u16be(data, 2)), source) + } + LegacyCanFormat.ADDRESS_IN_IDENTIFIER -> { + if (data.isEmpty() || data.size > 8 || data.size % 2 != 0) return null + LegacyCanPacket( + address = (canId and 0x7F).toInt(), + mask = 0xFF, + values = data.indices.step(2).map { u16be(data, it) }, + source = source, + ) + } + } + } + + fun encodeWrite( + format: LegacyCanFormat, + canId: Long, + address: Int, + values: List, + ): LegacyCanWireFrame { + require(address in 0..127) { "Адрес регистра должен быть в диапазоне 0..127" } + val maximum = if (format == LegacyCanFormat.ADDRESS_IN_IDENTIFIER) 4 else 3 + require(values.size in 1..maximum) { "Нужно от 1 до $maximum слов данных" } + values.forEach { require(it in 0..0xFFFF) { "Значение должно быть в диапазоне 0..65535" } } + return when (format) { + LegacyCanFormat.ADDRESS_IN_IDENTIFIER -> LegacyCanWireFrame( + (canId and 0x1FF0_0000L) + address, + values.flatMap { listOf((it ushr 8).toByte(), it.toByte()) }.toByteArray(), + ) + LegacyCanFormat.ROTATING_THREE_WORDS -> { + val padded = values + List(3 - values.size) { 0 } + val mask = when (values.size) { 1 -> 4; 2 -> 6; else -> 7 } + val data = byteArrayOf( + (padded[1] ushr 8).toByte(), padded[1].toByte(), + (padded[2] ushr 8).toByte(), padded[2].toByte(), + ((mask shl 5) or (address ushr 8)).toByte(), address.toByte(), + (padded[0] ushr 8).toByte(), padded[0].toByte(), + ) + LegacyCanWireFrame(canId and 0x1FFF_FFFFL, data) + } + } + } + + fun encodeCommand(project: LegacyCanProject, node: LegacyCanNode, commandIndex: Int): LegacyCanWireFrame { + require(commandIndex in 0..16) { "Номер команды должен быть в диапазоне 0..16" } + val value = if (commandIndex < 16) 1 shl commandIndex else 0 + return encodeWrite(project.format, node.rxId, 127, listOf(value)) + } + + fun signedWord(value: Int): Int = if (value < 0x8000) value else value - 0x10000 + + private fun u8(value: Byte): Int = value.toInt() and 0xFF + private fun u16be(data: ByteArray, offset: Int): Int = (u8(data[offset]) shl 8) or u8(data[offset + 1]) +} + +/** Project table migrated from CAN_terminal/Projects.ini (Windows-1251). */ +object LegacyCanProjects { + private val defaultCommands = listOf( + "Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", + "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all", + ) + + private fun project( + name: String, + baseId: Long = 0, + offset: Long = 0x10, + format: LegacyCanFormat = LegacyCanFormat.ROTATING_THREE_WORDS, + specs: List>, + commands: List = defaultCommands, + ): LegacyCanProject { + val shift = if (format == LegacyCanFormat.ADDRESS_IN_IDENTIFIER) 20 else 0 + val actualOffset = if (format == LegacyCanFormat.ADDRESS_IN_IDENTIFIER) 1L shl 28 else offset + val nodes = specs.map { spec -> + val index = spec[0] as Int + val canAddress = spec[1] as Int + val rsAddress = spec[2] as Int + val nodeName = spec[3] as String + val routed = canAddress.toLong() shl shift + LegacyCanNode(index, rsAddress, canAddress, baseId + routed, baseId + actualOffset + routed, nodeName) + } + return LegacyCanProject(name, format, baseId, actualOffset, nodes, commands) + } + + private fun s(index: Int, can: Int, rs: Int, name: String): List = listOf(index, can, rs, name) + + val all: List = listOf( + project("Буксир", 0x0031_8200, specs = listOf( + s(0, 0, 1, "УКСС СБ"), s(1, 1, 2, "БКСС ГД"), s(2, 2, 3, "УКСВЭП"), s(3, 3, 4, "Задатчик"), + )), + project("СЭДБМ", 0x0105_1020, specs = listOf( + s(0,0,0,"УКСС СК1 СБ1"),s(1,1,1,"УКСС СК2 СБ1"),s(2,2,2,"УКСС СК3 СБ1"),s(3,3,3,"УКСС СК4 СБ1"), + s(4,4,4,"УКССВЭП СБ1"),s(5,5,5,"Задатчик СБ1"),s(6,6,6,"БТР ИТЭС"),s(8,0x20,8,"УКСС СК1 СБ2"), + s(9,0x21,9,"УКСС СК2 СБ2"),s(10,0x22,10,"УКСС СК3 СБ2"),s(11,0x23,11,"УКСС СК4 СБ2"), + s(12,0x24,12,"УКССВЭП СБ2"),s(13,0x25,13,"Задатчик СБ2"),s(14,0x26,14,"УКСС БОИН"),s(15,0x27,15,"УКСВЭП БОИН"), + ), commands = defaultCommands.toMutableList().also { it[7]="Raw"; it[8]="HiVolt" }), + project("Ледокол", 0x001C_E020, -0x20, specs = listOf( + s(0,0,1,"УКСС БВ1 ПЧ1"),s(8,1,2,"УКСС БВ1 ПЧ2"),s(1,2,3,"УКСС БВ1 ПЧ1"),s(9,3,4,"УКСС БВ2 ПЧ2"), + s(2,4,5,"УКСС БИ1 ПЧ1"),s(10,5,6,"УКСС БИ1 ПЧ2"),s(3,6,7,"УКСС БИ2 ПЧ1"),s(11,7,8,"УКСС БИ2 ПЧ2"), + s(4,8,9,"УКССВЭП1 ПЧ1"),s(12,9,10,"УКССВЭП1 ПЧ2"),s(5,10,11,"УКССВЭП2 ПЧ1"),s(13,11,12,"УКССВЭП2 ПЧ2"), + ), commands = defaultCommands.toMutableList().also { it[4]="Raw"; it[5]="Read"; it[6]="ExtLamp"; it[7]="ExtLite"; it[8]="No log" }), + project("Бальзам", 0x00BA_0000, specs = listOf( + s(0,0,1,"БКСС Тр1"),s(8,1,2,"БКСС Тр2"),s(1,2,3,"УКСС СБ1"),s(9,3,4,"УКСС СБ2"), + s(2,4,5,"УКСС УМП1"),s(10,5,6,"УКСС УМП2"),s(3,6,7,"БКСС ГД"),s(4,7,9,"Задатчик"),s(5,8,11,"УКСС ВЭП"), + ), commands = defaultCommands.toMutableList().also { it[6]="Stop";it[7]="Start";it[8]="Init";it[9]="Tune";it[10]="Secret";it[11]="Light";it[12]="Raw" }), + project("23550", 0x0023_5500, specs = listOf( + s(0,0,1,"Задатчик"),s(1,1,2,"Выносной пульт"),s(2,2,3,"УКСВЭП"),s(3,3,4,"БКСС ГД"), + ), commands = defaultCommands.toMutableList().also { it[5]="Read";it[7]="Send";it[8]="-" }), + project("23550.X", format = LegacyCanFormat.ADDRESS_IN_IDENTIFIER, specs = listOf( + s(0,0,1,"Задатчик"),s(1,1,2,"Выносной пульт"),s(2,2,3,"УКСВЭП"),s(3,3,4,"БКСС ГД"), + ), commands = defaultCommands.toMutableList().also { it[5]="Read";it[7]="Send";it[8]="-" }), + project("23550.2", format = LegacyCanFormat.ADDRESS_IN_IDENTIFIER, specs = listOf( + s(0,0,1,"Задатчик"),s(1,1,2,"Выносной пульт"),s(2,2,3,"БКСС ГД"),s(3,4,4,"УКСС СИ СБ1"), + s(4,6,6,"УКСС СВФ СБ1"),s(5,8,8,"УКСВЭП СБ1"),s(11,5,5,"УКСС СИ СБ2"),s(12,7,7,"УКСС СВФ СБ2"), + s(13,9,9,"УКСВЭП СБ2"),s(16,0x1F,16,"BroadCast"), + ), commands = defaultCommands.toMutableList().also { it[5]="Calc";it[7]="Send" }), + project("Янтарь", 0x0021_3000, specs = listOf( + s(0,0,1,"УКСС БВ"),s(1,1,2,"УКСС БИ1"),s(2,2,3,"УКСС БИ2"),s(3,3,4,"БКСС ГД"), + s(4,4,5,"УКСВЭП"),s(5,5,6,"Задатчик"),s(6,6,7,"Выносной пульт"), + )), + project( + "23550 БСУ", 0x0CEB_0F1, -0x10, + specs = listOf(s(0,0,0,"БСУ1"),s(1,1,1,"БСУ2")), + commands = List(16) { "-" } + "Nothing at all", + ), + ) +} diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c index 863c2e5..1b5c5a0 100644 --- a/c/set-protocol/ports/android/setprotocol_jni.c +++ b/c/set-protocol/ports/android/setprotocol_jni.c @@ -6,6 +6,53 @@ #include "setprotocol_abi.h" #include "set_trends.h" #include "set_spectrum.h" +#include "balsam_can.h" + +JNIEXPORT jintArray JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamDecode( + JNIEnv *env, jobject self, jlong can_id, jbyteArray input) +{ + (void)self; + balsam_can_frame_t frame; + jsize size; + jbyte data[BALSAM_CAN_DLC]; + jint values[7]; + if (input == NULL) return NULL; + size = (*env)->GetArrayLength(env, input); + if (size != (jsize)BALSAM_CAN_DLC) return NULL; + (*env)->GetByteArrayRegion(env, input, 0, size, data); + if (balsam_can_decode((uint32_t)can_id, (const uint8_t *)data, + (size_t)size, &frame) != 1) return NULL; + values[0] = frame.device; + values[1] = frame.direction; + values[2] = frame.present_mask; + values[3] = frame.start_address; + values[4] = frame.values[0]; + values[5] = frame.values[1]; + values[6] = frame.values[2]; + jintArray result = (*env)->NewIntArray(env, 7); + if (result != NULL) (*env)->SetIntArrayRegion(env, result, 0, 7, values); + return result; +} + +JNIEXPORT jstring JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamDeviceName( + JNIEnv *env, jobject self, jint device) +{ + (void)self; + return (*env)->NewStringUTF(env, balsam_can_device_name((uint8_t)device)); +} + +JNIEXPORT jstring JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamRegisterName( + JNIEnv *env, jobject self, jint device, jint address) +{ + (void)self; + char name[128]; + balsam_can_register_name((uint8_t)device, (uint16_t)address, + name, sizeof name); + return (*env)->NewStringUTF(env, name); +} JNIEXPORT jdoubleArray JNICALL Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeSpectrum( diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt new file mode 100644 index 0000000..305b93b --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt @@ -0,0 +1,20 @@ +package ru.setcorp.setprotocol.balsam + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BalsamCanProtocolTest { + @Test + fun decodesThreeNamedSensorRegisters() { + val frame = requireNotNull(BalsamCanProtocol.decode( + 0x00BA_0010L, + byteArrayOf(0xE0.toByte(), 0x18, 0x00, 0x29, 0xFF.toByte(), 0xFE.toByte(), 0x12, 0x34), + )) + assertEquals(1, frame.device) + assertEquals(0x18, frame.startAddress) + assertEquals(listOf(41, 0xFFFE, 0x1234), frame.registers.map { it.value }) + assertEquals("Показания T° 1", frame.registers.first().displayName) + assertTrue(frame.summary().contains("Трансформатор 1")) + } +} diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt new file mode 100644 index 0000000..a4c63c0 --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt @@ -0,0 +1,49 @@ +package ru.setcorp.setprotocol.legacycan + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LegacyCanTerminalTest { + @Test fun rotatingFormatRoundTripsAllWordCounts() { + val project = LegacyCanProjects.all.first { it.name == "Бальзам" } + val node = project.nodes.first() + for (values in listOf(listOf(0x1234), listOf(0x1234, 0xABCD), listOf(0x1234, 0xABCD, 0x8001))) { + val wire = LegacyCanTerminalProtocol.encodeWrite(project.format, node.rxId, 0x18, values) + val decoded = LegacyCanTerminalProtocol.decode(project.format, node, wire.canId, wire.data)!! + assertEquals(0x18, decoded.address) + assertEquals(values, decoded.presentValues.map { it.second }) + assertEquals(LegacyCanSource.FROM_DEVICE, decoded.source) + } + } + + @Test fun addressInIdentifierRoundTripsFourWords() { + val project = LegacyCanProjects.all.first { it.name == "23550.2" } + val node = project.nodes.first { it.name == "БКСС ГД" } + val values = listOf(1, 2, 0x7FFF, 0xFFFF) + val wire = LegacyCanTerminalProtocol.encodeWrite(project.format, node.txId, 0x7F, values) + val decoded = LegacyCanTerminalProtocol.decode(project.format, node, wire.canId, wire.data)!! + assertEquals(values, decoded.values) + assertEquals(LegacyCanSource.TO_DEVICE, decoded.source) + assertEquals(-1, LegacyCanTerminalProtocol.signedWord(decoded.values.last())) + } + + @Test fun unrelatedIdDoesNotDecode() { + val project = LegacyCanProjects.all.first() + assertNull(LegacyCanTerminalProtocol.decode(project.format, project.nodes.first(), 0x123, ByteArray(8))) + } + + @Test fun packetReducerUpdatesOnlyPresentRegisters() { + val packet = LegacyCanPacket(10, 5, listOf(11, 22, 33), LegacyCanSource.FROM_DEVICE) + val bank = LegacyCanTerminalProtocol.applyPacket(LegacyCanTerminalProtocol.emptyRegisterBank(), packet, 42) + assertEquals(listOf(11, 33), listOf(bank[10].value, bank[12].value)) + assertEquals(0, bank[11].value) + assertEquals(42, bank[12].revision) + } + + @Test fun catalogContainsAllLegacyProjectsAndNodes() { + assertEquals(9, LegacyCanProjects.all.size) + assertEquals(10, LegacyCanProjects.all.first { it.name == "23550.2" }.nodes.size) + assertEquals("Start", LegacyCanProjects.all.first { it.name == "Бальзам" }.commandNames[7]) + } +} diff --git a/c/set-protocol/src/balsam_can.c b/c/set-protocol/src/balsam_can.c new file mode 100644 index 0000000..53ea9f2 --- /dev/null +++ b/c/set-protocol/src/balsam_can.c @@ -0,0 +1,182 @@ +#include "balsam_can.h" + +#include +#include + +static uint16_t get_be16(const uint8_t *data) +{ + return (uint16_t)(((uint16_t)data[0] << 8) | data[1]); +} + +static size_t copy_name(const char *name, char *output, size_t output_size) +{ + size_t length = strlen(name); + if ((output != NULL) && (output_size != 0U)) { + size_t copied = length < output_size - 1U ? length : output_size - 1U; + memcpy(output, name, copied); + output[copied] = '\0'; + } + return length; +} + +static size_t indexed_name(const char *prefix, unsigned index, + char *output, size_t output_size) +{ + int length; + if ((output == NULL) || (output_size == 0U)) { + char scratch[64]; + length = snprintf(scratch, sizeof scratch, "%s %u", prefix, index); + } else { + length = snprintf(output, output_size, "%s %u", prefix, index); + } + return length > 0 ? (size_t)length : 0U; +} + +int balsam_can_is_id(uint32_t can_id) +{ + uint32_t relative = (can_id & 0x1FFFFFFFUL) - BALSAM_CAN_BASE_ID; + return relative < BALSAM_CAN_NODE_COUNT + || (relative >= BALSAM_CAN_DATA_OFFSET + && relative < BALSAM_CAN_DATA_OFFSET + BALSAM_CAN_NODE_COUNT); +} + +int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size, + balsam_can_frame_t *output) +{ + uint32_t relative; + uint16_t header; + if ((data == NULL) || (output == NULL)) return -1; + can_id &= 0x1FFFFFFFUL; + if (!balsam_can_is_id(can_id)) return 0; + if (size != BALSAM_CAN_DLC) return -2; + + relative = can_id - BALSAM_CAN_BASE_ID; + output->direction = relative >= BALSAM_CAN_DATA_OFFSET + ? BALSAM_CAN_FROM_NODE : BALSAM_CAN_TO_NODE; + output->device = (uint8_t)((relative & 0x0FU) + 1U); + header = get_be16(data); + output->present_mask = (uint8_t)((header >> 13) & 0x07U); + output->start_address = (uint16_t)(header & 0x1FFFU); + output->values[0] = get_be16(&data[2]); + output->values[1] = get_be16(&data[4]); + output->values[2] = get_be16(&data[6]); + return 1; +} + +const char *balsam_can_device_name(uint8_t device) +{ + static const char *const names[BALSAM_CAN_NODE_COUNT] = { + "Трансформатор 1", "Трансформатор 2", "Силовой блок 1", + "Силовой блок 2", "УМП 1", "УМП 2", "Двигатель", "ВЭП", + "Задатчик", "Узел 10", "Узел 11", "Узел 12", "Терминал" + }; + return (device >= 1U && device <= BALSAM_CAN_NODE_COUNT) + ? names[device - 1U] : "Неизвестный узел"; +} + +size_t balsam_can_register_name(uint8_t device, uint16_t address, + char *output, size_t output_size) +{ + if ((device == 1U || device == 2U) && address < 20U) + return indexed_name("Диагностика T°", address + 1U, output, output_size); + if ((device == 1U || device == 2U) && address >= 0x18U && address <= 0x2BU) + return indexed_name("Показания T°", address - 0x17U, output, output_size); + if ((device == 1U || device == 2U) && address >= 0x30U && address <= 0x43U) + return indexed_name("Аварийная уставка T°", address - 0x2FU, output, output_size); + if ((device == 1U || device == 2U) && address >= 0x48U && address <= 0x5BU) + return indexed_name("Предупредительная уставка T°", address - 0x47U, output, output_size); + + if ((device == 3U || device == 4U) && address < 16U) + return indexed_name("Диагностика T°", address + 1U, output, output_size); + if ((device == 3U || device == 4U) && address >= 0x18U && address <= 0x27U) + return indexed_name("Показания T°", address - 0x17U, output, output_size); + if ((device == 3U || device == 4U) && address == 0x28U) + return copy_name("Действующее Uвх1", output, output_size); + if ((device == 3U || device == 4U) && address == 0x29U) + return copy_name("Амплитудное Uвх1", output, output_size); + if ((device == 3U || device == 4U) && address == 0x2AU) + return copy_name("Действующее Uвх2", output, output_size); + if ((device == 3U || device == 4U) && address == 0x2BU) + return copy_name("Амплитудное Uвх2", output, output_size); + + if ((device == 5U || device == 6U) && address == 0U) + return copy_name("Диагностика Utr a", output, output_size); + if ((device == 5U || device == 6U) && address == 1U) + return copy_name("Диагностика Utr c", output, output_size); + if ((device == 5U || device == 6U) && address == 2U) + return copy_name("Диагностика Itr a", output, output_size); + if ((device == 5U || device == 6U) && address == 3U) + return copy_name("Диагностика Itr c", output, output_size); + if ((device == 5U || device == 6U) && address == 0x18U) + return copy_name("Действующее Utr", output, output_size); + if ((device == 5U || device == 6U) && address == 0x19U) + return copy_name("Амплитудное Utr", output, output_size); + if ((device == 5U || device == 6U) && address == 0x1AU) + return copy_name("Действующее Itr", output, output_size); + if ((device == 5U || device == 6U) && address == 0x1BU) + return copy_name("Амплитудное Itr", output, output_size); + if ((device == 5U || device == 6U) && address == 0x1CU) + return copy_name("Ток СИФУ, задание (mA*10)", output, output_size); + if ((device == 5U || device == 6U) && address == 0x1DU) + return copy_name("Ток СИФУ, обратная связь (mA*10)", output, output_size); + + if (device == 7U && address < 8U) + return indexed_name("Диагностика T°", address + 1U, output, output_size); + if (device == 7U && address >= 0x18U && address <= 0x1FU) + return indexed_name("Показания T°", address - 0x17U, output, output_size); + + if (device == 8U) { + static const char *const vep_names[] = { + "Диагностика 380В Ф1", "Диагностика 380В Ф2", + "Диагностика 31В Ф1", "Диагностика 31В Ф2", + "Диагностика 31В UC1", "Диагностика 31В UC2", + "Диагностика 24В ПУ", "Диагностика 27В ФА", + "Диагностика 24В ПК", "Диагностика 15В ДР", + "Диагностика +24В ДТ", "Диагностика -24В ДТ", + "Диагностика 24В ПМУ", "Диагностика T° 1", "Диагностика T° 2" + }; + static const char *const vep_values[] = { + "Показания 380В Ф1", "Показания 380В Ф2", "Показания 31В Ф1", + "Показания 31В Ф2", "Показания 31В UC1", "Показания 31В UC2", + "Показания 24В ПУ", "Показания 27В ФА", "Показания 24В ПК", + "Показания 15В ДР", "Показания +24В ДТ", "Показания -24В ДТ", + "Показания 24В ПМУ", "Показания T° 1", "Показания T° 2" + }; + if (address < sizeof vep_names / sizeof vep_names[0]) + return copy_name(vep_names[address], output, output_size); + if (address >= 0x18U && address < 0x18U + sizeof vep_values / sizeof vep_values[0]) + return copy_name(vep_values[address - 0x18U], output, output_size); + if (address == 0x10U) + return copy_name("Дискретные датчики (14 бит)", output, output_size); + } + + if (device == 9U && address == 0U) + return copy_name("Обороты ГЭД, об/мин", output, output_size); + if (device == 9U && address == 1U) + return copy_name("Обороты ГВ, об/мин", output, output_size); + if (device == 9U && address == 2U) + return copy_name("Лампы (8 бит)", output, output_size); + if (device == 9U && address == 3U) + return copy_name("Диоды (16 бит)", output, output_size); + if (device == 9U && address == 0x10U) + return copy_name("Кнопки (12 бит)", output, output_size); + + if (address == 0x16U) + return copy_name("Дискретные входы / кнопки", output, output_size); + if (address == 0x17U) + return copy_name("Состояние джамперов", output, output_size); + if (address == 0x60U) + return copy_name("Период быстрого CAN-цикла", output, output_size); + if (address == 0x61U) + return copy_name("Период медленного CAN-цикла", output, output_size); + if (address == 0x62U) + return copy_name("Яркость индикации", output, output_size); + if (address == 0x63U) + return copy_name("Период опроса OWEN", output, output_size); + if (address == 0x7EU) + return copy_name("Последний режим", output, output_size); + if (address == 0x7FU) + return copy_name("Команды", output, output_size); + + return copy_name("", output, output_size); +} diff --git a/c/set-protocol/src/pcan_abi.c b/c/set-protocol/src/pcan_abi.c index 55e8293..8094e42 100644 --- a/c/set-protocol/src/pcan_abi.c +++ b/c/set-protocol/src/pcan_abi.c @@ -3,6 +3,7 @@ #include #include "pcan_crc.h" +#include "balsam_can.h" #include "pcan_frame.h" #include "pcan_id.h" #include "gui_frame.h" @@ -60,6 +61,33 @@ uint16_t pcan_abi_crc16(const uint8_t *data, size_t size) return pcan_crc16(data, size); } +int pcan_abi_balsam_decode(uint32_t can_id, const uint8_t *data, size_t size, + pcan_abi_balsam_frame_t *output) +{ + balsam_can_frame_t decoded; + int status; + if (output == NULL) return -1; + status = balsam_can_decode(can_id, data, size, &decoded); + if (status != 1) return status; + output->device = decoded.device; + output->direction = decoded.direction; + output->present_mask = decoded.present_mask; + output->start_address = decoded.start_address; + memcpy(output->values, decoded.values, sizeof output->values); + return 1; +} + +const char *pcan_abi_balsam_device_name(uint8_t device) +{ + return balsam_can_device_name(device); +} + +size_t pcan_abi_balsam_register_name(uint8_t device, uint16_t address, + char *output, size_t output_size) +{ + return balsam_can_register_name(device, address, output, output_size); +} + size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags, uint32_t can_id, const uint8_t *data, uint8_t dlc, uint8_t *output, size_t output_size) diff --git a/c/set-protocol/tests/test_balsam_can.c b/c/set-protocol/tests/test_balsam_can.c new file mode 100644 index 0000000..d00fe2e --- /dev/null +++ b/c/set-protocol/tests/test_balsam_can.c @@ -0,0 +1,30 @@ +#include +#include + +#include "balsam_can.h" + +#define CHECK(x) do { if (!(x)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); return 1; \ +} } while (0) + +int main(void) +{ + const uint8_t packet[8] = { 0xE0, 0x18, 0x00, 0x29, 0xFF, 0xFE, 0x12, 0x34 }; + balsam_can_frame_t frame; + char name[64]; + + CHECK(balsam_can_is_id(0x00BA0010UL)); + CHECK(balsam_can_is_id(0x00BA001CUL)); + CHECK(!balsam_can_is_id(0x00BA001DUL)); + CHECK(balsam_can_decode(0x00BA0010UL, packet, sizeof packet, &frame) == 1); + CHECK(frame.device == 1U); + CHECK(frame.direction == BALSAM_CAN_FROM_NODE); + CHECK(frame.present_mask == 7U); + CHECK(frame.start_address == 0x18U); + CHECK(frame.values[0] == 41U && frame.values[1] == 0xFFFEU + && frame.values[2] == 0x1234U); + CHECK(balsam_can_register_name(1U, 0x18U, name, sizeof name) > 0U); + CHECK(strstr(name, "T° 1") != NULL); + CHECK(balsam_can_decode(0x00BA0010UL, packet, 7U, &frame) == -2); + return 0; +} diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py index a978283..2f54914 100644 --- a/c/set-protocol/tools/build_host.py +++ b/c/set-protocol/tools/build_host.py @@ -21,7 +21,7 @@ JNI_INCLUDES: list[Path] = [] SOURCES = [ ROOT / "src" / name for name in ( "set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c", - "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c", + "balsam_can.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c", "pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c", "pcan_gas.c", ) diff --git a/python/protocan/__init__.py b/python/protocan/__init__.py index cbba06a..d7d39f3 100644 --- a/python/protocan/__init__.py +++ b/python/protocan/__init__.py @@ -5,9 +5,11 @@ from .native import ( NativeGuiParser, NativeParser, NativeProtocol, NativeProtocolUnavailable, get_native_core, get_native_protocol, ) +from .balsam import BalsamFrame, BalsamRegister, decode as decode_balsam __all__ = [ "NativeCore", "NativeCoreUnavailable", "NativeFrame", "NativeGuiFrame", "NativeGuiParser", "NativeParser", "NativeProtocol", "NativeProtocolUnavailable", "get_native_core", "get_native_protocol", + "BalsamFrame", "BalsamRegister", "decode_balsam", ] diff --git a/python/protocan/balsam.py b/python/protocan/balsam.py new file mode 100644 index 0000000..1d602ff --- /dev/null +++ b/python/protocan/balsam.py @@ -0,0 +1,98 @@ +"""Balsam 167 legacy CAN register decoder backed by the shared C99 core.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .native import NativeProtocolUnavailable, get_native_protocol + + +BASE_ID = 0x00BA0000 +DATA_OFFSET = 0x10 +NODE_COUNT = 13 + + +@dataclass(frozen=True) +class BalsamRegister: + address: int + value: int + name: str + + @property + def signed_value(self) -> int: + return self.value if self.value < 0x8000 else self.value - 0x10000 + + +@dataclass(frozen=True) +class BalsamFrame: + can_id: int + device: int + device_name: str + from_device: bool + start_address: int + present_mask: int + registers: tuple[BalsamRegister, ...] + + @property + def summary(self) -> str: + direction = "данные" if self.from_device else "команда" + values = ", ".join( + "%s=0x%04X (%d)" % (item.name or "R%04X" % item.address, + item.value, item.signed_value) + for item in self.registers + ) or "нет отмеченных регистров" + return "BALZAM · %s · %s · %s" % (self.device_name, direction, values) + + +def is_balsam_id(can_id: int) -> bool: + relative = (can_id & 0x1FFFFFFF) - BASE_ID + return (0 <= relative < NODE_COUNT + or DATA_OFFSET <= relative < DATA_OFFSET + NODE_COUNT) + + +def decode(can_id: int, data: bytes, native=None) -> BalsamFrame | None: + """Decode one abstract Balsam frame: BE mask/address plus three BE words.""" + if native is None: + try: + native = get_native_protocol() + except NativeProtocolUnavailable: + return _decode_fallback(can_id, data) + status, decoded = native.balsam_decode(can_id, bytes(data)) + if status == 0: + return None + if status == -2: + raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes") + if status != 1 or decoded is None: + raise ValueError("invalid BALZAM CAN frame") + device, direction, mask, start, values = decoded + registers = tuple( + BalsamRegister(start + index, values[index], + native.balsam_register_name(device, start + index)) + for index in range(3) if mask & (4 >> index) + ) + return BalsamFrame(can_id & 0x1FFFFFFF, device, + native.balsam_device_name(device), direction == 1, + start, mask, registers) + + +def _decode_fallback(can_id: int, data: bytes) -> BalsamFrame | None: + if not is_balsam_id(can_id): + return None + if len(data) != 8: + raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes") + relative = (can_id & 0x1FFFFFFF) - BASE_ID + device = (relative & 0x0F) + 1 + header = int.from_bytes(data[:2], "big") + values = tuple(int.from_bytes(data[offset:offset + 2], "big") + for offset in (2, 4, 6)) + names = { + 1: "Трансформатор 1", 2: "Трансформатор 2", + 3: "Силовой блок 1", 4: "Силовой блок 2", 5: "УМП 1", 6: "УМП 2", + 7: "Двигатель", 8: "ВЭП", 9: "Задатчик", 13: "Терминал", + } + start, mask = header & 0x1FFF, (header >> 13) & 7 + registers = tuple(BalsamRegister(start + i, values[i], "") + for i in range(3) if mask & (4 >> i)) + return BalsamFrame(can_id & 0x1FFFFFFF, device, + names.get(device, "Узел %d" % device), + relative >= DATA_OFFSET, start, mask, registers) diff --git a/python/protocan/native.py b/python/protocan/native.py index 40c8f11..23aad9d 100644 --- a/python/protocan/native.py +++ b/python/protocan/native.py @@ -41,6 +41,16 @@ class _AbiGuiFrame(ctypes.Structure): ] +class _AbiBalsamFrame(ctypes.Structure): + _fields_ = [ + ("device", ctypes.c_uint8), + ("direction", ctypes.c_uint8), + ("present_mask", ctypes.c_uint8), + ("start_address", ctypes.c_uint16), + ("values", ctypes.c_uint16 * 3), + ] + + @dataclass(frozen=True) class NativeFrame: sequence: int @@ -118,6 +128,17 @@ class NativeProtocol: ] lib.pcan_abi_crc16.argtypes = [ctypes.c_void_p, ctypes.c_size_t] lib.pcan_abi_crc16.restype = ctypes.c_uint16 + lib.pcan_abi_balsam_decode.argtypes = [ + ctypes.c_uint32, ctypes.c_void_p, ctypes.c_size_t, + ctypes.POINTER(_AbiBalsamFrame), + ] + lib.pcan_abi_balsam_decode.restype = ctypes.c_int + lib.pcan_abi_balsam_device_name.argtypes = [ctypes.c_uint8] + lib.pcan_abi_balsam_device_name.restype = ctypes.c_char_p + lib.pcan_abi_balsam_register_name.argtypes = [ + ctypes.c_uint8, ctypes.c_uint16, ctypes.c_void_p, ctypes.c_size_t, + ] + lib.pcan_abi_balsam_register_name.restype = ctypes.c_size_t lib.pcan_abi_frame_encode.argtypes = [ ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t, @@ -178,6 +199,27 @@ class NativeProtocol: source = (ctypes.c_uint8 * len(data)).from_buffer_copy(data) if data else None return int(self.lib.pcan_abi_crc16(source, len(data))) + def balsam_decode(self, can_id: int, data: bytes): + source = (ctypes.c_uint8 * len(data)).from_buffer_copy(data) if data else None + output = _AbiBalsamFrame() + status = int(self.lib.pcan_abi_balsam_decode( + can_id, source, len(data), ctypes.byref(output))) + if status != 1: + return status, None + return status, (int(output.device), int(output.direction), + int(output.present_mask), int(output.start_address), + tuple(int(value) for value in output.values)) + + def balsam_device_name(self, device: int) -> str: + value = self.lib.pcan_abi_balsam_device_name(device) + return value.decode("utf-8") if value else "" + + def balsam_register_name(self, device: int, address: int) -> str: + output = ctypes.create_string_buffer(128) + self.lib.pcan_abi_balsam_register_name( + device, address, output, len(output)) + return output.value.decode("utf-8") + def encode(self, sequence: int, flags: int, can_id: int, data: bytes) -> bytes: if len(data) > 8: raise ValueError("DLC cannot exceed 8 bytes") diff --git a/python/protocan/protocan.py b/python/protocan/protocan.py index 8a2789e..7ed69e2 100644 --- a/python/protocan/protocan.py +++ b/python/protocan/protocan.py @@ -392,6 +392,11 @@ class Decoded: registers: Optional[List[tuple]] = None #: Замечания о нарушениях протокола warnings: List[str] = field(default_factory=list) + #: Имя прикладного протокола для GUI, когда это не ProtoCAN. + protocol: str = "ProtoCAN" + #: UI labels for protocols whose identifier is not a ProtoCAN bit field. + device_label: str = "" + message_label: str = "" def _ascii(data: bytes) -> str: From 6eaa47d3a8d5a36ad3459d19bac34793e56eb79a Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 18:52:36 +0300 Subject: [PATCH 10/37] =?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=D0=B8=D0=B9=20Python=20API=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20CAN=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/protocan/legacycan.py | 227 +++++++++++++++++++++++++++++++++ python/tests/test_legacycan.py | 39 ++++++ 2 files changed, 266 insertions(+) create mode 100644 python/protocan/legacycan.py create mode 100644 python/tests/test_legacycan.py diff --git a/python/protocan/legacycan.py b/python/protocan/legacycan.py new file mode 100644 index 0000000..2c3b405 --- /dev/null +++ b/python/protocan/legacycan.py @@ -0,0 +1,227 @@ +"""UI-independent model of the historical CAN_terminal protocol. + +The catalog is migrated from ``CAN_terminal/Projects.ini``. Both desktop +SETGUI and Android keep only presentation and transport code in the apps; +wire layouts and project metadata live in the shared templates repository. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from enum import Enum + + +class LegacyCanFormat(Enum): + ROTATING_THREE_WORDS = "rotating_three_words" + ADDRESS_IN_IDENTIFIER = "address_in_identifier" + + +class LegacyCanSource(Enum): + TO_DEVICE = "TX" + FROM_DEVICE = "RX" + + +@dataclass(frozen=True) +class LegacyCanPacket: + address: int + mask: int + values: tuple[int, ...] + source: LegacyCanSource + + @property + def present_values(self) -> tuple[tuple[int, int], ...]: + return tuple( + (self.address + index, value) + for index, value in enumerate(self.values) + if self.mask == 0xFF or self.mask & (4 >> index) + ) + + +@dataclass(frozen=True) +class LegacyCanWireFrame: + can_id: int + data: bytes + + +@dataclass(frozen=True) +class LegacyCanRegisterValue: + address: int + value: int = 0 + source: LegacyCanSource | None = None + revision: int = 0 + + +@dataclass(frozen=True) +class LegacyCanNode: + index: int + rs_address: int + can_address: int + rx_id: int + tx_id: int + name: str + + +@dataclass(frozen=True) +class LegacyCanProject: + name: str + format: LegacyCanFormat + base_id: int + id_offset: int + nodes: tuple[LegacyCanNode, ...] + command_names: tuple[str, ...] + + def node_for(self, can_id: int) -> LegacyCanNode | None: + routed = routing_id(self.format, can_id) + return next( + (node for node in self.nodes + if node.rx_id == routed or node.tx_id == routed), + None, + ) + + +def empty_register_bank() -> tuple[LegacyCanRegisterValue, ...]: + return tuple(LegacyCanRegisterValue(index) for index in range(128)) + + +def apply_packet( + bank: tuple[LegacyCanRegisterValue, ...], + packet: LegacyCanPacket, + revision: int, +) -> tuple[LegacyCanRegisterValue, ...]: + if len(bank) != 128: + raise ValueError("Банк должен содержать 128 регистров") + updates = dict(packet.present_values) + return tuple( + replace(item, value=updates[item.address], source=packet.source, + revision=revision) + if item.address in updates else item + for item in bank + ) + + +def routing_id(format_: LegacyCanFormat, can_id: int) -> int: + if format_ is LegacyCanFormat.ROTATING_THREE_WORDS: + return can_id & 0x1FFFFFFF + return can_id & 0x1FF00000 + + +def decode( + format_: LegacyCanFormat, + node: LegacyCanNode, + can_id: int, + data: bytes, +) -> LegacyCanPacket | None: + route = routing_id(format_, can_id) + if route == node.tx_id: + source = LegacyCanSource.TO_DEVICE + elif route == node.rx_id: + source = LegacyCanSource.FROM_DEVICE + else: + return None + if format_ is LegacyCanFormat.ROTATING_THREE_WORDS: + if len(data) != 8: + return None + mask = (data[4] >> 5) & 7 + address = ((data[4] & 0x1F) << 8) | data[5] + values = (_u16be(data, 6), _u16be(data, 0), _u16be(data, 2)) + return LegacyCanPacket(address, mask, values, source) + if not data or len(data) > 8 or len(data) % 2: + return None + return LegacyCanPacket( + can_id & 0x7F, + 0xFF, + tuple(_u16be(data, offset) for offset in range(0, len(data), 2)), + source, + ) + + +def encode_write( + format_: LegacyCanFormat, + can_id: int, + address: int, + values: tuple[int, ...] | list[int], +) -> LegacyCanWireFrame: + if not 0 <= address <= 127: + raise ValueError("Адрес регистра должен быть в диапазоне 0..127") + maximum = 4 if format_ is LegacyCanFormat.ADDRESS_IN_IDENTIFIER else 3 + if not 1 <= len(values) <= maximum: + raise ValueError(f"Нужно от 1 до {maximum} слов данных") + if any(not 0 <= value <= 0xFFFF for value in values): + raise ValueError("Значение должно быть в диапазоне 0..65535") + if format_ is LegacyCanFormat.ADDRESS_IN_IDENTIFIER: + data = b"".join(int(value).to_bytes(2, "big") for value in values) + return LegacyCanWireFrame((can_id & 0x1FF00000) + address, data) + padded = tuple(values) + (0,) * (3 - len(values)) + mask = (4, 6, 7)[len(values) - 1] + data = ( + padded[1].to_bytes(2, "big") + + padded[2].to_bytes(2, "big") + + bytes(((mask << 5) | (address >> 8), address & 0xFF)) + + padded[0].to_bytes(2, "big") + ) + return LegacyCanWireFrame(can_id & 0x1FFFFFFF, data) + + +def encode_command( + project: LegacyCanProject, + node: LegacyCanNode, + command_index: int, +) -> LegacyCanWireFrame: + if not 0 <= command_index <= 16: + raise ValueError("Номер команды должен быть в диапазоне 0..16") + value = 1 << command_index if command_index < 16 else 0 + return encode_write(project.format, node.rx_id, 127, (value,)) + + +def signed_word(value: int) -> int: + return value if value < 0x8000 else value - 0x10000 + + +def _u16be(data: bytes, offset: int) -> int: + return int.from_bytes(data[offset:offset + 2], "big") + + +_DEFAULT_COMMANDS = ( + "Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", + "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", + "Nothing at all", +) + + +def _commands(**changes: str) -> tuple[str, ...]: + result = list(_DEFAULT_COMMANDS) + for index, name in changes.items(): + result[int(index)] = name + return tuple(result) + + +def _project( + name: str, + specs: tuple[tuple[int, int, int, str], ...], + base_id: int = 0, + offset: int = 0x10, + format_: LegacyCanFormat = LegacyCanFormat.ROTATING_THREE_WORDS, + commands: tuple[str, ...] = _DEFAULT_COMMANDS, +) -> LegacyCanProject: + shift = 20 if format_ is LegacyCanFormat.ADDRESS_IN_IDENTIFIER else 0 + actual_offset = 1 << 28 if format_ is LegacyCanFormat.ADDRESS_IN_IDENTIFIER else offset + nodes = tuple( + LegacyCanNode(index, rs, can, base_id + (can << shift), + base_id + actual_offset + (can << shift), node_name) + for index, can, rs, node_name in specs + ) + return LegacyCanProject(name, format_, base_id, actual_offset, nodes, commands) + + +PROJECTS = ( + _project("Буксир", ((0,0,1,"УКСС СБ"),(1,1,2,"БКСС ГД"),(2,2,3,"УКСВЭП"),(3,3,4,"Задатчик")), base_id=0x00318200), + _project("СЭДБМ", ((0,0,0,"УКСС СК1 СБ1"),(1,1,1,"УКСС СК2 СБ1"),(2,2,2,"УКСС СК3 СБ1"),(3,3,3,"УКСС СК4 СБ1"),(4,4,4,"УКССВЭП СБ1"),(5,5,5,"Задатчик СБ1"),(6,6,6,"БТР ИТЭС"),(8,0x20,8,"УКСС СК1 СБ2"),(9,0x21,9,"УКСС СК2 СБ2"),(10,0x22,10,"УКСС СК3 СБ2"),(11,0x23,11,"УКСС СК4 СБ2"),(12,0x24,12,"УКССВЭП СБ2"),(13,0x25,13,"Задатчик СБ2"),(14,0x26,14,"УКСС БОИН"),(15,0x27,15,"УКСВЭП БОИН")), base_id=0x01051020, commands=_commands(**{"7":"Raw","8":"HiVolt"})), + _project("Ледокол", ((0,0,1,"УКСС БВ1 ПЧ1"),(8,1,2,"УКСС БВ1 ПЧ2"),(1,2,3,"УКСС БВ1 ПЧ1"),(9,3,4,"УКСС БВ2 ПЧ2"),(2,4,5,"УКСС БИ1 ПЧ1"),(10,5,6,"УКСС БИ1 ПЧ2"),(3,6,7,"УКСС БИ2 ПЧ1"),(11,7,8,"УКСС БИ2 ПЧ2"),(4,8,9,"УКССВЭП1 ПЧ1"),(12,9,10,"УКССВЭП1 ПЧ2"),(5,10,11,"УКССВЭП2 ПЧ1"),(13,11,12,"УКССВЭП2 ПЧ2")), base_id=0x001CE020, offset=-0x20, commands=_commands(**{"4":"Raw","5":"Read","6":"ExtLamp","7":"ExtLite","8":"No log"})), + _project("Бальзам", ((0,0,1,"БКСС Тр1"),(8,1,2,"БКСС Тр2"),(1,2,3,"УКСС СБ1"),(9,3,4,"УКСС СБ2"),(2,4,5,"УКСС УМП1"),(10,5,6,"УКСС УМП2"),(3,6,7,"БКСС ГД"),(4,7,9,"Задатчик"),(5,8,11,"УКСС ВЭП")), base_id=0x00BA0000, commands=_commands(**{"6":"Stop","7":"Start","8":"Init","9":"Tune","10":"Secret","11":"Light","12":"Raw"})), + _project("23550", ((0,0,1,"Задатчик"),(1,1,2,"Выносной пульт"),(2,2,3,"УКСВЭП"),(3,3,4,"БКСС ГД")), base_id=0x00235500, commands=_commands(**{"5":"Read","7":"Send","8":"-"})), + _project("23550.X", ((0,0,1,"Задатчик"),(1,1,2,"Выносной пульт"),(2,2,3,"УКСВЭП"),(3,3,4,"БКСС ГД")), format_=LegacyCanFormat.ADDRESS_IN_IDENTIFIER, commands=_commands(**{"5":"Read","7":"Send","8":"-"})), + _project("23550.2", ((0,0,1,"Задатчик"),(1,1,2,"Выносной пульт"),(2,2,3,"БКСС ГД"),(3,4,4,"УКСС СИ СБ1"),(4,6,6,"УКСС СВФ СБ1"),(5,8,8,"УКСВЭП СБ1"),(11,5,5,"УКСС СИ СБ2"),(12,7,7,"УКСС СВФ СБ2"),(13,9,9,"УКСВЭП СБ2"),(16,0x1F,16,"BroadCast")), format_=LegacyCanFormat.ADDRESS_IN_IDENTIFIER, commands=_commands(**{"5":"Calc","7":"Send"})), + _project("Янтарь", ((0,0,1,"УКСС БВ"),(1,1,2,"УКСС БИ1"),(2,2,3,"УКСС БИ2"),(3,3,4,"БКСС ГД"),(4,4,5,"УКСВЭП"),(5,5,6,"Задатчик"),(6,6,7,"Выносной пульт")), base_id=0x00213000), + _project("23550 БСУ", ((0,0,0,"БСУ1"),(1,1,1,"БСУ2")), base_id=0x0CEB0F1, offset=-0x10, commands=("-",) * 16 + ("Nothing at all",)), +) + diff --git a/python/tests/test_legacycan.py b/python/tests/test_legacycan.py new file mode 100644 index 0000000..ae3c004 --- /dev/null +++ b/python/tests/test_legacycan.py @@ -0,0 +1,39 @@ +from protocan.legacycan import ( + LegacyCanFormat, LegacyCanSource, PROJECTS, apply_packet, decode, + empty_register_bank, encode_command, encode_write, signed_word, +) + + +def test_catalog_matches_legacy_projects(): + assert [project.name for project in PROJECTS] == [ + "Буксир", "СЭДБМ", "Ледокол", "Бальзам", "23550", "23550.X", + "23550.2", "Янтарь", "23550 БСУ", + ] + assert len(next(project for project in PROJECTS if project.name == "СЭДБМ").nodes) == 15 + + +def test_rotating_three_word_codec_and_bank(): + project = next(project for project in PROJECTS if project.name == "Бальзам") + node = project.nodes[0] + wire = encode_write(project.format, node.rx_id, 24, (41, 0xFFFE, 0x1234)) + assert wire.data == bytes.fromhex("FF FE 12 34 E0 18 00 29") + packet = decode(project.format, node, wire.can_id, wire.data) + assert packet is not None + assert packet.present_values == ((24, 41), (25, 0xFFFE), (26, 0x1234)) + assert packet.source is LegacyCanSource.FROM_DEVICE + bank = apply_packet(empty_register_bank(), packet, 7) + assert bank[25].value == 0xFFFE + assert bank[25].revision == 7 + assert signed_word(bank[25].value) == -2 + + +def test_address_in_identifier_codec_and_command(): + project = next(project for project in PROJECTS if project.name == "23550.2") + node = project.nodes[2] + assert project.format is LegacyCanFormat.ADDRESS_IN_IDENTIFIER + wire = encode_write(project.format, node.tx_id, 17, (1, 2, 0xFFFF, 4)) + assert wire.can_id & 0x7F == 17 + assert wire.data == bytes.fromhex("00 01 00 02 FF FF 00 04") + command = encode_command(project, node, 7) + assert command.can_id & 0x7F == 127 + assert command.data == b"\x00\x80" From 940d9ada76eff5b5bd82a024bb07494170b1a253 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 18:59:54 +0300 Subject: [PATCH 11/37] =?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=D0=B8=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=20Periph28335?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/protocan/periph28335.py | 98 ++++++++++++++++++++++++++++++++ python/tests/test_periph28335.py | 23 ++++++++ 2 files changed, 121 insertions(+) create mode 100644 python/protocan/periph28335.py create mode 100644 python/tests/test_periph28335.py diff --git a/python/protocan/periph28335.py b/python/protocan/periph28335.py new file mode 100644 index 0000000..05a6c04 --- /dev/null +++ b/python/protocan/periph28335.py @@ -0,0 +1,98 @@ +"""Portable RS command helpers from Set_Terminal_28335. + +The byte order and CRC match ``DTrans.pas``/``UNiiefa.pas``. This module is +deliberately UI- and serial-port-independent so desktop and Android clients +can share the same request builders. +""" + +from __future__ import annotations + + +def crc16_modbus(data: bytes, crc: int = 0xFFFF) -> int: + for byte in data: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return crc & 0xFFFF + + +def with_crc(payload: bytes) -> bytes: + crc = crc16_modbus(payload) + return bytes(payload) + crc.to_bytes(2, "little") + + +def build_read_registers(controller: int, start: int, count: int) -> bytes: + _range("Адрес контроллера", controller, 0xFF) + _range("Начальный регистр", start, 0xFFFF) + if not 1 <= count <= 128 or start + count > 128: + raise ValueError("Диапазон регистров должен находиться в 0..127") + return with_crc(bytes((controller, 3)) + start.to_bytes(2, "big") + + count.to_bytes(2, "big")) + + +def build_write_register(controller: int, address: int, value: int) -> bytes: + _range("Адрес контроллера", controller, 0xFF) + _range("Адрес регистра", address, 127) + _range("Значение", value, 0xFFFF) + return with_crc(bytes((controller, 6)) + address.to_bytes(2, "big") + + value.to_bytes(2, "big")) + + +def build_command(controller: int, command_index: int) -> bytes: + if not 0 <= command_index <= 16: + raise ValueError("Номер команды должен быть в диапазоне 0..16") + value = 1 << command_index if command_index < 16 else 0 + return build_write_register(controller, 127, value) + + +def expected_read_response_size(count: int) -> int: + return count * 2 + 5 + + +def decode_read_response(data: bytes, count: int) -> tuple[int, ...]: + expected = expected_read_response_size(count) + if len(data) != expected: + raise ValueError(f"Ожидалось {expected} байт, получено {len(data)}") + if crc16_modbus(data[:-2]) != int.from_bytes(data[-2:], "little"): + raise ValueError("Ошибка CRC ответа") + # Historical replies have a three-byte header; registers are big-endian. + body = data[3:-2] + if len(body) != count * 2: + raise ValueError("Неверная длина данных ответа") + return tuple(int.from_bytes(body[offset:offset + 2], "big") + for offset in range(0, len(body), 2)) + + +def bits_lsb_first(value: int) -> tuple[bool, ...]: + _range("Значение", value, 0xFFFF) + return tuple(bool(value & (1 << bit)) for bit in range(16)) + + +def word_from_bits(bits) -> int: + values = tuple(bool(item) for item in bits) + if len(values) != 16: + raise ValueError("Должно быть ровно 16 бит") + return sum(1 << bit for bit, checked in enumerate(values) if checked) + + +def signed_word(value: int) -> int: + _range("Значение", value, 0xFFFF) + return value if value < 0x8000 else value - 0x10000 + + +def _range(name: str, value: int, maximum: int) -> None: + if not 0 <= value <= maximum: + raise ValueError(f"{name} вне диапазона 0..{maximum}") + + +PROJECT_COMMANDS = { + "По умолчанию": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "23470": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "-", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "23550": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "Send", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "23550.2": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Send", "Raw", "Beep", "", "", "", "", "Log", "Reset", "Nothing at all"), + "ICE 22220.1-3": ("Test", "Zero", "Save", "Def", "Calibr", "Read", "ExtLamp", "ExtLite", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "ICE 22220.4-5": ("Test", "Def", "Save", "Load", "Raw", "Read", "ExtLamp", "ExtLite", "No log", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "Бальзам 161": ("Test", "Zero", "Save", "Def", "Calibr", "Clbr 400", "Stop", "Start", "Init", "Secret", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "Бальзам 162": ("Test", "Def", "Save", "Load", "Calibr", "Secret", "Stop", "Start", "Init", "Tune", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "Бальзам 163": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Stop", "Start", "Init", "Tune", "Secret", "-", "-", "-", "-", "Reset", "Nothing at all"), +} diff --git a/python/tests/test_periph28335.py b/python/tests/test_periph28335.py new file mode 100644 index 0000000..5338089 --- /dev/null +++ b/python/tests/test_periph28335.py @@ -0,0 +1,23 @@ +from protocan.periph28335 import ( + bits_lsb_first, build_command, build_read_registers, + build_write_register, crc16_modbus, word_from_bits, +) + + +def test_read_request_matches_delphi_byte_order(): + request = build_read_registers(16, 24, 64) + assert request[:6] == bytes.fromhex("10 03 00 18 00 40") + assert int.from_bytes(request[-2:], "little") == crc16_modbus(request[:-2]) + + +def test_write_and_command_use_register_127(): + request = build_write_register(16, 7, 0x1234) + assert request[:6] == bytes.fromhex("10 06 00 07 12 34") + command = build_command(16, 15) + assert command[:6] == bytes.fromhex("10 06 00 7F 80 00") + + +def test_bits_keep_original_lsb_first_order(): + bits = bits_lsb_first(0x8005) + assert bits[0] and bits[2] and bits[15] + assert word_from_bits(bits) == 0x8005 From 1799b00167d3a29c139c3a37dbcd862de3a511b2 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 19:10:06 +0300 Subject: [PATCH 12/37] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=20TMS=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=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 --- .../kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt | 2 +- python/setprotocol/firmware_catalog.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt index ffdb56c..1148676 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt @@ -142,7 +142,7 @@ class FirmwareCatalogClient(private val userAgent: String) { require(fileName.matches(Regex("[A-Za-zА-Яа-яЁё0-9._ -]+\\.(bin|hex)", RegexOption.IGNORE_CASE))) { "Некорректное имя файла прошивки №${index + 1}" } - require(transport in setOf("rs485", "can", "stm32")) { "Некорректный канал прошивки №${index + 1}" } + require(transport in setOf("rs485", "can", "stm32", "tms")) { "Некорректный канал прошивки №${index + 1}" } val baseAddress = row.opt("baseAddress")?.takeUnless { it == JSONObject.NULL }?.toString()?.let(::parseAddress) add(FirmwareRelease( product, versionName, versionCode, imageUrl, fileName, sha256, diff --git a/python/setprotocol/firmware_catalog.py b/python/setprotocol/firmware_catalog.py index 2b1c2e9..ab7da72 100644 --- a/python/setprotocol/firmware_catalog.py +++ b/python/setprotocol/firmware_catalog.py @@ -9,7 +9,7 @@ from pathlib import Path from urllib.parse import urljoin, urlparse MAX_MANIFEST_BYTES = 128 * 1024 -SUPPORTED_TRANSPORTS = frozenset({"rs485", "can", "stm32"}) +SUPPORTED_TRANSPORTS = frozenset({"rs485", "can", "stm32", "tms"}) @dataclass(frozen=True) From 4f788d39d6bd00f0f2835ff2e06bdcc782c5792f Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 19:17:27 +0300 Subject: [PATCH 13/37] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D1=8E=D1=8E=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B2=20Legacy=20CAN=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/protocan/legacycan.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/protocan/legacycan.py b/python/protocan/legacycan.py index 2c3b405..070c13a 100644 --- a/python/protocan/legacycan.py +++ b/python/protocan/legacycan.py @@ -224,4 +224,3 @@ PROJECTS = ( _project("Янтарь", ((0,0,1,"УКСС БВ"),(1,1,2,"УКСС БИ1"),(2,2,3,"УКСС БИ2"),(3,3,4,"БКСС ГД"),(4,4,5,"УКСВЭП"),(5,5,6,"Задатчик"),(6,6,7,"Выносной пульт")), base_id=0x00213000), _project("23550 БСУ", ((0,0,0,"БСУ1"),(1,1,1,"БСУ2")), base_id=0x0CEB0F1, offset=-0x10, commands=("-",) * 16 + ("Nothing at all",)), ) - From 57622ccd66b1c4712a5b18744f38527c432d16ec Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 20:39:04 +0300 Subject: [PATCH 14/37] =?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 1b3a6318eb0d0ae56bbdf2187fbe64cf82b54561 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 21:06:40 +0300 Subject: [PATCH 15/37] 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 b08ea02a5c020f8edee84323b64cfec13a35949d Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 21:10:02 +0300 Subject: [PATCH 16/37] 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 5523f049ec8e6b6fcc09240dbeb959f7170804e0 Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sat, 5 Sep 2026 02:37:11 +0300 Subject: [PATCH 17/37] =?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 87f2f2f56bb42a5a65fd7c8c03f6c3b6fb516ffb Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sat, 5 Sep 2026 02:43:07 +0300 Subject: [PATCH 18/37] =?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 bada3bbe8dab0d67f648ca5668264e5e25dc07b5 Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sat, 5 Sep 2026 02:44:08 +0300 Subject: [PATCH 19/37] =?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 8596918bf7c800d4a4201bec2bd058381fc3999a Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sat, 5 Sep 2026 03:05:17 +0300 Subject: [PATCH 20/37] =?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 821202b655e1be00865bc804594cfe2325988ae9 Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sat, 5 Sep 2026 03:16:16 +0300 Subject: [PATCH 21/37] =?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 11007b070469fac206d03b45b01929520f5649da Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sat, 5 Sep 2026 03:43:22 +0300 Subject: [PATCH 22/37] =?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 d002b14e4e1d92ef365c274806d26f7492265c50 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 5 Sep 2026 11:37:03 +0300 Subject: [PATCH 23/37] 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 3d374e77779f00701411414bc33ec080bd9cb23e Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sun, 6 Sep 2026 00:11:25 +0300 Subject: [PATCH 24/37] =?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 ed92ec346e5a689a1018dd360bf8cc669ef083b8 Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sun, 6 Sep 2026 01:01:27 +0300 Subject: [PATCH 25/37] =?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 e262b0acddbcbdefd8b991581984974bb46cafbc Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sun, 6 Sep 2026 02:08:52 +0300 Subject: [PATCH 26/37] =?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 245d486a6394d3cd2344de920488fe6fca1a7eda Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sun, 6 Sep 2026 03:03:59 +0300 Subject: [PATCH 27/37] =?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²Cstorage

Порт: I²C write, write-read и delay.

can-sensor

Однокадровые SETCAN SETTINGS для 64-битных ROM датчиков.

CANsensor

Порт: готовый 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/EthernetWindows + Android + STM32F4

Порт: COM, SLCAN, SocketCAN, JNI или callbacks MCU.

+

SETProtocol

Единое C99-ядро: SET v2, ProtoCAN/GUI v1, телеметрия, firmware flow, GAS и стабильный ABI.

UART/USB/CAN/EthernetWindows + Android + STM32F4

Порт: COM, SLCAN, SocketCAN, JNI или callbacks MCU.

protocan-boot

Прошивка по ProtoCAN: A/B-слоты, resume, CRC32, verify и rollback.

CANfirmware

Порт: 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 @@ +
@@ -107,6 +108,477 @@ wire = core.encode(1, 1, 0x1234567, b"\xAA\xBB") frames = core.parser().feed(wire) + + + +