Добавить протокол Altera Logic и общие клиенты прошивки

This commit is contained in:
2026-09-19 07:12:09 +03:00
parent def3eb08f3
commit 80ba17d77d
38 changed files with 3271 additions and 10 deletions

View File

@@ -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 |

View File

@@ -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 | Секунды |
| 811 | По два 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`.

View File

@@ -25,3 +25,7 @@ config, а не дублирования ядра.
KONOR публикует эти 24 байта ответом `FIRMWARE_INFO (0x03)`, сохраняя старый
32-байтовый `DEVICE_INFO` без изменений.
Подробная инструкция с кодом обработчика, форматом ответа и проверкой переноса:
[`PORTING.md`](PORTING.md). Для размещения файла прошивки в каталоге SETGUI
используется отдельный [общий публикатор](../../tools/firmware-publish/PORTING.md).

View 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 / USBUART.
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-модуль и прошивку;
программные проверки не заменяют аппаратную проверку.

View File

@@ -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)

View 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

View 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

View 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;
}

View 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);
}
}

View 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;
}

View 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;
}

View File

@@ -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",

View 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-контроллер не изменены.

View 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).

View File

@@ -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
View File

@@ -0,0 +1 @@
native/

View File

@@ -0,0 +1,4 @@
"""Portable Altera analyzer client. Wire protocol and sequencing live in C99."""
from .native import Capture, NativeAnalyzer
__all__ = ["Capture", "NativeAnalyzer"]

View 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)

View 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)

View 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()

View 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)

View 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])

View 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)

View File

@@ -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
View 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 завершена"

View File

@@ -131,9 +131,11 @@ MSGTYPE_RU = {
#: но не присваивает приборам имён.
DEVICE_TYPE_NAMES = {
0x0: "Верхний уровень",
0x6: "Логические анализаторы",
}
DEVICE_ADDRESS_NAMES = {
(0x6, 0xE): "Altera Logic",
(0x7, 0xD): "configurator",
}

View 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)))

View 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,))

View 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}")

View 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

View 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()

View 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()

View 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))

View 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()

View 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.

View 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-битных поля используйте части 0255. Укажите только реально реализованные
транспорты. 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 в исходники или конфигурации проекта.

View File

@@ -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, скачивает его обратно,

View 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())