Добавить протокол Altera Logic и общие клиенты прошивки
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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/<mcu>/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`.
|
||||
|
||||
@@ -25,3 +25,7 @@ config, а не дублирования ядра.
|
||||
|
||||
KONOR публикует эти 24 байта ответом `FIRMWARE_INFO (0x03)`, сохраняя старый
|
||||
32-байтовый `DEVICE_INFO` без изменений.
|
||||
|
||||
Подробная инструкция с кодом обработчика, форматом ответа и проверкой переноса:
|
||||
[`PORTING.md`](PORTING.md). Для размещения файла прошивки в каталоге SETGUI
|
||||
используется отдельный [общий публикатор](../../tools/firmware-publish/PORTING.md).
|
||||
|
||||
93
c/set-protocol/ALTERA_LOGIC.md
Normal file
93
c/set-protocol/ALTERA_LOGIC.md
Normal file
@@ -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-модуль и прошивку;
|
||||
программные проверки не заменяют аппаратную проверку.
|
||||
@@ -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)
|
||||
|
||||
35
c/set-protocol/include/altera_logic.h
Normal file
35
c/set-protocol/include/altera_logic.h
Normal file
@@ -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
|
||||
46
c/set-protocol/include/altera_stream.h
Normal file
46
c/set-protocol/include/altera_stream.h
Normal file
@@ -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
|
||||
142
c/set-protocol/src/altera_logic.c
Normal file
142
c/set-protocol/src/altera_logic.c
Normal file
@@ -0,0 +1,142 @@
|
||||
#include "altera_logic.h"
|
||||
#include <string.h>
|
||||
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; i<s->requested; ++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; i<size; ++i) {
|
||||
if(!s->pending || 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 || capacity<s->count) 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<<bit);
|
||||
s->samples[i]=v;
|
||||
}
|
||||
s->trigger=2048; s->flags=6; s->count=4096; s->command=0; s->state=LA_DONE;
|
||||
return LA_OK;
|
||||
}
|
||||
158
c/set-protocol/src/altera_stream.c
Normal file
158
c/set-protocol/src/altera_stream.c
Normal file
@@ -0,0 +1,158 @@
|
||||
#include "altera_stream.h"
|
||||
#include "pcan_id.h"
|
||||
#include <string.h>
|
||||
|
||||
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->count<LAS_CAPACITY) ++s->count;
|
||||
}
|
||||
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 || cap<s->count) return 0;
|
||||
pos=(s->head+LAS_CAPACITY-s->count)%LAS_CAPACITY;
|
||||
for(i=0;i<s->count;++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;i<s->count;++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;i<count;++i) {
|
||||
uint16_t a=0;idx=(uint32_t)s->next_index;
|
||||
for(bit=0;bit<16;++bit) {
|
||||
if((idx/(5U+bit*7U))&1U) a|=(uint16_t)(1U<<bit);
|
||||
}
|
||||
n=las_data(s->device,s->session,idx,a,1,&raw,packet_data,sizeof(packet_data));
|
||||
las_uart(s,packet_data,n);
|
||||
}
|
||||
}
|
||||
72
c/set-protocol/tests/test_altera_logic.c
Normal file
72
c/set-protocol/tests/test_altera_logic.c
Normal file
@@ -0,0 +1,72 @@
|
||||
#include "altera_logic.h"
|
||||
#include <assert.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
static void response(void *s, uint8_t *data, size_t n) {
|
||||
size_t i; uint8_t sum=0;
|
||||
for(i=0;i<n-1;++i) sum^=data[i]; data[n-1]=sum;
|
||||
/* Serial reads can split at every byte. */
|
||||
for(i=0;i<n;++i) la_feed(s,data+i,1);
|
||||
}
|
||||
static void connect_ok(void *s) {
|
||||
uint8_t p[6], info[]={0x5a,0x81,0,16,0,16,50,1,0xe8};
|
||||
const uint8_t request[]={0xa5,1,0,0,0,0xa4};
|
||||
la_init(s); assert(la_next(s,p,5)==0);
|
||||
assert(la_next(s,p,6)==6 && !memcmp(p,request,6));
|
||||
assert(la_next(s,p,6)==0);
|
||||
response(s,info,sizeof(info)); assert(la_get(s,LA_STATE)==LA_READY);
|
||||
}
|
||||
int main(void) {
|
||||
void *s=calloc(1,la_context_size()); uint8_t p[6], ack[4], status[7], block[133];
|
||||
uint16_t samples[4096]; unsigned int cmd, offset, i;
|
||||
assert(s); connect_ok(s);
|
||||
assert(la_start(s,65536,0,0,0,0)==LA_ARGUMENT);
|
||||
assert(la_start(s,49,1,0,1,1)==LA_ARGUMENT);
|
||||
assert(la_start(s,49,0,0,1,1)==0);
|
||||
for(cmd=2;cmd<=7;++cmd) {
|
||||
assert(la_next(s,p,6)==6 && p[1]==cmd);
|
||||
if(cmd==2) { const uint8_t v[]={0xa5,2,49,0,0,0x96}; assert(!memcmp(p,v,6)); }
|
||||
ack[0]=0x5a; ack[1]=(uint8_t)(cmd|0x80); ack[2]=0; response(s,ack,4);
|
||||
}
|
||||
assert(la_next(s,p,6)==0); la_tick(s,25);
|
||||
assert(la_next(s,p,6)==6 && p[1]==8);
|
||||
{ uint8_t waiting[]={0x5a,0x88,0,1,0,8,0}; response(s,waiting,7); }
|
||||
la_tick(s,60000); /* Waiting for a physical trigger is not a UART timeout. */
|
||||
assert(la_get(s,LA_STATE)==LA_CAPTURING);
|
||||
assert(la_next(s,p,6)==6 && p[1]==8);
|
||||
status[0]=0x5a; status[1]=0x88; status[2]=0; status[3]=6;
|
||||
status[4]=0;status[5]=8;response(s,status,7);
|
||||
for(offset=0;offset<4096;offset+=64) {
|
||||
assert(la_next(s,p,6)==6 && p[1]==9 && p[4]==64);
|
||||
assert((unsigned int)(p[2]|(p[3]<<8))==offset);
|
||||
block[0]=0x5a;block[1]=0x89;block[2]=0;block[3]=64;
|
||||
for(i=0;i<64;++i) { block[4+2*i]=(uint8_t)(offset+i);block[5+2*i]=(uint8_t)((offset+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;
|
||||
}
|
||||
62
c/set-protocol/tests/test_altera_stream.c
Normal file
62
c/set-protocol/tests/test_altera_stream.c
Normal file
@@ -0,0 +1,62 @@
|
||||
#include "altera_stream.h"
|
||||
#include <assert.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
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;++i) las_uart(s,data+i,1);
|
||||
else las_can(s,id,data,n,1,0);
|
||||
}
|
||||
static void sample(void *s,uint32_t session,uint32_t index,int uart) {
|
||||
uint8_t data[32];uint32_t id;size_t n;
|
||||
n=las_data(LAS_DEVICE_ID,session,index,index&65535U,(uint32_t)uart,&id,data,sizeof(data));assert(n);
|
||||
assert(id==0x1EE3FF00U);
|
||||
if(uart) las_uart(s,data,n);else las_can(s,id,data,n,1,0);
|
||||
}
|
||||
int main(void) {
|
||||
void *can=malloc(las_context_size()),*uart=malloc(las_context_size());
|
||||
uint64_t *indices=malloc(LAS_CAPACITY*sizeof(uint64_t));
|
||||
uint16_t *samples=malloc(LAS_CAPACITY*sizeof(uint16_t));
|
||||
uint8_t *breaks=malloc(LAS_CAPACITY);uint8_t packet[32];uint32_t id;size_t n,i;
|
||||
assert(can&&uart&&indices&&samples&&breaks);
|
||||
assert(las_init(can,14)&&las_init(uart,14));assert(!las_init(can,16));
|
||||
sample(can,1,0,0);assert(las_get(can,LAS_COUNT)==0);
|
||||
meta(can,1,0);meta(uart,1,1);
|
||||
/* Independent golden SETCAN bytes. */
|
||||
n=las_data(14,1,0x12345678U,0x8001,0,&id,packet,sizeof(packet));
|
||||
{ const uint8_t v[]={1,0,1,0x80,0x78,0x56,0x34,0x12};assert(n==8&&!memcmp(v,packet,8)); }
|
||||
for(i=0;i<9000;++i) { sample(can,1,(uint32_t)i,0);sample(uart,1,(uint32_t)i,1); }
|
||||
assert(las_get(can,LAS_COUNT)==8192&&las_get(uart,LAS_COUNT)==8192);
|
||||
assert(las_get(can,LAS_RECEIVED)==9000&&las_get(uart,LAS_RECEIVED)==9000);
|
||||
assert(las_snapshot(can,indices,samples,breaks,LAS_CAPACITY)==8192);
|
||||
assert(indices[0]==808&&indices[8191]==8999&&samples[8191]==8999);
|
||||
assert(las_snapshot(uart,indices,samples,breaks,LAS_CAPACITY)==8192);
|
||||
assert(indices[0]==808&&indices[8191]==8999&&samples[8191]==8999);
|
||||
sample(can,1,8999,1);assert(las_get(can,LAS_DUPLICATES)==1); /* second transport duplicate */
|
||||
sample(can,1,9003,0);assert(las_get(can,LAS_MISSING)==3);
|
||||
las_snapshot(can,indices,samples,breaks,LAS_CAPACITY);assert(breaks[8191]);
|
||||
/* Repeated metadata must not erase rolling history. */
|
||||
meta(can,1,1);assert(las_get(can,LAS_COUNT)==8192);
|
||||
meta(can,2,0);assert(las_get(can,LAS_COUNT)==0);
|
||||
sample(can,1,9004,1);assert(las_get(can,LAS_COUNT)==0);
|
||||
meta(can,1,1);assert(las_get(can,LAS_SESSION)==2);
|
||||
sample(can,2,0xffffffffU,0);sample(can,2,0,1);
|
||||
las_snapshot(can,indices,samples,breaks,LAS_CAPACITY);
|
||||
assert(indices[0]==0xffffffffULL&&indices[1]==0x100000000ULL&&!breaks[1]);
|
||||
/* Reject wrong address, standard ID, RTR and short payload. */
|
||||
n=las_data(14,2,1,0,0,&id,packet,sizeof(packet));assert(n==8);
|
||||
las_can(can,id,packet,8,0,0);las_can(can,id,packet,8,1,1);
|
||||
las_can(can,id^(1U<<20),packet,8,1,0);las_can(can,id,packet,7,1,0);
|
||||
assert(las_get(can,LAS_COUNT)==2&&las_get(can,LAS_INVALID)==1);
|
||||
n=las_data(14,2,1,1,1,&id,packet,sizeof(packet));packet[n-1]^=0x80;
|
||||
las_uart(can,packet,n);assert(las_get(can,LAS_CRC_ERRORS)==1);
|
||||
sample(can,2,1,1);assert(las_get(can,LAS_COUNT)==3); /* recovery after CRC */
|
||||
assert(!las_metadata(14,65536,1000,0,&id,packet,sizeof(packet)));
|
||||
assert(!las_metadata(14,1,0,0,&id,packet,sizeof(packet)));
|
||||
assert(!las_data(14,1,0,65536,0,&id,packet,sizeof(packet)));
|
||||
free(can);free(uart);free(indices);free(samples);free(breaks);
|
||||
puts("Altera SETCAN/CAN/UART stream tests passed");return 0;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ INCLUDE = ROOT / "include"
|
||||
JNI_INCLUDES: list[Path] = []
|
||||
SOURCES = [
|
||||
ROOT / "src" / name for name in (
|
||||
"altera_logic.c",
|
||||
"altera_stream.c",
|
||||
"set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c",
|
||||
"balsam_can.c", "set_crc.c", "periph28335.c", "tms2812.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c",
|
||||
"pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c",
|
||||
|
||||
189
doc/setcan/ALTERA_LOGIC_STREAM.md
Normal file
189
doc/setcan/ALTERA_LOGIC_STREAM.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Altera Logic — ONLINE через SETCAN / GAS / UART
|
||||
|
||||
Версия профиля: **1**. Реализация, порты и тесты принадлежат `templates`.
|
||||
Это профиль существующего classic SETCAN/ProtoCAN с 29-битным ID;
|
||||
он не подменяется кадром SETP v2 и не использует старые команды `A5 CMD` FPGA.
|
||||
|
||||
## Адрес и носители
|
||||
|
||||
| Поле | Значение |
|
||||
|---|---|
|
||||
| Priority | 1, стандартный |
|
||||
| Route | 1, от устройства |
|
||||
| DeviceType | `0x6` |
|
||||
| DeviceID | `0xE` |
|
||||
| Device Name | `Altera Logic` |
|
||||
| MsgType | `0x3`, GAS |
|
||||
| CAN | Extended ID, data frame, DLC=8 |
|
||||
|
||||
CAN доставляет ID + 8 байт непосредственно. UART доставляет ровно тот же ID
|
||||
и те же данные в существующем контейнере `pcan_frame`:
|
||||
|
||||
```text
|
||||
AA 55 | LEN | SEQ | FLAGS | CAN_ID u32 LE | DATA | CRC16 u16 LE
|
||||
```
|
||||
|
||||
Для этого профиля LEN=14, FLAGS=1 (IDE), DIR/RTR/ERR сброшены,
|
||||
итоговая длина 19 байтов. CRC-16/CCITT-FALSE считается существующим кодеком
|
||||
по LEN…DATA. SEQ — транспортный 8-битный счётчик; порядок выборок определяется
|
||||
полем SampleIndex, поэтому независимые CAN/UART потоки дают одинаковый результат.
|
||||
Предоставленные простые C-билдеры устанавливают SEQ=0; устройство может вести
|
||||
его самостоятельно, используя `pcan_frame_encode`.
|
||||
Настройки прямого UART-порта GUI: **921600, 8N1, без flow control**.
|
||||
|
||||
## GAS: метаданные потока
|
||||
|
||||
**CAN ID `0x1EE3FE00`, MsgBody/GAS address `0xFE00`, DLC=8.**
|
||||
|
||||
| Регистры GAS | Байты DATA | Поле |
|
||||
|---|---|---|
|
||||
| FE00 | 0…1 | Tag/version = `0x4C01` |
|
||||
| FE01 | 2…3 | SessionID u16 |
|
||||
| FE02…FE03 | 4…7 | SamplePeriodNs u32, строго больше 0 |
|
||||
|
||||
Порядок байтов little-endian. Число каналов профиля фиксировано: 16.
|
||||
Частота выборок = `1e9 / SamplePeriodNs`.
|
||||
|
||||
Пример сессии 1 с частотой 1000 выборок/с:
|
||||
|
||||
```text
|
||||
ID: 1EE3FE00
|
||||
DATA: 01 4C 01 00 40 42 0F 00
|
||||
```
|
||||
|
||||
Источник отправляет метаданные перед первой выборкой и повторяет примерно
|
||||
раз в секунду, чтобы подключившийся GUI мог присоединиться к потоку.
|
||||
Повтор с теми же SessionID/периодом историю не очищает.
|
||||
|
||||
При перезапуске потока или изменении периода источник увеличивает SessionID
|
||||
по модулю 65536. Новый SessionID очищает историю; задержанные метаданные
|
||||
предыдущей сессии игнорируются. Разница менее 32768 считается движением вперёд.
|
||||
Если после аппаратного сброса устройство начало счётчик с меньшего значения,
|
||||
нужно остановить и снова включить приём в GUI (либо сохранять счётчик сессий
|
||||
на устройстве). Без полученных метаданных отсчёты не отображаются.
|
||||
|
||||
## GAS: одна выборка всех 16 каналов
|
||||
|
||||
**CAN ID `0x1EE3FF00`, MsgBody/GAS address `0xFF00`, DLC=8.**
|
||||
|
||||
| Регистры GAS | Байты DATA | Поле |
|
||||
|---|---|---|
|
||||
| FF00 | 0…1 | SessionID u16, должен совпадать с метаданными |
|
||||
| FF01 | 2…3 | Sample u16: бит n = уровень канала Dn |
|
||||
| FF02…FF03 | 4…7 | SampleIndex u32 |
|
||||
|
||||
Пример: сессия 1, выборка 0, D0 и D15 равны 1:
|
||||
|
||||
```text
|
||||
ID: 1EE3FF00
|
||||
DATA: 01 00 01 80 00 00 00 00
|
||||
```
|
||||
|
||||
SampleIndex увеличивается на каждой аппаратной выборке, в том числе если
|
||||
пакет не удалось передать. GUI замечает скачок индекса, учитывает потерянные
|
||||
выборки и рисует разрыв, не соединяя его выдуманными состояниями.
|
||||
Время получается из индекса и периода устройства, а не из времени прихода UART.
|
||||
|
||||
Переполнение индекса u32 разворачивается в u64 на приёмнике. Однозначное
|
||||
восстановление порядка требует разрыва меньше 2^31 выборок. При более долгом
|
||||
перерыве следует начать новую сессию. При подключении после полного оборота
|
||||
индекса GUI не знает число оборотов до первой принятой выборки.
|
||||
|
||||
Одновременный CAN+UART приём объединяет один поток: одинаковые или запоздалые
|
||||
индексы в одной сессии не добавляются повторно. Их число показывается как
|
||||
«дубли/старые». Поздние данные старой сессии игнорируются по SessionID.
|
||||
Если два источника прислали разные значения с одинаковыми SessionID/индексом,
|
||||
остаётся первый принятый пакет: это не механизм голосования или сверки каналов.
|
||||
|
||||
## Нагрузка и история
|
||||
|
||||
Профиль передаёт одну 16-битную выборку на CAN-кадр. Это осознанный обмен
|
||||
пропускной способности на независимую проверку сессии, потерь и дублей.
|
||||
Начальный профиль для устройства — 1000 выборок/с.
|
||||
|
||||
На UART при 921600 бод и 19 байтах × 10 бит на пакет теоретический потолок
|
||||
составляет около 4850 выборок/с, без учёта метаданных и пауз. Для непрерывной
|
||||
передачи 50 Мвыб/с этот транспорт не подходит. Высокоскоростной буферный
|
||||
захват остаётся отдельным режимом.
|
||||
|
||||
Общая C-история ограничена 8192 последними принятыми выборками. Старые данные
|
||||
вытесняются; полный длительный архив в этом режиме не ведётся. GUI обновляет
|
||||
экран не чаще 20 раз в секунду. Пауза фиксирует показанное окно, но приём
|
||||
продолжается. CSV сохраняет текущее окно с индексами, временем и признаками разрывов.
|
||||
|
||||
## API для прошивки / порта
|
||||
|
||||
### Маркеры в ONLINE GUI
|
||||
|
||||
Колесо внутри графика прокручивает его по X (влево/вправо), Ctrl + колесо —
|
||||
по Y (вверх/вниз), не меняя масштаб. Когда вся ось помещается в окно,
|
||||
прокрутка этой оси не требуется. После ручной прокрутки X автоматический
|
||||
переход к концу потока отключается до сброса масштаба.
|
||||
Qt-порт этой обработки: `python/altera_logic/plot_scroll.py`.
|
||||
|
||||
Масштабирование внутри графика: зажать ЛКМ и двигать вправо/влево для X;
|
||||
Ctrl + ЛКМ и движение вверх/вниз — для Y. Начальная точка остаётся на месте
|
||||
в видимой области. Масштаб X: 1…16, Y: 1…8. «Сброс масштаба» возвращает
|
||||
обе оси к 1. ЛКМ за линию маркера перемещает маркер; Ctrl отдаёт приоритет
|
||||
масштабу Y. Обычный клик устанавливает выбранный маркер, если он включён.
|
||||
При увеличении Y растягиваются дорожки цифровых каналов, не изменяются
|
||||
измеренные значения. Прокрутка позволяет просматривать увеличенную область.
|
||||
|
||||
Две пары X1/X2 и X3/X4 включаются независимо и показывают время каждого
|
||||
маркера, знаковый интервал Δt = X2 − X1 (либо X4 − X3) и f = 1/|Δt|.
|
||||
При совпадении маркеров частота отображается как «—». Это обратный интервал,
|
||||
а не автоматическое определение частоты сигнала.
|
||||
Две пары Y1/Y2 и Y3/Y4 включаются независимо, показывают оба положения и
|
||||
знаковую ΔY в процентах высоты графика (это не измерение напряжения).
|
||||
Панель измерений построена по примеру AndroidGUI: отдельная строка для каждой
|
||||
пары и ползунок выбранного маркера. dB для цифровых дорожек не вычисляются.
|
||||
Разности вычисляет общий C-код `set_plot.c` через `protocan.plot.PlotMath`,
|
||||
тот же код используется Android-портом.
|
||||
Линии можно перетаскивать мышью; список «Установить кликом» выбирает маркер
|
||||
для установки в любой точке. Режим «Курсор» возвращает обычный просмотр выборок.
|
||||
Снятие галочки скрывает линии, сохраняя их позиции. «Маркеры в окно» возвращает
|
||||
их в текущую область. Для измерения неподвижного фрагмента включите паузу.
|
||||
X сохраняет положение во времени при обновлении истории и может уйти за окно;
|
||||
новая сессия устанавливает начальные позиции. Визуальная модель находится
|
||||
в `python/altera_logic/markers.py`, обработка мыши и рисование — в SETGUI.
|
||||
|
||||
`c/set-protocol/include/altera_stream.h` экспортирует:
|
||||
|
||||
- `las_metadata` — общий билдер метаданных для CAN либо UART;
|
||||
- `las_data` — общий билдер выборки для CAN либо UART;
|
||||
- `las_can` / `las_uart` — два входа одного декодера и истории;
|
||||
- `las_snapshot` — хронологический снимок с индексами и разрывами;
|
||||
- `las_get` — диагностика: потери, дубли, неправильные кадры, CRC.
|
||||
|
||||
Пример формирования одной выборки для CAN, без HAL-зависимостей:
|
||||
|
||||
```c
|
||||
#include "altera_stream.h"
|
||||
uint8_t bytes[32];
|
||||
uint32_t id;
|
||||
size_t size;
|
||||
/* SessionID=1, период=1 мс. CAN-драйверу передаются id и size байт. */
|
||||
size = las_metadata(LAS_DEVICE_ID, 1, 1000000, 0, &id, bytes, sizeof(bytes));
|
||||
/* board_can_send_extended(id, bytes, size); */
|
||||
size = las_data(LAS_DEVICE_ID, 1, 0, 0x8001, 0, &id, bytes, sizeof(bytes));
|
||||
/* board_can_send_extended(id, bytes, size); */
|
||||
/* Для UART последний флаг носителя заменить с 0 на 1:
|
||||
билдер вернёт полный пакет AA55, который передаётся в UART без изменений. */
|
||||
```
|
||||
|
||||
Драйвер платы должен передавать каждый пакет полностью и сохранять порядок,
|
||||
периодически публиковать метаданные и увеличивать SampleIndex даже при потере
|
||||
пакета в своей очереди. CAN-контроллер и трансивер остаются аппаратным портом.
|
||||
|
||||
## Размещение реализации и границы
|
||||
|
||||
- `templates/c/set-protocol/src/altera_stream.c`: протокол и кольцевая история.
|
||||
- `templates/python/altera_logic/stream.py`: тонкий ctypes-порт и CSV-модель.
|
||||
- `templates/python/altera_logic/stream_port.py`: Qt UART/CAN-приём и жизненный цикл.
|
||||
- SETGUI: только визуальная вкладка и подключение к существующему CAN-источнику.
|
||||
|
||||
Текущий FPGA RTL с протоколом `A5 CMD` не становится SETCAN-источником от
|
||||
обновления GUI. Нужен передатчик этого профиля в прошивке/RTL либо внешний
|
||||
мост, который получает реальные выборки и публикует их в описанном формате.
|
||||
В этой версии реализованы GUI-приёмник, общие C-билдеры для устройства и
|
||||
демопроизводитель; FPGA RTL и физический CAN-контроллер не изменены.
|
||||
21
doc/setcan/DEVICE_REGISTRY.md
Normal file
21
doc/setcan/DEVICE_REGISTRY.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Реестр адресов SETCAN
|
||||
|
||||
Дополнения к исходной таблице `Протокол CAN и ОАП.xlsx` фиксируются здесь.
|
||||
Числовая раскладка CAN ID не меняется: DeviceType — 3 бита, DeviceID — 4 бита.
|
||||
Device Name — отображаемое имя адреса, а не дополнительное поле CAN ID.
|
||||
|
||||
| DeviceType | DeviceID | Device Name | Назначение |
|
||||
|---|---|---|---|
|
||||
| `0x0` | существующие | Верхний уровень | Существующие адреса исходной таблицы |
|
||||
| `0x6` | `0xE` | **Altera Logic** | Онлайн-анализатор 16 цифровых каналов |
|
||||
| `0x7` | `0xD` | configurator | Существующий конфигуратор |
|
||||
| `0x7` | `0xF` | KONOR / SETTINGS | Существующий профиль SETCAN и привязка датчиков |
|
||||
|
||||
Для Altera Logic выбран предпоследний тип `0x6` и предпоследний экземпляр `0xE`:
|
||||
эта пара не была назначена в проверенных локальных таблицах и исходниках.
|
||||
Тип `0x7` уже используется. Наличие другого физического узла с тем же адресом
|
||||
на конкретной шине нужно исключить при вводе в эксплуатацию.
|
||||
|
||||
Числовые константы устройства: `c/set-protocol/include/altera_stream.h`.
|
||||
Отображаемые имена: `python/protocan/protocan.py`.
|
||||
Контракт потока: [ALTERA_LOGIC_STREAM.md](ALTERA_LOGIC_STREAM.md).
|
||||
@@ -32,3 +32,7 @@
|
||||
|
||||
Для пересборки HTML запустите `doc/setcan/build-html.bat` из корня
|
||||
репозитория `templates`.
|
||||
# Расширение Altera Logic
|
||||
|
||||
- [Реестр устройств SETCAN](DEVICE_REGISTRY.md).
|
||||
- [ONLINE-поток Altera Logic через GAS и UART](ALTERA_LOGIC_STREAM.md).
|
||||
|
||||
1
python/altera_logic/.gitignore
vendored
Normal file
1
python/altera_logic/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
native/
|
||||
4
python/altera_logic/__init__.py
Normal file
4
python/altera_logic/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Portable Altera analyzer client. Wire protocol and sequencing live in C99."""
|
||||
from .native import Capture, NativeAnalyzer
|
||||
|
||||
__all__ = ["Capture", "NativeAnalyzer"]
|
||||
89
python/altera_logic/markers.py
Normal file
89
python/altera_logic/markers.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Presentation state for waveform rulers; no transport or wire processing."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def drag_zoom_factor(delta_pixels):
|
||||
"""Visual gesture mapping: 200 pixels doubles the selected axis."""
|
||||
return 2 ** (max(-1000., min(1000., delta_pixels)) / 200.)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Marker:
|
||||
name: str
|
||||
axis: str
|
||||
value: float
|
||||
color: str
|
||||
visible: bool = False
|
||||
|
||||
|
||||
class WaveformMarkers:
|
||||
def __init__(self):
|
||||
self.items = [Marker('X1', 'x', .2, '#ffce63'),
|
||||
Marker('X2', 'x', .4, '#ffce63'),
|
||||
Marker('X3', 'x', .6, '#e794ff'),
|
||||
Marker('X4', 'x', .8, '#e794ff'),
|
||||
Marker('Y1', 'y', .2, '#72e7ff'),
|
||||
Marker('Y2', 'y', .4, '#72e7ff'),
|
||||
Marker('Y3', 'y', .6, '#a9df79'),
|
||||
Marker('Y4', 'y', .8, '#a9df79')]
|
||||
self._math = None
|
||||
self.session = None
|
||||
self.bounds = None
|
||||
|
||||
def window(self, session, start, end):
|
||||
if session != self.session or self.bounds is None:
|
||||
for marker, fraction in zip(self.items[:4], (.2, .4, .6, .8)):
|
||||
marker.value = start + (end-start)*fraction
|
||||
self.session, self.bounds = session, (start, end)
|
||||
|
||||
def reset(self):
|
||||
if self.bounds:
|
||||
start, end = self.bounds
|
||||
for marker, fraction in zip(self.items[:4], (.2, .4, .6, .8)):
|
||||
marker.value = start + (end-start)*fraction
|
||||
for marker, fraction in zip(self.items[4:], (.2, .4, .6, .8)):
|
||||
marker.value = fraction
|
||||
|
||||
def measurements(self, period_ns):
|
||||
"""Use the same native delta operation as Android's plotDelta."""
|
||||
if self._math is None:
|
||||
from protocan.plot import PlotMath
|
||||
from .native import NativeAnalyzer
|
||||
self._math = PlotMath(NativeAnalyzer().lib)
|
||||
result = []
|
||||
for index in range(0, 8, 2):
|
||||
a, b = self.items[index:index+2]
|
||||
if not (a.visible and b.visible):
|
||||
continue
|
||||
if a.axis == 'x':
|
||||
if self.bounds is None:
|
||||
continue
|
||||
factor = period_ns / 1e9
|
||||
av, bv = a.value*factor, b.value*factor
|
||||
delta = self._math.delta(a.value, b.value, factor)
|
||||
frequency = 1/abs(delta) if delta else None
|
||||
else:
|
||||
av, bv = 100*(1-a.value), 100*(1-b.value)
|
||||
delta = self._math.delta(av, bv)
|
||||
frequency = None
|
||||
result.append((a, b, av, bv, delta, frequency))
|
||||
return result
|
||||
|
||||
def place(self, index, fraction):
|
||||
marker = self.items[index]
|
||||
fraction = min(1., max(0., fraction))
|
||||
if marker.axis == 'x':
|
||||
if self.bounds is None:
|
||||
return
|
||||
start, end = self.bounds
|
||||
marker.value = start + (end-start)*fraction
|
||||
else:
|
||||
marker.value = fraction
|
||||
|
||||
def fraction(self, marker):
|
||||
if marker.axis == 'y':
|
||||
return marker.value
|
||||
if self.bounds is None:
|
||||
return 0.
|
||||
start, end = self.bounds
|
||||
return (marker.value-start)/max(1, end-start)
|
||||
131
python/altera_logic/native.py
Normal file
131
python/altera_logic/native.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""ctypes binding only: no Python packet codec or protocol fallback."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import ctypes as C
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Capture:
|
||||
samples: tuple[int, ...]
|
||||
trigger_index: int
|
||||
sample_rate: float
|
||||
demo: bool = False
|
||||
|
||||
def save_csv(self, path):
|
||||
with open(path, "w", newline="", encoding="utf-8") as stream:
|
||||
writer = csv.writer(stream)
|
||||
writer.writerow(["index", "time_s", "sample_hex", "demo"] +
|
||||
["D%d" % n for n in range(16)])
|
||||
for i, sample in enumerate(self.samples):
|
||||
writer.writerow([i, (i-self.trigger_index)/self.sample_rate,
|
||||
"%04X" % sample, int(self.demo)] +
|
||||
[(sample >> 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)
|
||||
27
python/altera_logic/plot_scroll.py
Normal file
27
python/altera_logic/plot_scroll.py
Normal file
@@ -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()
|
||||
114
python/altera_logic/qt_port.py
Normal file
114
python/altera_logic/qt_port.py
Normal file
@@ -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)
|
||||
108
python/altera_logic/stream.py
Normal file
108
python/altera_logic/stream.py
Normal file
@@ -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])
|
||||
103
python/altera_logic/stream_port.py
Normal file
103
python/altera_logic/stream_port.py
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
123
python/protocan/can_boot.py
Normal file
123
python/protocan/can_boot.py
Normal file
@@ -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("<BBH", frame.data)
|
||||
if status == 1:
|
||||
return [], "Загрузчик занят"
|
||||
if status not in (0, 8) or (status == 8 and self.stage != "data"):
|
||||
raise RuntimeError(f"ProtoCAN Boot: ошибка 0x{status:02X}")
|
||||
if self.stage == "enter":
|
||||
self.stage = "image"
|
||||
return self._control(3, struct.pack("<II", len(self.image.data), self.image.crc32)), "Передача метаданных"
|
||||
if self.stage == "image":
|
||||
self.stage = "compat"
|
||||
t = self.target
|
||||
return self._control(4, struct.pack("<HBBI", t.product_type, t.hardware_min, t.hardware_max, self.image.version)), "Проверка совместимости"
|
||||
if self.stage == "compat":
|
||||
if slot not in (0, 1):
|
||||
raise RuntimeError("ProtoCAN Boot: неверный слот")
|
||||
self.slot, self.stage = slot, "erase"
|
||||
return self._control(5), "Стирание неактивного слота"
|
||||
if self.stage in ("erase", "data"):
|
||||
total = (len(self.image.data) + 7) // 8
|
||||
# The final index of a 512 KiB image wraps the u16 status field.
|
||||
if self.stage == "data" and expected == 0 and self._window_end == 65536 and status == 0:
|
||||
expected = 65536
|
||||
limit = total if self.stage == "erase" else self._window_end
|
||||
if expected > 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 завершена"
|
||||
@@ -131,9 +131,11 @@ MSGTYPE_RU = {
|
||||
#: но не присваивает приборам имён.
|
||||
DEVICE_TYPE_NAMES = {
|
||||
0x0: "Верхний уровень",
|
||||
0x6: "Логические анализаторы",
|
||||
}
|
||||
|
||||
DEVICE_ADDRESS_NAMES = {
|
||||
(0x6, 0xE): "Altera Logic",
|
||||
(0x7, 0xD): "configurator",
|
||||
}
|
||||
|
||||
|
||||
239
python/protocan/setp_firmware.py
Normal file
239
python/protocan/setp_firmware.py
Normal file
@@ -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(
|
||||
"<II32s", len(self.image.data), self.image.crc32,
|
||||
bytes.fromhex(self.image.sha256),
|
||||
)
|
||||
return self._request(MessageType.FW_END, payload), "Проверка CRC32 и SHA-256"
|
||||
self.stage = "data"
|
||||
data = self.image.data[self.offset : self.offset + self.block_size]
|
||||
return (
|
||||
self._request(MessageType.FW_DATA, encode_firmware_data(self.offset, data)),
|
||||
"Передача блоков SETProtocol v2",
|
||||
)
|
||||
|
||||
def handle_status(self, _frame: tr.Frame) -> 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)))
|
||||
47
python/protocan/stm32_boot.py
Normal file
47
python/protocan/stm32_boot.py
Normal file
@@ -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,))
|
||||
118
python/protocan/tms_firmware.py
Normal file
118
python/protocan/tms_firmware.py
Normal file
@@ -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("<H", crc16_modbus(raw))
|
||||
|
||||
|
||||
def init_load(controller: int, ram: int, size: int) -> bytes:
|
||||
return packet(controller, CMD_INITLOAD, struct.pack("<II", ram, size))
|
||||
|
||||
|
||||
def load_data(controller: int, data: bytes) -> 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("<III", ram, flash, size))
|
||||
|
||||
|
||||
def extend(controller: int, first: int, second: int, size: int, code: int, board: int) -> bytes:
|
||||
return packet(controller, CMD_EXTEND, struct.pack("<IIIBB", first, second, size, code, board))
|
||||
|
||||
|
||||
def extend_result(raw: bytes) -> tuple[int, int, int]:
|
||||
return struct.unpack_from("<III", raw, 2)
|
||||
|
||||
|
||||
def normalize_reply(raw: bytes, controller: int, command: int, size: int) -> 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}")
|
||||
268
python/setprotocol/firmware_database.py
Normal file
268
python/setprotocol/firmware_database.py
Normal file
@@ -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
|
||||
57
python/tests/test_altera_logic.py
Normal file
57
python/tests/test_altera_logic.py
Normal file
@@ -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()
|
||||
92
python/tests/test_altera_stream.py
Normal file
92
python/tests/test_altera_stream.py
Normal file
@@ -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()
|
||||
135
python/tests/test_firmware_clients.py
Normal file
135
python/tests/test_firmware_clients.py
Normal file
@@ -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("<II", request, 2)
|
||||
offset = (address-target.flash)*2
|
||||
chunk = data[offset:offset+count].ljust(count, b"\xff")
|
||||
if corrupt:
|
||||
chunk = bytes([chunk[0] ^ 1]) + chunk[1:]
|
||||
response = reply(command, chunk, 4)
|
||||
assert len(response) == size
|
||||
elif command == 60:
|
||||
response = reply(command, struct.pack("<III", (len(data)+1)//2, 0, 0))
|
||||
else:
|
||||
response = reply(command)
|
||||
|
||||
|
||||
def test_tms_word_offsets_and_odd_final_byte_readback():
|
||||
requests = run_tms(bytes(range(256)) + b"x", tms.TmsTarget())
|
||||
assert [p[1] for p in requests] == [58, 51, 58, 51, 55, 52, 52]
|
||||
assert struct.unpack_from("<II", requests[2], 2) == (0xA0080, 1)
|
||||
assert struct.unpack_from("<II", requests[-1], 2) == (0x110080, 2)
|
||||
with pytest.raises(ValueError, match="не пройдена"):
|
||||
run_tms(b"test", tms.TmsTarget(), corrupt=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,codes", [("spartan2e", [6, 17]), ("spartan6", [10])])
|
||||
def test_peripheral_flashing_and_verification(kind, codes):
|
||||
packets = run_tms(b"test", tms.TmsTarget(kind=kind, flash=0, board=3))
|
||||
assert [p[14] for p in packets if p[1] == 60] == codes
|
||||
assert all(p[15] == 3 for p in packets if p[1] == 60)
|
||||
|
||||
|
||||
def test_load_only_never_writes_flash():
|
||||
assert [p[1] for p in run_tms(b"test", tms.TmsTarget(load_only=True))] == [58, 51]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target,size", [(tms.TmsTarget(flash=0), 4), (tms.TmsTarget(), 0),
|
||||
(tms.TmsTarget(block_size=257), 4), (tms.TmsTarget(flash=0x17FFFF), 3),
|
||||
(tms.TmsTarget(ram=0xFFFFFFFF), 4)])
|
||||
def test_invalid_targets_fail_before_transmission(target, size):
|
||||
with pytest.raises(ValueError):
|
||||
target.validate(size)
|
||||
|
||||
|
||||
def image(data):
|
||||
return SimpleNamespace(data=data, crc32=zlib.crc32(data), version=7)
|
||||
|
||||
|
||||
def status(t, cmd, code=0, slot=1, expected=0):
|
||||
target = t.target
|
||||
can_id = ProtoCanId.build(1, 1, target.device_type, target.device, 12, target.session_id << 8 | cmd)
|
||||
return build_frame(can_id, struct.pack("<BBHI", code, slot, expected, 0), to_can=False)
|
||||
|
||||
|
||||
def enter_data(t):
|
||||
assert ProtoCanId.parse(t.start()[0].can_id).msg_type == 9
|
||||
for cmd in (2, 3, 4):
|
||||
t.handle_status(status(t, cmd))
|
||||
return t.handle_status(status(t, 5))[0]
|
||||
|
||||
|
||||
def test_legacy_can_full_transaction_and_retransmission():
|
||||
t = CanBootTransfer(image(bytes(range(130))), CanBootTarget())
|
||||
frames = enter_data(t)
|
||||
assert len(frames) == 16
|
||||
assert all(ProtoCanId.parse(f.can_id).msg_type == 11 for f in frames)
|
||||
frames, _ = t.handle_status(status(t, 0, code=8, expected=8))
|
||||
assert ProtoCanId.parse(frames[0].can_id).body == 8
|
||||
assert frames[-1].data == bytes([128,129]) + b"\xff" * 6
|
||||
frames, _ = t.handle_status(status(t, 0, expected=17))
|
||||
assert t.stage == "verify"
|
||||
for cmd in (6, 7, 9):
|
||||
t.handle_status(status(t, cmd))
|
||||
assert t.finished and t.percent == 100
|
||||
|
||||
|
||||
def test_can_rejects_unsent_blocks_wrong_slot_and_foreign_session():
|
||||
t = CanBootTransfer(image(bytes(200)), CanBootTarget())
|
||||
enter_data(t)
|
||||
with pytest.raises(RuntimeError, match="непереданный"):
|
||||
t.handle_status(status(t, 0, expected=20))
|
||||
foreign = CanBootTransfer(t.image, replace(t.target, session_id=2))
|
||||
assert not t.accepts(status(foreign, 0))
|
||||
t = CanBootTransfer(image(bytes(8)), CanBootTarget())
|
||||
t.handle_status(status(t, 2))
|
||||
t.handle_status(status(t, 3))
|
||||
with pytest.raises(RuntimeError, match="слот"):
|
||||
t.handle_status(status(t, 4, slot=2))
|
||||
192
python/tests/test_firmware_database.py
Normal file
192
python/tests/test_firmware_database.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from setprotocol.firmware_catalog import FirmwareRelease
|
||||
from setprotocol.firmware_database import (
|
||||
Credentials, FirmwareDatabase, GiteaFirmwarePublisher, GiteaRepository, HttpsClient,
|
||||
)
|
||||
from setprotocol.firmware_publish import FirmwarePublication
|
||||
|
||||
|
||||
class Response(io.BytesIO):
|
||||
def __init__(self, data, headers=None):
|
||||
super().__init__(data)
|
||||
self.headers = headers or {}
|
||||
|
||||
|
||||
class Server:
|
||||
"""In-memory HTTP boundary; no real credentials, network or repository writes."""
|
||||
def __init__(self, *, bad_image=False, conflict=False, missing_release=False):
|
||||
self.manifest = {"windows": {"versionCode": 42}, "firmware": {"releases": []}}
|
||||
self.assets = {}
|
||||
self.calls = []
|
||||
self.bad_image = bad_image
|
||||
self.conflict = conflict
|
||||
self.missing_release = missing_release
|
||||
|
||||
def open(self, url, *, method="GET", data=None, **kwargs):
|
||||
self.calls.append((method, url))
|
||||
if self.missing_release and "/releases/tags/" in url:
|
||||
raise HTTPError(url, 404, "Not found", {}, None)
|
||||
if "/releases/download/" in url:
|
||||
return Response(b"wrong" if self.bad_image else self.assets[url.rsplit("/", 1)[1]])
|
||||
if "/raw/branch/" in url:
|
||||
result = self.manifest
|
||||
elif "/contents/" in url:
|
||||
if method == "PUT":
|
||||
if self.conflict:
|
||||
raise HTTPError(url, 409, "Conflict", {}, None)
|
||||
payload = json.loads(data)
|
||||
assert payload["sha"] == "revision-1"
|
||||
self.manifest = json.loads(base64.b64decode(payload["content"]))
|
||||
result = {"sha": "revision-1", "content": base64.b64encode(json.dumps(self.manifest).encode()).decode()}
|
||||
elif "/assets" in url:
|
||||
if method == "POST":
|
||||
self.assets[url.split("?name=", 1)[1]] = data
|
||||
result = {"id": 2}
|
||||
else:
|
||||
result = [{"name": name, "id": index} for index, name in enumerate(self.assets)]
|
||||
else:
|
||||
result = {"id": 1}
|
||||
return Response(json.dumps(result).encode())
|
||||
|
||||
|
||||
class FirmwareDatabaseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.image = self.root / "image.bin"
|
||||
self.image.write_bytes(b"test firmware")
|
||||
self.repository = GiteaRepository("https://git.example", "team", "images")
|
||||
self.publication = FirmwarePublication(self.image, "DEVICE", "1.2.3", 0x10203, "can")
|
||||
|
||||
def test_publish_read_and_download_round_trip_and_idempotency(self):
|
||||
server = Server()
|
||||
publisher = GiteaFirmwarePublisher(self.repository, client=server)
|
||||
entry = publisher.publish(self.publication)
|
||||
self.assertEqual(server.manifest["windows"], {"versionCode": 42})
|
||||
self.assertEqual(server.manifest["firmware"]["catalogVersion"], 1)
|
||||
first_commit = next(i for i, (method, _) in enumerate(server.calls) if method == "PUT")
|
||||
first_verification = next(i for i, (_, url) in enumerate(server.calls) if "/releases/download/" in url)
|
||||
self.assertLess(first_verification, first_commit)
|
||||
publisher.publish(self.publication)
|
||||
self.assertEqual(sum(method == "PUT" for method, _ in server.calls), 1)
|
||||
self.assertEqual(len(server.assets), 1)
|
||||
database = FirmwareDatabase(self.repository.manifest_url, self.root / "cache", client=server)
|
||||
rows = database.read_catalog(product="device", transport="can")
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(database.read_catalog(transport="rs485"), [])
|
||||
progress = []
|
||||
downloaded = database.download(rows[0], progress.append)
|
||||
self.assertEqual(downloaded.read_bytes(), self.image.read_bytes())
|
||||
self.assertEqual(progress[-1], 100)
|
||||
count = len(server.calls)
|
||||
self.assertEqual(database.download(rows[0]), downloaded)
|
||||
self.assertEqual(len(server.calls), count)
|
||||
self.assertEqual(entry["fileName"], "image.bin")
|
||||
|
||||
def test_failed_image_verification_never_writes_catalog(self):
|
||||
server = Server(bad_image=True)
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
GiteaFirmwarePublisher(self.repository, client=server).publish(self.publication)
|
||||
self.assertFalse(any(method == "PUT" for method, _ in server.calls))
|
||||
|
||||
def test_rebuilt_image_keeps_old_asset_and_manifest_on_conflict(self):
|
||||
server = Server()
|
||||
publisher = GiteaFirmwarePublisher(self.repository, client=server)
|
||||
publisher.publish(self.publication)
|
||||
before = json.dumps(server.manifest)
|
||||
self.image.write_bytes(b"new firmware")
|
||||
server.conflict = True
|
||||
with self.assertRaises(HTTPError) as caught:
|
||||
publisher.publish(self.publication)
|
||||
caught.exception.close()
|
||||
self.assertEqual(json.dumps(server.manifest), before)
|
||||
self.assertEqual(len(server.assets), 2)
|
||||
self.assertFalse(any(method == "DELETE" for method, _ in server.calls))
|
||||
|
||||
def test_preflight_is_offline(self):
|
||||
server = Server()
|
||||
GiteaFirmwarePublisher(self.repository, client=server).preflight(self.publication)
|
||||
self.assertEqual(server.calls, [])
|
||||
|
||||
def test_missing_release_is_created(self):
|
||||
server = Server(missing_release=True)
|
||||
GiteaFirmwarePublisher(self.repository, client=server).publish(self.publication)
|
||||
self.assertTrue(any(method == "POST" and url.endswith("/releases")
|
||||
for method, url in server.calls))
|
||||
|
||||
def test_download_size_limit_and_catalog_readback_failure(self):
|
||||
from setprotocol.firmware_publish import MAX_FIRMWARE_BYTES
|
||||
client = Mock()
|
||||
client.open.return_value = Response(b"", {"Content-Length": str(MAX_FIRMWARE_BYTES + 1)})
|
||||
database = FirmwareDatabase(self.repository.manifest_url, self.root / "cache", client=client)
|
||||
release = FirmwareRelease("D", "1", 1, "https://git.example/a.bin", "ab" * 32, "a.bin")
|
||||
with self.assertRaisesRegex(ValueError, "maximum size"):
|
||||
database.download(release)
|
||||
self.assertEqual(list((self.root / "cache").rglob("*.part")), [])
|
||||
|
||||
server = Server()
|
||||
original_open = server.open
|
||||
|
||||
def ignore_manifest_write(url, **kwargs):
|
||||
if kwargs.get("method") == "PUT":
|
||||
return Response(b"{}")
|
||||
return original_open(url, **kwargs)
|
||||
|
||||
server.open = ignore_manifest_write
|
||||
with self.assertRaisesRegex(RuntimeError, "readback"):
|
||||
GiteaFirmwarePublisher(self.repository, client=server).publish(self.publication)
|
||||
|
||||
def test_bad_download_cleans_staging_and_rejects_unsafe_filename(self):
|
||||
release = FirmwareRelease("D", "1", 1, "https://git.example/a.bin",
|
||||
hashlib.sha256(b"good").hexdigest(), "a.bin")
|
||||
client = Mock()
|
||||
client.open.return_value = Response(b"bad")
|
||||
database = FirmwareDatabase(self.repository.manifest_url, self.root / "cache", client=client)
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
database.download(release)
|
||||
self.assertEqual(list((self.root / "cache").rglob("*.part")), [])
|
||||
with self.assertRaises(ValueError):
|
||||
database.download(replace(release, file_name="../escaped.bin"))
|
||||
self.assertEqual(client.open.call_count, 1)
|
||||
|
||||
def test_https_credentials_do_not_follow_cross_origin_redirect(self):
|
||||
client = HttpsClient("https://git.example", Credentials("test", "secret"))
|
||||
client.opener = Mock()
|
||||
client.opener.open.side_effect = [
|
||||
HTTPError("https://git.example/a", 302, "Redirect", {"Location": "https://cdn.example/a"}, None),
|
||||
Response(b"image"),
|
||||
]
|
||||
client.open("https://git.example/a").close()
|
||||
first, second = [call.args[0] for call in client.opener.open.call_args_list]
|
||||
self.assertIn("Authorization", first.headers)
|
||||
self.assertNotIn("Authorization", second.headers)
|
||||
|
||||
def test_http_redirect_and_write_redirect_are_rejected(self):
|
||||
for method, destination in (("GET", "http://git.example/a"), ("POST", "https://cdn.example/a")):
|
||||
with self.subTest(method=method):
|
||||
client = HttpsClient("https://git.example")
|
||||
client.opener = Mock()
|
||||
client.opener.open.side_effect = HTTPError(
|
||||
"https://git.example/a", 302, "Redirect", {"Location": destination}, None)
|
||||
with self.assertRaises((ValueError, HTTPError)) as caught:
|
||||
client.open("https://git.example/a", method=method)
|
||||
if isinstance(caught.exception, HTTPError):
|
||||
caught.exception.close()
|
||||
self.assertEqual(client.opener.open.call_count, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
178
tools/firmware-publish/DATABASE.md
Normal file
178
tools/firmware-publish/DATABASE.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# База прошивок как в SETGUI
|
||||
|
||||
Это общий сервис **базы файлов прошивок**: получить список опубликованных
|
||||
образов, выбрать изделие/версию/транспорт, скачать файл, проверить SHA-256 и
|
||||
передать его загрузчику. Также можно опубликовать новый образ в эту же базу.
|
||||
Модуль не зависит от версии самого приложения и от `c/firmware-info`.
|
||||
|
||||
## Состав
|
||||
|
||||
| Файл | Ответственность |
|
||||
|---|---|
|
||||
| [`firmware_database.py`](../../python/setprotocol/firmware_database.py) | Готовый HTTPS-клиент, чтение базы, кэш скачанных образов, публикация в Gitea |
|
||||
| [`firmware_catalog.py`](../../python/setprotocol/firmware_catalog.py) | Общий формат записей базы, используемый SETGUI |
|
||||
| [`firmware_publish.py`](../../python/setprotocol/firmware_publish.py) | Метаданные образа, SHA-256, объединение записей каталога |
|
||||
| [`firmware_db.py`](firmware_db.py) | Самостоятельный консольный запуск без SETGUI |
|
||||
|
||||
Требования: Python 3.10+ и стандартная библиотека. Qt, Windows Credential
|
||||
Manager, установленный SETGUI и библиотеки МК не требуются. Это сервис и CLI;
|
||||
готовое Qt-окно остаётся в SETGUI, а другой GUI подключает сервис по API ниже.
|
||||
|
||||
## Как устроена база
|
||||
|
||||
В текущей конфигурации SETGUI файлы находятся в Gitea Releases репозитория
|
||||
`setcorp/SETRD12-Releases` на `https://git.rd12.ru`. Список файлов лежит в
|
||||
`firmware.releases` файла `update.json` ветки `main`. Запись содержит изделие,
|
||||
версию, транспорт, адрес образа, имя файла, SHA-256, описание и необязательный
|
||||
адрес приложения во Flash. Это файловый каталог; SQL-сервер не нужен.
|
||||
|
||||
Новый сервис использует тот же JSON-контракт, поэтому выпуски видны клиентам
|
||||
SETGUI, поддерживающим указанный транспорт. В текущем SETGUI транспорт `tms`
|
||||
фильтруется; общий сервис его сохраняет. Публикация не добавляет новый
|
||||
загрузочный протокол в приложение или МК.
|
||||
|
||||
```text
|
||||
публикация .hex/.bin → Gitea release asset → скачать и проверить SHA-256
|
||||
↓
|
||||
обновить firmware.releases
|
||||
↓
|
||||
GUI: «Обновить каталог» → выбрать запись → «Скачать и выбрать» → загрузчик МК
|
||||
```
|
||||
|
||||
Для своего сервера задайте `server`, `owner`, `repository`, `branch` и
|
||||
`manifest_path`. Репозиторий, ветка и JSON-файл должны уже существовать.
|
||||
Для пустой базы достаточно файла `{"firmware":{"catalogVersion":0,"releases":[]}}`.
|
||||
Если `update.json` общий с обновлениями приложения, его другие разделы
|
||||
сохраняются при добавлении прошивки.
|
||||
|
||||
## Консоль: проверить, опубликовать, посмотреть, скачать
|
||||
|
||||
Из корня `templates` в PowerShell (параметры сервера стоят перед командой):
|
||||
|
||||
```powershell
|
||||
$db = @('--server', 'https://git.rd12.ru', '--owner', 'setcorp', '--repo', 'SETRD12-Releases')
|
||||
|
||||
# Без сети: проверить файл и сформировать запись каталога.
|
||||
python tools/firmware-publish/firmware_db.py @db preflight --file C:/build/device.hex --product MY_DEVICE --version 1.2.3 --version-code 0x010203 --transport can --base-address 0x08003000
|
||||
|
||||
# Публикация образа в базу (изменяет сервер).
|
||||
python tools/firmware-publish/firmware_db.py @db publish --file C:/build/device.hex --product MY_DEVICE --version 1.2.3 --version-code 0x010203 --transport can --base-address 0x08003000
|
||||
|
||||
# Получить список записей и скачать конкретную.
|
||||
python tools/firmware-publish/firmware_db.py @db list --product MY_DEVICE
|
||||
python tools/firmware-publish/firmware_db.py @db download --product MY_DEVICE --version-code 0x010203 --transport can --cache C:/firmware-cache
|
||||
```
|
||||
|
||||
`MY_DEVICE`, версия, транспорт и адрес — примеры, замените их данными изделия.
|
||||
SCI8 для TMS публикуйте как `.bin` с `--transport tms`, без `--base-address`.
|
||||
Инструмент не конвертирует `.axf/.out` и не проверяет пригодность образа для МК.
|
||||
|
||||
CLI читает логин и пароль/токен из переменных окружения `FIRMWARE_DB_LOGIN` и
|
||||
`FIRMWARE_DB_PASSWORD`. Задавайте их средствами своего секрет-хранилища/CI,
|
||||
а не в файлах проекта и не в аргументах командной строки. Для интерактивного
|
||||
ввода в PowerShell можно использовать:
|
||||
|
||||
```powershell
|
||||
$fwCredential = Get-Credential -Message 'Gitea: логин и пароль либо токен вместо пароля'
|
||||
$env:FIRMWARE_DB_LOGIN = $fwCredential.UserName
|
||||
$env:FIRMWARE_DB_PASSWORD = $fwCredential.GetNetworkCredential().Password
|
||||
# Выполнить нужные команды; после работы убрать переменные из текущего процесса.
|
||||
Remove-Item Env:FIRMWARE_DB_LOGIN, Env:FIRMWARE_DB_PASSWORD
|
||||
```
|
||||
|
||||
Публичный каталог можно читать без входа. Публикация через CLI требует обе
|
||||
переменные. Сервис не извлекает сохранённый пароль SETGUI автоматически.
|
||||
Команда `publish` явно разрешает запись и не запрашивает дополнительного
|
||||
подтверждения; `preflight` ничего не загружает. Старый BAT и конфигурации
|
||||
`firmware-release.cmd` продолжают использовать прежний путь через SETGUI.
|
||||
|
||||
## Портирование в другое Python-приложение
|
||||
|
||||
Подключите `templates/python` в import path приложения; переносите зависимость
|
||||
целиком, не одну копию файла. Сеть и кэш реализованы в сервисе, UI и хранение
|
||||
учётных данных задаёт приложение:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from setprotocol.firmware_database import (
|
||||
Credentials, FirmwareDatabase, GiteaRepository, GiteaFirmwarePublisher,
|
||||
)
|
||||
from setprotocol.firmware_publish import FirmwarePublication
|
||||
|
||||
repo = GiteaRepository('https://git.rd12.ru', 'setcorp', 'SETRD12-Releases')
|
||||
# login и password получает ваше приложение из формы/хранилища.
|
||||
credentials = Credentials(login, password)
|
||||
database = FirmwareDatabase(repo.manifest_url, Path('firmware'), credentials=credentials)
|
||||
|
||||
# «Обновить каталог»: результат заполнит список в вашем UI.
|
||||
releases = database.read_catalog(product='MY_DEVICE', transport='can')
|
||||
# После выбора пользователем строки списка:
|
||||
selected = releases[selected_index]
|
||||
path = database.download(selected, progress=lambda percent: print(percent))
|
||||
# Передайте path, selected.transport и selected.base_address своему загрузчику.
|
||||
|
||||
# Публикация нового образа: отдельное действие оператора.
|
||||
publisher = GiteaFirmwarePublisher(repo, credentials)
|
||||
publication = FirmwarePublication(
|
||||
Path('build/device.hex'), 'MY_DEVICE', '1.2.3', 0x010203, 'can', 0x08003000,
|
||||
'Описание выпуска',
|
||||
)
|
||||
preview = publisher.preflight(publication) # можно показать перед выпуском
|
||||
entry = publisher.publish(publication)
|
||||
```
|
||||
|
||||
Для публичного каталога передайте `credentials=None`. Для чтения каталога,
|
||||
расположенного вне стандартного Gitea URL, передайте его HTTPS-адрес прямо в
|
||||
`FirmwareDatabase`; `GiteaRepository` нужен сетевому публикатору.
|
||||
|
||||
В Qt выполняйте `read_catalog`, `download`, `publish` в worker/QThread,
|
||||
передавайте прогресс сигналами в главный поток. По аналогии с SETGUI:
|
||||
|
||||
1. Кнопка «Обновить каталог» вызывает `read_catalog`, заполняет combo/table.
|
||||
2. В строке показываются `product`, `version`, `transport`; в деталях —
|
||||
`file_name`, `notes`, `base_address`.
|
||||
3. «Скачать и выбрать» вызывает `download`, возвращает проверенный путь.
|
||||
4. Сигнал в основное окно передаёт путь и выбранную `FirmwareRelease`;
|
||||
окно выставляет файл и транспорт на вкладке прошивки.
|
||||
|
||||
Скачивание не запускает прошивку устройства автоматически. Для C++/Android
|
||||
можно использовать CLI как отдельный процесс на ПК или реализовать клиент
|
||||
этого JSON-контракта на языке платформы; Python-модуль не является C-библиотекой.
|
||||
|
||||
## Поведение и ошибки
|
||||
|
||||
- Кэш: `<cache>/<sha256>/<fileName>`. Одинаковые имена разных образов не
|
||||
конфликтуют. Проверенный кэш используется повторно; незавершённые временные
|
||||
файлы удаляются. Результат появляется только после совпадения 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.
|
||||
164
tools/firmware-publish/PORTING.md
Normal file
164
tools/firmware-publish/PORTING.md
Normal file
@@ -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 в исходники или конфигурации проекта.
|
||||
@@ -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, скачивает его обратно,
|
||||
|
||||
82
tools/firmware-publish/firmware_db.py
Normal file
82
tools/firmware-publish/firmware_db.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user