9 Commits

49 changed files with 2002 additions and 86 deletions

View File

@@ -13,6 +13,7 @@
templates/ templates/
c/ библиотеки на C99: заголовок, реализация, README, где есть — порт и тесты c/ библиотеки на C99: заголовок, реализация, README, где есть — порт и тесты
python/ модули на чистом Python 3.9+, только stdlib python/ модули на чистом Python 3.9+, только stdlib
tools/ общие инструменты сборки и выпуска прошивок
``` ```
Пошаговая раскладка нового проекта и выбор портов для STM32F103, STM32G431 Пошаговая раскладка нового проекта и выбор портов для STM32F103, STM32G431
@@ -48,6 +49,12 @@ templates/
| [`python/protocan`](python/protocan) | разбор ProtoCAN, транспортный кадр моста, кадр SETGUI, кодеки каталога | stdlib, Python 3.9+ | | [`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 | | [`python/protocan/trends.py`](python/protocan/trends.py) | общие настройки графиков, ограниченная история, ctypes-декодер GAS/raw CAN | stdlib, опционально SETProtocol DLL/SO |
### Инструменты
| Инструмент | Что делает |
|---|---|
| [`tools/firmware-publish`](tools/firmware-publish) | единый BAT и конфигурации для проверки и публикации `.hex` Keil / `.bin` CCS 12 в каталоге SETGUI |
Общие тренды для Android GUI и SETGUI: [формат JSON, C99-ядро и адаптеры](c/set-protocol/docs/GUI_TRENDS.md). Общие тренды для Android GUI и SETGUI: [формат JSON, C99-ядро и адаптеры](c/set-protocol/docs/GUI_TRENDS.md).
Общие масштабирование и маркеры: [C99, JNI и Python/Qt](c/set-protocol/docs/GUI_PLOT.md). Общие масштабирование и маркеры: [C99, JNI и Python/Qt](c/set-protocol/docs/GUI_PLOT.md).

80
RULES.md Normal file
View File

@@ -0,0 +1,80 @@
# Правила общего кроссплатформенного кода
Этот репозиторий — единственный источник общих алгоритмов для `GUI_Android`,
`SETGUI`, прошивок и будущих GUI. Копирование одной реализации между Kotlin,
Python, C# или другим языком запрещено.
## 1. Граница C-ядра и порта
В `c/set-protocol` на C99 обязательно размещаются:
- форматы кадров и идентификаторов, CRC/checksum, endian-преобразования;
- построение команд, разбор и проверка ответов;
- автоматы обмена, сегментация, повтор, таймаутные состояния без системных часов;
- общие вычисления, таблицы и каталоги, влияющие на поведение протокола;
- проверка образов прошивки и других бинарных форматов.
Порт на языке GUI содержит только:
- вызовы C через стабильный ABI (`ctypes`, JNI, P/Invoke, Swift FFI и т. п.);
- преобразование C-структур в модели языка без повторения алгоритма;
- работу с USB, COM, Bluetooth, SocketCAN и API операционной системы;
- жизненный цикл, потоки, разрешения, хранение настроек и UI;
- локализованный текст и чисто визуальные преобразования.
Порт не вычисляет CRC, не собирает wire-пакет и не разбирает его поля заново.
Если для функции C-ядро недоступно, приложение сообщает об ошибке сборки или
загрузки. Алгоритмический fallback на языке GUI запрещён: он снова создаёт две
версии протокола.
## 2. Разделение контроллеров
Профили контроллеров нельзя сливать по совпадению названия транспорта:
- **ПМ67 / TMS320F2812** — основной контроллер, собственные RS и CAN;
- **ПМ35 / TMS320F28335 periph** — отдельный контроллер и отдельный CAN для
настроечного терминала, а также собственный прямой RS232/485-протокол.
Выбор профиля выполняется в GUI, но выбранный профиль вызывает свой отдельный
модуль C-ядра. Наличие одной CAN-линии не даёт права удалить или подменить
другую.
## 3. Порядок изменения протокола
1. Добавить или изменить публичный заголовок и реализацию в
`c/set-protocol/include` и `c/set-protocol/src`.
2. Зафиксировать эталонные байты и ошибочные случаи в C-тесте.
3. При необходимости расширить `pcan_abi.h`, сохраняя бинарную совместимость.
4. Добавить тонкие порты в `ports/<platform>` и `python/`; в них не должно быть
второго кодека.
5. Одними и теми же векторами проверить C, Python и Android/JVM.
6. Собрать SETGUI и Android с одним commit submodule `templates`.
Изменение только в одном GUI считается незавершённым. Сначала меняется
`templates`, затем оба потребителя обновляют ссылку submodule на проверенный
commit.
## 4. Требования к C-ядру
- C99, без зависимости от GUI и конкретной ОС.
- Буферы и их размеры передаются явно; владение памятью остаётся у вызывающего.
- Для MCU основная логика не требует heap, исключений или файловой системы.
- Endian и размеры целых задаются через `stdint.h`, структуры wire-формата не
передаются через ABI без явного стабильного представления.
- Экспорт shared library идёт через `PCAN_ABI_API`; существующие символы не
меняют смысл и сигнатуру.
- Ошибки возвращаются детерминированным кодом и тестируются наряду с успехом.
## 5. Проверка на ревью
Изменение нельзя принимать, если ответ «да» хотя бы на один вопрос:
- появился одинаковый CRC/parser/builder в двух языках;
- UI знает byte offset, endian или служебный байт wire-протокола;
- Python и Kotlin содержат одинаковую таблицу команд, влияющую на обмен;
- добавлен тихий fallback, поведение которого отличается от C;
- обновлён один GUI без обновления и теста `templates`;
- ПМ67 и ПМ35 сведены к одному соединению или одному состоянию контроллера.
Текущее состояние и очередь переноса перечислены в
[`doc/CROSS_PLATFORM_AUDIT.md`](doc/CROSS_PLATFORM_AUDIT.md).

View File

@@ -18,6 +18,7 @@ set(SETPROTOCOL_V2_SOURCES
# Совместимые ProtoCAN/SETGUI v1 форматы переходного периода. # Совместимые ProtoCAN/SETGUI v1 форматы переходного периода.
set(SETPROTOCOL_LEGACY_SOURCES set(SETPROTOCOL_LEGACY_SOURCES
src/balsam_can.c src/balsam_can.c
src/periph28335.c
src/gui_catalog.c src/gui_catalog.c
src/gui_frame.c src/gui_frame.c
src/pcan_abi.c src/pcan_abi.c
@@ -98,6 +99,9 @@ if(SETP_BUILD_TESTS)
add_executable(test_balsam_can tests/test_balsam_can.c) add_executable(test_balsam_can tests/test_balsam_can.c)
target_link_libraries(test_balsam_can PRIVATE setprotocol_static) target_link_libraries(test_balsam_can PRIVATE setprotocol_static)
add_test(NAME legacy_balsam_can COMMAND test_balsam_can) add_test(NAME legacy_balsam_can COMMAND test_balsam_can)
add_executable(test_periph28335 tests/test_periph28335.c)
target_link_libraries(test_periph28335 PRIVATE setprotocol_static)
add_test(NAME shared_periph28335 COMMAND test_periph28335)
add_executable(test_trends tests/test_trends.c) add_executable(test_trends tests/test_trends.c)
target_link_libraries(test_trends PRIVATE setprotocol_static) target_link_libraries(test_trends PRIVATE setprotocol_static)
add_test(NAME shared_trends COMMAND test_trends) add_test(NAME shared_trends COMMAND test_trends)

View File

@@ -21,6 +21,12 @@ TCP, UART/DMA и аппаратный CAN подключаются портам
Масштабирование, координаты и измерительные маркеры Android/SETGUI используют Масштабирование, координаты и измерительные маркеры Android/SETGUI используют
общий `set_plot.c`: [границы модулей, ABI и проверки](docs/GUI_PLOT.md). общий `set_plot.c`: [границы модулей, ABI и проверки](docs/GUI_PLOT.md).
Прямой терминал ПМ35/TMS320F28335 использует единый wire contract
`Set_Terminal_28335`: функции MODBUS RTU `03/06`, 128 регистров и CRC16.
Эталонные адаптеры находятся в
[`python/protocan/periph28335.py`](../../python/protocan/periph28335.py) и
[`ports/android/kotlin/.../periph28335`](ports/android/kotlin/ru/setcorp/setprotocol/periph28335).
## Структура ## Структура
| Каталог | Назначение | | Каталог | Назначение |

View File

@@ -3,7 +3,8 @@
Численная логика находится в `include/set_plot.h` и `src/set_plot.c`. Численная логика находится в `include/set_plot.h` и `src/set_plot.c`.
Это модуль C99 без Qt, Android, транспорта, динамической памяти и глобального Это модуль C99 без Qt, Android, транспорта, динамической памяти и глобального
состояния. Он собирается в существующую библиотеку SETProtocol; отдельная DLL состояния. Он собирается в существующую библиотеку SETProtocol; отдельная DLL
для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 1`. для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 1`;
новые операции добавляются без изменения существующих значений и сигнатур.
| Общее в templates | Адаптер приложения | | Общее в templates | Адаптер приложения |
|---|---| |---|---|
@@ -13,6 +14,7 @@
| Перевод значения в долю экрана и обратно, инверсия Y | Canvas/QPainter и оформление шкал | | Перевод значения в долю экрана и обратно, инверсия Y | Canvas/QPainter и оформление шкал |
| Перемещение маркера от начальной координаты, ограничение видимой областью | Захват линии пальцем/мышью, редактор положения | | Перемещение маркера от начальной координаты, ограничение видимой областью | Захват линии пальцем/мышью, редактор положения |
| Разность BA, DC и множитель единиц, шаги шкалы 1/2/5 | Подписи, цвета, миллисекунды/герцы/единицы сигнала | | Разность BA, DC и множитель единиц, шаги шкалы 1/2/5 | Подписи, цвета, миллисекунды/герцы/единицы сигнала |
| Проверка абсолютных границ X/Y для фиксации осей | Диалог ввода и хранение отдельных границ времени/FFT |
| Модели маркеров и их размещение в Kotlin/Python-портах | Жизненный цикл экрана, очистка и выбор источника | | Модели маркеров и их размещение в Kotlin/Python-портах | Жизненный цикл экрана, очистка и выбор источника |
Модель маркеров: A/B — координаты X и вертикальные линии во всю высоту поля; Модель маркеров: A/B — координаты X и вертикальные линии во всю высоту поля;
@@ -59,8 +61,9 @@ SETGUI: `ui/plot_interaction.py` адаптирует общий модуль к
используются исходные метки приёма. Дискретные дорожки имеют общую X-шкалу, используются исходные метки приёма. Дискретные дорожки имеют общую X-шкалу,
а маркеры уровня Y относятся к аналоговому полю. а маркеры уровня Y относятся к аналоговому полю.
Расчёт FFT не является частью этого модуля. Android вычисляет спектр через Расчёт FFT не является частью этого модуля. Android и SETGUI вычисляют спектр через
`set_spectrum.c`; вкладка спектра SETGUI получает готовые уровни в дБмВ от `set_spectrum.c`; там же находится общий поиск доминирующего узкополосного пика,
а трёхсекундный таймер его отображения остаётся состоянием GUI. Вкладка спектра SETGUI получает готовые уровни в дБмВ от
прибора. Общая интерактивная часть не меняет эти данные или единицы. прибора. Общая интерактивная часть не меняет эти данные или единицы.
## Проверка и сборка ## Проверка и сборка

View File

@@ -12,7 +12,7 @@
| `include/set_trends.h`, `src/set_trends.c` | C99: фильтрация GAS/raw CAN, signed/unsigned word, payload подписки SET GUI; использует `pcan_id` и ABI export macro | | `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/kotlin/ru/setcorp/setprotocol/trends/` | Модель, валидация JSON, ограниченная история, GAS_WATCH; JVM + org.json, без Android/Compose |
| `ports/android/setprotocol_jni.c` | Только преобразование JNI-аргументов | | `ports/android/setprotocol_jni.c` | Только преобразование JNI-аргументов |
| `python/protocan/trends.py` | Модель, JSON, история и `NativeTrends`; Python 3.9+, stdlib, без Qt | | `python/protocan/trends.py` | Модель, JSON, история и `NativeTrends` (word/CAN/GAS request/ack/data); Python 3.9+, stdlib, без Qt |
| `tests/fixtures/trends-v1.json` | Один образец для тестов обоих GUI и обмена настройками | | `tests/fixtures/trends-v1.json` | Один образец для тестов обоих GUI и обмена настройками |
## Формат файла ## Формат файла
@@ -44,6 +44,8 @@ UTF-8 JSON: `format = "setflash-trends"`, `version = 1`, `profiles` — слов
CAN — пассивный приём, без записи GAS и автоматической отправки запросов. CAN — пассивный приём, без записи GAS и автоматической отправки запросов.
GAS принимает только входящие FROM_DEVICE; TX/RTR/ошибки исключены. GAS принимает только входящие FROM_DEVICE; TX/RTR/ошибки исключены.
Полный `GAS_WATCH_DATA`, включая 32-битную метку прибора, разбирается функцией
`set_trend_watch_decode`; UI не знает смещений полей и endian.
SET GUI оформляет подписку в порядке адресов. ACK должен подтвердить весь SET GUI оформляет подписку в порядке адресов. ACK должен подтвердить весь
список: при частичном принятии нельзя определить пропущенные адреса, поэтому список: при частичном принятии нельзя определить пропущенные адреса, поэтому
строить график по смещённым индексам запрещено. При паузе порт приложения строить график по смещённым индексам запрещено. При паузе порт приложения

View File

@@ -73,6 +73,34 @@ PCAN_ABI_API const char *pcan_abi_balsam_device_name(uint8_t device);
PCAN_ABI_API size_t pcan_abi_balsam_register_name( PCAN_ABI_API size_t pcan_abi_balsam_register_name(
uint8_t device, uint16_t address, char *output, size_t output_size); uint8_t device, uint16_t address, char *output, size_t output_size);
/* PM35/TMS320F28335 direct RS232/485 register terminal. */
PCAN_ABI_API uint16_t pcan_abi_periph28335_crc16(
const uint8_t *data, size_t size);
PCAN_ABI_API size_t pcan_abi_periph28335_append_crc(
const uint8_t *payload, size_t payload_size,
uint8_t *output, size_t output_size);
PCAN_ABI_API size_t pcan_abi_periph28335_build_read(
uint8_t controller, uint16_t start, uint16_t count,
uint8_t *output, size_t output_size);
PCAN_ABI_API size_t pcan_abi_periph28335_build_write(
uint8_t controller, uint16_t address, uint16_t value,
uint8_t *output, size_t output_size);
PCAN_ABI_API size_t pcan_abi_periph28335_build_command(
uint8_t controller, uint8_t command_index,
uint8_t *output, size_t output_size);
PCAN_ABI_API size_t pcan_abi_periph28335_expected_read_size(uint16_t count);
PCAN_ABI_API int pcan_abi_periph28335_decode_read(
const uint8_t *data, size_t size, uint8_t controller, uint16_t count,
uint16_t *output, size_t output_count);
PCAN_ABI_API int pcan_abi_periph28335_validate_write(
const uint8_t *response, size_t response_size,
const uint8_t *request, size_t request_size);
PCAN_ABI_API size_t pcan_abi_periph28335_project_count(void);
PCAN_ABI_API const char *pcan_abi_periph28335_project_name(
size_t project_index);
PCAN_ABI_API const char *pcan_abi_periph28335_command_name(
size_t project_index, size_t command_index);
PCAN_ABI_API size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags, PCAN_ABI_API size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags,
uint32_t can_id, uint32_t can_id,
const uint8_t *data, uint8_t dlc, const uint8_t *data, uint8_t dlc,

View File

@@ -0,0 +1,68 @@
/**
* @file periph28335.h
* @brief Shared PM35/TMS320F28335 register protocol.
*
* The protocol is independent from PM67/TMS320F2812 CAN and RS channels.
* It implements the direct RS232/485 register terminal used by PM35.
*/
#ifndef PERIPH28335_H
#define PERIPH28335_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define PERIPH28335_REGISTER_COUNT 128U
#define PERIPH28335_REQUEST_SIZE 8U
#define PERIPH28335_COMMAND_COUNT 17U
typedef enum {
PERIPH28335_OK = 0,
PERIPH28335_ERROR_ARGUMENT = -1,
PERIPH28335_ERROR_RANGE = -2,
PERIPH28335_ERROR_LENGTH = -3,
PERIPH28335_ERROR_CRC = -4,
PERIPH28335_ERROR_HEADER = -5,
PERIPH28335_ERROR_CAPACITY = -6
} periph28335_status_t;
uint16_t periph28335_crc16_modbus(const uint8_t *data, size_t size);
size_t periph28335_append_crc(const uint8_t *payload, size_t payload_size,
uint8_t *output, size_t output_size);
size_t periph28335_build_read_registers(
uint8_t controller, uint16_t start, uint16_t count,
uint8_t *output, size_t output_size);
size_t periph28335_build_write_register(
uint8_t controller, uint16_t address, uint16_t value,
uint8_t *output, size_t output_size);
size_t periph28335_build_command(
uint8_t controller, uint8_t command_index,
uint8_t *output, size_t output_size);
size_t periph28335_expected_read_response_size(uint16_t count);
int periph28335_decode_read_response(
const uint8_t *data, size_t size, uint8_t controller, uint16_t count,
uint16_t *output, size_t output_count);
int periph28335_validate_write_response(
const uint8_t *response, size_t response_size,
const uint8_t *request, size_t request_size);
size_t periph28335_project_count(void);
const char *periph28335_project_name(size_t project_index);
const char *periph28335_command_name(size_t project_index,
size_t command_index);
#ifdef __cplusplus
}
#endif
#endif /* PERIPH28335_H */

View File

@@ -17,10 +17,11 @@ enum set_plot_operation {
SET_PLOT_DRAG = 4, /* initial,deltaPixels,length,low,high,inverted -> clamped value */ SET_PLOT_DRAG = 4, /* initial,deltaPixels,length,low,high,inverted -> clamped value */
SET_PLOT_TICK_STEP = 5, /* range,lengthPixels -> nice step */ SET_PLOT_TICK_STEP = 5, /* range,lengthPixels -> nice step */
SET_PLOT_DELTA = 6, /* A,B,multiplier -> (B-A)*multiplier */ SET_PLOT_DELTA = 6, /* A,B,multiplier -> (B-A)*multiplier */
SET_PLOT_DB_DELTA = 7 /* A,B -> 20*log10(abs(B/A)); zero is invalid */ SET_PLOT_DB_DELTA = 7, /* A,B -> 20*log10(abs(B/A)); zero is invalid */
SET_PLOT_LIMITS = 8 /* xMin,xMax,yMin,yMax -> validated unchanged limits */
}; };
/** Version of this plot ABI, independently of the transport ABI. */ /** Version of this additive plot ABI, independently of the transport ABI. */
PCAN_ABI_API uint32_t set_plot_abi_version(void); PCAN_ABI_API uint32_t set_plot_abi_version(void);
/** Evaluates one operation. Returns output count, or 0 for invalid arguments. /** Evaluates one operation. Returns output count, or 0 for invalid arguments.
* TRANSFORM rejects malformed viewports; invalid gesture values return the * TRANSFORM rejects malformed viewports; invalid gesture values return the

View File

@@ -28,6 +28,15 @@ PCAN_ABI_API int set_spectrum_analyze(const double *times, const double *values,
size_t max_size, int window, int filter, double low_hz, double high_hz, int remove_mean, size_t max_size, int window, int filter, double low_hz, double high_hz, int remove_mean,
double *amplitudes, size_t capacity, double *meta); double *amplitudes, size_t capacity, double *meta);
/** Find the strongest non-DC local maximum above both the absolute floor and
* relative_threshold * median(non-DC amplitudes). The caller supplies scratch
* storage of at least count-1 doubles. peak = {frequency_hz, amplitude}.
* Returns 1 when found, 0 when no narrow-band peak exists, -1 on invalid input.
*/
PCAN_ABI_API int set_spectrum_dominant_peak(const double *amplitudes, size_t count,
double bin_hz, double relative_threshold, double absolute_floor,
double *scratch, size_t scratch_capacity, double *peak, size_t peak_capacity);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif

View File

@@ -48,6 +48,13 @@ PCAN_ABI_API int set_trend_watch_ack(
PCAN_ABI_API int set_trend_watch_values( PCAN_ABI_API int set_trend_watch_values(
const uint8_t *payload, size_t size, uint16_t *words, size_t capacity); const uint8_t *payload, size_t size, uint16_t *words, size_t capacity);
/** Decode the complete GAS_WATCH_DATA payload including its device timestamp.
* This is the preferred ABI for GUI ports; the older values-only symbol remains
* available for binary compatibility.
*/
PCAN_ABI_API int set_trend_watch_decode(const uint8_t *payload, size_t size,
uint32_t *timestamp_ms, uint16_t *words, size_t capacity);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif

View File

@@ -6,6 +6,7 @@
#define SETPROTOCOL_H #define SETPROTOCOL_H
#include "balsam_can.h" #include "balsam_can.h"
#include "periph28335.h"
/* Основной SET protocol v2. */ /* Основной SET protocol v2. */
#include "set_protocol.h" #include "set_protocol.h"

View File

@@ -13,6 +13,7 @@ LOCAL_SRC_FILES := \
../../src/set_trends.c \ ../../src/set_trends.c \
../../src/set_spectrum.c \ ../../src/set_spectrum.c \
../../src/balsam_can.c \ ../../src/balsam_can.c \
../../src/periph28335.c \
../../src/gui_catalog.c \ ../../src/gui_catalog.c \
../../src/gui_frame.c \ ../../src/gui_frame.c \
../../src/pcan_abi.c \ ../../src/pcan_abi.c \

View File

@@ -175,7 +175,9 @@ object CanBridgeProtocol {
stats = Stats( stats = Stats(
frames = values[0], frames = values[0],
crcErrors = values[1], crcErrors = values[1],
resyncBytes = values[2] + values[3], // The former Kotlin parser counted one resync step
// for a rejected CRC frame in addition to stray bytes.
resyncBytes = values[1] + values[2] + values[3],
sequenceLost = values[4], sequenceLost = values[4],
) )
} }

View File

@@ -4,7 +4,10 @@ package ru.setcorp.setprotocol
object NativeSetProtocol { object NativeSetProtocol {
val available: Boolean by lazy { val available: Boolean by lazy {
runCatching { runCatching {
System.loadLibrary("setprotocol") val hostLibrary = System.getProperty("setprotocol.library")
?: System.getProperty("setplot.library")
if (hostLibrary != null) System.load(hostLibrary)
else System.loadLibrary("setprotocol")
nativeAbiVersion() == 1 nativeAbiVersion() == 1
}.getOrDefault(false) }.getOrDefault(false)
} }
@@ -13,12 +16,18 @@ object NativeSetProtocol {
/** {status, N, Fs, jitter, amplitudes...}; status != 0 has no amplitudes. */ /** {status, N, Fs, jitter, amplitudes...}; status != 0 has no amplitudes. */
external fun nativeSpectrum(times: DoubleArray, values: DoubleArray, maxSize: Int, external fun nativeSpectrum(times: DoubleArray, values: DoubleArray, maxSize: Int,
window: Int, filter: Int, lowHz: Double, highHz: Double, removeMean: Boolean): DoubleArray? window: Int, filter: Int, lowHz: Double, highHz: Double, removeMean: Boolean): DoubleArray?
/** {frequencyHz, amplitude}, empty when no narrow-band peak is present. */
external fun nativeSpectrumPeak(amplitudes: DoubleArray, binHz: Double,
relativeThreshold: Double, absoluteFloor: Double): DoubleArray?
external fun nativeTrendCanValue( external fun nativeTrendCanValue(
source: Int, address: Long, deviceType: Int, device: Int, byteOffset: Int, source: Int, address: Long, deviceType: Int, device: Int, byteOffset: Int,
extended: Boolean, signed: Boolean, canId: Long, flags: Int, input: ByteArray, extended: Boolean, signed: Boolean, canId: Long, flags: Int, input: ByteArray,
): Int ): Int
external fun nativeTrendWatchRequest(period: Int, addresses: IntArray): ByteArray? external fun nativeTrendWatchRequest(period: Int, addresses: IntArray): ByteArray?
external fun nativeTrendWatchAck(input: ByteArray, period: Int, count: Int): Boolean
external fun nativeTrendWatchValues(input: ByteArray): IntArray? external fun nativeTrendWatchValues(input: ByteArray): IntArray?
/** {unsigned timestampMs, word0, ...}. */
external fun nativeTrendWatchDecode(input: ByteArray): LongArray?
external fun nativePackId( external fun nativePackId(
priority: Int, priority: Int,
route: Int, route: Int,
@@ -32,6 +41,17 @@ object NativeSetProtocol {
external fun nativeBalsamDecode(canId: Long, input: ByteArray): IntArray? external fun nativeBalsamDecode(canId: Long, input: ByteArray): IntArray?
external fun nativeBalsamDeviceName(device: Int): String external fun nativeBalsamDeviceName(device: Int): String
external fun nativeBalsamRegisterName(device: Int, address: Int): String external fun nativeBalsamRegisterName(device: Int, address: Int): String
external fun nativePeriph28335Crc16(input: ByteArray): Int
external fun nativePeriph28335AppendCrc(input: ByteArray): ByteArray?
external fun nativePeriph28335BuildRead(controller: Int, start: Int, count: Int): ByteArray?
external fun nativePeriph28335BuildWrite(controller: Int, address: Int, value: Int): ByteArray?
external fun nativePeriph28335BuildCommand(controller: Int, commandIndex: Int): ByteArray?
external fun nativePeriph28335ExpectedReadSize(count: Int): Int
external fun nativePeriph28335DecodeRead(input: ByteArray, controller: Int, count: Int): IntArray?
external fun nativePeriph28335ValidateWrite(response: ByteArray, request: ByteArray): Int
external fun nativePeriph28335ProjectCount(): Int
external fun nativePeriph28335ProjectName(projectIndex: Int): String?
external fun nativePeriph28335CommandName(projectIndex: Int, commandIndex: Int): String?
external fun nativeEncodeFrame( external fun nativeEncodeFrame(
sequence: Int, sequence: Int,
flags: Int, flags: Int,

View File

@@ -0,0 +1,117 @@
package ru.setcorp.setprotocol.periph28335
import ru.setcorp.setprotocol.NativeSetProtocol
/** Kotlin UI adapter; all PM35 wire logic and the command catalog live in C99. */
object Periph28335Protocol {
const val REGISTER_COUNT = 128
const val DEFAULT_CONTROLLER = 16
const val DEFAULT_BAUD_RATE = 115_200
val projectCommands: Map<String, List<String>> by lazy {
requireNative()
buildMap {
repeat(NativeSetProtocol.nativePeriph28335ProjectCount()) { project ->
val name = requireNotNull(
NativeSetProtocol.nativePeriph28335ProjectName(project),
) { "Повреждён каталог проектов ПМ35" }
put(name, List(17) { command ->
requireNotNull(
NativeSetProtocol.nativePeriph28335CommandName(project, command),
) { "Повреждён каталог команд ПМ35" }
})
}
}
}
fun crc16Modbus(data: ByteArray, initial: Int = 0xFFFF): Int {
require(initial == 0xFFFF) {
"Произвольное начальное значение CRC не входит в протокол ПМ35"
}
requireNative()
return NativeSetProtocol.nativePeriph28335Crc16(data)
}
fun withCrc(payload: ByteArray): ByteArray {
requireNative()
return requireNotNull(NativeSetProtocol.nativePeriph28335AppendCrc(payload)) {
"SETProtocol отклонил данные ПМ35"
}
}
fun buildReadRegisters(controller: Int, start: Int, count: Int): ByteArray {
requireRange("Адрес контроллера", controller, 0xFF)
requireRange("Начальный регистр", start, 0xFFFF)
require(count in 1..REGISTER_COUNT && start + count <= REGISTER_COUNT) {
"Диапазон регистров должен находиться в 0..127"
}
requireNative()
return requireNotNull(
NativeSetProtocol.nativePeriph28335BuildRead(controller, start, count),
) { "SETProtocol отклонил запрос чтения ПМ35" }
}
fun buildWriteRegister(controller: Int, address: Int, value: Int): ByteArray {
requireRange("Адрес контроллера", controller, 0xFF)
requireRange("Адрес регистра", address, REGISTER_COUNT - 1)
requireRange("Значение", value, 0xFFFF)
requireNative()
return requireNotNull(
NativeSetProtocol.nativePeriph28335BuildWrite(controller, address, value),
) { "SETProtocol отклонил запрос записи ПМ35" }
}
fun buildCommand(controller: Int, commandIndex: Int): ByteArray {
require(commandIndex in 0..16) { "Номер команды должен быть в диапазоне 0..16" }
requireNative()
return requireNotNull(
NativeSetProtocol.nativePeriph28335BuildCommand(controller, commandIndex),
) { "SETProtocol отклонил команду ПМ35" }
}
fun expectedReadResponseSize(count: Int): Int {
require(count in 1..REGISTER_COUNT)
requireNative()
return NativeSetProtocol.nativePeriph28335ExpectedReadSize(count)
}
fun decodeReadResponse(data: ByteArray, controller: Int, count: Int): List<Int> {
val expected = expectedReadResponseSize(count)
require(data.size == expected) { "Ожидалось $expected байт, получено ${data.size}" }
requireNative()
return requireNotNull(
NativeSetProtocol.nativePeriph28335DecodeRead(data, controller, count),
) { "Повреждён ответ ПМ35: заголовок, длина или CRC" }.toList()
}
fun validateWriteResponse(data: ByteArray, request: ByteArray): Boolean {
requireNative()
return NativeSetProtocol.nativePeriph28335ValidateWrite(data, request) == 0
}
// Presentation-only conversions stay in the GUI port; they do not define wire bytes.
fun bitsLsbFirst(value: Int): List<Boolean> {
requireRange("Значение", value, 0xFFFF)
return (0 until 16).map { bit -> value and (1 shl bit) != 0 }
}
fun wordFromBits(bits: List<Boolean>): Int {
require(bits.size == 16) { "Должно быть ровно 16 бит" }
return bits.foldIndexed(0) { bit, value, checked ->
if (checked) value or (1 shl bit) else value
}
}
fun signedWord(value: Int): Int {
requireRange("Значение", value, 0xFFFF)
return if (value < 0x8000) value else value - 0x10000
}
private fun requireNative() {
check(NativeSetProtocol.available) { "Нативное ядро SETProtocol недоступно" }
}
private fun requireRange(name: String, value: Int, maximum: Int) {
require(value in 0..maximum) { "$name вне диапазона 0..$maximum" }
}
}

View File

@@ -10,37 +10,28 @@ object GuiGasWatch {
fun request(periodMs: Int, addresses: List<Int>): ByteArray { fun request(periodMs: Int, addresses: List<Int>): ByteArray {
require(periodMs in 0..65535 && addresses.size <= 64 && addresses.all { it in 0..65535 }) require(periodMs in 0..65535 && addresses.size <= 64 && addresses.all { it in 0..65535 })
if (NativeSetProtocol.available) return requireNotNull(NativeSetProtocol.nativeTrendWatchRequest(periodMs, addresses.toIntArray())) check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" }
return ByteArray(4 + addresses.size * 2).also { output -> return requireNotNull(NativeSetProtocol.nativeTrendWatchRequest(periodMs, addresses.toIntArray()))
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) { fun validateAck(payload: ByteArray, periodMs: Int, count: Int) {
require(payload.size == 4 && read16(payload, 0) == periodMs && read16(payload, 2) == count) { require(NativeSetProtocol.available && NativeSetProtocol.nativeTrendWatchAck(payload, periodMs, count)) {
"Прибор принял не все адреса GAS. Проверьте карту регистров; отображение по неполной подписке невозможно" "Прибор принял не все адреса GAS. Проверьте карту регистров; отображение по неполной подписке невозможно"
} }
} }
fun values(payload: ByteArray, expectedCount: Int): List<Int> { fun values(payload: ByteArray, expectedCount: Int): List<Int> {
val values = if (NativeSetProtocol.available) { check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" }
requireNotNull(NativeSetProtocol.nativeTrendWatchValues(payload)) { "Повреждён GAS_WATCH_DATA" }.toList() val values = 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 не соответствует подписке" } require(values.size == expectedCount) { "Число значений GAS не соответствует подписке" }
return values return values
} }
private fun read16(data: ByteArray, offset: Int): Int = data class Sample(val timestampMs: Long, val values: List<Int>)
(data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) fun decode(payload: ByteArray, expectedCount: Int): Sample {
private fun put16(data: ByteArray, offset: Int, value: Int) { check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" }
data[offset] = value.toByte() val decoded = requireNotNull(NativeSetProtocol.nativeTrendWatchDecode(payload)) { "Повреждён GAS_WATCH_DATA" }
data[offset + 1] = (value ushr 8).toByte() require(decoded.size == expectedCount + 1) { "Число значений GAS не соответствует подписке" }
return Sample(decoded[0], decoded.drop(1).map(Long::toInt))
} }
} }

View File

@@ -32,3 +32,10 @@ fun plotTickStep(range: Double, pixels: Double): Double = NativePlot.call(5, ran
fun plotDelta(a: Double, b: Double, multiplier: Double = 1.0): Double = NativePlot.call(6, a, b, multiplier)[0] fun plotDelta(a: Double, b: Double, multiplier: Double = 1.0): Double = NativePlot.call(6, a, b, multiplier)[0]
fun plotDbDelta(a: Double, b: Double): Double? = fun plotDbDelta(a: Double, b: Double): Double? =
runCatching { NativePlot.call(7, a, b)[0] }.getOrNull() runCatching { NativePlot.call(7, a, b)[0] }.getOrNull()
data class PlotLimits(val xMin: Double, val xMax: Double, val yMin: Double, val yMax: Double) {
fun validated(): PlotLimits {
val values = NativePlot.call(8, xMin, xMax, yMin, yMax)
return PlotLimits(values[0], values[1], values[2], values[3])
}
}

View File

@@ -6,7 +6,7 @@ enum class TrendSection { SIGNALS, CHART }
/** File-format IDs; host applications map their connection profiles to these IDs. */ /** File-format IDs; host applications map their connection profiles to these IDs. */
enum class TrendProfile(val title: String) { enum class TrendProfile(val title: String) {
SET_V1("SET GUI v1"), TMS2812("TMS320F2812 / BALZAM"), CAN_BRIDGE("CAN ↔ RS485"), SET_V1("SET GUI v1"), TMS2812("TMS320F2812 / BALZAM"), TMS28335("TMS320F28335 / ПМ35"), CAN_BRIDGE("CAN ↔ RS485"),
GS_USB_CAN("CANgaroo / gs_usb"), SLCAN("SKLab SLCAN"), CANGAROO_SLCAN("CANgaroo / SLCAN"), GS_USB_CAN("CANgaroo / gs_usb"), SLCAN("SKLab SLCAN"), CANGAROO_SLCAN("CANgaroo / SLCAN"),
BALZAM_CAN("Старый CAN BALZAM"), BALZAM_CAN("Старый CAN BALZAM"),
} }
@@ -29,7 +29,7 @@ enum class TrendValueType(val title: String) {
} }
fun TrendProfile.trendSources(): List<TrendSource> = when (this) { fun TrendProfile.trendSources(): List<TrendSource> = when (this) {
TrendProfile.TMS2812 -> listOf(TrendSource.TMS_MEMORY) TrendProfile.TMS2812, TrendProfile.TMS28335 -> listOf(TrendSource.TMS_MEMORY)
TrendProfile.SET_V1 -> listOf(TrendSource.SET_GAS, TrendSource.SET_SENSOR) TrendProfile.SET_V1 -> listOf(TrendSource.SET_GAS, TrendSource.SET_SENSOR)
TrendProfile.BALZAM_CAN -> listOf(TrendSource.CAN_RAW) TrendProfile.BALZAM_CAN -> listOf(TrendSource.CAN_RAW)
else -> listOf(TrendSource.CAN_GAS, TrendSource.CAN_RAW) else -> listOf(TrendSource.CAN_GAS, TrendSource.CAN_RAW)

View File

@@ -34,6 +34,8 @@ data class TrendSpectrum(
val binHz: Double get() = if (size > 0) sampleRate / size else 0.0 val binHz: Double get() = if (size > 0) sampleRate / size else 0.0
} }
data class SpectrumPeak(val frequencyHz: Double, val amplitude: Double)
/** Math is implemented once in C and used unchanged by JNI and ctypes. */ /** Math is implemented once in C and used unchanged by JNI and ctypes. */
object SpectrumAnalyzer { object SpectrumAnalyzer {
fun analyze(points: List<TrendPoint>, options: SpectrumOptions): TrendSpectrum { fun analyze(points: List<TrendPoint>, options: SpectrumOptions): TrendSpectrum {
@@ -64,4 +66,12 @@ object SpectrumAnalyzer {
return TrendSpectrum(output[1].toInt(), output[2], output[3], return TrendSpectrum(output[1].toInt(), output[2], output[3],
if (error == null) output.drop(4) else emptyList(), error) if (error == null) output.drop(4) else emptyList(), error)
} }
fun dominantPeak(spectrum: TrendSpectrum, relativeThreshold: Double = 3.0,
absoluteFloor: Double = 1e-6): SpectrumPeak? {
if (spectrum.error != null || spectrum.size <= 0 || spectrum.amplitudes.size < 3) return null
val result = NativeSetProtocol.nativeSpectrumPeak(spectrum.amplitudes.toDoubleArray(),
spectrum.binHz, relativeThreshold, absoluteFloor) ?: return null
return result.takeIf { it.size == 2 }?.let { SpectrumPeak(it[0], it[1]) }
}
} }

View File

@@ -7,6 +7,182 @@
#include "set_trends.h" #include "set_trends.h"
#include "set_spectrum.h" #include "set_spectrum.h"
#include "balsam_can.h" #include "balsam_can.h"
#include "periph28335.h"
JNIEXPORT jint JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335Crc16(
JNIEnv *env, jobject self, jbyteArray input)
{
(void)self;
if (input == NULL) return 0;
jsize size = (*env)->GetArrayLength(env, input);
jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
if ((data == NULL) && (size != 0)) return 0;
uint16_t crc = periph28335_crc16_modbus((const uint8_t *)data, (size_t)size);
if (data != NULL) (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
return (jint)crc;
}
JNIEXPORT jbyteArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335AppendCrc(
JNIEnv *env, jobject self, jbyteArray input)
{
(void)self;
if (input == NULL) return NULL;
jsize size = (*env)->GetArrayLength(env, input);
jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
if ((data == NULL) && (size != 0)) return NULL;
uint8_t *output = (uint8_t *)malloc((size_t)size + 2U);
if (output == NULL) {
if (data != NULL) (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
return NULL;
}
size_t written = periph28335_append_crc(
(const uint8_t *)data, (size_t)size, output, (size_t)size + 2U);
if (data != NULL) (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
if (written == 0U) { free(output); return NULL; }
jbyteArray result = (*env)->NewByteArray(env, (jsize)written);
if (result != NULL) (*env)->SetByteArrayRegion(
env, result, 0, (jsize)written, (const jbyte *)output);
free(output);
return result;
}
JNIEXPORT jbyteArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335BuildRead(
JNIEnv *env, jobject self, jint controller, jint start, jint count)
{
(void)self;
uint8_t output[PERIPH28335_REQUEST_SIZE];
if (controller < 0 || controller > 255 || start < 0 || start > 65535 ||
count < 0 || count > 65535) return NULL;
size_t written = periph28335_build_read_registers(
(uint8_t)controller, (uint16_t)start, (uint16_t)count,
output, sizeof output);
if (written == 0U) return NULL;
jbyteArray result = (*env)->NewByteArray(env, (jsize)written);
if (result != NULL) (*env)->SetByteArrayRegion(
env, result, 0, (jsize)written, (const jbyte *)output);
return result;
}
JNIEXPORT jbyteArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335BuildWrite(
JNIEnv *env, jobject self, jint controller, jint address, jint value)
{
(void)self;
uint8_t output[PERIPH28335_REQUEST_SIZE];
if (controller < 0 || controller > 255 || address < 0 || address > 65535 ||
value < 0 || value > 65535) return NULL;
size_t written = periph28335_build_write_register(
(uint8_t)controller, (uint16_t)address, (uint16_t)value,
output, sizeof output);
if (written == 0U) return NULL;
jbyteArray result = (*env)->NewByteArray(env, (jsize)written);
if (result != NULL) (*env)->SetByteArrayRegion(
env, result, 0, (jsize)written, (const jbyte *)output);
return result;
}
JNIEXPORT jbyteArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335BuildCommand(
JNIEnv *env, jobject self, jint controller, jint command_index)
{
(void)self;
uint8_t output[PERIPH28335_REQUEST_SIZE];
if (controller < 0 || controller > 255 || command_index < 0 || command_index > 255) return NULL;
size_t written = periph28335_build_command(
(uint8_t)controller, (uint8_t)command_index, output, sizeof output);
if (written == 0U) return NULL;
jbyteArray result = (*env)->NewByteArray(env, (jsize)written);
if (result != NULL) (*env)->SetByteArrayRegion(
env, result, 0, (jsize)written, (const jbyte *)output);
return result;
}
JNIEXPORT jint JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335ExpectedReadSize(
JNIEnv *env, jobject self, jint count)
{
(void)env; (void)self;
if (count < 0 || count > 65535) return 0;
return (jint)periph28335_expected_read_response_size((uint16_t)count);
}
JNIEXPORT jintArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335DecodeRead(
JNIEnv *env, jobject self, jbyteArray input, jint controller, jint count)
{
(void)self;
if (input == NULL || controller < 0 || controller > 255 ||
count < 1 || count > (jint)PERIPH28335_REGISTER_COUNT) return NULL;
jsize size = (*env)->GetArrayLength(env, input);
jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
if ((data == NULL) && (size != 0)) return NULL;
uint16_t words[PERIPH28335_REGISTER_COUNT];
int status = periph28335_decode_read_response(
(const uint8_t *)data, (size_t)size, (uint8_t)controller,
(uint16_t)count, words, PERIPH28335_REGISTER_COUNT);
if (data != NULL) (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
if (status != PERIPH28335_OK) return NULL;
jint values[PERIPH28335_REGISTER_COUNT];
for (jint index = 0; index < count; ++index) values[index] = words[index];
jintArray result = (*env)->NewIntArray(env, count);
if (result != NULL) (*env)->SetIntArrayRegion(env, result, 0, count, values);
return result;
}
JNIEXPORT jint JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335ValidateWrite(
JNIEnv *env, jobject self, jbyteArray response, jbyteArray request)
{
(void)self;
if (response == NULL || request == NULL) return PERIPH28335_ERROR_ARGUMENT;
jsize response_size = (*env)->GetArrayLength(env, response);
jsize request_size = (*env)->GetArrayLength(env, request);
jbyte *response_data = (*env)->GetByteArrayElements(env, response, NULL);
jbyte *request_data = (*env)->GetByteArrayElements(env, request, NULL);
if (response_data == NULL || request_data == NULL) {
if (response_data != NULL) (*env)->ReleaseByteArrayElements(env, response, response_data, JNI_ABORT);
if (request_data != NULL) (*env)->ReleaseByteArrayElements(env, request, request_data, JNI_ABORT);
return PERIPH28335_ERROR_ARGUMENT;
}
int status = periph28335_validate_write_response(
(const uint8_t *)response_data, (size_t)response_size,
(const uint8_t *)request_data, (size_t)request_size);
(*env)->ReleaseByteArrayElements(env, response, response_data, JNI_ABORT);
(*env)->ReleaseByteArrayElements(env, request, request_data, JNI_ABORT);
return status;
}
JNIEXPORT jint JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335ProjectCount(
JNIEnv *env, jobject self)
{
(void)env; (void)self;
return (jint)periph28335_project_count();
}
JNIEXPORT jstring JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335ProjectName(
JNIEnv *env, jobject self, jint project_index)
{
(void)self;
const char *name = project_index >= 0
? periph28335_project_name((size_t)project_index) : NULL;
return name != NULL ? (*env)->NewStringUTF(env, name) : NULL;
}
JNIEXPORT jstring JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativePeriph28335CommandName(
JNIEnv *env, jobject self, jint project_index, jint command_index)
{
(void)self;
const char *name = project_index >= 0 && command_index >= 0
? periph28335_command_name((size_t)project_index, (size_t)command_index)
: NULL;
return name != NULL ? (*env)->NewStringUTF(env, name) : NULL;
}
JNIEXPORT jintArray JNICALL JNIEXPORT jintArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamDecode( Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamDecode(
@@ -100,6 +276,30 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendCanValue(
(uint8_t)flags, (const uint8_t *)data, (size_t)size); (uint8_t)flags, (const uint8_t *)data, (size_t)size);
} }
JNIEXPORT jdoubleArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeSpectrumPeak(
JNIEnv *env, jobject self, jdoubleArray input, jdouble bin_hz,
jdouble relative_threshold, jdouble absolute_floor)
{
(void)self;
jsize count = input == NULL ? 0 : (*env)->GetArrayLength(env, input);
if (count < 3 || count > (jsize)(SET_SPECTRUM_MAX / 2U + 1U)) return NULL;
double *buffer = (double *)malloc(sizeof(double) * (size_t)(count * 2 - 1));
if (buffer == NULL) return NULL;
double *amplitudes = buffer, *scratch = buffer + count, peak[2];
(*env)->GetDoubleArrayRegion(env, input, 0, count, amplitudes);
int status = (*env)->ExceptionCheck(env) ? -1 : set_spectrum_dominant_peak(
amplitudes, (size_t)count, bin_hz, relative_threshold, absolute_floor,
scratch, (size_t)count - 1U, peak, 2U);
jdoubleArray result = NULL;
if (status >= 0) {
result = (*env)->NewDoubleArray(env, status == 1 ? 2 : 0);
if (result != NULL && status == 1) (*env)->SetDoubleArrayRegion(env, result, 0, 2, peak);
}
free(buffer);
return result;
}
JNIEXPORT jbyteArray JNICALL JNIEXPORT jbyteArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest( Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest(
JNIEnv *env, jobject self, jint period, jintArray input) JNIEnv *env, jobject self, jint period, jintArray input)
@@ -122,6 +322,20 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest(
return result; return result;
} }
JNIEXPORT jboolean JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchAck(
JNIEnv *env, jobject self, jbyteArray input, jint period, jint count)
{
(void)self;
if (input == NULL || period < 0 || period > 65535 || count < 0) return JNI_FALSE;
jsize size = (*env)->GetArrayLength(env, input);
if (size != 4) return JNI_FALSE;
jbyte payload[4];
(*env)->GetByteArrayRegion(env, input, 0, size, payload);
return set_trend_watch_ack((const uint8_t *)payload, (size_t)size,
(uint16_t)period, (size_t)count) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jintArray JNICALL JNIEXPORT jintArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues( Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues(
JNIEnv *env, jobject self, jbyteArray input) JNIEnv *env, jobject self, jbyteArray input)
@@ -141,6 +355,28 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues(
return result; return result;
} }
JNIEXPORT jlongArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchDecode(
JNIEnv *env, jobject self, jbyteArray input)
{
(void)self;
jsize size = input == NULL ? 0 : (*env)->GetArrayLength(env, input);
if (size < 6 || size > (jsize)(6U + 2U * SET_TREND_WATCH_MAX)) return NULL;
jbyte payload[6U + 2U * SET_TREND_WATCH_MAX];
uint16_t words[SET_TREND_WATCH_MAX];
uint32_t timestamp = 0U;
jlong values[1U + SET_TREND_WATCH_MAX];
(*env)->GetByteArrayRegion(env, input, 0, size, payload);
int count = set_trend_watch_decode((const uint8_t *)payload, (size_t)size,
&timestamp, words, SET_TREND_WATCH_MAX);
if (count < 0) return NULL;
values[0] = (jlong)timestamp;
for (int i = 0; i < count; ++i) values[i + 1] = (jlong)words[i];
jlongArray result = (*env)->NewLongArray(env, count + 1);
if (result != NULL) (*env)->SetLongArrayRegion(env, result, 0, count + 1, values);
return result;
}
typedef struct { typedef struct {
uint8_t *storage; uint8_t *storage;
size_t storage_size; size_t storage_size;

View File

@@ -0,0 +1,35 @@
package ru.setcorp.setprotocol.periph28335
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class Periph28335ProtocolTest {
@Test fun readRequestMatchesDelphiByteOrder() {
val request = Periph28335Protocol.buildReadRegisters(16, 24, 64)
assertTrue(request.copyOf(6).contentEquals(byteArrayOf(0x10, 0x03, 0x00, 0x18, 0x00, 0x40)))
assertEquals(
Periph28335Protocol.crc16Modbus(request.copyOf(6)),
(request[6].toInt() and 0xFF) or ((request[7].toInt() and 0xFF) shl 8),
)
}
@Test fun writeAndCommandUseRegister127() {
assertTrue(
Periph28335Protocol.buildWriteRegister(16, 7, 0x1234).copyOf(6)
.contentEquals(byteArrayOf(0x10, 0x06, 0x00, 0x07, 0x12, 0x34)),
)
assertTrue(
Periph28335Protocol.buildCommand(16, 15).copyOf(6)
.contentEquals(byteArrayOf(0x10, 0x06, 0x00, 0x7F, 0x80.toByte(), 0x00)),
)
}
@Test fun responseAndBitsRoundTrip() {
val response = Periph28335Protocol.withCrc(byteArrayOf(0x10, 0x03, 0x04, 0x80.toByte(), 0x05, 0x12, 0x34))
assertEquals(listOf(0x8005, 0x1234), Periph28335Protocol.decodeReadResponse(response, 16, 2))
val bits = Periph28335Protocol.bitsLsbFirst(0x8005)
assertTrue(bits[0] && bits[2] && bits[15])
assertEquals(0x8005, Periph28335Protocol.wordFromBits(bits))
}
}

View File

@@ -36,4 +36,12 @@ class PlotViewportTest {
} }
SpectrumOptions().validate() SpectrumOptions().validate()
} }
@Test fun absoluteLimitsAndDominantPeakUseNativeCore() {
assertEquals(PlotLimits(0.0, 500.0, -1.0, 1.0),
PlotLimits(0.0, 500.0, -1.0, 1.0).validated())
assertTrue(runCatching { PlotLimits(1.0, 1.0, -1.0, 1.0).validated() }.isFailure)
val spectrum = TrendSpectrum(12, 60.0, 0.0,
listOf(10.0, .01, .02, .8, .03, .4, .02))
assertEquals(15.0, SpectrumAnalyzer.dominantPeak(spectrum)!!.frequencyHz, 0.0)
}
} }

View File

@@ -4,6 +4,7 @@
#include "pcan_crc.h" #include "pcan_crc.h"
#include "balsam_can.h" #include "balsam_can.h"
#include "periph28335.h"
#include "pcan_frame.h" #include "pcan_frame.h"
#include "pcan_id.h" #include "pcan_id.h"
#include "gui_frame.h" #include "gui_frame.h"
@@ -88,6 +89,79 @@ size_t pcan_abi_balsam_register_name(uint8_t device, uint16_t address,
return balsam_can_register_name(device, address, output, output_size); return balsam_can_register_name(device, address, output, output_size);
} }
uint16_t pcan_abi_periph28335_crc16(const uint8_t *data, size_t size)
{
return periph28335_crc16_modbus(data, size);
}
size_t pcan_abi_periph28335_append_crc(
const uint8_t *payload, size_t payload_size,
uint8_t *output, size_t output_size)
{
return periph28335_append_crc(payload, payload_size, output, output_size);
}
size_t pcan_abi_periph28335_build_read(
uint8_t controller, uint16_t start, uint16_t count,
uint8_t *output, size_t output_size)
{
return periph28335_build_read_registers(
controller, start, count, output, output_size);
}
size_t pcan_abi_periph28335_build_write(
uint8_t controller, uint16_t address, uint16_t value,
uint8_t *output, size_t output_size)
{
return periph28335_build_write_register(
controller, address, value, output, output_size);
}
size_t pcan_abi_periph28335_build_command(
uint8_t controller, uint8_t command_index,
uint8_t *output, size_t output_size)
{
return periph28335_build_command(
controller, command_index, output, output_size);
}
size_t pcan_abi_periph28335_expected_read_size(uint16_t count)
{
return periph28335_expected_read_response_size(count);
}
int pcan_abi_periph28335_decode_read(
const uint8_t *data, size_t size, uint8_t controller, uint16_t count,
uint16_t *output, size_t output_count)
{
return periph28335_decode_read_response(
data, size, controller, count, output, output_count);
}
int pcan_abi_periph28335_validate_write(
const uint8_t *response, size_t response_size,
const uint8_t *request, size_t request_size)
{
return periph28335_validate_write_response(
response, response_size, request, request_size);
}
size_t pcan_abi_periph28335_project_count(void)
{
return periph28335_project_count();
}
const char *pcan_abi_periph28335_project_name(size_t project_index)
{
return periph28335_project_name(project_index);
}
const char *pcan_abi_periph28335_command_name(
size_t project_index, size_t command_index)
{
return periph28335_command_name(project_index, command_index);
}
size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags, size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags,
uint32_t can_id, const uint8_t *data, uint8_t dlc, uint32_t can_id, const uint8_t *data, uint8_t dlc,
uint8_t *output, size_t output_size) uint8_t *output, size_t output_size)

View File

@@ -0,0 +1,171 @@
#include "periph28335.h"
#include <string.h>
typedef struct {
const char *project;
const char *commands[PERIPH28335_COMMAND_COUNT];
} periph28335_project_t;
static const periph28335_project_t projects[] = {
{ "По умолчанию", { "Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all" } },
{ "23470", { "Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "-", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all" } },
{ "23550", { "Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "Send", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all" } },
{ "23550.2", { "Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Send", "Raw", "Beep", "", "", "", "", "Log", "Reset", "Nothing at all" } },
{ "ICE 22220.1-3", { "Test", "Zero", "Save", "Def", "Calibr", "Read", "ExtLamp", "ExtLite", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all" } },
{ "ICE 22220.4-5", { "Test", "Def", "Save", "Load", "Raw", "Read", "ExtLamp", "ExtLite", "No log", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all" } },
{ "Бальзам 161", { "Test", "Zero", "Save", "Def", "Calibr", "Clbr 400", "Stop", "Start", "Init", "Secret", "-", "-", "-", "-", "-", "Reset", "Nothing at all" } },
{ "Бальзам 162", { "Test", "Def", "Save", "Load", "Calibr", "Secret", "Stop", "Start", "Init", "Tune", "-", "-", "-", "-", "-", "Reset", "Nothing at all" } },
{ "Бальзам 163", { "Test", "Def", "Save", "Load", "Calibr", "Calcul", "Stop", "Start", "Init", "Tune", "Secret", "-", "-", "-", "-", "Reset", "Nothing at all" } }
};
static void put_be16(uint8_t *output, uint16_t value)
{
output[0] = (uint8_t)(value >> 8);
output[1] = (uint8_t)value;
}
static uint16_t get_be16(const uint8_t *input)
{
return (uint16_t)(((uint16_t)input[0] << 8) | input[1]);
}
uint16_t periph28335_crc16_modbus(const uint8_t *data, size_t size)
{
uint16_t crc = 0xFFFFU;
size_t index;
uint8_t bit;
if ((data == NULL) && (size != 0U)) return 0U;
for (index = 0U; index < size; ++index) {
crc ^= data[index];
for (bit = 0U; bit < 8U; ++bit) {
crc = (crc & 1U) != 0U
? (uint16_t)((crc >> 1) ^ 0xA001U)
: (uint16_t)(crc >> 1);
}
}
return crc;
}
size_t periph28335_append_crc(const uint8_t *payload, size_t payload_size,
uint8_t *output, size_t output_size)
{
uint16_t crc;
if ((output == NULL) || ((payload == NULL) && (payload_size != 0U)) ||
(payload_size > output_size) || (output_size - payload_size < 2U)) {
return 0U;
}
if (payload_size != 0U) memcpy(output, payload, payload_size);
crc = periph28335_crc16_modbus(payload, payload_size);
output[payload_size] = (uint8_t)crc;
output[payload_size + 1U] = (uint8_t)(crc >> 8);
return payload_size + 2U;
}
size_t periph28335_build_read_registers(
uint8_t controller, uint16_t start, uint16_t count,
uint8_t *output, size_t output_size)
{
uint8_t payload[6];
if ((count == 0U) || (count > PERIPH28335_REGISTER_COUNT) ||
(start >= PERIPH28335_REGISTER_COUNT) ||
((uint32_t)start + count > PERIPH28335_REGISTER_COUNT)) return 0U;
payload[0] = controller;
payload[1] = 3U;
put_be16(&payload[2], start);
put_be16(&payload[4], count);
return periph28335_append_crc(payload, sizeof payload, output, output_size);
}
size_t periph28335_build_write_register(
uint8_t controller, uint16_t address, uint16_t value,
uint8_t *output, size_t output_size)
{
uint8_t payload[6];
if (address >= PERIPH28335_REGISTER_COUNT) return 0U;
payload[0] = controller;
payload[1] = 6U;
put_be16(&payload[2], address);
put_be16(&payload[4], value);
return periph28335_append_crc(payload, sizeof payload, output, output_size);
}
size_t periph28335_build_command(
uint8_t controller, uint8_t command_index,
uint8_t *output, size_t output_size)
{
uint16_t value;
if (command_index >= PERIPH28335_COMMAND_COUNT) return 0U;
value = command_index < 16U ? (uint16_t)(1UL << command_index) : 0U;
return periph28335_build_write_register(
controller, PERIPH28335_REGISTER_COUNT - 1U, value,
output, output_size);
}
size_t periph28335_expected_read_response_size(uint16_t count)
{
return (count >= 1U && count <= PERIPH28335_REGISTER_COUNT)
? (size_t)count * 2U + 5U : 0U;
}
int periph28335_decode_read_response(
const uint8_t *data, size_t size, uint8_t controller, uint16_t count,
uint16_t *output, size_t output_count)
{
size_t expected = periph28335_expected_read_response_size(count);
uint16_t crc;
size_t index;
if ((data == NULL) || (output == NULL)) return PERIPH28335_ERROR_ARGUMENT;
if (expected == 0U) return PERIPH28335_ERROR_RANGE;
if (size != expected) return PERIPH28335_ERROR_LENGTH;
if (output_count < count) return PERIPH28335_ERROR_CAPACITY;
crc = periph28335_crc16_modbus(data, size - 2U);
if ((data[size - 2U] != (uint8_t)crc) ||
(data[size - 1U] != (uint8_t)(crc >> 8))) {
return PERIPH28335_ERROR_CRC;
}
if ((data[0] != controller) || (data[1] != 3U) ||
(data[2] != (uint8_t)(count * 2U))) {
return PERIPH28335_ERROR_HEADER;
}
for (index = 0U; index < count; ++index) {
output[index] = get_be16(&data[3U + index * 2U]);
}
return PERIPH28335_OK;
}
int periph28335_validate_write_response(
const uint8_t *response, size_t response_size,
const uint8_t *request, size_t request_size)
{
uint16_t crc;
if ((response == NULL) || (request == NULL)) return PERIPH28335_ERROR_ARGUMENT;
if ((response_size != PERIPH28335_REQUEST_SIZE) ||
(request_size != PERIPH28335_REQUEST_SIZE)) return PERIPH28335_ERROR_LENGTH;
crc = periph28335_crc16_modbus(response, response_size - 2U);
if ((response[response_size - 2U] != (uint8_t)crc) ||
(response[response_size - 1U] != (uint8_t)(crc >> 8))) {
return PERIPH28335_ERROR_CRC;
}
return memcmp(response, request, PERIPH28335_REQUEST_SIZE) == 0
? PERIPH28335_OK : PERIPH28335_ERROR_HEADER;
}
size_t periph28335_project_count(void)
{
return sizeof projects / sizeof projects[0];
}
const char *periph28335_project_name(size_t project_index)
{
return project_index < periph28335_project_count()
? projects[project_index].project : NULL;
}
const char *periph28335_command_name(size_t project_index,
size_t command_index)
{
return project_index < periph28335_project_count() &&
command_index < PERIPH28335_COMMAND_COUNT
? projects[project_index].commands[command_index] : NULL;
}

View File

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

View File

@@ -4,6 +4,13 @@
#define PI 3.14159265358979323846 #define PI 3.14159265358979323846
static int compare_double(const void *left, const void *right)
{
const double a = *(const double *)left;
const double b = *(const double *)right;
return (a > b) - (a < b);
}
static double window_value(int window, size_t index, size_t n) static double window_value(int window, size_t index, size_t n)
{ {
double phase = 2.0 * PI * (double)index / (double)n; double phase = 2.0 * PI * (double)index / (double)n;
@@ -124,3 +131,35 @@ int set_spectrum_analyze(const double *times, const double *values, size_t count
free(scratch); free(scratch);
return result; return result;
} }
int set_spectrum_dominant_peak(const double *amplitudes, size_t count,
double bin_hz, double relative_threshold, double absolute_floor,
double *scratch, size_t scratch_capacity, double *peak, size_t peak_capacity)
{
size_t i, usable = 0U, peak_bin = 0U;
double peak_amplitude = -1.0, median, threshold;
if (amplitudes == NULL || scratch == NULL || peak == NULL || count < 3U ||
scratch_capacity < count - 1U || peak_capacity < 2U || !isfinite(bin_hz) ||
bin_hz <= 0.0 || !isfinite(relative_threshold) || relative_threshold <= 0.0 ||
!isfinite(absolute_floor) || absolute_floor < 0.0) return -1;
for (i = 1U; i < count; ++i) {
if (isfinite(amplitudes[i]) && amplitudes[i] >= 0.0)
scratch[usable++] = amplitudes[i];
}
if (usable < 2U) return 0;
qsort(scratch, usable, sizeof(double), compare_double);
median = scratch[usable / 2U];
for (i = 1U; i + 1U < count; ++i) {
const double value = amplitudes[i];
if (isfinite(value) && isfinite(amplitudes[i - 1U]) && isfinite(amplitudes[i + 1U]) &&
value >= amplitudes[i - 1U] && value > amplitudes[i + 1U] && value > peak_amplitude) {
peak_bin = i;
peak_amplitude = value;
}
}
threshold = fmax(absolute_floor, median * relative_threshold);
if (peak_bin == 0U || peak_amplitude < threshold) return 0;
peak[0] = (double)peak_bin * bin_hz;
peak[1] = peak_amplitude;
return 1;
}

View File

@@ -6,6 +6,12 @@ static uint16_t get16(const uint8_t *p)
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
} }
static uint32_t get32(const uint8_t *p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static void put16(uint8_t *p, uint16_t value) static void put16(uint8_t *p, uint16_t value)
{ {
p[0] = (uint8_t)value; p[0] = (uint8_t)value;
@@ -63,6 +69,12 @@ int set_trend_watch_ack(const uint8_t *payload, size_t size, uint16_t period_ms,
} }
int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words, size_t capacity) int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words, size_t capacity)
{
return set_trend_watch_decode(payload, size, NULL, words, capacity);
}
int set_trend_watch_decode(const uint8_t *payload, size_t size, uint32_t *timestamp_ms,
uint16_t *words, size_t capacity)
{ {
size_t i, count; size_t i, count;
if (payload == NULL || size < 6U) return -1; if (payload == NULL || size < 6U) return -1;
@@ -70,5 +82,6 @@ int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words,
if (count > SET_TREND_WATCH_MAX || size != 6U + count * 2U || count > capacity || if (count > SET_TREND_WATCH_MAX || size != 6U + count * 2U || count > capacity ||
(count > 0U && words == NULL)) return -1; (count > 0U && words == NULL)) return -1;
for (i = 0U; i < count; ++i) words[i] = get16(payload + 6U + i * 2U); for (i = 0U; i < count; ++i) words[i] = get16(payload + 6U + i * 2U);
if (timestamp_ms != NULL) *timestamp_ms = get32(payload);
return (int)count; return (int)count;
} }

View File

@@ -23,6 +23,8 @@
{"name":"db_gain","op":7,"input":[1,10],"output":[20]}, {"name":"db_gain","op":7,"input":[1,10],"output":[20]},
{"name":"db_attenuation","op":7,"input":[10,1],"output":[-20]}, {"name":"db_attenuation","op":7,"input":[10,1],"output":[-20]},
{"name":"db_zero_reference","op":7,"input":[0,1],"output":null}, {"name":"db_zero_reference","op":7,"input":[0,1],"output":null},
{"name":"absolute_limits","op":8,"input":[0,500,-2,2],"output":[0,500,-2,2]},
{"name":"reversed_x_limits","op":8,"input":[1,1,-2,2],"output":null},
{"name":"zero_range","op":2,"input":[1,1,1,0],"output":null}, {"name":"zero_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":"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} {"name":"bad_viewport","op":0,"input":[0,0,0,1,2,1,0,0,0.5,0.5],"output":null}

View File

@@ -0,0 +1,32 @@
#include <stdio.h>
#include <string.h>
#include "periph28335.h"
int main(void)
{
uint8_t request[PERIPH28335_REQUEST_SIZE];
static const uint8_t read_prefix[] = { 0x10U, 0x03U, 0x00U, 0x18U, 0x00U, 0x40U };
static const uint8_t response_payload[] = { 0x10U, 0x03U, 0x04U, 0x80U, 0x05U, 0x12U, 0x34U };
uint8_t response[sizeof response_payload + 2U];
uint16_t words[2];
if (periph28335_build_read_registers(16U, 24U, 64U, request, sizeof request)
!= sizeof request || memcmp(request, read_prefix, sizeof read_prefix) != 0) return 1;
if (periph28335_build_write_register(16U, 7U, 0x1234U, request, sizeof request)
!= sizeof request || memcmp(request, "\x10\x06\x00\x07\x12\x34", 6U) != 0) return 2;
if (periph28335_build_command(16U, 15U, request, sizeof request)
!= sizeof request || memcmp(request, "\x10\x06\x00\x7F\x80\x00", 6U) != 0) return 3;
if (periph28335_append_crc(response_payload, sizeof response_payload,
response, sizeof response) != sizeof response) return 4;
if (periph28335_decode_read_response(response, sizeof response, 16U, 2U,
words, 2U) != PERIPH28335_OK || words[0] != 0x8005U || words[1] != 0x1234U) return 5;
response[0] = 17U;
if (periph28335_decode_read_response(response, sizeof response, 16U, 2U,
words, 2U) != PERIPH28335_ERROR_CRC) return 6;
if (periph28335_project_count() != 9U ||
strcmp(periph28335_project_name(2U), "23550") != 0 ||
strcmp(periph28335_command_name(2U, 7U), "Send") != 0) return 7;
puts("PM35/TMS320F28335 protocol tests passed");
return 0;
}

View File

@@ -26,6 +26,13 @@ int main(void) {
assert(output[0] == y[0]); assert(output[0] == y[0]);
assert(set_plot_eval(SET_PLOT_DRAG, drag, 6, output, 4) == 1); assert(set_plot_eval(SET_PLOT_DRAG, drag, 6, output, 4) == 1);
assert(output[0] == -4); assert(output[0] == -4);
{
const double limits[] = {0, 500, -2, 2};
const double reversed[] = {1, 1, -2, 2};
assert(set_plot_eval(SET_PLOT_LIMITS, limits, 4, output, 4) == 4);
assert(output[0] == 0 && output[1] == 500 && output[2] == -2 && output[3] == 2);
assert(set_plot_eval(SET_PLOT_LIMITS, reversed, 4, output, 4) == 0);
}
puts("shared plot: OK"); puts("shared plot: OK");
return 0; return 0;
} }

View File

@@ -17,6 +17,15 @@ int main(void)
assert(set_spectrum_analyze(times, values, 32, 32, 0, SET_FILTER_LOW_PASS, 0, 16, 1, output, 17, meta) == SET_SPECTRUM_CUTOFF); 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]; times[12] = times[11];
assert(set_spectrum_analyze(times, values, 32, 32, 0, 0, 0, 0, 1, output, 17, meta) == SET_SPECTRUM_TIMING); assert(set_spectrum_analyze(times, values, 32, 32, 0, 0, 0, 0, 1, output, 17, meta) == SET_SPECTRUM_TIMING);
{
const double amplitudes[] = {10.0, .01, .02, .8, .03, .4, .02};
const double noise[] = {0, .10, .12, .11, .09, .10};
double scratch[6], peak[2];
assert(set_spectrum_dominant_peak(amplitudes, 7, 5, 3, 1e-6, scratch, 6, peak, 2) == 1);
assert(peak[0] == 15 && peak[1] == .8);
assert(set_spectrum_dominant_peak(noise, 6, 1, 3, 1e-6, scratch, 6, peak, 2) == 0);
assert(set_spectrum_dominant_peak(amplitudes, 7, 0, 3, 1e-6, scratch, 6, peak, 2) == -1);
}
puts("shared spectrum: OK"); puts("shared spectrum: OK");
return 0; return 0;
} }

View File

@@ -34,8 +34,11 @@ int main(void)
assert(!set_trend_watch_ack(expected, 4, 1000, 3)); assert(!set_trend_watch_ack(expected, 4, 1000, 3));
const uint8_t packet[] = {1, 2, 3, 4, 2, 0, 52, 18, 255, 255}; const uint8_t packet[] = {1, 2, 3, 4, 2, 0, 52, 18, 255, 255};
uint16_t words[64]; uint16_t words[64];
uint32_t timestamp = 0;
assert(set_trend_watch_values(packet, sizeof(packet), words, 64) == 2); assert(set_trend_watch_values(packet, sizeof(packet), words, 64) == 2);
assert(words[0] == 0x1234 && words[1] == 0xFFFF); assert(words[0] == 0x1234 && words[1] == 0xFFFF);
assert(set_trend_watch_decode(packet, sizeof(packet), &timestamp, words, 64) == 2);
assert(timestamp == 0x04030201UL);
assert(set_trend_watch_values(packet, sizeof(packet) - 1, words, 64) == -1); assert(set_trend_watch_values(packet, sizeof(packet) - 1, words, 64) == -1);
assert(set_trend_watch_values(packet, sizeof(packet), words, 1) == -1); assert(set_trend_watch_values(packet, sizeof(packet), words, 1) == -1);
puts("Shared trend tests passed"); puts("Shared trend tests passed");

View File

@@ -21,7 +21,7 @@ JNI_INCLUDES: list[Path] = []
SOURCES = [ SOURCES = [
ROOT / "src" / name for name in ( ROOT / "src" / name for name in (
"set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c", "set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c",
"balsam_can.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c", "balsam_can.c", "periph28335.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c",
"pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c", "pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c",
"pcan_gas.c", "pcan_gas.c",
) )
@@ -84,7 +84,10 @@ def main() -> int:
if not (include / "jni.h").is_file(): if not (include / "jni.h").is_file():
parser.error("--java-home must contain include/jni.h") parser.error("--java-home must contain include/jni.h")
JNI_INCLUDES.extend([include, include / {"Windows": "win32", "Darwin": "darwin"}.get(platform.system(), "linux")]) JNI_INCLUDES.extend([include, include / {"Windows": "win32", "Darwin": "darwin"}.get(platform.system(), "linux")])
SOURCES.append(ROOT / "ports" / "android" / "set_plot_jni.c") SOURCES.extend([
ROOT / "ports" / "android" / "set_plot_jni.c",
ROOT / "ports" / "android" / "setprotocol_jni.c",
])
output = args.output.resolve() output = args.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
build_dir = output.parent / ".setprotocol-build" build_dir = output.parent / ".setprotocol-build"

View File

@@ -0,0 +1,46 @@
# Аудит общего кода GUI_Android и SETGUI
Дата проверки: 2026-09-04.
## Уже является общим C-ядром
| Область | Каноническая реализация |
|---|---|
| SETProtocol v2, CAN segmentation, firmware flow, telemetry | `c/set-protocol/src/set_*.c` |
| ProtoCAN ID, кадр, parser, link, CRC-CCITT | `c/set-protocol/src/pcan_*.c` |
| GUI frame и каталог | `c/set-protocol/src/gui_*.c` |
| CAN Бальзам | `c/set-protocol/src/balsam_can.c` |
| График, тренды и спектр | `c/set-protocol/src/set_plot.c`, `set_trends.c`, `set_spectrum.c` |
| ПМ35 / TMS320F28335: MODBUS-подобные запросы, CRC16/Modbus, ответы и каталог команд | `c/set-protocol/src/periph28335.c` |
Python вызывает это ядро через `python/protocan/native.py`, Android — через
`ports/android/setprotocol_jni.c`. Файлы на Python и Kotlin являются портами и
не должны содержать wire-алгоритм. В старых портах ещё встречаются fallback-
реализации; они отмечены ниже и удаляются по мере включения обязательной
загрузки C-ядра.
## Найденное общее, которое ещё нужно перенести
| Приоритет | Android | SETGUI | Что вынести в C |
|---|---|---|---|
| 0 | `ports/android/.../CanBridgeProtocol.kt`, `GuiProtocol.kt` | Python transport wrappers | удалить оставшиеся алгоритмические fallback, так как codec/parser уже есть в C |
| 1 | `core/Tms2812Protocol.kt` | `core/tms_flash.py`, `core/tms_terminal.py` | весь RS-протокол ПМ67/2812, CRC, команды и validators |
| 2 | `core/Ds18b20Protocol.kt` | `core/ds18b20.py` | кодеки списков/данных/EEPROM, CRC8 и значения датчиков |
| 3 | `core/CanFirmwareProtocol.kt` | `core/can_firmware.py` | автомат CAN boot, статусы, окна блоков |
| 4 | `core/SlCanProtocol.kt` | `adapters/slcan_adapter.py` и CAN transport | ASCII codec/parser SLCAN; доступ к COM остаётся в портах |
| 5 | `core/FirmwareImage.kt` | `core/firmware.py` | Intel HEX parser, CRC32/SHA и правила диапазонов |
Это очередь миграции, а не разрешение поддерживать две реализации. При первом
изменении любой строки из таблицы сначала создаётся соответствующий модуль C и
общие тестовые векторы.
## Что должно остаться платформенным
- Compose и PySide widgets, навигация и внешний вид;
- Android USB host, разрешения, foreground lifecycle;
- Windows COM/MOXA/драйверы и выбор последовательного порта;
- хранилище настроек, диалоги файлов и уведомления;
- привязка моделей C к Kotlin/Python и локализованные сообщения UI.
Таким образом, язык GUI можно менять без повторного написания протокола: новый
клиент реализует только FFI и свой транспортный/UI-порт.

View File

@@ -139,6 +139,44 @@ class NativeProtocol:
ctypes.c_uint8, ctypes.c_uint16, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint8, ctypes.c_uint16, ctypes.c_void_p, ctypes.c_size_t,
] ]
lib.pcan_abi_balsam_register_name.restype = ctypes.c_size_t lib.pcan_abi_balsam_register_name.restype = ctypes.c_size_t
lib.pcan_abi_periph28335_crc16.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
lib.pcan_abi_periph28335_crc16.restype = ctypes.c_uint16
lib.pcan_abi_periph28335_append_crc.argtypes = [
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t,
]
lib.pcan_abi_periph28335_append_crc.restype = ctypes.c_size_t
lib.pcan_abi_periph28335_build_read.argtypes = [
ctypes.c_uint8, ctypes.c_uint16, ctypes.c_uint16,
ctypes.c_void_p, ctypes.c_size_t,
]
lib.pcan_abi_periph28335_build_read.restype = ctypes.c_size_t
lib.pcan_abi_periph28335_build_write.argtypes = [
ctypes.c_uint8, ctypes.c_uint16, ctypes.c_uint16,
ctypes.c_void_p, ctypes.c_size_t,
]
lib.pcan_abi_periph28335_build_write.restype = ctypes.c_size_t
lib.pcan_abi_periph28335_build_command.argtypes = [
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t,
]
lib.pcan_abi_periph28335_build_command.restype = ctypes.c_size_t
lib.pcan_abi_periph28335_expected_read_size.argtypes = [ctypes.c_uint16]
lib.pcan_abi_periph28335_expected_read_size.restype = ctypes.c_size_t
lib.pcan_abi_periph28335_decode_read.argtypes = [
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint8, ctypes.c_uint16,
ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t,
]
lib.pcan_abi_periph28335_decode_read.restype = ctypes.c_int
lib.pcan_abi_periph28335_validate_write.argtypes = [
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t,
]
lib.pcan_abi_periph28335_validate_write.restype = ctypes.c_int
lib.pcan_abi_periph28335_project_count.restype = ctypes.c_size_t
lib.pcan_abi_periph28335_project_name.argtypes = [ctypes.c_size_t]
lib.pcan_abi_periph28335_project_name.restype = ctypes.c_char_p
lib.pcan_abi_periph28335_command_name.argtypes = [
ctypes.c_size_t, ctypes.c_size_t,
]
lib.pcan_abi_periph28335_command_name.restype = ctypes.c_char_p
lib.pcan_abi_frame_encode.argtypes = [ lib.pcan_abi_frame_encode.argtypes = [
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint32,
ctypes.c_void_p, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t,
@@ -220,6 +258,83 @@ class NativeProtocol:
device, address, output, len(output)) device, address, output, len(output))
return output.value.decode("utf-8") return output.value.decode("utf-8")
@staticmethod
def _bytes_buffer(data: bytes):
return (ctypes.c_uint8 * len(data)).from_buffer_copy(data) if data else None
def periph28335_crc16(self, data: bytes) -> int:
return int(self.lib.pcan_abi_periph28335_crc16(
self._bytes_buffer(data), len(data)))
def periph28335_append_crc(self, payload: bytes) -> bytes:
source = self._bytes_buffer(payload)
output = (ctypes.c_uint8 * (len(payload) + 2))()
size = int(self.lib.pcan_abi_periph28335_append_crc(
source, len(payload), output, len(output)))
if size == 0:
raise ValueError("SETProtocol rejected PM35 payload")
return bytes(output[:size])
def periph28335_build_read(self, controller: int, start: int,
count: int) -> bytes:
output = (ctypes.c_uint8 * 8)()
size = int(self.lib.pcan_abi_periph28335_build_read(
controller, start, count, output, len(output)))
if size == 0:
raise ValueError("SETProtocol rejected PM35 read request")
return bytes(output[:size])
def periph28335_build_write(self, controller: int, address: int,
value: int) -> bytes:
output = (ctypes.c_uint8 * 8)()
size = int(self.lib.pcan_abi_periph28335_build_write(
controller, address, value, output, len(output)))
if size == 0:
raise ValueError("SETProtocol rejected PM35 write request")
return bytes(output[:size])
def periph28335_build_command(self, controller: int,
command_index: int) -> bytes:
output = (ctypes.c_uint8 * 8)()
size = int(self.lib.pcan_abi_periph28335_build_command(
controller, command_index, output, len(output)))
if size == 0:
raise ValueError("SETProtocol rejected PM35 command")
return bytes(output[:size])
def periph28335_expected_read_size(self, count: int) -> int:
return int(self.lib.pcan_abi_periph28335_expected_read_size(count))
def periph28335_decode_read(self, data: bytes, controller: int,
count: int) -> tuple[int, tuple[int, ...]]:
source = self._bytes_buffer(data)
output = (ctypes.c_uint16 * count)()
status = int(self.lib.pcan_abi_periph28335_decode_read(
source, len(data), controller, count, output, count))
return status, tuple(int(value) for value in output) if status == 0 else ()
def periph28335_validate_write(self, response: bytes,
request: bytes) -> int:
return int(self.lib.pcan_abi_periph28335_validate_write(
self._bytes_buffer(response), len(response),
self._bytes_buffer(request), len(request)))
def periph28335_catalog(self) -> dict[str, tuple[str, ...]]:
result: dict[str, tuple[str, ...]] = {}
for project in range(int(self.lib.pcan_abi_periph28335_project_count())):
raw_name = self.lib.pcan_abi_periph28335_project_name(project)
if not raw_name:
raise NativeProtocolUnavailable("invalid PM35 project catalog")
commands = []
for command in range(17):
raw_command = self.lib.pcan_abi_periph28335_command_name(
project, command)
if raw_command is None:
raise NativeProtocolUnavailable("invalid PM35 command catalog")
commands.append(raw_command.decode("utf-8"))
result[raw_name.decode("utf-8")] = tuple(commands)
return result
def encode(self, sequence: int, flags: int, can_id: int, data: bytes) -> bytes: def encode(self, sequence: int, flags: int, can_id: int, data: bytes) -> bytes:
if len(data) > 8: if len(data) > 8:
raise ValueError("DLC cannot exceed 8 bytes") raise ValueError("DLC cannot exceed 8 bytes")

View File

@@ -1,66 +1,85 @@
"""Portable RS command helpers from Set_Terminal_28335. """Thin Python port of the shared C99 PM35/TMS320F28335 protocol."""
The byte order and CRC match ``DTrans.pas``/``UNiiefa.pas``. This module is
deliberately UI- and serial-port-independent so desktop and Android clients
can share the same request builders.
"""
from __future__ import annotations from __future__ import annotations
from .native import get_native_protocol
REGISTER_COUNT = 128
DEFAULT_CONTROLLER = 16
DEFAULT_BAUD_RATE = 115_200
def _core():
return get_native_protocol()
def crc16_modbus(data: bytes, crc: int = 0xFFFF) -> int: def crc16_modbus(data: bytes, crc: int = 0xFFFF) -> int:
for byte in data: if crc != 0xFFFF:
crc ^= byte raise ValueError("Произвольное начальное значение CRC не входит в протокол ПМ35")
for _ in range(8): return _core().periph28335_crc16(bytes(data))
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc & 0xFFFF
def with_crc(payload: bytes) -> bytes: def with_crc(payload: bytes) -> bytes:
crc = crc16_modbus(payload) return _core().periph28335_append_crc(bytes(payload))
return bytes(payload) + crc.to_bytes(2, "little")
def build_read_registers(controller: int, start: int, count: int) -> bytes: def build_read_registers(controller: int, start: int, count: int) -> bytes:
_range("Адрес контроллера", controller, 0xFF) _range("Адрес контроллера", controller, 0xFF)
_range("Начальный регистр", start, 0xFFFF) _range("Начальный регистр", start, 0xFFFF)
if not 1 <= count <= 128 or start + count > 128: if not 1 <= count <= REGISTER_COUNT or start + count > REGISTER_COUNT:
raise ValueError("Диапазон регистров должен находиться в 0..127") raise ValueError("Диапазон регистров должен находиться в 0..127")
return with_crc(bytes((controller, 3)) + start.to_bytes(2, "big") return _core().periph28335_build_read(controller, start, count)
+ count.to_bytes(2, "big"))
def build_write_register(controller: int, address: int, value: int) -> bytes: def build_write_register(controller: int, address: int, value: int) -> bytes:
_range("Адрес контроллера", controller, 0xFF) _range("Адрес контроллера", controller, 0xFF)
_range("Адрес регистра", address, 127) _range("Адрес регистра", address, REGISTER_COUNT - 1)
_range("Значение", value, 0xFFFF) _range("Значение", value, 0xFFFF)
return with_crc(bytes((controller, 6)) + address.to_bytes(2, "big") return _core().periph28335_build_write(controller, address, value)
+ value.to_bytes(2, "big"))
def build_command(controller: int, command_index: int) -> bytes: def build_command(controller: int, command_index: int) -> bytes:
_range("Адрес контроллера", controller, 0xFF)
if not 0 <= command_index <= 16: if not 0 <= command_index <= 16:
raise ValueError("Номер команды должен быть в диапазоне 0..16") raise ValueError("Номер команды должен быть в диапазоне 0..16")
value = 1 << command_index if command_index < 16 else 0 return _core().periph28335_build_command(controller, command_index)
return build_write_register(controller, 127, value)
def expected_read_response_size(count: int) -> int: def expected_read_response_size(count: int) -> int:
return count * 2 + 5 if not 1 <= count <= REGISTER_COUNT:
raise ValueError("Число регистров должно быть в диапазоне 1..128")
size = _core().periph28335_expected_read_size(count)
if size == 0:
raise ValueError("SETProtocol отклонил размер ответа ПМ35")
return size
def decode_read_response(data: bytes, count: int) -> tuple[int, ...]: def decode_read_response(data: bytes, count: int,
controller: int | None = None) -> tuple[int, ...]:
expected = expected_read_response_size(count) expected = expected_read_response_size(count)
if len(data) != expected: if len(data) != expected:
raise ValueError(f"Ожидалось {expected} байт, получено {len(data)}") raise ValueError(f"Ожидалось {expected} байт, получено {len(data)}")
if crc16_modbus(data[:-2]) != int.from_bytes(data[-2:], "little"): expected_controller = data[0] if controller is None and data else controller
raise ValueError("Ошибка CRC ответа") if expected_controller is None:
# Historical replies have a three-byte header; registers are big-endian. raise ValueError("Пустой ответ ПМ35")
body = data[3:-2] status, values = _core().periph28335_decode_read(
if len(body) != count * 2: bytes(data), expected_controller, count)
raise ValueError("Неверная длина данных ответа") errors = {
return tuple(int.from_bytes(body[offset:offset + 2], "big") -1: "Неверные аргументы ответа",
for offset in range(0, len(body), 2)) -2: "Диапазон регистров вне 0..127",
-3: "Неверная длина данных ответа",
-4: "Ошибка CRC ответа",
-5: "Неверный заголовок ответа",
-6: "Недостаточный буфер ответа",
}
if status != 0:
raise ValueError(errors.get(status, f"Ошибка ответа ПМ35: {status}"))
return values
def validate_write_response(data: bytes, request: bytes) -> bool:
return _core().periph28335_validate_write(bytes(data), bytes(request)) == 0
def bits_lsb_first(value: int) -> tuple[bool, ...]: def bits_lsb_first(value: int) -> tuple[bool, ...]:
@@ -85,14 +104,4 @@ def _range(name: str, value: int, maximum: int) -> None:
raise ValueError(f"{name} вне диапазона 0..{maximum}") raise ValueError(f"{name} вне диапазона 0..{maximum}")
PROJECT_COMMANDS = { PROJECT_COMMANDS = _core().periph28335_catalog()
"По умолчанию": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"23470": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "-", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"23550": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "Send", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"23550.2": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Send", "Raw", "Beep", "", "", "", "", "Log", "Reset", "Nothing at all"),
"ICE 22220.1-3": ("Test", "Zero", "Save", "Def", "Calibr", "Read", "ExtLamp", "ExtLite", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"ICE 22220.4-5": ("Test", "Def", "Save", "Load", "Raw", "Read", "ExtLamp", "ExtLite", "No log", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"Бальзам 161": ("Test", "Zero", "Save", "Def", "Calibr", "Clbr 400", "Stop", "Start", "Init", "Secret", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"Бальзам 162": ("Test", "Def", "Save", "Load", "Calibr", "Secret", "Stop", "Start", "Init", "Tune", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
"Бальзам 163": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Stop", "Start", "Init", "Tune", "Secret", "-", "-", "-", "-", "Reset", "Nothing at all"),
}

View File

@@ -67,6 +67,10 @@ class PlotMath:
except ValueError: except ValueError:
return None return None
def limits(self, left: float, right: float, bottom: float, top: float) -> "Bounds":
"""Validate absolute axis limits in the shared core."""
return Bounds(*self.call(8, left, right, bottom, top))
@dataclass(frozen=True) @dataclass(frozen=True)
class Viewport: class Viewport:
@@ -92,6 +96,9 @@ class Bounds:
bottom: float bottom: float
top: float top: float
def validated(self, core: PlotMath) -> "Bounds":
return core.limits(self.left, self.right, self.bottom, self.top)
def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float: def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float:
return core.call(2, value, self.bottom if horizontal else self.left, return core.call(2, value, self.bottom if horizontal else self.left,
self.top if horizontal else self.right, int(horizontal))[0] self.top if horizontal else self.right, int(horizontal))[0]

View File

@@ -37,6 +37,12 @@ class Spectrum:
return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes))) return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes)))
@dataclass(frozen=True)
class SpectrumPeak:
frequency_hz: float
amplitude: float
class NativeSpectrum: class NativeSpectrum:
def __init__(self, library: ctypes.CDLL): def __init__(self, library: ctypes.CDLL):
self.lib = library self.lib = library
@@ -46,6 +52,11 @@ class NativeSpectrum:
ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_int,
pointer, ctypes.c_size_t, pointer] pointer, ctypes.c_size_t, pointer]
self._analyze.restype = ctypes.c_int self._analyze.restype = ctypes.c_int
self._peak = library.set_spectrum_dominant_peak
self._peak.argtypes = [pointer, ctypes.c_size_t, ctypes.c_double,
ctypes.c_double, ctypes.c_double, pointer, ctypes.c_size_t,
pointer, ctypes.c_size_t]
self._peak.restype = ctypes.c_int
def analyze(self, times, values, *, max_size=4096, window=Window.HANN, filter=Filter.NONE, 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: low_hz=10.0, high_hz=100.0, remove_mean=True) -> Spectrum:
@@ -69,3 +80,15 @@ class NativeSpectrum:
raise ValueError(message.get(status, "FFT failed")) raise ValueError(message.get(status, "FFT failed"))
n = int(meta[0]) n = int(meta[0])
return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1])) return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1]))
def dominant_peak(self, spectrum: Spectrum, *, relative_threshold: float = 3.0,
absolute_floor: float = 1e-6) -> SpectrumPeak | None:
amplitudes = (ctypes.c_double * len(spectrum.amplitudes))(*spectrum.amplitudes)
scratch = (ctypes.c_double * max(1, len(spectrum.amplitudes) - 1))()
output = (ctypes.c_double * 2)()
status = self._peak(amplitudes, len(spectrum.amplitudes),
spectrum.sample_rate / spectrum.size, relative_threshold, absolute_floor,
scratch, len(scratch), output, 2)
if status < 0:
raise ValueError("Invalid spectrum peak input")
return SpectrumPeak(output[0], output[1]) if status else None

View File

@@ -54,6 +54,8 @@ class TrendSignal:
deviceType: int = 7 deviceType: int = 7
device: int = 13 device: int = 13
byteOffset: int = 0 byteOffset: int = 0
multiplier: float = 1.0
iq: int = 0
extended: bool = True extended: bool = True
@classmethod @classmethod
@@ -72,9 +74,11 @@ class TrendSignal:
for field in ("id", "name", "source", "address", "color", "valueType"): for field in ("id", "name", "source", "address", "color", "valueType"):
if type(getattr(self, field)) is not str: if type(getattr(self, field)) is not str:
raise ValueError(f"{field} must be a string") raise ValueError(f"{field} must be a string")
for field in ("order", "deviceType", "device", "byteOffset"): for field in ("order", "deviceType", "device", "byteOffset", "iq"):
if type(getattr(self, field)) is not int: if type(getattr(self, field)) is not int:
raise ValueError(f"{field} must be an integer") raise ValueError(f"{field} must be an integer")
if type(self.multiplier) not in (int, float) or not math.isfinite(self.multiplier):
raise ValueError("multiplier must be a finite number")
if type(self.visible) is not bool or type(self.extended) is not bool: if type(self.visible) is not bool or type(self.extended) is not bool:
raise ValueError("Visibility and CAN format must be boolean") 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: if not self.id.strip() or len(self.id) > 80 or not 1 <= self.order <= 9999:
@@ -98,12 +102,11 @@ class TrendSignal:
raise ValueError("Invalid ProtoCAN device") raise ValueError("Invalid ProtoCAN device")
if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6: if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6:
raise ValueError("Word offset must be 0..6") raise ValueError("Word offset must be 0..6")
if not 0 <= self.iq <= 30:
raise ValueError("IQ must be 0..30")
def word_value(self, word: int) -> float: def display_value(self, raw_value: float) -> float:
if not 0 <= word <= 65535: return raw_value * self.multiplier / (1 << self.iq)
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: def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None:
ids = set() ids = set()
@@ -138,14 +141,15 @@ def decode_settings(text: str) -> dict[str, list[TrendSignal]]:
raise ValueError("profiles must be an object") raise ValueError("profiles must be an object")
result = {} result = {}
fields = set(TrendSignal.__dataclass_fields__) fields = set(TrendSignal.__dataclass_fields__)
required_fields = fields - {"multiplier", "iq"}
for profile, items in profiles.items(): for profile, items in profiles.items():
if not isinstance(items, list) or len(items) > MAX_SIGNALS: if not isinstance(items, list) or len(items) > MAX_SIGNALS:
raise ValueError("Expected up to 64 signals") raise ValueError("Expected up to 64 signals")
signals = [] signals = []
for item in items: for item in items:
if not isinstance(item, dict) or not fields <= item.keys(): if not isinstance(item, dict) or not required_fields <= item.keys():
raise ValueError("Missing signal fields") raise ValueError("Missing signal fields")
signals.append(TrendSignal(**{key: item[key] for key in fields})) signals.append(TrendSignal(**{key: item[key] for key in fields if key in item}))
result[profile] = signals result[profile] = signals
validate_settings(result) validate_settings(result)
return result return result
@@ -178,6 +182,20 @@ class NativeTrends:
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, 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] ctypes.c_uint32, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t]
self.decode.restype = ctypes.c_int32 self.decode.restype = ctypes.c_int32
self._word = library.set_trend_word_value
self._word.argtypes = [ctypes.c_uint16, ctypes.c_uint8]
self._word.restype = ctypes.c_int32
self._watch_request = library.set_trend_watch_request
self._watch_request.argtypes = [ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16),
ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t]
self._watch_request.restype = ctypes.c_size_t
self._watch_ack = library.set_trend_watch_ack
self._watch_ack.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint16, ctypes.c_size_t]
self._watch_ack.restype = ctypes.c_int
self._watch_decode = library.set_trend_watch_decode
self._watch_decode.argtypes = [ctypes.c_void_p, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t]
self._watch_decode.restype = ctypes.c_int
def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None: def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None:
if signal.source not in ("CAN_GAS", "CAN_RAW"): if signal.source not in ("CAN_GAS", "CAN_RAW"):
@@ -191,3 +209,33 @@ class NativeTrends:
signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags, signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags,
payload, len(data)) payload, len(data))
return None if value == -2147483648 else float(value) return None if value == -2147483648 else float(value)
def word_value(self, signal: TrendSignal, word: int) -> float:
if not 0 <= word <= 65535:
raise ValueError("Not a 16-bit word")
return float(self._word(word, signal.valueType == "INT16"))
def watch_request(self, period_ms: int, addresses: list[int]) -> bytes:
if not 0 <= period_ms <= 65535 or len(addresses) > MAX_SIGNALS or any(
type(address) is not int or not 0 <= address <= 65535 for address in addresses):
raise ValueError("Invalid GAS watch request")
source = (ctypes.c_uint16 * len(addresses))(*addresses)
output = (ctypes.c_uint8 * (4 + len(addresses) * 2))()
size = self._watch_request(period_ms, source, len(addresses), output, len(output))
if not size:
raise ValueError("Invalid GAS watch request")
return bytes(output[:size])
def validate_watch_ack(self, payload: bytes, period_ms: int, count: int) -> None:
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
if not self._watch_ack(data, len(payload), period_ms, count):
raise ValueError("Device did not accept the complete GAS subscription")
def watch_values(self, payload: bytes, expected_count: int | None = None) -> tuple[int, list[int]]:
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
timestamp = ctypes.c_uint32()
words = (ctypes.c_uint16 * MAX_SIGNALS)()
count = self._watch_decode(data, len(payload), ctypes.byref(timestamp), words, MAX_SIGNALS)
if count < 0 or expected_count is not None and count != expected_count:
raise ValueError("Invalid GAS watch data")
return timestamp.value, list(words[:count])

View File

@@ -0,0 +1,145 @@
"""Reusable helpers for publishing the shared firmware release catalog."""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
from .firmware_catalog import (
MAX_MANIFEST_BYTES,
SUPPORTED_TRANSPORTS,
parse_firmware_catalog,
)
MAX_FIRMWARE_BYTES = 128 * 1024 * 1024
@dataclass(frozen=True)
class FirmwarePublication:
"""Metadata required to publish one firmware image."""
path: Path
product: str
version_name: str
version_code: int
transport: str
base_address: int | None = None
notes: str = ""
def validate(self) -> None:
if not self.path.is_file():
raise ValueError(f"Firmware file is missing: {self.path}")
if self.path.suffix.lower() not in {".bin", ".hex"}:
raise ValueError("Firmware file must have a .bin or .hex extension")
size = self.path.stat().st_size
if size <= 0:
raise ValueError("Firmware file is empty")
if size > MAX_FIRMWARE_BYTES:
raise ValueError("Firmware file exceeds the maximum size")
if not self.product.strip():
raise ValueError("Firmware product is empty")
if not self.version_name.strip():
raise ValueError("Firmware version name is empty")
if not 0 <= self.version_code <= 0x7FFFFFFF:
raise ValueError(
"Firmware version code must be between 0 and 2147483647"
)
if self.transport not in SUPPORTED_TRANSPORTS:
raise ValueError("Unsupported firmware transport")
if (
self.base_address is not None
and not 0 <= self.base_address <= 0xFFFFFFFF
):
raise ValueError("Firmware base address is outside the uint32 range")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def safe_release_tag_part(value: str) -> str:
source = value.strip()
part = re.sub(r"[^A-Za-z0-9._-]+", "-", source).strip("-.")
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:8]
if not part:
return digest
return part if part == source else f"{part}-{digest}"
def firmware_release_tag(publication: FirmwarePublication) -> str:
return "firmware-%s-v%s" % (
safe_release_tag_part(publication.product),
safe_release_tag_part(publication.version_name),
)
def firmware_release_entry(
publication: FirmwarePublication, image_url: str, sha256: str
) -> dict:
result = {
"product": publication.product.strip(),
"versionCode": publication.version_code,
"versionName": publication.version_name.strip(),
"imageUrl": image_url,
"fileName": publication.path.name,
"sha256": sha256,
"transport": publication.transport,
"notes": publication.notes.strip(),
}
if publication.base_address is not None:
result["baseAddress"] = f"0x{publication.base_address:08X}"
return result
def firmware_entry_identity(entry: dict) -> tuple[str, int, str]:
return (
str(entry.get("product", entry.get("device", ""))).strip().casefold(),
int(entry.get("versionCode", 0)),
str(entry.get("transport", "rs485")).strip().lower(),
)
def update_firmware_manifest(manifest: dict, entry: dict) -> dict:
"""Insert or replace one release without disturbing other manifest data."""
result = dict(manifest)
existing = manifest.get("firmware")
firmware = dict(existing) if isinstance(existing, dict) else {}
rows = firmware.get("releases") if isinstance(existing, dict) else existing
releases = (
[dict(row) for row in rows if isinstance(row, dict)]
if isinstance(rows, list)
else []
)
identity = firmware_entry_identity(entry)
releases = [
row for row in releases if firmware_entry_identity(row) != identity
]
releases.append(dict(entry))
releases.sort(
key=lambda row: (
str(row.get("product", row.get("device", ""))).casefold(),
-int(row.get("versionCode", 0)),
str(row.get("transport", "rs485")),
)
)
previous_rows = firmware.get("releases")
changed = releases != previous_rows
firmware["catalogVersion"] = (
int(firmware.get("catalogVersion", 0)) + int(changed)
)
firmware["releases"] = releases
result["firmware"] = firmware
encoded = (json.dumps(result, ensure_ascii=False) + "\n").encode("utf-8")
if len(encoded) > MAX_MANIFEST_BYTES:
raise ValueError("Updated update.json exceeds the maximum size")
parse_firmware_catalog(encoded, "https://catalog.invalid/update.json")
return result

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from setprotocol.firmware_publish import (
FirmwarePublication,
firmware_release_entry,
firmware_release_tag,
update_firmware_manifest,
)
class FirmwarePublishTests(unittest.TestCase):
def publication(self, path: Path, **overrides) -> FirmwarePublication:
fields = {
"path": path,
"product": "F103DS18",
"version_name": "1.1.0",
"version_code": 0x00010100,
"transport": "can",
"base_address": 0x08003000,
"notes": "Verified release",
}
fields.update(overrides)
return FirmwarePublication(**fields)
def test_publication_validates_file_and_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
image = Path(temporary) / "image.hex"
image.write_text(":00000001FF\n", encoding="ascii")
self.publication(image).validate()
with self.assertRaisesRegex(ValueError, "Unsupported"):
self.publication(image, transport="unknown").validate()
def test_entry_and_tag_are_deterministic(self) -> None:
publication = self.publication(Path("image.hex"))
entry = firmware_release_entry(
publication, "https://example.test/image.hex", "ab" * 32
)
self.assertEqual(
firmware_release_tag(publication), "firmware-F103DS18-v1.1.0"
)
self.assertEqual(entry["baseAddress"], "0x08003000")
def test_update_preserves_sections_and_replaces_same_release(self) -> None:
first = {
"product": "Device",
"versionCode": 7,
"versionName": "1.2.3",
"imageUrl": "https://example.test/old.bin",
"fileName": "old.bin",
"sha256": "11" * 32,
"transport": "rs485",
}
manifest = update_firmware_manifest(
{"windows": {"versionCode": 8}}, first
)
replacement = {
**first,
"imageUrl": "https://example.test/new.bin",
"fileName": "new.bin",
"sha256": "22" * 32,
}
updated = update_firmware_manifest(manifest, replacement)
self.assertEqual(updated["windows"], {"versionCode": 8})
self.assertEqual(len(updated["firmware"]["releases"]), 1)
self.assertEqual(
updated["firmware"]["releases"][0]["sha256"], "22" * 32
)
self.assertEqual(updated["firmware"]["catalogVersion"], 2)
def test_legacy_array_is_migrated_without_data_loss(self) -> None:
legacy = {
"product": "Legacy",
"versionCode": 1,
"versionName": "1.0",
"imageUrl": "https://example.test/legacy.bin",
"fileName": "legacy.bin",
"sha256": "33" * 32,
"transport": "rs485",
}
current = {**legacy, "product": "Current", "versionCode": 2}
updated = update_firmware_manifest({"firmware": [legacy]}, current)
self.assertCountEqual(
[row["product"] for row in updated["firmware"]["releases"]],
["Legacy", "Current"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -56,6 +56,9 @@ class PlotTests(unittest.TestCase):
self.assertAlmostEqual(20, self.core.db_delta(1, 10)) self.assertAlmostEqual(20, self.core.db_delta(1, 10))
self.assertAlmostEqual(-20, self.core.db_delta(10, 1)) self.assertAlmostEqual(-20, self.core.db_delta(10, 1))
self.assertIsNone(self.core.db_delta(0, 1)) self.assertIsNone(self.core.db_delta(0, 1))
self.assertEqual(Bounds(0, 500, -2, 2), self.core.limits(0, 500, -2, 2))
with self.assertRaises(ValueError):
self.core.limits(1, 1, -2, 2)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -35,6 +35,9 @@ class SpectrumTests(unittest.TestCase):
peak = max(range(len(result.amplitudes)), key=result.amplitudes.__getitem__) peak = max(range(len(result.amplitudes)), key=result.amplitudes.__getitem__)
self.assertEqual(64, result.frequencies[peak]) self.assertEqual(64, result.frequencies[peak])
self.assertAlmostEqual(3.25, result.amplitudes[peak], places=9) self.assertAlmostEqual(3.25, result.amplitudes[peak], places=9)
detected = self.core.dominant_peak(result)
self.assertAlmostEqual(64, detected.frequency_hz, places=9)
self.assertAlmostEqual(3.25, detected.amplitude, places=9)
def test_dc_and_nyquist_are_not_doubled(self): def test_dc_and_nyquist_are_not_doubled(self):
times, _ = self.sample() times, _ = self.sample()

View File

@@ -14,6 +14,16 @@ FIXTURE = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/t
class TrendTests(unittest.TestCase): class TrendTests(unittest.TestCase):
def test_multiplier_and_iq_scale_display_and_old_json_defaults(self):
signal = TrendSignal("scaled", multiplier=2.5, iq=3)
self.assertEqual(10.0, signal.display_value(32.0))
signal.validate("TMS2812")
payload = json.loads(encode_settings({"TMS2812": [signal]}))
payload["profiles"]["TMS2812"][0].pop("multiplier")
payload["profiles"]["TMS2812"][0].pop("iq")
restored = decode_settings(json.dumps(payload))["TMS2812"][0]
self.assertEqual((1.0, 0), (restored.multiplier, restored.iq))
def test_shared_kotlin_fixture_and_round_trip(self): def test_shared_kotlin_fixture_and_round_trip(self):
settings = decode_settings(FIXTURE.read_text(encoding="utf-8")) settings = decode_settings(FIXTURE.read_text(encoding="utf-8"))
self.assertEqual(5, sum(map(len, settings.values()))) self.assertEqual(5, sum(map(len, settings.values())))
@@ -57,7 +67,6 @@ class TrendTests(unittest.TestCase):
for value in ("-1", "+1", "FF", "0x", "1.0", "256"): for value in ("-1", "+1", "FF", "0x", "1.0", "256"):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
parse_address(value, 255) parse_address(value, 255)
self.assertEqual(-2.0, TrendSignal("s", valueType="INT16").word_value(65534))
def test_bounded_history(self): def test_bounded_history(self):
history = TrendHistory() history = TrendHistory()
@@ -81,6 +90,13 @@ class TrendTests(unittest.TestCase):
self.assertIsNone(core.can_value(signal, frame_id ^ 0x08000000, 1, data)) self.assertIsNone(core.can_value(signal, frame_id ^ 0x08000000, 1, data))
raw = replace(signal, source="CAN_RAW", address="0x321", extended=False, byteOffset=2) raw = replace(signal, source="CAN_RAW", address="0x321", extended=False, byteOffset=2)
self.assertEqual(-2.0, core.can_value(raw, 0x321, 0, data)) self.assertEqual(-2.0, core.can_value(raw, 0x321, 0, data))
self.assertEqual(-2.0, core.word_value(TrendSignal("s", valueType="INT16"), 65534))
self.assertEqual(bytes([232, 3, 2, 0, 52, 18, 255, 255]),
core.watch_request(1000, [0x1234, 0xFFFF]))
core.validate_watch_ack(bytes([232, 3, 2, 0]), 1000, 2)
timestamp, words = core.watch_values(bytes([1, 2, 3, 4, 2, 0, 52, 18, 255, 255]), 2)
self.assertEqual(0x04030201, timestamp)
self.assertEqual([0x1234, 0xFFFF], words)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,221 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "FW_TOOL_DIR=%~dp0"
set "FW_MODE=--preflight"
set "FW_CONFIG="
set "FW_ASSUME_YES=0"
set "FW_ALLOW_DIRTY=0"
:parse_arguments
if "%~1"=="" goto arguments_done
if /i "%~1"=="--preflight" (
set "FW_MODE=--preflight"
shift
goto parse_arguments
)
if /i "%~1"=="--publish" (
set "FW_MODE=--publish"
shift
goto parse_arguments
)
if /i "%~1"=="--yes" (
set "FW_ASSUME_YES=1"
shift
goto parse_arguments
)
if /i "%~1"=="--allow-dirty" (
set "FW_ALLOW_DIRTY=1"
shift
goto parse_arguments
)
if /i "%~1"=="--config" (
if "%~2"=="" goto missing_config_value
set "FW_CONFIG=%~2"
shift
shift
goto parse_arguments
)
if /i "%~1"=="--help" goto usage
if /i "%~1"=="-h" goto usage
echo ERROR: unknown argument: %~1
goto usage_error
:missing_config_value
echo ERROR: --config requires a path.
goto usage_error
:arguments_done
if not defined FW_CONFIG (
if exist "%CD%\firmware-release.cmd" (
set "FW_CONFIG=%CD%\firmware-release.cmd"
) else (
set "FW_CONFIG=%FW_TOOL_DIR%firmware-release.cmd"
)
)
for %%I in ("%FW_CONFIG%") do set "FW_CONFIG=%%~fI"
if not exist "%FW_CONFIG%" (
echo ERROR: publication config not found:
echo %FW_CONFIG%
echo Copy one of the examples to the firmware project root as
echo firmware-release.cmd, then edit its values.
exit /b 2
)
for %%I in ("%FW_CONFIG%") do set "FW_CONFIG_DIR=%%~dpI"
call "%FW_CONFIG%"
if errorlevel 1 (
echo ERROR: publication config returned an error.
exit /b 2
)
if not defined FW_PRODUCT goto missing_FW_PRODUCT
if not defined FW_VERSION goto missing_FW_VERSION
if not defined FW_VERSION_CODE goto missing_FW_VERSION_CODE
if not defined FW_TRANSPORTS goto missing_FW_TRANSPORTS
if not defined FW_IMAGE goto missing_FW_IMAGE
if not defined FW_PROJECT_ROOT set "FW_PROJECT_ROOT=%FW_CONFIG_DIR%"
pushd "%FW_CONFIG_DIR%" >nul
if errorlevel 1 (
echo ERROR: cannot enter config directory: %FW_CONFIG_DIR%
exit /b 2
)
for %%I in ("%FW_PROJECT_ROOT%") do set "FW_PROJECT_ROOT_ABS=%%~fI"
for %%I in ("%FW_IMAGE%") do set "FW_IMAGE_ABS=%%~fI"
if defined FW_SETGUI_ROOT for %%I in ("%FW_SETGUI_ROOT%") do set "FW_SETGUI_ROOT_ABS=%%~fI"
popd
if not defined FW_SETGUI_ROOT_ABS call :find_setgui
if not defined FW_SETGUI_ROOT_ABS (
echo ERROR: SETGUI was not found.
echo Set FW_SETGUI_ROOT in %FW_CONFIG%
exit /b 2
)
set "FW_SETGUI_PUBLISHER=%FW_SETGUI_ROOT_ABS%\PUBLISH_FIRMWARE.bat"
if not exist "%FW_SETGUI_PUBLISHER%" (
echo ERROR: SETGUI publisher not found:
echo %FW_SETGUI_PUBLISHER%
exit /b 2
)
if not exist "%FW_IMAGE_ABS%" (
echo ERROR: firmware image not found:
echo %FW_IMAGE_ABS%
exit /b 2
)
for %%I in ("%FW_IMAGE_ABS%") do set "FW_IMAGE_EXT=%%~xI"
if /i not "%FW_IMAGE_EXT%"==".hex" if /i not "%FW_IMAGE_EXT%"==".bin" (
echo ERROR: FW_IMAGE must point to a .hex or .bin file, not %FW_IMAGE_EXT%.
echo Keil must create a HEX file; CCS must run C2000 Hex Utility.
exit /b 2
)
echo.
echo Firmware publication
echo Mode: %FW_MODE%
echo Project: %FW_PROJECT_ROOT_ABS%
echo Image: %FW_IMAGE_ABS%
echo Product: %FW_PRODUCT%
echo Version: %FW_VERSION% ^(%FW_VERSION_CODE%^)
echo Transports: %FW_TRANSPORTS%
if defined FW_BASE_ADDRESS echo Base address: %FW_BASE_ADDRESS%
echo.
if /i "%FW_MODE%"=="--publish" call :guard_worktree
if errorlevel 1 exit /b %errorlevel%
if /i "%FW_MODE%"=="--publish" if "%FW_ASSUME_YES%"=="0" (
echo This will upload the image and change the public firmware catalog.
choice /C YN /N /M "Continue [Y/N]? "
if errorlevel 2 (
echo Publication cancelled.
exit /b 2
)
)
for %%T in (%FW_TRANSPORTS:,= %) do (
call :publish_transport "%%~T"
if errorlevel 1 goto publication_failed
)
if /i "%FW_MODE%"=="--publish" (
echo.
echo Publication completed and verified for all transports.
) else (
echo.
echo Preflight passed. Run with --publish only when this build is a release.
)
exit /b 0
:publication_failed
echo.
echo ERROR: publication step failed; remaining transports were not processed.
exit /b 1
:publish_transport
set "FW_TRANSPORT=%~1"
if "%FW_TRANSPORT%"=="" exit /b 0
echo ----- %FW_TRANSPORT% -----
if defined FW_BASE_ADDRESS (
call "%FW_SETGUI_PUBLISHER%" %FW_MODE% --file "%FW_IMAGE_ABS%" --product "%FW_PRODUCT%" --version "%FW_VERSION%" --version-code "%FW_VERSION_CODE%" --transport "%FW_TRANSPORT%" --base-address "%FW_BASE_ADDRESS%" --notes "%FW_NOTES%"
) else (
call "%FW_SETGUI_PUBLISHER%" %FW_MODE% --file "%FW_IMAGE_ABS%" --product "%FW_PRODUCT%" --version "%FW_VERSION%" --version-code "%FW_VERSION_CODE%" --transport "%FW_TRANSPORT%" --notes "%FW_NOTES%"
)
exit /b %errorlevel%
:guard_worktree
where git >nul 2>nul
if errorlevel 1 (
echo WARNING: Git is unavailable; source cleanliness was not checked.
exit /b 0
)
git -C "%FW_PROJECT_ROOT_ABS%" rev-parse --is-inside-work-tree >nul 2>nul
if errorlevel 1 (
echo WARNING: project is not a Git worktree; source cleanliness was not checked.
exit /b 0
)
git -C "%FW_PROJECT_ROOT_ABS%" diff --quiet HEAD -- >nul 2>nul
if not errorlevel 1 exit /b 0
if "%FW_ALLOW_DIRTY%"=="1" (
echo WARNING: publishing from a worktree with tracked changes.
exit /b 0
)
echo ERROR: tracked project files differ from HEAD.
echo Commit the release sources or repeat with --allow-dirty deliberately.
exit /b 3
:find_setgui
for %%I in ("%FW_CONFIG_DIR%..\SETGUI" "%FW_CONFIG_DIR%..\..\SETGUI" "%FW_CONFIG_DIR%..\..\..\SETGUI") do (
if exist "%%~fI\PUBLISH_FIRMWARE.bat" if not defined FW_SETGUI_ROOT_ABS set "FW_SETGUI_ROOT_ABS=%%~fI"
)
exit /b 0
:missing_FW_PRODUCT
echo ERROR: FW_PRODUCT is not set in %FW_CONFIG%
exit /b 2
:missing_FW_VERSION
echo ERROR: FW_VERSION is not set in %FW_CONFIG%
exit /b 2
:missing_FW_VERSION_CODE
echo ERROR: FW_VERSION_CODE is not set in %FW_CONFIG%
exit /b 2
:missing_FW_TRANSPORTS
echo ERROR: FW_TRANSPORTS is not set in %FW_CONFIG%
exit /b 2
:missing_FW_IMAGE
echo ERROR: FW_IMAGE is not set in %FW_CONFIG%
exit /b 2
:usage
echo Usage:
echo PUBLISH_FIRMWARE.bat [--config PATH] --preflight
echo PUBLISH_FIRMWARE.bat [--config PATH] --publish [--yes] [--allow-dirty]
echo.
echo Default mode is --preflight. Default config is firmware-release.cmd
echo in the current directory or next to this script.
exit /b 0
:usage_error
call :usage
exit /b 2

View File

@@ -0,0 +1,159 @@
# Публикация прошивки из Keil и CCS 12
Этот комплект связывает проект прошивки с каталогом SETGUI. IDE по-прежнему
собирает штатный загрузочный файл, а `PUBLISH_FIRMWARE.bat`:
1. читает метаданные выпуска из `firmware-release.cmd`;
2. проверяет наличие и формат `.hex` или `.bin`;
3. запускает локальную проверку SETGUI (`--preflight`) либо публикацию
(`--publish`);
4. для каждого транспорта загружает образ в Gitea, скачивает его обратно,
сверяет SHA-256 и только после этого обновляет `update.json`.
Пароль в проекте не хранится. Публикатор использует учётные данные Gitea,
которые сохранены в SETGUI через окно «Версия и обновление».
## Один раз на рабочем компьютере
1. Соберите или запустите SETGUI и сохраните в нём логин и пароль (либо токен
вместо пароля) Gitea.
2. Убедитесь, что у SETGUI создано Python-окружение `.venv`.
3. Подключите `templates` как сабмодуль проекта либо используйте уже общий
checkout. Не делайте отдельные исправленные копии скрипта в каждом проекте.
4. Скопируйте подходящий файл из [`examples`](examples) в корень проекта под
именем `firmware-release.cmd` и исправьте значения.
`firmware-release.cmd` содержит только метаданные:
```bat
set "FW_PROJECT_ROOT=%~dp0"
set "FW_SETGUI_ROOT=%~dp0..\SETGUI"
set "FW_PRODUCT=F103DS18"
set "FW_VERSION=1.1.0"
set "FW_VERSION_CODE=0x00010100"
set "FW_TRANSPORTS=can rs485"
set "FW_BASE_ADDRESS=0x08003000"
set "FW_IMAGE=mdk\build\ds18b20_f103.hex"
set "FW_NOTES=Краткое описание выпуска"
```
Пути считаются относительно каталога `firmware-release.cmd`. Если SETGUI не
лежит рядом с проектом, укажите абсолютный `FW_SETGUI_ROOT` или системную
переменную с тем же именем.
### Обязательные поля
| Поле | Значение |
|---|---|
| `FW_PRODUCT` | Стабильный идентификатор изделия. Не меняйте регистр/написание между версиями. |
| `FW_VERSION` | Читаемая версия SemVer, например `1.1.0`. |
| `FW_VERSION_CODE` | Число для сравнения версий. Рекомендуется `(major << 16) + (minor << 8) + patch`: `1.1.0` = `0x00010100`. |
| `FW_TRANSPORTS` | Один или несколько транспортов через пробел: `can`, `rs485`, `tms`. |
| `FW_IMAGE` | Готовый файл `.hex` или `.bin`. `.axf` и `.out` публиковать нельзя. |
`FW_BASE_ADDRESS` нужен для обычного бинарного образа STM32. Для Intel HEX
адрес уже записан в файле, но поле каталога всё равно лучше заполнить адресом
приложения. Для загрузочной таблицы TMS SCI8 оставьте поле пустым.
## Keil MDK / Arm Compiler 6
1. В **Options for Target → Output** включите **Create HEX File**.
2. Укажите в `FW_IMAGE` реальный выходной файл, например
`mdk\build\ds18b20_f103.hex`.
3. Запустите полную сборку и локальную проверку. В `KONOR_ds18b20`, где
сабмодуль `templates` подключён как `lib`, команда выглядит так:
```bat
call "lib\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight
```
В **Options for Target → User → After Build/Rebuild** можно добавить эту же
команду с `--preflight`. Если Keil запускает её из каталога `mdk`, передайте
явный путь к конфигурации, например `--config "..\firmware-release.cmd"`.
Не ставьте `--publish` в post-build: иначе обычная сборка станет внешней
операцией и сможет перезаписать опубликованную версию.
## Code Composer Studio 12 / C2000
SETGUI не преобразует `.out`. Для активной конфигурации **Debug и/или Release**
включите **C2000 Hex Utility** и сформируйте загрузочный `.bin`. Для SCI8 boot
TMS320F2812 используются параметры проекта:
```text
--binary
--boot
--sci8
```
Выход удобно складывать в `bin\${BuildArtifactFileBaseName}.bin`. Затем укажите
этот путь в `FW_IMAGE`, `tms` в `FW_TRANSPORTS`, а `FW_BASE_ADDRESS` оставьте
пустым.
После Build выполните:
```bat
call "..\newProject\templates\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight
```
Команда выше соответствует текущей раскладке `SETGIT\BALZAM_ALL` и
`SETGIT\newProject\templates`. Если `templates` подключён в сам проект как
`lib\templates`, используйте `lib\templates\tools\firmware-publish\...`.
При желании ту же команду можно добавить в **Project Properties → Build →
Steps → Post-build steps**. В конфигурации CCS, где Hex Utility не включён,
файл `.bin` не обновится — это особенно важно отдельно проверить для Release.
## Выпуск
Рабочая последовательность одинакова для обеих IDE:
```bat
rem 1. Собрать Release в IDE.
rem 2. Проверить метаданные, размер, имя, SHA-256 и запись каталога без сети.
call "lib\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight
rem 3. Закоммитить исходники выпуска и опубликовать.
call "lib\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --publish
```
Перед публикацией скрипт показывает все параметры, проверяет tracked-файлы
Git и просит подтверждение. `--allow-dirty` осознанно разрешает публикацию из
изменённого worktree, а `--yes` отключает только интерактивное подтверждение
для доверенного CI:
```bat
call PUBLISH_FIRMWARE.bat --publish --yes --allow-dirty
```
Повторная публикация той же комбинации `product + versionCode + transport`
заменяет запись каталога. Новый `versionCode` добавляет новую версию. Если при
нескольких транспортах сеть оборвалась посередине, исправьте причину и повторите
ту же команду: уже опубликованные записи будут безопасно заменены теми же
данными.
## Контроль после публикации
Успешное завершение означает, что образ:
- загружен как asset выпуска;
- скачан обратно и совпал по SHA-256;
- записан в `firmware.releases` файла `update.json`;
- повторно прочитан и разобран тем же кодом, который использует SETGUI.
После этого откройте в SETGUI **Версия и обновление → База прошивок → Обновить
каталог** и проверьте изделие, версию и транспорт. Для окончательной проверки
выполните загрузку на тестовое устройство именно тем транспортом, который
указан в записи.
## Частые ошибки
| Сообщение | Что проверить |
|---|---|
| `firmware image not found` | Сборка завершилась успешно, `FW_IMAGE` задан относительно конфигурации, нужная конфигурация IDE создаёт `.hex/.bin`. |
| `SETGUI was not found` | Исправьте `FW_SETGUI_ROOT`. |
| `Missing build environment: .venv` | Создайте окружение SETGUI и установите зависимости проекта. |
| Ошибка авторизации Gitea | Заново сохраните логин и токен в SETGUI; не записывайте токен в `.cmd`. |
| `tracked project files differ from HEAD` | Закоммитьте точные исходники выпуска либо осознанно добавьте `--allow-dirty`. |
| Версия не видна в SETGUI | Нажмите «Обновить каталог» и проверьте точное значение `FW_PRODUCT` и поддерживаемый выбранным устройством транспорт. |

View File

@@ -0,0 +1,15 @@
@echo off
rem Copy this file to the firmware repository root as firmware-release.cmd.
set "FW_PROJECT_ROOT=%~dp0"
rem This relative path matches SETGIT/BALZAM_ALL + SETGIT/newProject/SETGUI.
rem Change it if the repositories are stored differently.
set "FW_SETGUI_ROOT=%~dp0..\newProject\SETGUI"
set "FW_PRODUCT=BALZAM-166"
set "FW_VERSION=1.0.0"
set "FW_VERSION_CODE=0x00010000"
set "FW_TRANSPORTS=tms"
set "FW_BASE_ADDRESS="
set "FW_IMAGE=ccs12_8_proj\bin\balzam_166_ccs12_8.bin"
set "FW_NOTES=TMS320F2812 SCI8 boot image"

View File

@@ -0,0 +1,13 @@
@echo off
rem Copy this file to the firmware repository root as firmware-release.cmd.
set "FW_PROJECT_ROOT=%~dp0"
set "FW_SETGUI_ROOT=%~dp0..\SETGUI"
set "FW_PRODUCT=F103DS18"
set "FW_VERSION=1.1.0"
set "FW_VERSION_CODE=0x00010100"
set "FW_TRANSPORTS=can rs485"
set "FW_BASE_ADDRESS=0x08003000"
set "FW_IMAGE=mdk\build\ds18b20_f103.hex"
set "FW_NOTES=DS18B20 firmware release"