From 80ba17d77dcd51d3ad27df3c32d5e200a27341ee Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sat, 19 Sep 2026 07:12:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=20Alt?= =?UTF-8?q?era=20Logic=20=D0=B8=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=8B=20=D0=BF=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + c/firmware-info/PORTING.md | 151 +++++++++++- c/firmware-info/README.md | 4 + c/set-protocol/ALTERA_LOGIC.md | 93 ++++++++ c/set-protocol/CMakeLists.txt | 8 + c/set-protocol/include/altera_logic.h | 35 +++ c/set-protocol/include/altera_stream.h | 46 ++++ c/set-protocol/src/altera_logic.c | 142 ++++++++++++ c/set-protocol/src/altera_stream.c | 158 +++++++++++++ c/set-protocol/tests/test_altera_logic.c | 72 ++++++ c/set-protocol/tests/test_altera_stream.c | 62 +++++ c/set-protocol/tools/build_host.py | 2 + doc/setcan/ALTERA_LOGIC_STREAM.md | 189 +++++++++++++++ doc/setcan/DEVICE_REGISTRY.md | 21 ++ doc/setcan/README.md | 4 + python/altera_logic/.gitignore | 1 + python/altera_logic/__init__.py | 4 + python/altera_logic/markers.py | 89 +++++++ python/altera_logic/native.py | 131 +++++++++++ python/altera_logic/plot_scroll.py | 27 +++ python/altera_logic/qt_port.py | 114 +++++++++ python/altera_logic/stream.py | 108 +++++++++ python/altera_logic/stream_port.py | 103 +++++++++ python/protocan/__init__.py | 5 + python/protocan/can_boot.py | 123 ++++++++++ python/protocan/protocan.py | 2 + python/protocan/setp_firmware.py | 239 +++++++++++++++++++ python/protocan/stm32_boot.py | 47 ++++ python/protocan/tms_firmware.py | 118 ++++++++++ python/setprotocol/firmware_database.py | 268 ++++++++++++++++++++++ python/tests/test_altera_logic.py | 57 +++++ python/tests/test_altera_stream.py | 92 ++++++++ python/tests/test_firmware_clients.py | 135 +++++++++++ python/tests/test_firmware_database.py | 192 ++++++++++++++++ tools/firmware-publish/DATABASE.md | 178 ++++++++++++++ tools/firmware-publish/PORTING.md | 164 +++++++++++++ tools/firmware-publish/README.md | 12 +- tools/firmware-publish/firmware_db.py | 82 +++++++ 38 files changed, 3271 insertions(+), 10 deletions(-) create mode 100644 c/set-protocol/ALTERA_LOGIC.md create mode 100644 c/set-protocol/include/altera_logic.h create mode 100644 c/set-protocol/include/altera_stream.h create mode 100644 c/set-protocol/src/altera_logic.c create mode 100644 c/set-protocol/src/altera_stream.c create mode 100644 c/set-protocol/tests/test_altera_logic.c create mode 100644 c/set-protocol/tests/test_altera_stream.c create mode 100644 doc/setcan/ALTERA_LOGIC_STREAM.md create mode 100644 doc/setcan/DEVICE_REGISTRY.md create mode 100644 python/altera_logic/.gitignore create mode 100644 python/altera_logic/__init__.py create mode 100644 python/altera_logic/markers.py create mode 100644 python/altera_logic/native.py create mode 100644 python/altera_logic/plot_scroll.py create mode 100644 python/altera_logic/qt_port.py create mode 100644 python/altera_logic/stream.py create mode 100644 python/altera_logic/stream_port.py create mode 100644 python/protocan/can_boot.py create mode 100644 python/protocan/setp_firmware.py create mode 100644 python/protocan/stm32_boot.py create mode 100644 python/protocan/tms_firmware.py create mode 100644 python/setprotocol/firmware_database.py create mode 100644 python/tests/test_altera_logic.py create mode 100644 python/tests/test_altera_stream.py create mode 100644 python/tests/test_firmware_clients.py create mode 100644 python/tests/test_firmware_database.py create mode 100644 tools/firmware-publish/DATABASE.md create mode 100644 tools/firmware-publish/PORTING.md create mode 100644 tools/firmware-publish/firmware_db.py diff --git a/README.md b/README.md index e8a609c..963e27e 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ templates/ | Модуль | Что делает | Зависимости | |---|---|---| +| [`python/setprotocol/firmware_database.py`](python/setprotocol/firmware_database.py) | база прошивок: HTTPS-каталог, скачивание с SHA-256 и кэшем, публикация в Gitea без SETGUI; [подключение и CLI](tools/firmware-publish/DATABASE.md) | stdlib, Python 3.10+ | +| [`python/setprotocol/firmware_publish.py`](python/setprotocol/firmware_publish.py) | общие метаданные публикации, SHA-256, release tag и обновление каталога прошивок; [подключение](tools/firmware-publish/PORTING.md) | stdlib; сетевой адаптер в SETGUI | +| [`python/setprotocol/firmware_catalog.py`](python/setprotocol/firmware_catalog.py) | модель и parser каталога `firmware.releases` | stdlib | | [`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 | diff --git a/c/firmware-info/PORTING.md b/c/firmware-info/PORTING.md index addc92e..b112be8 100644 --- a/c/firmware-info/PORTING.md +++ b/c/firmware-info/PORTING.md @@ -1,11 +1,144 @@ -# PORTING +# Портирование firmware-info -Аппаратно-зависимый контракт ограничен `firmware_info_config.h`: -`FIRMWARE_VERSION_MAJOR`, `FIRMWARE_VERSION_MINOR`, `FIRMWARE_VERSION_PATCH` и -необязательный `FIRMWARE_BUILD_ID`. Выберите порт по семейству МК или создайте -его копию. Публикация — ответственность транспорта: 12 `uint16_t` для Modbus -либо 24 little-endian байта для SETGUI. +## Назначение и границы -При переносе проверьте: поддержку `__DATE__`/`__TIME__`, наличие generated в -include path, запуск генератора до компиляции и декодирование build ID как двух -ASCII-байтов в каждом логическом слове. +Это C-библиотека описания работающего образа: SemVer, дата/время компиляции, +8 символов build ID и сериализация контракта v1. Она не обращается к сети, +не читает версию из сервера и не записывает Flash. HAL, RTOS, UART и CAN +ядру не нужны. Отправку результата выполняет приложение. + +Для размещения `.hex/.bin` в каталоге SETGUI уже существуют +[`setprotocol.firmware_publish`](../../python/setprotocol/firmware_publish.py) +и [`tools/firmware-publish`](../../tools/firmware-publish/README.md). +Связь компонентов и перенос публикации описаны в +[`tools/firmware-publish/PORTING.md`](../../tools/firmware-publish/PORTING.md). + +## 1. Файлы и конфигурация + +Пример ниже предполагает `lib/templates` внутри нового проекта: + +```text +project/ + inc/firmware_info_config.h + generated/firmware_build_id.h # создаётся до сборки + lib/templates/c/firmware-info/ + src/ +``` + +Добавьте в сборку оба файла: + +```text +lib/templates/c/firmware-info/src/firmware_info.c +lib/templates/c/firmware-info/src/firmware_info_port.c +``` + +В include path добавьте `inc`, `generated` и +`lib/templates/c/firmware-info/include`. Встроенный `CMakeLists.txt` собирает +только ядро и host-тест; `firmware_info_port.c` и каталоги конфигурации нужно +добавлять к целевому firmware target самостоятельно. + +Скопируйте `ports//firmware_info_config.template.h` как +`inc/firmware_info_config.h`. Есть варианты STM32F1/F4/G4 и К1921ВК028; +они не содержат регистров МК. Для другого МК с обычными 8-битными байтами +достаточно такого же config: + +```c +#ifndef FIRMWARE_INFO_CONFIG_H +#define FIRMWARE_INFO_CONFIG_H +#define FIRMWARE_VERSION_MAJOR 1U +#define FIRMWARE_VERSION_MINOR 2U +#define FIRMWARE_VERSION_PATCH 3U +#include "firmware_build_id.h" +#endif +``` + +Обязательный include в этом примере позволяет обнаружить пропущенный pre-build. +В готовых шаблонах include опциональный через `__has_include`; если заголовок +не подключён, порт использует `LOCALDEV`. Для компилятора без `__has_include` +используйте явный include. Можно связать три версии с существующими макросами +проекта, как это сделано в `KONOR_ds18b20/inc/firmware_info_config.h`. + +## 2. Build ID + +Из корня нового проекта перед компиляцией запустите: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "lib/templates/c/firmware-info/tools/make_build_id.ps1" -Repository "." -Output "generated/firmware_build_id.h" +``` + +Указывайте `-Repository` и `-Output` явно: расположение сабмодуля и рабочий +каталог IDE различаются между проектами. `-Repository` должен указывать на +исходники прошивки, а не на репозиторий `templates`. Для Keil из каталога `mdk` +соответственно используйте `..\lib\templates\...`, `-Repository ".."` и +`-Output ".\Generated\firmware_build_id.h"`. + +При доступном Git чистый checkout получает 8 знаков commit, изменения tracked +файлов — 7 знаков и `+`. Если commit определить нельзя, используется `NOGIT000`. +Новые untracked-файлы текущий генератор при определении dirty не учитывает. +Дата/время берутся из `__DATE__`/`__TIME__` при компиляции `firmware_info_port.c`; +для выпуска выполняйте полный Rebuild, чтобы не оставить старый объектный файл. + +## 3. Обработчик запроса версии + +Пример функции подготовки полезной нагрузки, вызываемой вашим обработчиком: + +```c +#include "firmware_info_port.h" + +firmware_info_status_t app_make_firmware_info(uint8_t *payload, size_t capacity) +{ + firmware_info_t info; + firmware_info_status_t status = firmware_info_port_describe(&info); + if (status != FIRMWARE_INFO_OK) return status; + return firmware_info_to_le_bytes(&info, payload, capacity); +} +``` + +При успехе передайте ровно `FIRMWARE_INFO_PAYLOAD_SIZE` (24) байта в собственный +транспорт. При ошибке верните ошибку протокола, а не содержимое буфера. +Не отправляйте `sizeof(firmware_info_t)`: структура не является wire format. + +В KONOR обработчик `app_send_firmware_info()` отвечает на +`PROTO_MSG_FIRMWARE_INFO = 0x03`, сохраняя sequence запроса. Старый 32-байтовый +`DEVICE_INFO` остаётся отдельным сообщением. Для нового протокола сначала +согласуйте команду с клиентом: подключение библиотеки само по себе не добавит +декодер и отображение версии в GUI. + +Для Modbus вызовите `firmware_info_to_registers()` с массивом из 12 `uint16_t` +и разместите его в выбранной карте регистров. Адреса и Modbus-порядок байтов +обеспечивает ваш Modbus-стек; LE-буфер в Modbus напрямую не копируйте. + +## 4. Контракт v1 + +| Индекс слова | Содержимое | +|---|---| +| 0 | Версия контракта: 1 | +| 1, 2, 3 | major, minor, patch | +| 4 | Год | +| 5 | `(month << 8) \| day` | +| 6 | `(hour << 8) \| minute` | +| 7 | Секунды | +| 8–11 | По два ASCII-символа build ID: первый в старшем байте слова | + +В LE-представлении младший байт каждого слова идёт первым. Например, build ID +`ab` в начале строки даёт слово `0x6162`, но байты `62 61`; декодируйте сначала +слово, затем символы из старшего/младшего байта. NUL-терминатор не передаётся. + +Текущая проверка допускает major/minor до 255 и patch до 999. Если в каталоге +используется `(major << 16) | (minor << 8) | patch`, ограничьте **все три** части +до 255, иначе значения пересекутся. Версия каталога автоматически из C-config +не извлекается. + +Для C2000 с 16-битным `char` нельзя считать готовым байтовый порт: отдельно +проверьте наличие `uint8_t` и представление октетов в транспорте. Публикация +TMS SCI8-файла с ПК поддерживается независимо от переноса этой C-библиотеки. + +## 5. Проверка переноса + +1. Соберите host-тест из `tests/test_firmware_info.c` вместе с ядром либо + используйте CMake/CTest. Он проверяет дату, регистры и порядок байтов build ID. +2. Соберите целевую прошивку с обоими `.c` и сгенерированным заголовком. +3. Запросите версию у устройства: сравните SemVer, build ID, время и 24-байтовый + ответ с конкретной сборкой. Проверьте, что прежний `DEVICE_INFO` не изменился. +4. Подключите выпуск по инструкции публикатора; сравните версию в config и + `firmware-release.cmd` перед Rebuild и `--preflight`. diff --git a/c/firmware-info/README.md b/c/firmware-info/README.md index 2626f6e..5b4eefe 100644 --- a/c/firmware-info/README.md +++ b/c/firmware-info/README.md @@ -25,3 +25,7 @@ config, а не дублирования ядра. KONOR публикует эти 24 байта ответом `FIRMWARE_INFO (0x03)`, сохраняя старый 32-байтовый `DEVICE_INFO` без изменений. + +Подробная инструкция с кодом обработчика, форматом ответа и проверкой переноса: +[`PORTING.md`](PORTING.md). Для размещения файла прошивки в каталоге SETGUI +используется отдельный [общий публикатор](../../tools/firmware-publish/PORTING.md). diff --git a/c/set-protocol/ALTERA_LOGIC.md b/c/set-protocol/ALTERA_LOGIC.md new file mode 100644 index 0000000..348b41b --- /dev/null +++ b/c/set-protocol/ALTERA_LOGIC.md @@ -0,0 +1,93 @@ +# Altera Logic: общее ядро и порты + +Потоковый SETCAN/GAS-профиль и общие порты описаны отдельно: +[Altera Logic ONLINE](../../doc/setcan/ALTERA_LOGIC_STREAM.md). +Ниже — совместимый прежний буферный протокол UART `A5 CMD`. + +Клиент UART-протокола FPGA-анализатора Cyclone IV: 16 каналов, 4096 выборок. +Работает с существующим RTL `altera_loganalisator`, версия протокола 1. +Ядро C99 не зависит от Qt, ОС, системных часов и динамической памяти. + +Слои: SETGUI → Python ctypes → C99 → Python Qt-порт → COM / USB–UART. +GUI передаёт числовые настройки и получает готовый массив; формат пакетов, +XOR, endian, последовательность команд, парсер и состояния тайм-аутов находятся в C. + +| Файл | Назначение и зависимости | +|---|---| +| `include/altera_logic.h` | Публичный ABI, макрос экспорта из `pcan_abi.h` | +| `src/altera_logic.c` | Кодек и автомат обмена; только стандартная библиотека C | +| `tests/test_altera_logic.c` | Эталонные пакеты RTL, полный захват, ошибки | +| `../../python/altera_logic/native.py` | ctypes-модели, загрузка DLL, экспорт CSV; stdlib | +| `../../python/altera_logic/qt_port.py` | Асинхронный COM-порт; PySide6 QtSerialPort | + +## Сборка + +Из корня `templates`: + +```powershell +python c/set-protocol/tools/build_host.py --output python/altera_logic/native/setprotocol.dll +``` + +На Linux выходной файл — `python/altera_logic/native/libsetprotocol.so`. +Также поддерживается основная CMake-сборка `c/set-protocol`; путь к результату +задаётся через `ALTERA_LOGIC_LIBRARY`. Библиотека без символов `la_*` +не поддерживается: тихого Python-fallback нет. + +## Контракт порта + +Выделить выровненную память размером `la_context_size()`; все обращения к +одному контексту выполняются в одном потоке. + +- `la_init(ctx)` — начать с INFO. +- `la_next(ctx, out, capacity)` — получить следующий запрос, либо 0, если отправлять нечего. +- `la_feed(ctx, bytes, size)` — передать принятые байты, включая частичные ответы. +- `la_tick(ctx, elapsed_ms)` — сообщить прошедшее время; тайм-аут ответа 1000 мс. +- `la_start(ctx, divider, mask, value, edge_mask, edge_value)` — настройка и новый захват. +- `la_get(ctx, field)` — состояние и прогресс; `la_samples` — завершённая запись. +- `la_fail(ctx, LA_IO_ERROR)` — сообщить ошибку транспорта. + +COM: 921600 бод, 8N1, без управления потоком. Одновременно ожидается только +один ответ. После ARM STATUS опрашивается каждые 25 мс; ожидание самого +триггера не ограничено. После done читаются 64 блока по 64 выборки. +Ошибки закрывают сеанс без автоматического повтора ARM. Неверный размер, +код команды или XOR прекращает сеанс. При рассинхронизации запроса на FPGA +может потребоваться аппаратный RESET_N; переподключение не гарантирует её сброс. + +Клиент намеренно принимает только профиль 16 × 4096 версии 1. +Поддержка внешней SDRAM и расширенных адресов сюда пока не входит. +Прерывание в GUI закрывает порт ПК: STOP в протоколе FPGA отсутствует. + +## Быстрый старт без платы + +Добавить `templates/python` в PYTHONPATH: + +```python +from altera_logic import NativeAnalyzer + +client = NativeAnalyzer() +client.reset(demo=True) +client.start(divider=49) +capture = client.capture() +print(len(capture.samples)) # 4096 +print(capture.sample_rate) # 1_000_000 +print(capture.trigger_index) # 2048 +capture.save_csv("demo.csv") +``` + +Демо создаёт синтетическую запись в C; не моделирует заданные условия триггера. +CSV содержит явный признак demo, время относительно триггера и состояния D0…D15. + +## Потребители и проверка + +Используется вкладкой SETGUI «Altera Logic». Для Android/JNI новый потребитель +может использовать тот же ABI; интерфейс Android в этой задаче не изменяется. +Существующий ABI SETProtocol не изменён, добавлены только символы `la_*`. + +```powershell +$env:PYTHONPATH = "$PWD/python" +python -m unittest discover -s python/tests -p test_altera_logic.py +``` + +CMake включает `test_altera_logic` и CTest `altera_logic`. +На плате требуется отдельно проверить разводку пинов, RAM-модуль и прошивку; +программные проверки не заменяют аппаратную проверку. diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt index bdc5b96..5165838 100644 --- a/c/set-protocol/CMakeLists.txt +++ b/c/set-protocol/CMakeLists.txt @@ -6,6 +6,8 @@ set(CMAKE_C_STANDARD_REQUIRED ON) # SET protocol v2: управление, телеметрия, CAN segmentation и firmware flow. set(SETPROTOCOL_V2_SOURCES + src/altera_logic.c + src/altera_stream.c src/set_protocol.c src/set_can.c src/set_firmware.c @@ -79,6 +81,12 @@ endif() option(SETP_BUILD_TESTS "Build host tests" ON) if(SETP_BUILD_TESTS) enable_testing() + add_executable(test_altera_logic tests/test_altera_logic.c) + target_link_libraries(test_altera_logic PRIVATE setprotocol_static) + add_test(NAME altera_logic COMMAND test_altera_logic) + add_executable(test_altera_stream tests/test_altera_stream.c) + target_link_libraries(test_altera_stream PRIVATE setprotocol_static) + add_test(NAME altera_stream COMMAND test_altera_stream) add_executable(test_plot tests/test_plot.c) target_link_libraries(test_plot PRIVATE setprotocol_static) diff --git a/c/set-protocol/include/altera_logic.h b/c/set-protocol/include/altera_logic.h new file mode 100644 index 0000000..00136e2 --- /dev/null +++ b/c/set-protocol/include/altera_logic.h @@ -0,0 +1,35 @@ +/** Altera logic analyzer UART client. C99, caller-owned memory, no OS/heap. + * One request at a time; no automatic retries (ARM is not idempotent). + * Initialize aligned storage of la_context_size() bytes, then call la_next, + * la_feed and la_tick from one thread. tick receives elapsed milliseconds. + */ +#ifndef ALTERA_LOGIC_H +#define ALTERA_LOGIC_H +#include "pcan_abi.h" +#ifdef __cplusplus +extern "C" { +#endif +enum { LA_CONNECTING, LA_READY, LA_CAPTURING, LA_DONE, LA_ERROR }; +enum { LA_OK, LA_BAD_FRAME, LA_CHECKSUM, LA_DEVICE_ERROR, LA_TIMEOUT, + LA_UNSUPPORTED, LA_ARGUMENT, LA_IO_ERROR }; +enum { LA_STATE, LA_ERROR_CODE, LA_CHANNELS, LA_DEPTH, LA_CLOCK_HZ, + LA_FLAGS, LA_TRIGGER_INDEX, LA_READ_COUNT, LA_DIVIDER }; +PCAN_ABI_API size_t la_context_size(void); +PCAN_ABI_API void la_init(void *ctx); +/** Return 0 on success. Configuration is immutable throughout this capture. */ +PCAN_ABI_API int la_start(void *ctx, uint32_t divider, uint32_t mask, + uint32_t value, uint32_t edge_mask, uint32_t edge_value); +/** Returns 6 when a request is ready, otherwise 0. Capacity must be >=6. */ +PCAN_ABI_API size_t la_next(void *ctx, uint8_t *out, size_t capacity); +PCAN_ABI_API void la_feed(void *ctx, const uint8_t *data, size_t size); +PCAN_ABI_API void la_tick(void *ctx, uint32_t elapsed_ms); +PCAN_ABI_API void la_fail(void *ctx, uint32_t code); +PCAN_ABI_API uint32_t la_get(const void *ctx, uint32_t field); +PCAN_ABI_API size_t la_samples(const void *ctx, uint16_t *out, size_t capacity); +/** Offline fixture, never communicates with hardware or claims a real trigger. */ +PCAN_ABI_API void la_demo_init(void *ctx); +PCAN_ABI_API int la_demo_capture(void *ctx, uint32_t divider); +#ifdef __cplusplus +} +#endif +#endif diff --git a/c/set-protocol/include/altera_stream.h b/c/set-protocol/include/altera_stream.h new file mode 100644 index 0000000..18c5d53 --- /dev/null +++ b/c/set-protocol/include/altera_stream.h @@ -0,0 +1,46 @@ +/** SETCAN/GAS Altera Logic online stream, shared by CAN and UART. + * Caller-owned context; one serialized caller. No heap, OS or Qt dependencies. + */ +#ifndef ALTERA_STREAM_H +#define ALTERA_STREAM_H +#include "pcan_abi.h" +#include "pcan_frame.h" +#ifdef __cplusplus +extern "C" { +#endif +#define LAS_DEVICE_TYPE 6U +#define LAS_DEVICE_ID 14U +#define LAS_GAS_META 0xFE00U +#define LAS_GAS_DATA 0xFF00U +#define LAS_CAPACITY 8192U +#define LAS_TAG 0x4C01U +enum { LAS_COUNT, LAS_PERIOD_NS, LAS_SESSION, LAS_RECEIVED, LAS_MISSING, + LAS_DUPLICATES, LAS_INVALID, LAS_IGNORED, LAS_CRC_ERRORS, LAS_HAS_META }; +PCAN_ABI_API size_t las_context_size(void); +PCAN_ABI_API int las_init(void *ctx, uint32_t device_id); +PCAN_ABI_API void las_can(void *ctx, uint32_t id, const uint8_t *data, size_t size, + uint32_t extended, uint32_t remote); +PCAN_ABI_API void las_uart(void *ctx, const uint8_t *data, size_t size); +PCAN_ABI_API uint32_t las_get(const void *ctx, uint32_t field); +/** Chronological bounded history. breaks[i]=1 marks a discontinuity before i. */ +PCAN_ABI_API size_t las_snapshot(const void *ctx, uint64_t *indices, uint16_t *samples, + uint8_t *breaks, size_t capacity); +/** Sparse step trace for rendering; capacity >= 2*LAS_COUNT, move=pen-up. */ +PCAN_ABI_API size_t las_trace(const void *ctx, uint32_t channel, uint64_t *indices, + uint8_t *levels, uint8_t *moves, size_t capacity); +PCAN_ABI_API uint32_t las_device_type(void); +PCAN_ABI_API uint32_t las_device_id(void); +PCAN_ABI_API const char *las_device_name(void); +PCAN_ABI_API void las_demo_step(void *ctx, uint32_t count); +/** Device-side packet builders; return zero for invalid arguments. + * uart=0: 8-byte CAN data + *id; uart=1: complete AA55 transport + *id. + * Same builders are used by firmware ports, tests and the demo producer. + */ +PCAN_ABI_API size_t las_metadata(uint32_t device, uint32_t session, uint32_t period_ns, + uint32_t uart, uint32_t *id, uint8_t *out, size_t capacity); +PCAN_ABI_API size_t las_data(uint32_t device, uint32_t session, uint32_t index, + uint32_t sample, uint32_t uart, uint32_t *id, uint8_t *out, size_t capacity); +#ifdef __cplusplus +} +#endif +#endif diff --git a/c/set-protocol/src/altera_logic.c b/c/set-protocol/src/altera_logic.c new file mode 100644 index 0000000..2c97957 --- /dev/null +++ b/c/set-protocol/src/altera_logic.c @@ -0,0 +1,142 @@ +#include "altera_logic.h" +#include +typedef struct { + uint32_t state, error, clock_hz, elapsed, delay; + uint16_t config[5], channels, depth, trigger, count, offset; + uint16_t samples[4096]; + uint8_t command, pending, flags, requested, rx[133]; + size_t used; +} la_context; +static uint8_t checksum(const uint8_t *p, size_t n) { + uint8_t x = 0; size_t i; + for (i = 0; i < n; ++i) x ^= p[i]; + return x; +} +static uint16_t u16(const uint8_t *p) { + return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); +} +size_t la_context_size(void) { return sizeof(la_context); } +void la_init(void *ctx) { + la_context *s = (la_context *)ctx; + memset(s, 0, sizeof(*s)); s->command = 1; s->state = LA_CONNECTING; +} +void la_fail(void *ctx, uint32_t code) { + la_context *s = (la_context *)ctx; + s->state = LA_ERROR; s->error = code; s->pending = 0; s->command = 0; +} +int la_start(void *ctx, uint32_t div, uint32_t mask, uint32_t val, + uint32_t emask, uint32_t eval) { + la_context *s = (la_context *)ctx; + if ((s->state != LA_READY && s->state != LA_DONE) || + div > 65535 || mask > 65535 || val > 65535 || + emask > 65535 || eval > 65535 || ((val ^ eval) & mask & emask)) + return LA_ARGUMENT; + s->config[0]=(uint16_t)div; s->config[1]=(uint16_t)mask; + s->config[2]=(uint16_t)val; s->config[3]=(uint16_t)emask; + s->config[4]=(uint16_t)eval; + s->state=LA_CAPTURING; s->error=0; s->flags=0; s->count=0; + s->offset=0; s->command=2; s->delay=0; + return LA_OK; +} +size_t la_next(void *ctx, uint8_t *out, size_t capacity) { + la_context *s = (la_context *)ctx; uint16_t arg = 0; + if (!out || capacity < 6 || !s->command || s->pending || s->delay) return 0; + if (s->command >= 2 && s->command <= 6) arg=s->config[s->command-2]; + if (s->command == 9) { + arg=s->offset; + s->requested=(uint8_t)((s->depth-s->offset > 64) ? 64 : s->depth-s->offset); + } + out[0]=0xa5; out[1]=s->command; out[2]=(uint8_t)arg; + out[3]=(uint8_t)(arg >> 8); out[4]=(s->command == 9) ? s->requested : 0; + out[5]=checksum(out,5); + s->pending=1; s->elapsed=0; s->used=0; + return 6; +} +static void complete(la_context *s) { + size_t i; + if (checksum(s->rx,s->used)) { la_fail(s,LA_CHECKSUM); return; } + if (s->rx[2]) { la_fail(s,LA_DEVICE_ERROR); return; } + s->pending=0; + if (s->command == 1) { + s->channels=s->rx[3]; s->depth=u16(s->rx+4); + s->clock_hz=(uint32_t)s->rx[6]*1000000U; + if (s->channels != 16 || s->depth != 4096 || !s->clock_hz || s->rx[7]!=1) { + la_fail(s,LA_UNSUPPORTED); return; + } + s->state=LA_READY; s->command=0; + } else if (s->command < 7) ++s->command; + else if (s->command == 7) { s->command=8; s->delay=25; } + else if (s->command == 8) { + s->flags=s->rx[3]; s->trigger=u16(s->rx+4); + if (s->flags & 4) { + if (s->trigger >= s->depth || (s->flags & 1)) { la_fail(s,LA_BAD_FRAME); return; } + s->command=9; + } else s->delay=25; + } else { + for(i=0; irequested; ++i) s->samples[s->offset+i]=u16(s->rx+4+i*2); + s->offset=(uint16_t)(s->offset+s->requested); s->count=s->offset; + if(s->offset == s->depth) { s->state=LA_DONE; s->command=0; } + } +} +void la_feed(void *ctx, const uint8_t *data, size_t size) { + la_context *s = (la_context *)ctx; size_t i, expected; + if (!data) return; + for(i=0; ipending || s->used >= sizeof(s->rx)) { la_fail(s,LA_BAD_FRAME); return; } + s->rx[s->used++]=data[i]; + if(s->rx[0]!=0x5a || (s->used>=2 && s->rx[1]!=(s->command|0x80))) { + la_fail(s,LA_BAD_FRAME); return; + } + if(s->used<3) continue; + expected=4; + if(!s->rx[2]) { + if(s->command==1) expected=9; + else if(s->command==8) expected=7; + else if(s->command==9) { + if(s->used<4) continue; + if(s->rx[3]!=s->requested) { la_fail(s,LA_BAD_FRAME); return; } + expected=5U+2U*s->requested; + } + } + if(s->used==expected) complete(s); + } +} +void la_tick(void *ctx, uint32_t ms) { + la_context *s=(la_context *)ctx; + if(s->pending) { + if(ms >= 1000U-s->elapsed) la_fail(s,LA_TIMEOUT); + else s->elapsed+=ms; + } + s->delay=(ms >= s->delay) ? 0 : s->delay-ms; +} +uint32_t la_get(const void *ctx, uint32_t field) { + const la_context *s=(const la_context *)ctx; + switch(field) { + case LA_STATE:return s->state; case LA_ERROR_CODE:return s->error; + case LA_CHANNELS:return s->channels; case LA_DEPTH:return s->depth; + case LA_CLOCK_HZ:return s->clock_hz; case LA_FLAGS:return s->flags; + case LA_TRIGGER_INDEX:return s->trigger; case LA_READ_COUNT:return s->count; + case LA_DIVIDER:return s->config[0]; default:return 0; + } +} +size_t la_samples(const void *ctx, uint16_t *out, size_t capacity) { + const la_context *s=(const la_context *)ctx; + if(s->state!=LA_DONE || !out || capacitycount) return 0; + memcpy(out,s->samples,s->count*sizeof(uint16_t)); return s->count; +} +void la_demo_init(void *ctx) { + la_context *s=(la_context *)ctx; la_init(s); + s->channels=16; s->depth=4096; s->clock_hz=50000000; + s->state=LA_READY; s->command=0; +} +int la_demo_capture(void *ctx, uint32_t divider) { + la_context *s=(la_context *)ctx; size_t i, bit; + int rc=la_start(s,divider,0,0,0,0); if(rc) return rc; + for(i=0;i<4096;++i) { + uint16_t v=0; + for(bit=0;bit<16;++bit) if(((i/(4U+bit*7U))&1U)!=0) v|=(uint16_t)(1U<samples[i]=v; + } + s->trigger=2048; s->flags=6; s->count=4096; s->command=0; s->state=LA_DONE; + return LA_OK; +} diff --git a/c/set-protocol/src/altera_stream.c b/c/set-protocol/src/altera_stream.c new file mode 100644 index 0000000..a99d8a9 --- /dev/null +++ b/c/set-protocol/src/altera_stream.c @@ -0,0 +1,158 @@ +#include "altera_stream.h" +#include "pcan_id.h" +#include + +typedef struct { + pcan_parser_t parser; + uint64_t indices[LAS_CAPACITY], next_index; + uint16_t samples[LAS_CAPACITY]; + uint8_t breaks[LAS_CAPACITY]; + uint32_t head, count, period, session, received, missing, duplicates, invalid, ignored; + uint8_t device, metadata, started; +} las_context; +static uint16_t r16(const uint8_t *p) { return (uint16_t)(p[0]|((uint16_t)p[1]<<8)); } +static uint32_t r32(const uint8_t *p) { return (uint32_t)r16(p)|((uint32_t)r16(p+2)<<16); } +static void w16(uint8_t *p,uint16_t v) { p[0]=(uint8_t)v;p[1]=(uint8_t)(v>>8); } +static void w32(uint8_t *p,uint32_t v) { w16(p,(uint16_t)v);w16(p+2,(uint16_t)(v>>16)); } +uint32_t las_device_type(void) { return LAS_DEVICE_TYPE; } +uint32_t las_device_id(void) { return LAS_DEVICE_ID; } +const char *las_device_name(void) { return "Altera Logic"; } +size_t las_context_size(void) { return sizeof(las_context); } +int las_init(void *ctx,uint32_t device) { + las_context *s=(las_context *)ctx; + if(!ctx || device>15) return 0; + memset(s,0,sizeof(*s));s->device=(uint8_t)device;pcan_parser_init(&s->parser);return 1; +} +static void append(las_context *s,uint64_t index,uint16_t value,uint8_t gap) { + s->indices[s->head]=index;s->samples[s->head]=value;s->breaks[s->head]=gap; + s->head=(s->head+1U)%LAS_CAPACITY; + if(s->countcount; +} +void las_can(void *ctx,uint32_t raw,const uint8_t *p,size_t n,uint32_t extended,uint32_t remote) { + las_context *s=(las_context *)ctx;pcan_id_t id;uint32_t index,delta,period,session; + if(!extended || remote || raw>PCAN_ID_MASK) { ++s->ignored;return; } + pcan_id_unpack(raw,&id); + if(id.device_type!=LAS_DEVICE_TYPE || id.device_id!=s->device || + id.route!=PCAN_ROUTE_FROM_DEVICE || id.msg_type!=PCAN_MSG_GAS || + (id.msg_body!=LAS_GAS_META && id.msg_body!=LAS_GAS_DATA)) { ++s->ignored;return; } + if(n!=8 || !p) { ++s->invalid;return; } + if(id.msg_body==LAS_GAS_META) { + period=r32(p+4);session=r16(p+2); + if(r16(p)!=LAS_TAG || period==0) { ++s->invalid;return; } + if(s->metadata && session==s->session) { + if(period!=s->period) ++s->invalid; + return; + } + if(s->metadata && (uint16_t)(session-s->session)>=0x8000U) { + ++s->duplicates;return; + } + s->metadata=1;s->session=session;s->period=period; + s->count=0;s->head=0;s->started=0;s->next_index=0; + return; + } + if(!s->metadata) { ++s->ignored;return; } + if(r16(p)!=s->session) { ++s->ignored;return; } + index=r32(p+4); + delta=0; + if(s->started) { + delta=index-(uint32_t)s->next_index; + if(delta>=0x80000000U) { ++s->duplicates;return; } + s->missing+=delta; + } else s->next_index=index; + s->next_index+=delta; + append(s,s->next_index,r16(p+2),(uint8_t)(!s->started || delta!=0)); + s->next_index+=1;s->started=1;++s->received; +} +static void frame_callback(const pcan_frame_t *frame,void *user) { + if(frame->flags & (PCAN_FLAG_DIR|PCAN_FLAG_ERR)) { + ++((las_context *)user)->ignored;return; + } + las_can(user,frame->id,frame->data,frame->dlc, + frame->flags&PCAN_FLAG_IDE,frame->flags&PCAN_FLAG_RTR); +} +void las_uart(void *ctx,const uint8_t *data,size_t size) { + las_context *s=(las_context *)ctx; + if(data) pcan_parser_feed(&s->parser,data,size,frame_callback,s); +} +uint32_t las_get(const void *ctx,uint32_t field) { + const las_context *s=(const las_context *)ctx; + switch(field) { + case LAS_COUNT:return s->count;case LAS_PERIOD_NS:return s->period; + case LAS_SESSION:return s->session;case LAS_RECEIVED:return s->received; + case LAS_MISSING:return s->missing;case LAS_DUPLICATES:return s->duplicates; + case LAS_INVALID:return s->invalid;case LAS_IGNORED:return s->ignored; + case LAS_CRC_ERRORS:return s->parser.stats.crc_errors; + case LAS_HAS_META:return s->metadata;default:return 0; + } +} +size_t las_snapshot(const void *ctx,uint64_t *indices,uint16_t *samples,uint8_t *breaks,size_t cap) { + const las_context *s=(const las_context *)ctx;uint32_t i,pos; + if(!indices || !samples || !breaks || capcount) return 0; + pos=(s->head+LAS_CAPACITY-s->count)%LAS_CAPACITY; + for(i=0;icount;++i) { + indices[i]=s->indices[pos];samples[i]=s->samples[pos];breaks[i]=s->breaks[pos]; + pos=(pos+1U)%LAS_CAPACITY; + } + return s->count; +} +size_t las_trace(const void *ctx,uint32_t channel,uint64_t *indices,uint8_t *levels, + uint8_t *moves,size_t cap) { + const las_context *s=(const las_context *)ctx;uint32_t i,pos; + uint64_t previous_x=0;uint8_t previous_y=0;size_t n=0; + if(channel>=16 || !indices || !levels || !moves || cap<2U*s->count) return 0; + pos=(s->head+LAS_CAPACITY-s->count)%LAS_CAPACITY; + for(i=0;icount;++i) { + uint64_t x=s->indices[pos];uint8_t y=(uint8_t)((s->samples[pos]>>channel)&1U); + if(!i) { indices[n]=x;levels[n]=y;moves[n++]=1; } + else if(s->breaks[pos]) { + indices[n]=previous_x;levels[n]=previous_y;moves[n++]=0; + indices[n]=x;levels[n]=y;moves[n++]=1; + } else if(y!=previous_y) { + indices[n]=x;levels[n]=previous_y;moves[n++]=0; + indices[n]=x;levels[n]=y;moves[n++]=0; + } + previous_x=x;previous_y=y;pos=(pos+1U)%LAS_CAPACITY; + } + if(s->count>1) { indices[n]=previous_x;levels[n]=previous_y;moves[n++]=0; } + return n; +} +static size_t packet(uint32_t device,uint16_t body,const uint8_t *data,uint32_t uart, + uint32_t *raw,uint8_t *out,size_t cap) { + pcan_id_t id;pcan_frame_t frame; + if(device>15 || !raw || !out || cap<(uart?PCAN_FRAME_MAX:8U)) return 0; + memset(&id,0,sizeof(id));id.device_type=LAS_DEVICE_TYPE;id.device_id=(uint8_t)device; + id.priority=PCAN_PRIORITY_STANDARD;id.route=PCAN_ROUTE_FROM_DEVICE; + id.msg_type=PCAN_MSG_GAS;id.msg_body=body; + memset(&frame,0,sizeof(frame));frame.id=pcan_id_pack(&id); + frame.flags=PCAN_FLAG_IDE;frame.dlc=8;memcpy(frame.data,data,8);*raw=frame.id; + if(uart) return pcan_frame_encode(&frame,out,cap); + memcpy(out,data,8);return 8; +} +size_t las_metadata(uint32_t device,uint32_t session,uint32_t period,uint32_t uart, + uint32_t *id,uint8_t *out,size_t cap) { + uint8_t data[8];if(session>65535 || !period) return 0; + w16(data,LAS_TAG);w16(data+2,(uint16_t)session);w32(data+4,period); + return packet(device,LAS_GAS_META,data,uart,id,out,cap); +} +size_t las_data(uint32_t device,uint32_t session,uint32_t index,uint32_t sample,uint32_t uart, + uint32_t *id,uint8_t *out,size_t cap) { + uint8_t data[8];if(session>65535 || sample>65535) return 0; + w16(data,(uint16_t)session);w16(data+2,(uint16_t)sample);w32(data+4,index); + return packet(device,LAS_GAS_DATA,data,uart,id,out,cap); +} +void las_demo_step(void *ctx,uint32_t count) { + las_context *s=(las_context *)ctx;uint32_t i,bit,idx,raw;uint8_t packet_data[PCAN_FRAME_MAX];size_t n; + if(!s->metadata) { + n=las_metadata(s->device,1,1000000,1,&raw,packet_data,sizeof(packet_data)); + las_uart(s,packet_data,n); + } + if(count>4096) count=4096; + for(i=0;inext_index; + for(bit=0;bit<16;++bit) { + if((idx/(5U+bit*7U))&1U) a|=(uint16_t)(1U<device,s->session,idx,a,1,&raw,packet_data,sizeof(packet_data)); + las_uart(s,packet_data,n); + } +} diff --git a/c/set-protocol/tests/test_altera_logic.c b/c/set-protocol/tests/test_altera_logic.c new file mode 100644 index 0000000..3e68221 --- /dev/null +++ b/c/set-protocol/tests/test_altera_logic.c @@ -0,0 +1,72 @@ +#include "altera_logic.h" +#include +#include +#include +#include +static void response(void *s, uint8_t *data, size_t n) { + size_t i; uint8_t sum=0; + for(i=0;i>8); } + response(s,block,133); + } + assert(la_get(s,LA_STATE)==LA_DONE); + assert(la_samples(s,samples,4095)==0); + assert(la_samples(s,samples,4096)==4096); + for(i=0;i<4096;++i) assert(samples[i]==i); + assert(la_get(s,LA_TRIGGER_INDEX)==2048); + /* Corrupt XOR, wrong command, device error, timeout and extra data. */ + la_init(s);la_next(s,p,6); + { uint8_t bad[]={0x5a,0x81,0,16,0,16,50,1,0};la_feed(s,bad,9); } + assert(la_get(s,LA_ERROR_CODE)==LA_CHECKSUM); + la_init(s);la_next(s,p,6); + { uint8_t bad[]={0x5a,0x88};la_feed(s,bad,2); } + assert(la_get(s,LA_ERROR_CODE)==LA_BAD_FRAME); + la_init(s);la_next(s,p,6); + { uint8_t bad[]={0x5a,0x81,1,0};response(s,bad,4); } + assert(la_get(s,LA_ERROR_CODE)==LA_DEVICE_ERROR); + la_init(s);la_next(s,p,6);la_tick(s,999); + assert(la_get(s,LA_STATE)==LA_CONNECTING);la_tick(s,1); + assert(la_get(s,LA_ERROR_CODE)==LA_TIMEOUT && la_next(s,p,6)==0); + connect_ok(s);la_feed(s,p,1);assert(la_get(s,LA_ERROR_CODE)==LA_BAD_FRAME); + la_init(s);la_next(s,p,6); + { uint8_t bad[]={0x5a,0x81,0,8,0,16,50,1,0};response(s,bad,9); } + assert(la_get(s,LA_ERROR_CODE)==LA_UNSUPPORTED); + la_demo_init(s);assert(la_demo_capture(s,49)==0); + assert(la_get(s,LA_STATE)==LA_DONE && la_samples(s,samples,4096)==4096); + free(s);puts("Altera Logic C tests passed");return 0; +} diff --git a/c/set-protocol/tests/test_altera_stream.c b/c/set-protocol/tests/test_altera_stream.c new file mode 100644 index 0000000..4e1b04f --- /dev/null +++ b/c/set-protocol/tests/test_altera_stream.c @@ -0,0 +1,62 @@ +#include "altera_stream.h" +#include +#include +#include +#include +static void meta(void *s,uint32_t session,int uart) { + uint8_t data[32];uint32_t id;size_t n,i; + n=las_metadata(LAS_DEVICE_ID,session,1000000,(uint32_t)uart,&id,data,sizeof(data));assert(n); + assert(id==0x1EE3FE00U); + if(uart) for(i=0;i> n) & 1 for n in range(16)]) + + +class NativeAnalyzer: + CONNECTING, READY, CAPTURING, DONE, ERROR = range(5) + ERROR_TEXT = { + 1: "Неверный формат ответа FPGA", + 2: "Ошибка контрольной суммы ответа", + 3: "FPGA отклонила команду", + 4: "Тайм-аут UART. При потере синхронизации нужен RESET_N и повторная настройка", + 5: "Неподдерживаемая конфигурация FPGA: ожидается 16 каналов, 4096 выборок, версия 1", + 6: "Недопустимые или противоречивые настройки триггера", + 7: "Ошибка последовательного порта", + } + + def __init__(self, library=None): + if library is None: + explicit = os.environ.get("ALTERA_LOGIC_LIBRARY") + name = "setprotocol.dll" if sys.platform == "win32" else "libsetprotocol.so" + bundled = Path(getattr(sys, "_MEIPASS", "")) / "gui_desktop/native" / name + local = Path(__file__).resolve().parent / "native" / name + path = Path(explicit) if explicit else (bundled if bundled.is_file() else local) + try: + library = C.CDLL(str(path)) + except OSError as exc: + raise RuntimeError("Не загружено C-ядро Altera Logic. Соберите templates/" + "c/set-protocol/tools/build_host.py; " + str(exc)) from exc + self.lib = library + signatures = { + "la_context_size": ([], C.c_size_t), + "la_init": ([C.c_void_p], None), + "la_start": ([C.c_void_p] + [C.c_uint32]*5, C.c_int), + "la_next": ([C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t), + "la_feed": ([C.c_void_p, C.c_void_p, C.c_size_t], None), + "la_tick": ([C.c_void_p, C.c_uint32], None), + "la_fail": ([C.c_void_p, C.c_uint32], None), + "la_get": ([C.c_void_p, C.c_uint32], C.c_uint32), + "la_samples": ([C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t), + "la_demo_init": ([C.c_void_p], None), + "la_demo_capture": ([C.c_void_p, C.c_uint32], C.c_int), + } + try: + for name, (args, result) in signatures.items(): + fn = getattr(self.lib, name) + fn.argtypes, fn.restype = args, result + except AttributeError as exc: + raise RuntimeError("C-ядро устарело: пересоберите templates с Altera Logic") from exc + # Explicitly aligned caller-owned storage; C never allocates memory. + self.ctx = (C.c_uint64 * ((self.lib.la_context_size()+7)//8))() + self.demo = False + self.reset() + + def reset(self, demo=False): + self.demo = demo + (self.lib.la_demo_init if demo else self.lib.la_init)(self.ctx) + + def get(self, field): + return int(self.lib.la_get(self.ctx, field)) + + @property + def state(self): + return self.get(0) + + @property + def error(self): + return self.ERROR_TEXT.get(self.get(1), "Ошибка обмена") + + @property + def progress(self): + return self.get(7) + + @property + def flags(self): + return self.get(5) + + def start(self, divider, mask=0, value=0, edge_mask=0, edge_value=0): + values = (divider, mask, value, edge_mask, edge_value) + if any(not isinstance(v, int) or not 0 <= v <= 65535 for v in values): + raise ValueError(self.ERROR_TEXT[6]) + result = (self.lib.la_demo_capture(self.ctx, divider) if self.demo else + self.lib.la_start(self.ctx, *values)) + if result: + raise ValueError(self.ERROR_TEXT.get(result, "Захват уже выполняется")) + + def next_request(self): + out = (C.c_ubyte*6)() + size = self.lib.la_next(self.ctx, out, len(out)) + return bytes(out[:size]) + + def feed(self, data): + self.lib.la_feed(self.ctx, data, len(data)) + + def tick(self, elapsed_ms): + self.lib.la_tick(self.ctx, max(0, min(int(elapsed_ms), 0xffffffff))) + + def fail_io(self): + self.lib.la_fail(self.ctx, 7) + + def capture(self): + out = (C.c_uint16*4096)() + count = self.lib.la_samples(self.ctx, out, len(out)) + if not count: + raise RuntimeError("Запись ещё не завершена") + return Capture(tuple(out[:count]), self.get(6), + self.get(4)/(self.get(8)+1), self.demo) diff --git a/python/altera_logic/plot_scroll.py b/python/altera_logic/plot_scroll.py new file mode 100644 index 0000000..2139443 --- /dev/null +++ b/python/altera_logic/plot_scroll.py @@ -0,0 +1,27 @@ +"""Qt presentation port: wheel pans X; Ctrl+wheel pans Y, never zooms.""" +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QScrollArea + + +class PlotScrollArea(QScrollArea): + user_scrolled = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._remainder = {'x': 0., 'y': 0.} + + def wheelEvent(self, event): + axis = 'y' if event.modifiers() & Qt.KeyboardModifier.ControlModifier else 'x' + bar = self.verticalScrollBar() if axis == 'y' else self.horizontalScrollBar() + pixels, angle = event.pixelDelta(), event.angleDelta() + if not pixels.isNull(): + delta = pixels.y() if pixels.y() else pixels.x() + else: + delta = (angle.y() if angle.y() else angle.x()) / 120. * 60. + self._remainder[axis] -= delta + movement = int(self._remainder[axis]) + self._remainder[axis] -= movement + if delta: + self.user_scrolled.emit(axis) + bar.setValue(bar.value()+movement) + event.accept() diff --git a/python/altera_logic/qt_port.py b/python/altera_logic/qt_port.py new file mode 100644 index 0000000..c5fdce9 --- /dev/null +++ b/python/altera_logic/qt_port.py @@ -0,0 +1,114 @@ +"""Qt serial/lifecycle port. Protocol decisions are exclusively in C99.""" +from __future__ import annotations + +from PySide6.QtCore import QObject, QTimer, QElapsedTimer, Signal +from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo +from .native import NativeAnalyzer + + +class AnalyzerPort(QObject): + changed = Signal() + completed = Signal(object) + error = Signal(str) + log = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.core = None + self.active = False + self._last_done = False + self.serial = QSerialPort(self) + self.serial.readyRead.connect(self._read) + self.serial.errorOccurred.connect(self._serial_error) + self.timer = QTimer(self) + self.timer.setInterval(10) + self.timer.timeout.connect(self._tick) + self.clock = QElapsedTimer() + + @staticmethod + def ports(): + return [(p.portName(), p.description()) for p in QSerialPortInfo.availablePorts()] + + def open(self, name, demo=False): + self.close() + try: + self.core = NativeAnalyzer() + self.core.reset(demo) + except RuntimeError as exc: + self.error.emit(str(exc)) + return + if not demo: + self.serial.setPortName(name) + self.serial.setBaudRate(921600) + self.serial.setDataBits(QSerialPort.DataBits.Data8) + self.serial.setParity(QSerialPort.Parity.NoParity) + self.serial.setStopBits(QSerialPort.StopBits.OneStop) + self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) + if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite): + self.error.emit(self.serial.errorString()) + return + self.active = True + self._last_done = False + self.clock.start() + self.timer.start() + self.log.emit("ДЕМО: синтетические данные, триггер не моделируется" if demo + else "%s · 921600 8N1" % name) + self._pump() + + def close(self): + self.active = False + self.timer.stop() + self.serial.close() + self.changed.emit() + + def start(self, *settings): + if not self.active or not self.core: + return + try: + self.core.start(*settings) + except ValueError as exc: + self.error.emit(str(exc)) + return + self._last_done = False + self._pump() + + def _tick(self): + if not self.active: + return + self.core.tick(self.clock.restart()) + self._pump() + + def _read(self): + data = bytes(self.serial.readAll()) + if self.active and data: + self.log.emit("RX " + data.hex(" ").upper()) + self.core.feed(data) + self._pump() + + def _pump(self): + if not self.active: + return + if self.core.state == NativeAnalyzer.ERROR: + message = self.core.error + self.close() + self.error.emit(message) + return + packet = self.core.next_request() + if packet: + self.log.emit("TX " + packet.hex(" ").upper()) + if self.serial.write(packet) != len(packet): + self.core.fail_io() + self._pump() + return + self.clock.restart() + if self.core.state == NativeAnalyzer.DONE and not self._last_done: + self._last_done = True + self.completed.emit(self.core.capture()) + self.changed.emit() + + def _serial_error(self, code): + if self.active and code != QSerialPort.SerialPortError.NoError: + message = self.serial.errorString() + self.core.fail_io() + self.close() + self.error.emit(message) diff --git a/python/altera_logic/stream.py b/python/altera_logic/stream.py new file mode 100644 index 0000000..0a6b733 --- /dev/null +++ b/python/altera_logic/stream.py @@ -0,0 +1,108 @@ +"""SETCAN streaming FFI and immutable display/export models. No wire codec.""" +from __future__ import annotations +import csv +import ctypes as C +from dataclasses import dataclass +from .native import NativeAnalyzer + + +@dataclass(frozen=True) +class StreamSnapshot: + indices: tuple[int, ...] + samples: tuple[int, ...] + breaks: tuple[int, ...] + period_ns: int + session: int + demo: bool = False + traces: tuple = () + + def save_csv(self, path): + with open(path, "w", newline="", encoding="utf-8") as file: + writer = csv.writer(file) + writer.writerow(["session", "sample_index", "time_s", "gap_before", "sample_hex", "demo"] + + ["D%d" % n for n in range(16)]) + for index, sample, gap in zip(self.indices, self.samples, self.breaks): + writer.writerow([self.session, index, index*self.period_ns/1e9, gap, + "%04X" % sample, int(self.demo)] + + [(sample >> bit)&1 for bit in range(16)]) + + +class NativeStream: + def __init__(self, device_id=None): + self._owner = NativeAnalyzer() + self.lib = self._owner.lib + signatures = { + "las_context_size": ([], C.c_size_t), + "las_init": ([C.c_void_p, C.c_uint32], C.c_int), + "las_can": ([C.c_void_p, C.c_uint32, C.c_void_p, C.c_size_t, C.c_uint32, C.c_uint32], None), + "las_uart": ([C.c_void_p, C.c_void_p, C.c_size_t], None), + "las_get": ([C.c_void_p, C.c_uint32], C.c_uint32), + "las_snapshot": ([C.c_void_p, C.c_void_p, C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t), + "las_trace": ([C.c_void_p, C.c_uint32, C.c_void_p, C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t), + "las_device_type": ([], C.c_uint32), + "las_device_id": ([], C.c_uint32), + "las_device_name": ([], C.c_char_p), + "las_demo_step": ([C.c_void_p, C.c_uint32], None), + "las_metadata": ([C.c_uint32]*4+[C.POINTER(C.c_uint32), C.c_void_p, C.c_size_t], C.c_size_t), + "las_data": ([C.c_uint32]*5+[C.POINTER(C.c_uint32), C.c_void_p, C.c_size_t], C.c_size_t), + } + try: + for name, (args, result) in signatures.items(): + fn = getattr(self.lib, name) + fn.argtypes, fn.restype = args, result + except AttributeError as exc: + raise RuntimeError("Обновите DLL templates: отсутствует SETCAN Altera stream") from exc + self.device_type = int(self.lib.las_device_type()) + self.device_id = int(self.lib.las_device_id()) if device_id is None else device_id + self.device_name = self.lib.las_device_name().decode("utf-8") + self.ctx = (C.c_uint64*((self.lib.las_context_size()+7)//8))() + if not 0 <= self.device_id <= 15 or not self.lib.las_init(self.ctx, self.device_id): + raise ValueError("DeviceID должен быть 0…15") + + @property + def stats(self): + fields = ("count", "period_ns", "session", "received", "missing", "duplicates", + "invalid", "ignored", "crc_errors", "has_meta") + return {name: int(self.lib.las_get(self.ctx, i)) for i, name in enumerate(fields)} + + def feed_can(self, identifier, data, extended=True, remote=False): + if not 0 <= identifier <= 0xffffffff: + return + self.lib.las_can(self.ctx, identifier, data, len(data), int(extended), int(remote)) + + def feed_uart(self, data): + self.lib.las_uart(self.ctx, data, len(data)) + + def snapshot(self, demo=False): + stats = self.stats + count = stats["count"] + indices, samples, breaks = (C.c_uint64*count)(), (C.c_uint16*count)(), (C.c_ubyte*count)() + n = self.lib.las_snapshot(self.ctx, indices, samples, breaks, count) + traces = [] + xs, ys, moves = (C.c_uint64*(2*count))(), (C.c_ubyte*(2*count))(), (C.c_ubyte*(2*count))() + for channel in range(16): + size = self.lib.las_trace(self.ctx, channel, xs, ys, moves, 2*count) + traces.append(tuple(zip(xs[:size], ys[:size], moves[:size]))) + return StreamSnapshot(tuple(indices[:n]), tuple(samples[:n]), tuple(breaks[:n]), + stats["period_ns"], stats["session"], demo, tuple(traces)) + + def demo_step(self, count=50): + self.lib.las_demo_step(self.ctx, count) + + def metadata_packet(self, session, period_ns, uart=False): + if not 0 <= session <= 65535 or not 1 <= period_ns <= 0xffffffff: + raise ValueError("Invalid stream metadata") + return self._packet(self.lib.las_metadata, session, period_ns, int(uart)) + + def data_packet(self, session, index, sample, uart=False): + if not 0 <= index <= 0xffffffff or any(not 0 <= v <= 65535 for v in (session, sample)): + raise ValueError("Invalid stream samples") + return self._packet(self.lib.las_data, session, index, sample, int(uart)) + + def _packet(self, fn, *args): + identifier = C.c_uint32() + out = (C.c_ubyte*32)() + n = fn(self.device_id, *args, C.byref(identifier), out, len(out)) + if not n: + raise ValueError("Invalid SETCAN packet arguments") + return identifier.value, bytes(out[:n]) diff --git a/python/altera_logic/stream_port.py b/python/altera_logic/stream_port.py new file mode 100644 index 0000000..e7e01a4 --- /dev/null +++ b/python/altera_logic/stream_port.py @@ -0,0 +1,103 @@ +"""Qt transport/lifecycle port for the C SETCAN stream receiver. + +CAN ingress accepts canonical RX events from an existing bus connection. +UART uses the same SETCAN frames inside the shared AA55/CRC16 transport. +Reception and rendering clocks are separate; no packet-rate repainting. +""" +from __future__ import annotations +from PySide6.QtCore import QObject, QTimer, QElapsedTimer, Signal +from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo +from .stream import NativeStream + + +class StreamPort(QObject): + updated = Signal(object, object) + changed = Signal(bool) + error = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.core = None + self.active = False + self.mode = "demo" + self.paused = False + self.serial = QSerialPort(self) + self.serial.readyRead.connect(self._read) + self.serial.errorOccurred.connect(self._error) + self.timer = QTimer(self) + self.timer.setInterval(50) + self.timer.timeout.connect(self._tick) + self._previous = None + self._last_data = QElapsedTimer() + self._last_received = None + + @staticmethod + def ports(): + return [(p.portName(), p.description()) for p in QSerialPortInfo.availablePorts()] + + def open(self, mode, port_name="", device_id=None): + self.close() + if mode not in ("demo", "can", "uart", "both"): + self.error.emit("Неизвестный транспорт") + return + try: + self.core = NativeStream(device_id) + except (RuntimeError, ValueError) as exc: + self.error.emit(str(exc)) + return + self.mode = mode + if mode in ("uart", "both"): + self.serial.setPortName(port_name) + self.serial.setBaudRate(921600) + self.serial.setDataBits(QSerialPort.DataBits.Data8) + self.serial.setParity(QSerialPort.Parity.NoParity) + self.serial.setStopBits(QSerialPort.StopBits.OneStop) + self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) + if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite): + self.error.emit(self.serial.errorString()) + return + self.active = True + self.paused = False + self._previous = None + self._last_received = None + self._last_data.start() + self.timer.start() + self.changed.emit(True) + + def close(self): + self.active = False + self.timer.stop() + self.serial.close() + self.changed.emit(False) + + def receive_event(self, event): + if (self.active and self.mode in ("can", "both") and event.get("kind") == "can" + and event.get("direction") == "RX"): + self.core.feed_can(event["identifier"], event["data"], + event.get("extended", False), event.get("remote", False)) + + def _read(self): + data = bytes(self.serial.readAll()) + if self.active and self.mode in ("uart", "both"): + self.core.feed_uart(data) + + def _tick(self): + if not self.active: + return + if self.mode == "demo": + self.core.demo_step() + stats = self.core.stats + signature = (stats["received"], stats["session"]) + if signature != self._last_received: + self._last_received = signature + self._last_data.restart() + stats["stale"] = self._last_data.elapsed() > max(2000, stats["period_ns"]*3/1e6) + if not self.paused and stats != self._previous: + self._previous = stats + self.updated.emit(self.core.snapshot(self.mode == "demo"), stats) + + def _error(self, code): + if self.active and code != QSerialPort.SerialPortError.NoError: + message = self.serial.errorString() + self.close() + self.error.emit(message) diff --git a/python/protocan/__init__.py b/python/protocan/__init__.py index d7d39f3..914dd0e 100644 --- a/python/protocan/__init__.py +++ b/python/protocan/__init__.py @@ -1,5 +1,10 @@ """Переносимые модули и тонкая Python-обёртка SETProtocol.""" +# Consumers may supply additional platform ports from their pinned templates +# checkout. The selected primary checkout wins; missing modules can coexist. +from pkgutil import extend_path +__path__ = extend_path(__path__, __name__) + from .native import ( NativeCore, NativeCoreUnavailable, NativeFrame, NativeGuiFrame, NativeGuiParser, NativeParser, NativeProtocol, NativeProtocolUnavailable, diff --git a/python/protocan/can_boot.py b/python/protocan/can_boot.py new file mode 100644 index 0000000..e909c8c --- /dev/null +++ b/python/protocan/can_boot.py @@ -0,0 +1,123 @@ +"""Legacy ProtoCAN Boot client ported from Gui_Android CanFirmwareProtocol.kt.""" +from __future__ import annotations + +from dataclasses import dataclass +import struct + +from .protocan import ProtoCanId +from .transport import build_frame + + +@dataclass(frozen=True) +class CanBootTarget: + device_type: int = 0 + device: int = 13 + product_type: int = 0x166 + hardware_min: int = 0 + hardware_max: int = 255 + session_id: int = 1 + + def __post_init__(self): + if not 0 <= self.device_type <= 7 or not 0 <= self.device <= 15: + raise ValueError("Адрес ProtoCAN: Device Type 0…7, Device 0…15") + if not 0 <= self.product_type <= 65535: + raise ValueError("Product Type вне диапазона u16") + if not 0 <= self.hardware_min <= self.hardware_max <= 255: + raise ValueError("Неверный диапазон аппаратных ревизий") + if not 1 <= self.session_id <= 255: + raise ValueError("Session ID должен быть 1…255") + + +class CanBootTransfer: + """Windowed 8-byte blocks; control 0x9, slots 0xA/0xB, status 0xC.""" + def __init__(self, image, target: CanBootTarget): + if not 0 < len(image.data) <= 512 * 1024: + raise ValueError("ProtoCAN Boot: размер образа 1…512 КиБ") + if not 0 <= image.version <= 0xFFFFFFFF: + raise ValueError("Версия образа вне диапазона u32") + self.image, self.target = image, target + self.stage = "enter" + self.next_block = 0 + self.slot = None + self.finished = False + self._window_end = 0 + + @property + def percent(self): + return min(100, self.next_block * 8 * 100 // len(self.image.data)) + + def _frame(self, kind, body, data=b""): + t = self.target + return build_frame(ProtoCanId.build(1, 0, t.device_type, t.device, kind, body), data, to_can=True) + + def _control(self, command, data=b""): + return [self._frame(9, self.target.session_id << 8 | command, data)] + + def start(self): + return self._control(2) + + def abort(self): + return self._control(10) + + def accepts(self, frame): + p = ProtoCanId.parse(frame.can_id) + t = self.target + return (not frame.to_can and frame.ide and not frame.rtr and not frame.is_error + and p.msg_type == 12 and p.pm == 1 and p.device_type == t.device_type + and p.device == t.device and p.body >> 8 == t.session_id and len(frame.data) == 8) + + def _window(self): + total = (len(self.image.data) + 7) // 8 + self._window_end = min(total, self.next_block + 16) + return [self._frame(10 + self.slot, i, self.image.data[i * 8:(i + 1) * 8].ljust(8, b"\xff")) + for i in range(self.next_block, self._window_end)] + + def handle_status(self, frame): + if not self.accepts(frame): + return [], "" + command = ProtoCanId.parse(frame.can_id).body & 255 + expected_command = {"enter": 2, "image": 3, "compat": 4, "erase": 5, + "data": 0, "verify": 6, "commit": 7, "reboot": 9}.get(self.stage) + if command != expected_command: + return [], "" + status, slot, expected = struct.unpack_from(" limit: + raise RuntimeError("ProtoCAN Boot: подтверждён непереданный блок") + if self.stage == "data" and status == 0 and expected < self.next_block: + return [], "" + self.next_block = expected + if expected == total: + self.stage = "verify" + return self._control(6), "Проверка CRC32" + self.stage = "data" + return self._window(), "Передача блоков ProtoCAN Boot" + if self.stage == "verify": + self.stage = "commit" + return self._control(7), "Активация образа" + if self.stage == "commit": + self.stage = "reboot" + return self._control(9), "Перезапуск прибора" + self.stage, self.finished = "finished", True + return [], "Прошивка ProtoCAN Boot завершена" diff --git a/python/protocan/protocan.py b/python/protocan/protocan.py index 7ed69e2..fe8ee7a 100644 --- a/python/protocan/protocan.py +++ b/python/protocan/protocan.py @@ -131,9 +131,11 @@ MSGTYPE_RU = { #: но не присваивает приборам имён. DEVICE_TYPE_NAMES = { 0x0: "Верхний уровень", + 0x6: "Логические анализаторы", } DEVICE_ADDRESS_NAMES = { + (0x6, 0xE): "Altera Logic", (0x7, 0xD): "configurator", } diff --git a/python/protocan/setp_firmware.py b/python/protocan/setp_firmware.py new file mode 100644 index 0000000..f4f693e --- /dev/null +++ b/python/protocan/setp_firmware.py @@ -0,0 +1,239 @@ +"""SETProtocol v2 firmware client over segmented classic CAN.""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass +from time import perf_counter + +from setprotocol.can import CanAddress, CanFrame, CanReassembler, segment +from setprotocol.core import ( + Capabilities, + DeviceInfo, + Feature, + FirmwareBegin, + FirmwareFlag, + Frame as SetFrame, + FrameFlag, + MessageType, + SetProtocolError, + Status, + build_frame, + decode_datagram, + decode_response, + encode_firmware_data, +) + +from . import transport as tr +from typing import Any + +FirmwareImage = Any + + +@dataclass(frozen=True) +class CanFirmwareTarget: + """Address and compatibility settings for a SETProtocol v2 target.""" + + node_id: int + device_class: int + hardware_min: int = 0 + hardware_max: int = 0xFF + + def __post_init__(self) -> None: + if not 0 <= self.node_id <= 0xFF: + raise ValueError("SETP node ID вне диапазона 0..255") + if not 0 <= self.device_class <= 0xFFFF: + raise ValueError("Device class вне диапазона u16") + if not 0 <= self.hardware_min <= self.hardware_max <= 0xFFFFFFFF: + raise ValueError("Диапазон hardware version задан неверно") + + +class CanFirmwareTransfer: + """Stop-and-wait SETP v2 firmware transaction for a classic CAN link.""" + + HOST_NODE = 0 + CHANNEL = 1 + DEFAULT_BLOCK_SIZE = 64 + + def __init__(self, image: FirmwareImage, target: CanFirmwareTarget) -> None: + if not image.data: + raise ValueError("Образ прошивки пуст") + if len(image.data) > 512 * 1024: + raise ValueError("BALZAM поддерживает образ не более 512 КиБ") + self.image = image + self.target = target + self.offset = 0 + self.block_size = self.DEFAULT_BLOCK_SIZE + self.stage = "ping" + self.finished = False + self._sequence = 0 + self._expected_sequence = 0 + self._expected_type = 0 + self._response: SetFrame | None = None + self._reassembler = CanReassembler() + + def _request(self, message_type: int, payload: bytes = b"") -> list[tr.Frame]: + self._sequence = (self._sequence + 1) & 0xFFFF + if self._sequence == 0: + self._sequence = 1 + self._expected_sequence = self._sequence + self._expected_type = int(message_type) + packet = build_frame( + SetFrame( + message_type=message_type, + sequence=self._sequence, + payload=payload, + flags=FrameFlag.ACK_REQUIRED | FrameFlag.PRIORITY, + source=self.HOST_NODE, + destination=self.target.node_id, + ) + ) + address = CanAddress( + destination=self.target.node_id, + source=self.HOST_NODE, + priority=1, + channel=self.CHANNEL, + ) + return [tr.build_frame(item.can_id, item.data, to_can=True) + for item in segment(packet, address)] + + def start(self) -> list[tr.Frame]: + return self._request(MessageType.PING) + + def abort(self) -> list[tr.Frame]: + return self._request(MessageType.FW_ABORT) + + def accepts(self, frame: tr.Frame) -> bool: + if frame.to_can or not frame.ide: + return False + try: + address = CanAddress.unpack(frame.can_id) + except SetProtocolError: + return False + if address.source != self.target.node_id or address.destination != self.HOST_NODE: + return False + try: + packet = self._reassembler.feed( + CanFrame(frame.can_id, frame.data), int(perf_counter() * 1000) + ) + if packet is None: + return False + response = decode_datagram(packet) + except SetProtocolError: + return False + if ( + not response.flags & FrameFlag.RESPONSE + or response.source != self.target.node_id + or response.destination != self.HOST_NODE + or response.sequence != self._expected_sequence + or response.message_type != self._expected_type + ): + return False + self._response = response + return True + + def _begin(self) -> list[tr.Frame]: + begin = FirmwareBegin( + image_size=len(self.image.data), + image_crc32=self.image.crc32, + image_version=self.image.version, + base_address=self.image.base_address, + slot=0, + block_size=self.block_size, + sha256=bytes.fromhex(self.image.sha256), + flags=FirmwareFlag.RESUME | FirmwareFlag.ERASE_SLOT, + ) + self.stage = "begin" + return self._request(MessageType.FW_BEGIN, begin.encode()) + + def _next_data_or_end(self) -> tuple[list[tr.Frame], str]: + if self.offset >= len(self.image.data): + self.stage = "end" + payload = struct.pack( + " tuple[list[tr.Frame], str]: + response, self._response = self._response, None + if response is None: + return [], "" + status, body = decode_response(response) + if status != Status.OK: + try: + name = Status(status).name + except ValueError: + name = "0x%04X" % status + raise RuntimeError(name) + + if self.stage == "ping": + if len(body) != 4: + raise RuntimeError("PING: неверная длина ответа") + self.stage = "device_info" + return self._request(MessageType.DEVICE_INFO), "Чтение информации об устройстве" + + if self.stage == "device_info": + info = DeviceInfo.decode(body) + if self.target.device_class and info.device_class != self.target.device_class: + raise RuntimeError( + "device class 0x%04X вместо 0x%04X" + % (info.device_class, self.target.device_class) + ) + if not self.target.hardware_min <= info.hardware_version <= self.target.hardware_max: + raise RuntimeError("hardware version устройства вне разрешённого диапазона") + self.stage = "capabilities" + return self._request(MessageType.CAPABILITIES), "Проверка возможностей устройства" + + if self.stage == "capabilities": + capabilities = Capabilities.decode(body) + if not capabilities.features & Feature.FIRMWARE: + raise RuntimeError("устройство не объявило поддержку firmware update") + self.block_size = min( + self.DEFAULT_BLOCK_SIZE, + capabilities.max_payload - 12, + ) + if self.block_size <= 0: + raise RuntimeError("устройство объявило слишком маленький CAN firmware MTU") + return self._begin(), "Начало SETProtocol v2 firmware session" + + if self.stage == "begin": + if len(body) != 4: + raise RuntimeError("FW_BEGIN: неверный next_offset") + self.offset = int.from_bytes(body, "little") + if self.offset > len(self.image.data): + raise RuntimeError("FW_BEGIN: next_offset за пределами образа") + return self._next_data_or_end() + + if self.stage == "data": + if len(body) != 4: + raise RuntimeError("FW_DATA: неверный next_offset") + next_offset = int.from_bytes(body, "little") + expected = min(self.offset + self.block_size, len(self.image.data)) + if next_offset != expected: + raise RuntimeError( + "FW_DATA: подтверждён offset %d вместо %d" % (next_offset, expected) + ) + self.offset = next_offset + return self._next_data_or_end() + + if self.stage == "end": + if len(body) != 4 or int.from_bytes(body, "little") != len(self.image.data): + raise RuntimeError("FW_END: устройство не подтвердило полный образ") + self.stage = "activate" + return self._request(MessageType.FW_ACTIVATE), "Активация образа" + + if self.stage == "activate": + self.finished = True + return [], "Прошивка по CAN (SETProtocol v2) завершена" + return [], "" + + @property + def percent(self) -> int: + return min(100, int(self.offset * 100 / len(self.image.data))) diff --git a/python/protocan/stm32_boot.py b/python/protocan/stm32_boot.py new file mode 100644 index 0000000..9b75a10 --- /dev/null +++ b/python/protocan/stm32_boot.py @@ -0,0 +1,47 @@ +"""Packets and constants for the STM32 system-memory UART bootloader (AN3155).""" + +from __future__ import annotations + +ACK = 0x79 +NACK = 0x1F +SYNC = b"\x7f" + + +def command(code: int) -> bytes: + """Encode an AN3155 command and its one's-complement checksum.""" + if not 0 <= code <= 0xFF: + raise ValueError("Код команды вне диапазона байта") + return bytes((code, code ^ 0xFF)) + + +def address(value: int) -> bytes: + """Encode a big-endian 32-bit address followed by XOR checksum.""" + if not 0 <= value <= 0xFFFFFFFF: + raise ValueError("Адрес вне диапазона u32") + raw = value.to_bytes(4, "big") + return raw + bytes((raw[0] ^ raw[1] ^ raw[2] ^ raw[3],)) + + +def write_payload(data: bytes) -> bytes: + """Encode one Write Memory payload (1..256 bytes).""" + if not 1 <= len(data) <= 256: + raise ValueError("Блок STM32 должен содержать от 1 до 256 байт") + count = len(data) - 1 + checksum = count + for value in data: + checksum ^= value + return bytes((count,)) + data + bytes((checksum,)) + + +MASS_ERASE = b"\xff\x00" + + +def erase_pages_payload(pages: list[int]) -> bytes: + """Encode the standard Erase Memory page list used by STM32F1.""" + if not pages or len(pages) > 256 or any(not 0 <= page <= 0xFF for page in pages): + raise ValueError("Список страниц STM32 должен содержать 1..256 номеров") + body = bytes((len(pages) - 1, *pages)) + checksum = 0 + for value in body: + checksum ^= value + return body + bytes((checksum,)) diff --git a/python/protocan/tms_firmware.py b/python/protocan/tms_firmware.py new file mode 100644 index 0000000..e6f1fe3 --- /dev/null +++ b/python/protocan/tms_firmware.py @@ -0,0 +1,118 @@ +"""BALZAM/PM67 firmware protocol, ported from Gui_Android Tms2812Protocol. + +Addresses count 16-bit words; transfer lengths count bytes. +""" +from __future__ import annotations + +from dataclasses import dataclass +import struct + +from protocan.tms2812 import crc16_modbus, build_upload_request, decode_upload_reply + +CMD_LOAD = 51 +CMD_UPLOAD = 52 +CMD_TFLASH = 55 +CMD_INITLOAD = 58 +CMD_EXTEND = 60 + + +def packet(controller: int, command: int, payload: bytes) -> bytes: + raw = bytes((controller, command)) + payload + return raw + struct.pack(" bytes: + return packet(controller, CMD_INITLOAD, struct.pack(" bytes: + return packet(controller, CMD_LOAD, data) + + +def tflash(controller: int, ram: int, flash: int, size: int) -> bytes: + return packet(controller, CMD_TFLASH, struct.pack(" bytes: + return packet(controller, CMD_EXTEND, struct.pack(" tuple[int, int, int]: + return struct.unpack_from(" bytes | None: + """Accept omitted reserved tail bytes only after the receive settle interval.""" + tail = 4 if command == CMD_UPLOAD and size > 6 else 2 + if not size - tail <= len(raw) <= size or raw[:2] != bytes((controller, command)): + return None + full = raw.ljust(size, b"\0") + if tail == 4: + try: + decode_upload_reply(full, controller, size - 8) + except ValueError: + return None + elif crc16_modbus(full[:-4]) != int.from_bytes(full[-4:-2], "little"): + return None + return full + + +@dataclass(frozen=True) +class TmsTarget: + controller: int = 10 + ram: int = 0x000A0000 + flash: int = 0x00110000 + block_size: int = 256 + kind: str = "tms" + board: int = 0 + load_only: bool = False + + def validate(self, size: int) -> None: + if not 0 <= self.controller <= 255: + raise ValueError("Адрес контроллера должен быть 0…255") + if not 256 <= self.block_size <= 65534 or self.block_size % 2: + raise ValueError("Блок LOAD должен быть чётным, 256…65534 байт") + if size <= 0: + raise ValueError("Образ пуст") + words = (size + 1) // 2 + if not 0 <= self.ram <= 0xFFFFFFFF or self.ram + words > 0x100000000: + raise ValueError("Диапазон RAM выходит за пределы uint32") + if self.kind not in ("tms", "spartan2e", "spartan6"): + raise ValueError("Неизвестный протокол прошивки TMS") + if not 0 <= self.board <= 255 or not 0 <= self.flash <= 0xFFFFFFFF: + raise ValueError("Неверный адрес платы или памяти") + if not self.load_only and self.kind == "tms" and (not 0x00100000 <= self.flash < 0x00180000 or self.flash + words > 0x00180000): + raise ValueError("Flash должна находиться в диапазоне слов 0x00100000…0x00180000") + + +def programming_steps(data: bytes, target: TmsTarget): + """Yield requests; send each validated response back into the generator.""" + t = target + t.validate(len(data)) + for offset in range(0, len(data), t.block_size): + block = data[offset:offset + t.block_size] + ram = t.ram + offset // 2 + yield init_load(t.controller, ram, len(block)), 6, 1500, "CMD_INITLOAD", int(offset * 85 / len(data)) + yield load_data(t.controller, block), 6, 3000, "CMD_LOAD", int(offset * 85 / len(data)) + if t.load_only: + return + if t.kind != "tms": + code = 6 if t.kind == "spartan2e" else 10 + response = yield extend(t.controller, t.ram, t.flash, len(data), code, t.board), 18, 600000, "CMD_EXTEND: запись платы", 85 + words, error, repeats = extend_result(response) + if error or words != (len(data) + 1) // 2: + raise ValueError(f"Ошибка записи платы: код {error}, обработано {words} слов, повторов {repeats}") + if t.kind == "spartan2e": + response = yield extend(t.controller, t.ram, t.flash, len(data), 17, t.board), 18, 600000, "CMD_EXTEND: проверка EEPROM", 95 + words, error, repeats = extend_result(response) + if error or words != (len(data) + 1) // 2: + raise ValueError(f"Проверка EEPROM не пройдена: код {error}, обработано {words} слов") + return + yield tflash(t.controller, t.ram, t.flash, len(data)), 6, 180000, "CMD_TFLASH: запись Flash", 85 + for offset in range(0, len(data), 256): + block = data[offset:offset + 256] + count = (len(block) + 1) & ~1 + response = yield build_upload_request(t.controller, t.flash + offset // 2, count), count + 8, 30000, "CMD_UPLOAD: проверка Flash", 90 + int(offset * 10 / len(data)) + actual = decode_upload_reply(response, t.controller, count)[:len(block)] + if actual != block: + raise ValueError(f"Проверка Flash не пройдена: блок по адресу слова 0x{t.flash + offset // 2:08X}") diff --git a/python/setprotocol/firmware_database.py b/python/setprotocol/firmware_database.py new file mode 100644 index 0000000..276d82f --- /dev/null +++ b/python/setprotocol/firmware_database.py @@ -0,0 +1,268 @@ +"""Standalone firmware database: HTTPS catalog, verified downloads and Gitea publishing. + +No Qt, desktop application, credential store or MCU dependencies. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable +from urllib.error import HTTPError +from urllib.parse import quote, unquote, urljoin, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from .firmware_catalog import MAX_MANIFEST_BYTES, FirmwareRelease, parse_firmware_catalog +from .firmware_publish import ( + MAX_FIRMWARE_BYTES, FirmwarePublication, firmware_release_entry, + firmware_release_tag, sha256_file, update_firmware_manifest, +) + +CHUNK = 128 * 1024 + + +def _origin(url: str) -> tuple[str, str, int]: + parsed = urlsplit(url) + if (parsed.scheme != "https" or not parsed.hostname + or parsed.username is not None or parsed.password is not None): + raise ValueError("Expected an HTTPS URL without embedded credentials") + return parsed.scheme, parsed.hostname, parsed.port or 443 + + +@dataclass(frozen=True) +class Credentials: + login: str + password: str = field(repr=False) + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class HttpsClient: + """Credentials are restricted to one origin, including across redirects.""" + + def __init__(self, credential_origin: str, credentials: Credentials | None = None): + self.origin = _origin(credential_origin) + self.credentials = credentials + self.opener = build_opener(_NoRedirect()) + + def open(self, url: str, *, method: str = "GET", data: bytes | None = None, + content_type: str = "application/json"): + for attempt in range(6): + origin = _origin(url) + headers = {"Accept": "application/json, application/octet-stream", + "Content-Type": content_type, "User-Agent": "templates-firmware-db/1"} + if self.credentials is not None and origin == self.origin: + raw = f"{self.credentials.login}:{self.credentials.password}".encode("utf-8") + headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii") + request = Request(url, data=data, headers=headers, method=method) + try: + return self.opener.open(request, timeout=180) + except HTTPError as error: + # Never replay a write or its body at a redirect destination. + if (method != "GET" or error.code not in (301, 302, 303, 307, 308) + or not error.headers.get("Location") or attempt == 5): + raise + destination = urljoin(url, error.headers["Location"]) + error.close() + url = destination + raise RuntimeError("Too many redirects") + + +@dataclass(frozen=True) +class GiteaRepository: + server: str + owner: str + repository: str + branch: str = "main" + manifest_path: str = "update.json" + + def __post_init__(self): + _origin(self.server) + parsed = urlsplit(self.server) + if parsed.query or parsed.fragment: + raise ValueError("Server URL must not contain a query or fragment") + if not all((self.owner, self.repository, self.branch, self.manifest_path)): + raise ValueError("Repository settings must not be empty") + + @property + def web(self) -> str: + return f"{self.server.rstrip('/')}/{quote(self.owner, safe='')}/{quote(self.repository, safe='')}" + + @property + def api(self) -> str: + return (f"{self.server.rstrip('/')}/api/v1/repos/" + f"{quote(self.owner, safe='')}/{quote(self.repository, safe='')}") + + @property + def manifest_url(self) -> str: + return (f"{self.web}/raw/branch/{quote(self.branch, safe='')}/" + f"{quote(self.manifest_path, safe='/')}") + + +class FirmwareDatabase: + """Blocking service; call from a worker when integrating with a GUI.""" + + def __init__(self, manifest_url: str, cache_dir: Path, *, + credentials: Credentials | None = None, client=None): + _origin(manifest_url) + self.manifest_url = manifest_url + self.cache_dir = Path(cache_dir) + self.client = client if client is not None else HttpsClient(manifest_url, credentials) + + def read_catalog(self, *, product: str | None = None, + transport: str | None = None) -> list[FirmwareRelease]: + with self.client.open(self.manifest_url) as response: + data = response.read(MAX_MANIFEST_BYTES + 1) + releases = parse_firmware_catalog(data, self.manifest_url) + return [r for r in releases + if (product is None or r.product.casefold() == product.casefold()) + and (transport is None or r.transport == transport.lower())] + + def download(self, release: FirmwareRelease, + progress: Callable[[int], None] | None = None) -> Path: + # Validate even objects constructed directly by callers (including file names). + entry = {"product": release.product, "versionName": release.version, + "versionCode": release.version_code, "imageUrl": release.image_url, + "fileName": release.file_name, "sha256": release.sha256, + "transport": release.transport} + release = parse_firmware_catalog( + json.dumps({"firmware": [entry]}).encode(), self.manifest_url)[0] + directory = self.cache_dir / release.sha256 + directory.mkdir(parents=True, exist_ok=True) + target = directory / release.file_name + if target.is_file() and sha256_file(target) == release.sha256: + if progress: + progress(100) + return target + # Unique staging files keep concurrent downloads independent. + with tempfile.NamedTemporaryFile(dir=directory, suffix=".part", delete=False) as output: + temporary = Path(output.name) + try: + digest = hashlib.sha256() + received = 0 + with self.client.open(release.image_url) as response, temporary.open("wb") as output: + total = int(response.headers.get("Content-Length", "-1") or -1) + if total > MAX_FIRMWARE_BYTES: + raise ValueError("Firmware exceeds maximum size") + while chunk := response.read(CHUNK): + received += len(chunk) + if received > MAX_FIRMWARE_BYTES: + raise ValueError("Firmware exceeds maximum size") + digest.update(chunk) + output.write(chunk) + if progress and total > 0: + progress(min(99, received * 100 // total)) + output.flush() + os.fsync(output.fileno()) + if not received or digest.hexdigest() != release.sha256: + raise ValueError("Downloaded firmware is empty or SHA-256 does not match") + temporary.replace(target) + if progress: + progress(100) + return target + finally: + temporary.unlink(missing_ok=True) + + +class GiteaFirmwarePublisher: + """Publish an immutable image, verify it, then commit the shared catalog.""" + + def __init__(self, repository: GiteaRepository, credentials: Credentials | None = None, + *, client=None): + self.repository = repository + self.client = client if client is not None else HttpsClient(repository.server, credentials) + + def _call(self, method: str, path: str, payload=None): + binary = isinstance(payload, bytes) + data = payload if binary or payload is None else json.dumps(payload).encode("utf-8") + with self.client.open( + self.repository.api + path, method=method, data=data, + content_type="application/octet-stream" if binary else "application/json", + ) as response: + raw = response.read(4 * MAX_MANIFEST_BYTES + 1) + if len(raw) > 4 * MAX_MANIFEST_BYTES: + raise ValueError("Gitea response exceeds maximum size") + return json.loads(raw) if raw else None + + def preflight(self, publication: FirmwarePublication) -> dict: + publication.validate() + return self._entry(publication, sha256_file(publication.path)) + + def _entry(self, publication: FirmwarePublication, digest: str) -> dict: + tag = firmware_release_tag(publication) + # Different bytes never replace an asset used by an already published row. + asset_name = f"{publication.path.stem}-{digest}{publication.path.suffix.lower()}" + url = f"{self.repository.web}/releases/download/{quote(tag, safe='')}/{quote(asset_name, safe='')}" + entry = firmware_release_entry(publication, url, digest) + update_firmware_manifest({}, entry) + return entry + + def publish(self, publication: FirmwarePublication) -> dict: + publication.validate() + # Snapshot the bytes once so a concurrent rebuild cannot change the upload. + with publication.path.open("rb") as source: + image = source.read(MAX_FIRMWARE_BYTES + 1) + if not image or len(image) > MAX_FIRMWARE_BYTES: + raise ValueError("Firmware is empty or exceeds maximum size") + entry = self._entry(publication, hashlib.sha256(image).hexdigest()) + tag = firmware_release_tag(publication) + tag_path = f"/releases/tags/{quote(tag, safe='')}" + try: + release = self._call("GET", tag_path) + except HTTPError as error: + if error.code != 404: + raise + error.close() + release = self._call("POST", "/releases", { + "tag_name": tag, "target_commitish": self.repository.branch, + "name": f"{publication.product} {publication.version_name}", + "body": publication.notes, "draft": False, "prerelease": False, + }) + asset_name = urlsplit(entry["imageUrl"]).path.rsplit("/", 1)[1] + assets_path = f"/releases/{int(release['id'])}/assets" + # Gitea assets are paginated; do not silently miss an existing image. + found = False + page = 1 + while True: + assets = self._call("GET", f"{assets_path}?limit=50&page={page}") + if any(asset["name"] == unquote(asset_name) for asset in assets): + found = True + break + if len(assets) < 50: + break + page += 1 + if not found: + self._call("POST", f"{assets_path}?name={asset_name}", image) + # Always verify against the server, never against a previous local cache. + with tempfile.TemporaryDirectory(prefix="firmware-verify-") as cache: + reader = FirmwareDatabase(self.repository.manifest_url, Path(cache), client=self.client) + parsed = parse_firmware_catalog( + json.dumps({"firmware": [entry]}).encode(), self.repository.manifest_url)[0] + reader.download(parsed) + + path = f"/contents/{quote(self.repository.manifest_path, safe='/')}" + source = self._call("GET", f"{path}?ref={quote(self.repository.branch, safe='')}") + manifest = json.loads(base64.b64decode(source["content"])) + if not isinstance(manifest, dict): + raise ValueError("Manifest must be a JSON object") + updated = update_firmware_manifest(manifest, entry) + if updated != manifest: + self._call("PUT", path, { + "branch": self.repository.branch, "sha": source["sha"], + "message": f"Publish firmware {publication.product} {publication.version_name}", + "content": base64.b64encode(json.dumps(updated, ensure_ascii=False).encode()).decode(), + }) + # Use the contents API to avoid a stale raw-file CDN cache on readback. + check = self._call("GET", f"{path}?ref={quote(self.repository.branch, safe='')}") + rows = parse_firmware_catalog(base64.b64decode(check["content"]), self.repository.manifest_url) + if parsed not in rows: + raise RuntimeError("Published catalog readback does not match the release") + return entry diff --git a/python/tests/test_altera_logic.py b/python/tests/test_altera_logic.py new file mode 100644 index 0000000..55d86d9 --- /dev/null +++ b/python/tests/test_altera_logic.py @@ -0,0 +1,57 @@ +"""Golden RTL vectors through the Python FFI (no Python production codec).""" +import tempfile +import unittest +from pathlib import Path +from altera_logic import NativeAnalyzer + + +class AnalyzerTests(unittest.TestCase): + def test_rtl_info_and_first_configuration_vector(self): + core = NativeAnalyzer() + self.assertEqual(core.next_request(), bytes.fromhex("A5 01 00 00 00 A4")) + self.assertEqual(core.next_request(), b"") + for byte in bytes.fromhex("5A 81 00 10 00 10 32 01 E8"): + core.feed(bytes([byte])) + self.assertEqual(core.state, core.READY) + with self.assertRaises(ValueError): + core.start(49, 1, 0, 1, 1) + core.start(49, 0, 0, 1, 1) + self.assertEqual(core.next_request(), bytes.fromhex("A5 02 31 00 00 96")) + core.feed(bytes.fromhex("5A 82 00 D8")) + self.assertEqual(core.next_request(), bytes.fromhex("A5 03 00 00 00 A6")) + + def test_error_response_is_short_even_for_info(self): + core = NativeAnalyzer() + core.next_request() + core.feed(bytes.fromhex("5A 81 01 DA")) + self.assertEqual(core.state, core.ERROR) + self.assertEqual(core.get(1), 3) + + def test_partial_response_times_out_without_retry(self): + core = NativeAnalyzer() + core.next_request() + core.feed(bytes.fromhex("5A 81 00")) + core.tick(1000) + self.assertEqual(core.get(1), 4) + self.assertEqual(core.next_request(), b"") + + def test_demo_export_uses_capture_rate_not_current_ui_settings(self): + core = NativeAnalyzer() + core.reset(demo=True) + core.start(49) + capture = core.capture() + self.assertEqual(len(capture.samples), 4096) + self.assertEqual(capture.sample_rate, 1000000) + self.assertEqual(capture.trigger_index, 2048) + self.assertTrue(capture.demo) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory)/"capture.csv" + capture.save_csv(path) + rows = path.read_text().splitlines() + self.assertEqual(len(rows), 4097) + self.assertIn("time_s", rows[0]) + self.assertEqual(rows[2049].split(",")[1], "0.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_altera_stream.py b/python/tests/test_altera_stream.py new file mode 100644 index 0000000..a312f5a --- /dev/null +++ b/python/tests/test_altera_stream.py @@ -0,0 +1,92 @@ +import tempfile +import unittest +from pathlib import Path +from altera_logic.stream import NativeStream + + +class StreamTests(unittest.TestCase): + def test_can_uart_identical_and_duplicates(self): + source, can, uart = NativeStream(), NativeStream(), NativeStream() + self.assertEqual((source.device_type, source.device_id, source.device_name), (6, 14, "Altera Logic")) + ident, data = source.metadata_packet(7, 1000000) + self.assertEqual(ident, 0x1EE3FE00) + self.assertEqual(data, bytes.fromhex("01 4C 07 00 40 42 0F 00")) + can.feed_can(ident, data) + _, raw = source.metadata_packet(7, 1000000, uart=True) + for byte in raw: + uart.feed_uart(bytes([byte])) + for i in range(100): + ident, data = source.data_packet(7, i, i) + can.feed_can(ident, data) + _, raw = source.data_packet(7, i, i, uart=True) + uart.feed_uart(raw) + can.feed_uart(raw) # Duplicate publication through the second transport. + self.assertEqual(can.snapshot(), uart.snapshot()) + self.assertEqual(can.stats["duplicates"], 100) + self.assertEqual(can.stats["received"], 100) + + def test_gap_old_session_and_counter_wrap(self): + s = NativeStream() + s.feed_can(*s.metadata_packet(10, 1000000)) + s.feed_can(*s.data_packet(10, 0xfffffffe, 1)) + s.feed_can(*s.data_packet(10, 1, 2)) + snap = s.snapshot() + self.assertEqual(snap.indices, (0xfffffffe, 0x100000001)) + self.assertEqual(s.stats["missing"], 2) + self.assertEqual(snap.breaks, (1, 1)) + s.feed_can(*s.metadata_packet(11, 2000000)) + s.feed_can(*s.data_packet(10, 5, 3)) + s.feed_can(*s.metadata_packet(10, 1000000)) + self.assertEqual(s.stats["session"], 11) + self.assertEqual(s.snapshot().samples, ()) + s.feed_can(*s.data_packet(11, 0, 0x8001)) + self.assertEqual(s.snapshot().samples, (0x8001,)) + + def test_malformed_filter_crc_and_recovery(self): + s = NativeStream() + ident, meta = s.metadata_packet(1, 1000) + s.feed_can(ident, meta, extended=False) + s.feed_can(ident, meta, remote=True) + s.feed_can(ident ^ (1 << 20), meta) + self.assertFalse(s.stats["has_meta"]) + raw = bytearray(s.metadata_packet(1, 1000, uart=True)[1]) + raw[-1] ^= 1 + s.feed_uart(bytes(raw)) + self.assertEqual(s.stats["crc_errors"], 1) + s.feed_uart(s.metadata_packet(1, 1000, uart=True)[1]) + self.assertTrue(s.stats["has_meta"]) + ident, data = s.data_packet(1, 0, 5) + s.feed_can(ident, data[:-1]) + self.assertEqual(s.stats["invalid"], 1) + self.assertEqual(s.stats["count"], 0) + + def test_bounded_history_and_export(self): + s = NativeStream() + for _ in range(3): + s.demo_step(4096) + snap = s.snapshot(demo=True) + self.assertEqual(len(snap.samples), 8192) + self.assertEqual(snap.indices[0], 4096) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory)/"stream.csv" + snap.save_csv(path) + rows = path.read_text().splitlines() + self.assertEqual(len(rows), 8193) + self.assertIn("gap_before", rows[0]) + self.assertEqual(rows[1].split(",")[1], "4096") + + def test_sparse_trace_does_not_connect_across_lost_samples(self): + s = NativeStream() + s.feed_can(*s.metadata_packet(1, 1000000)) + for index, value in ((0, 0), (1, 1), (4, 0)): + s.feed_can(*s.data_packet(1, index, value)) + trace = s.snapshot().traces[0] + self.assertEqual(trace[0], (0, 0, 1)) + self.assertIn((1, 0, 0), trace) + self.assertIn((1, 1, 0), trace) + self.assertIn((4, 0, 1), trace) + self.assertNotIn((4, 1, 0), trace) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_firmware_clients.py b/python/tests/test_firmware_clients.py new file mode 100644 index 0000000..e829ff7 --- /dev/null +++ b/python/tests/test_firmware_clients.py @@ -0,0 +1,135 @@ +"""Android wire compatibility and complete host-side flashing transactions.""" +from dataclasses import replace +from types import SimpleNamespace +import struct +import zlib + +import pytest + +from protocan import tms_firmware as tms +from protocan.can_boot import CanBootTarget, CanBootTransfer +from protocan.protocan import ProtoCanId +from protocan.transport import build_frame + + +def reply(command, payload=b"", tail=2): + return tms.packet(10, command, payload) + bytes(tail) + + +def test_android_packet_layout_and_crc(): + assert tms.crc16_modbus(b"123456789") == 0x4B37 + assert tms.init_load(10, 0xA0000, 0x1234)[:10] == bytes.fromhex("0a3a00000a0034120000") + assert tms.load_data(10, b"\1\2\3\4")[:6] == bytes.fromhex("0a3301020304") + assert tms.tflash(10, 0xA0000, 0x110000, 0x123456)[:14] == bytes.fromhex("0a3700000a000000110056341200") + assert tms.extend(10, 0xA0000, 0, 2, 10, 3)[:16] == bytes.fromhex("0a3c00000a0000000000020000000a03") + + +@pytest.mark.parametrize("command,payload,tail", [(58, b"", 2), (52, b"abcdef", 4)]) +def test_only_reserved_tail_may_be_missing(command, payload, tail): + full = reply(command, payload, tail) + for n in range(tail + 1): + assert tms.normalize_reply(full[:len(full)-n], 10, command, len(full)) == full + assert tms.normalize_reply(full[:len(full)-tail-1], 10, command, len(full)) is None + corrupt = bytearray(full) + corrupt[2] ^= 1 + assert tms.normalize_reply(bytes(corrupt), 10, command, len(full)) is None + assert tms.normalize_reply(full, 11, command, len(full)) is None + + +def run_tms(data, target, corrupt=False): + plan = tms.programming_steps(data, target) + response = None + requests = [] + while True: + try: + request, size, timeout, title, percent = plan.send(response) + except StopIteration: + return requests + requests.append(request) + command = request[1] + if command == 52: + address, count = struct.unpack_from("//`. Одинаковые имена разных образов не + конфликтуют. Проверенный кэш используется повторно; незавершённые временные + файлы удаляются. Результат появляется только после совпадения SHA-256. +- Только HTTPS. Учётные данные отправляются лишь исходному origin (схема, + хост, порт); на сторонний CDN и при смене origin в redirect не передаются. + Redirect для запросов записи отклоняется. +- Публикация использует asset с SHA-256 в имени: новые байты не заменяют файл, + на который ещё ссылается старый каталог. `fileName` в каталоге остаётся + исходным именем. В этом деталь реализации отличается от старого публикатора + SETGUI, но формат каталога совместим. +- Каталог меняется только после обратного скачивания образа. Запись проверяет + ревизию файла (`sha`). При конфликте операция завершается ошибкой: повторите + выпуск после проверки причины. Загруженный asset может остаться без записи + каталога; повторный запуск использует его и снова проверит скачивание. +- Повтор той же записи не увеличивает `catalogVersion`. Обновление одного + `product + versionCode + transport` заменяет запись. Полный rollback и + автоматическая очистка старых assets не выполняются. +- Текущий upload держит образ в памяти (лимит 128 MiB), чтобы параллельная + пересборка не подменила байты между вычислением хэша и отправкой. + +## Проверка + +Из `templates`: + +```powershell +$env:PYTHONPATH = "$PWD/python" +python -m unittest discover -s python/tests -p test_firmware_database.py -v +python -m unittest discover -s python/tests -p test_firmware_publish.py -v +``` + +Тесты используют сервер в памяти: каталог → публикация → скачивание, повторный +выпуск, сохранение других разделов, ошибки SHA-256 и конфликты ревизии, +изоляцию авторизации при redirect. Проверка реальной Gitea и загрузка на МК +в эти тесты не входят. Перед производственным подключением проверьте выпуск +в тестовом репозитории вашей Gitea и чтение каталога целевым GUI. diff --git a/tools/firmware-publish/PORTING.md b/tools/firmware-publish/PORTING.md new file mode 100644 index 0000000..fba9f82 --- /dev/null +++ b/tools/firmware-publish/PORTING.md @@ -0,0 +1,164 @@ +# Подключение публикации прошивки к новому проекту + +**Дополнение:** полная независимая библиотека базы прошивок теперь реализована +в `python/setprotocol/firmware_database.py`, с CLI `firmware_db.py`. +Чтение, скачивание, публикация и перенос GUI описаны в [`DATABASE.md`](DATABASE.md). +Ниже сохранено описание интеграции через прежний BAT и SETGUI. + +## Что уже реализовано + +По локальным исходникам на 19 сентября 2026 года общий механизм уже есть. +Создавать вторую библиотеку для той же схемы SETGUI не требуется: + +| Компонент | Назначение | Зависимость | +|---|---|---| +| `c/firmware-info` | Версия реально запущенного образа, 12 слов / 24 байта | C-config проекта, без сети | +| `python/setprotocol/firmware_publish.py` | Проверка параметров файла, SHA-256, release tag, запись и обновление каталога | Python stdlib, `firmware_catalog.py` | +| `python/setprotocol/firmware_catalog.py` | Модель и чтение `firmware.releases` | Python stdlib | +| `tools/firmware-publish/PUBLISH_FIRMWARE.bat` | Конфигурация IDE, транспорты, проверка Git, запуск выпуска | Windows, checkout SETGUI и его `.venv` | +| `SETGUI/scripts/publish_firmware.py` | Загрузка в Gitea, скачивание и проверка SHA-256, запись и повторное чтение `update.json` | Общие Python-модули, API и учётные данные SETGUI | + +Модуль `firmware_publish.py` сам не выполняет HTTP-запросы. Старый общий BAT +использует сетевой публикатор SETGUI. Для автономного CI без SETGUI теперь +используйте `GiteaFirmwarePublisher` из `firmware_database.py` или CLI +`firmware_db.py`; одного `firmware_publish.py` для загрузки недостаточно. + +```text +проект МК → сборка .hex/.bin → общий BAT → SETGUI/scripts/publish_firmware.py + ↓ + setprotocol.firmware_publish + ↓ + Gitea release asset → SHA-256 → update.json + +проект МК → firmware-info → обработчик версии → транспорт → клиент +``` + +Эти две цепочки не синхронизируют версии автоматически. Bootloader и запись +Flash — отдельные компоненты (`rs485-boot`, `protocan-boot`, `set-protocol`); +публикация файла не добавляет поддержку обновления в устройство. + +## Фактические подключения в этой рабочей папке + +- `KONOR_ds18b20/src/main.c`: `app_send_firmware_info()` и команда `0x03`. + `mdk/SETGUI_DS18B20.uvprojx` подключает оба C-файла через `..\lib\c`, config + через `..\inc`, generated через `mdk/Generated`, генератор — Before Build. +- В текущем checkout `KONOR_ds18b20/lib/c` является Windows junction на общий + `newProject/templates/c`. Это локальная связь; её нельзя считать переносимым + путём для другого компьютера. `.gitmodules` при этом декларирует `templates` + в `lib`: перед переносом нужно сверить реальную раскладку, а не копировать + пути из Keil вслепую. Корневой `firmware-release.cmd` у KONOR при проверке + отсутствует. +- SETGUI импортирует модули публикации из `SETGUI/third_party/templates/python`. + Изменение соседнего `newProject/templates/python` само по себе эту копию не + обновляет. Версию зависимости в SETGUI обновляют отдельно. +- `SETGUI/src/gui_desktop/firmware_update.py` читает каталог через общий parser. + Поиск по текущему `SETGUI/src` не обнаружил обработчика `FIRMWARE_INFO` / + `firmware_info`: упаковка совместимого ответа ещё не означает, что эта версия + клиента запрашивает и показывает расширенный контракт KONOR. +- В исходниках сетевого публикатора задан Gitea `https://git.rd12.ru`, владелец + `setcorp`, репозиторий `SETRD12-Releases`, ветка `main`, файл `update.json`. + Это настройки кода, а не результат проверки доступности сервера. + +## 1. Выбрать раскладку + +Для нового проекта используйте явное расположение зависимости: + +```text +workspace/ + SETGUI/ + PUBLISH_FIRMWARE.bat + .venv/Scripts/python.exe + MyFirmware/ + firmware-release.cmd + lib/templates/tools/firmware-publish/PUBLISH_FIRMWARE.bat + mdk/build/application.hex +``` + +Подключите `templates` принятой в проекте системой зависимостей. Скопируйте +только конфигурацию из `examples/keil-firmware-release.cmd` или +`examples/ccs12-firmware-release.cmd` в корень прошивки. Общий BAT оставьте +в зависимости. + +## 2. Заполнить firmware-release.cmd + +Пример для приложения STM32 с bootloader; адрес `0x08003000` является примером +и должен совпасть с linker script и настройками загрузчика вашего устройства: + +```bat +@echo off +set "FW_PROJECT_ROOT=%~dp0" +set "FW_SETGUI_ROOT=%~dp0..\SETGUI" +set "FW_PRODUCT=MY_DEVICE" +set "FW_VERSION=1.2.3" +set "FW_VERSION_CODE=0x00010203" +set "FW_TRANSPORTS=can rs485" +set "FW_BASE_ADDRESS=0x08003000" +set "FW_IMAGE=mdk\build\application.hex" +set "FW_NOTES=Firmware 1.2.3" +``` + +`FW_PRODUCT` должен соответствовать идентификатору изделия, с которым клиент +выбирает прошивку. Сверьте `FW_VERSION` с C-config; при упаковке версии в три +8-битных поля используйте части 0–255. Укажите только реально реализованные +транспорты. Parser допускает `can`, `rs485`, `stm32`, `tms`; для их применения +нужна соответствующая поддержка клиента и загрузчика. + +Все транспорты одного запуска используют **один и тот же файл**. Если образы +для CAN и RS-485 отличаются, заведите разные конфигурации с разными именами +файлов и запускайте их отдельно. Одинаковое имя asset в одном release tag +нельзя использовать для разных образов. + +Для CCS 12 создайте SCI8 boot `.bin` через C2000 Hex Utility (`--binary --boot +--sci8`), задайте `FW_TRANSPORTS=tms` и очистите адрес: +`set "FW_BASE_ADDRESS="`. `.out` и `.axf` публикатор не преобразует. + +## 3. Проверить и выпустить + +Подготовьте окружение SETGUI по документации этого проекта. Учётные данные +Gitea сохраняются через окно SETGUI «Версия и обновление»; в `.cmd` они не нужны. + +Из корня MyFirmware в cmd.exe: + +```bat +call "lib\templates\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight +``` + +Для post-build в `mdk` используйте пути `..\lib\templates\...` и +`--config "..\firmware-release.cmd"`. В post-build оставляйте `--preflight`. + +Preflight проверяет существование, расширение, размер, метаданные, SHA-256 и +схему записи каталога. Он **не разбирает содержимое Intel HEX/SCI8**, не сверяет +версию с C-config, адреса с linker script и файл с текущими исходниками. +Успех preflight не заменяет Rebuild и загрузку на тестовое устройство. + +После проверки конкретного Release-образа и фиксации его исходников: + +```bat +call "lib\templates\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --publish +``` + +Скрипт запросит подтверждение. Успех означает загрузку asset, проверку скачанных +байтов, обновление каталога и его повторное чтение. Несколько транспортов +публикуются последовательно, общей транзакции для всего запуска нет. Откройте +базу прошивок SETGUI, обновите каталог и проверьте загрузку на тестовое устройство. + +## 4. Другой сервер или собственный инструмент + +`FW_SETGUI_ROOT` выбирает checkout SETGUI, а не адрес Gitea. Переменная +`SETGUI_UPDATE_MANIFEST_URL` меняет URL чтения каталога, но не перенастраивает +`WEB_ROOT`, `OWNER`, `RELEASE_REPO` сетевого публикатора. Для другого сервера +нужно согласованно перенести сетевой адаптер, адреса чтения и авторизацию. + +Для собственного Python-инструмента добавьте `templates/python` в import path +и используйте `FirmwarePublication`, `sha256_file`, `firmware_release_tag`, +`firmware_release_entry`, `update_firmware_manifest` из +`setprotocol.firmware_publish`. Вызывайте `publication.validate()` до создания +записи. Сетевой адаптер должен обеспечить тот же порядок, что SETGUI: + +1. Загрузить образ в release и скачать его обратно, сверить SHA-256. +2. Прочитать актуальный manifest, объединить через `update_firmware_manifest`. +3. Записать manifest с проверкой исходной ревизии, чтобы не затереть чужое + параллельное изменение; конфликт требует повторного чтения и объединения. +4. Перечитать каталог через `parse_firmware_catalog` и проверить запись выпуска. + +Не переносите пароли из профиля SETGUI в исходники или конфигурации проекта. diff --git a/tools/firmware-publish/README.md b/tools/firmware-publish/README.md index 9c27223..1111ea0 100644 --- a/tools/firmware-publish/README.md +++ b/tools/firmware-publish/README.md @@ -1,10 +1,20 @@ # Публикация прошивки из Keil и CCS 12 +**Самостоятельная база прошивок без SETGUI:** +[`DATABASE.md`](DATABASE.md) — библиотека и CLI для чтения каталога, +скачивания и публикации образов. Ниже описан прежний BAT-путь через SETGUI. + +Архитектура, фактические зависимости и пошаговое подключение нового проекта: +[`PORTING.md`](PORTING.md). Общая Python-библиотека подготовки каталога уже +находится в `python/setprotocol/firmware_publish.py`; сетевую загрузку выполняет +SETGUI. + Этот комплект связывает проект прошивки с каталогом SETGUI. IDE по-прежнему собирает штатный загрузочный файл, а `PUBLISH_FIRMWARE.bat`: 1. читает метаданные выпуска из `firmware-release.cmd`; -2. проверяет наличие и формат `.hex` или `.bin`; +2. проверяет наличие, расширение и размер `.hex` или `.bin` (содержимое + Intel HEX/SCI8 и совместимость с устройством не проверяются); 3. запускает локальную проверку SETGUI (`--preflight`) либо публикацию (`--publish`); 4. для каждого транспорта загружает образ в Gitea, скачивает его обратно, diff --git a/tools/firmware-publish/firmware_db.py b/tools/firmware-publish/firmware_db.py new file mode 100644 index 0000000..53fdaa1 --- /dev/null +++ b/tools/firmware-publish/firmware_db.py @@ -0,0 +1,82 @@ +"""Standalone firmware database CLI; requires Python 3.10+, no SETGUI.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import asdict +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python")) + +from setprotocol.firmware_database import ( + Credentials, FirmwareDatabase, GiteaFirmwarePublisher, GiteaRepository, +) +from setprotocol.firmware_publish import FirmwarePublication, SUPPORTED_TRANSPORTS + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--server", required=True, help="Gitea HTTPS URL") + parser.add_argument("--owner", required=True) + parser.add_argument("--repo", required=True) + parser.add_argument("--branch", default="main") + parser.add_argument("--manifest", default="update.json") + commands = parser.add_subparsers(dest="command", required=True) + catalog = commands.add_parser("list") + catalog.add_argument("--product") + catalog.add_argument("--transport", choices=sorted(SUPPORTED_TRANSPORTS)) + download = commands.add_parser("download") + download.add_argument("--product", required=True) + download.add_argument("--version-code", type=lambda v: int(v, 0), required=True) + download.add_argument("--transport", choices=sorted(SUPPORTED_TRANSPORTS), required=True) + download.add_argument("--cache", type=Path, default=Path("firmware")) + for name in ("preflight", "publish"): + command = commands.add_parser(name) + command.add_argument("--file", type=Path, required=True) + command.add_argument("--product", required=True) + command.add_argument("--version", required=True) + command.add_argument("--version-code", type=lambda v: int(v, 0), required=True) + command.add_argument("--transport", choices=sorted(SUPPORTED_TRANSPORTS), required=True) + command.add_argument("--base-address", type=lambda v: int(v, 0)) + command.add_argument("--notes", default="") + args = parser.parse_args() + try: + repository = GiteaRepository(args.server, args.owner, args.repo, args.branch, args.manifest) + login = os.environ.get("FIRMWARE_DB_LOGIN") + password = os.environ.get("FIRMWARE_DB_PASSWORD") + credentials = Credentials(login, password) if login and password else None + if args.command in ("list", "download"): + database = FirmwareDatabase(repository.manifest_url, + getattr(args, "cache", Path("firmware")), + credentials=credentials) + rows = database.read_catalog(product=args.product, transport=args.transport) + if args.command == "list": + print(json.dumps([asdict(row) for row in rows], ensure_ascii=False, indent=2)) + else: + rows = [row for row in rows if row.version_code == args.version_code] + if len(rows) != 1: + raise ValueError("Expected exactly one matching firmware release") + print(database.download(rows[0])) + else: + publication = FirmwarePublication(args.file.resolve(), args.product, args.version, + args.version_code, args.transport, + args.base_address, args.notes) + publisher = GiteaFirmwarePublisher(repository, credentials) + if args.command == "publish": + if credentials is None: + raise ValueError("Set FIRMWARE_DB_LOGIN and FIRMWARE_DB_PASSWORD for publication") + result = publisher.publish(publication) + else: + result = publisher.preflight(publication) + print(json.dumps(result, ensure_ascii=False, indent=2)) + except (OSError, ValueError, RuntimeError) as error: + print(f"Firmware database: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())