diff --git a/README.md b/README.md index 9e98a9d..d7e75a6 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,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. 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/README.md b/c/set-protocol/README.md index 84ea25a..ace75b2 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). + ## Структура | Каталог | Назначение | 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/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..326dc76 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt @@ -0,0 +1,43 @@ +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", true), D("D", 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, +) { + fun value(marker: TrendMarker): Double? = when (marker) { + TrendMarker.A -> a; TrendMarker.B -> b; TrendMarker.C -> c; TrendMarker.D -> d + } + 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) + } + fun positioned(bounds: PlotBounds): TrendMarkers = copy( + a = a ?: bounds.value(1.0 / 3, false), b = b ?: bounds.value(2.0 / 3, false), + c = c ?: bounds.value(2.0 / 3, true), d = d ?: bounds.value(1.0 / 3, true)) + fun reset(bounds: PlotBounds): TrendMarkers = copy(a = null, b = null, c = null, d = 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..580d523 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendModels.kt @@ -0,0 +1,133 @@ +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 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 байт" + else -> null + } + + /** 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) == 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) + +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..c7403a6 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSettingsJson.kt @@ -0,0 +1,89 @@ +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("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"), 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() + } + + 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..3c58740 --- /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].timestampMs.toDouble() + val times = DoubleArray(count) { (points[start + it].timestampMs.toDouble() - origin) / 1000.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..f334c56 --- /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(100.0, 50.0, 500.0, 100.0, 100.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/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/python/protocan/plot.py b/python/protocan/plot.py new file mode 100644 index 0000000..4a297c7 --- /dev/null +++ b/python/protocan/plot.py @@ -0,0 +1,150 @@ +"""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 + + @property + def horizontal(self) -> bool: + return self in (Marker.C, Marker.D) + + +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 + + 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, 1/3), (Marker.B, 2/3), (Marker.C, 2/3), (Marker.D, 1/3)): + 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).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/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..6234391 --- /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, 100, 50, 500, 100, 100, 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()