Add shared waveform processing, generation and firmware image support

This commit is contained in:
2026-09-27 01:45:08 +03:00
parent 2ad29e7ffd
commit cc22c803d1
36 changed files with 2591 additions and 61 deletions

3
.gitignore vendored
View File

@@ -24,3 +24,6 @@ __pycache__/
Thumbs.db
Desktop.ini
.codex-build/
# Generated native library (built before packaging)
python/protocan/native/

View File

@@ -6,6 +6,7 @@ set(CMAKE_C_STANDARD_REQUIRED ON)
# SET protocol v2: управление, телеметрия, CAN segmentation и firmware flow.
set(SETPROTOCOL_V2_SOURCES
src/firmware_image.c
src/altera_logic.c
src/altera_stream.c
src/set_protocol.c
@@ -15,6 +16,8 @@ set(SETPROTOCOL_V2_SOURCES
src/set_plot.c
src/set_trends.c
src/set_spectrum.c
src/set_signal.c
src/set_wavegen.c
)
# Совместимые ProtoCAN/SETGUI v1 форматы переходного периода.
@@ -81,6 +84,12 @@ endif()
option(SETP_BUILD_TESTS "Build host tests" ON)
if(SETP_BUILD_TESTS)
enable_testing()
add_executable(test_signal_wave tests/test_signal_wave.c)
target_link_libraries(test_signal_wave PRIVATE setprotocol_static)
add_test(NAME signal_wave COMMAND test_signal_wave)
add_executable(test_firmware_image tests/test_firmware_image.c)
target_link_libraries(test_firmware_image PRIVATE setprotocol_static)
add_test(NAME firmware_image COMMAND test_firmware_image)
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)

View File

@@ -1,5 +1,8 @@
# SETProtocol
Генератор произвольного сигнала, интерполяция, таблицы ЦАП и порты
STM32F407/G474: [SIGNAL_GENERATOR.md](docs/SIGNAL_GENERATOR.md).
Единое переносимое протокольное ядро SET для `SETGUI`, Android GUI, устройств
и сервисных утилит. В одном C99-проекте собраны:
@@ -94,3 +97,9 @@ FFI-клиенты подключают `setprotocol_abi.h`. Имена функ
- Изменение wire contract требует новой версии и тестовых векторов.
Полная интерактивная документация: [`../../doc/setprotocol.html`](../../doc/setprotocol.html).
Обработка кривых: МНК, линейная интерполяция, PCHIP, кубический сплайн,
общие модели и Qt-панель для разных графиков:
[PLOT_PROCESSING.md](docs/PLOT_PROCESSING.md).
Генератор сигнала, USB и порты ЦАП F407/G474:
[SIGNAL_GENERATOR.md](docs/SIGNAL_GENERATOR.md).

View File

@@ -0,0 +1,146 @@
# Общая обработка графиков и сигналов
Аппроксимация, интерполяция и восстановление кривой по редким отсчётам имеют
один расчётный API. Источник может быть временным графиком, журналом, спектром
или таблицей ручного генератора. Выбор устройства и отрисовка остаются в порте.
| Метод | Назначение | Ограничения |
|---|---|---|
| `polynomial` | Аппроксимация полиномом МНК, степени 1–5; сглаживание шумных отсчётов | Нужно не меньше `degree + 1` различных X; степень задаёт пользователь |
| `linear` | Линейная интерполяция | Минимум два различных X; изломы в узлах |
| `pchip` | Интерполяция с сохранением формы | Минимум два различных X; подходит для фронтов и монотонных участков |
| `spline` | Восстановление естественным кубическим сплайном | Минимум два различных X; возможны выбросы между узлами |
Все методы строят сетку только между крайними выбранными отсчётами.
Восстановление по малому числу точек — оценка, зависящая от метода; утраченные
высокочастотные детали не определяются однозначно. СКО считается на исходных
измерениях, поэтому нулевая СКО интерполяции не означает нулевую ошибку между ними.
Одинаковые X усредняются, нечисловые и бесконечные значения отклоняются.
## Слои и зависимости
```text
График / журнал → Snapshot → Request → set_signal_reconstruct (C99) → Curve
Ручные точки генератора → waveform.generate → то же C99-ядро → таблица ЦАП
Qt-панель → worker → Curve → отдельный слой графика / CSV
МК / Android / другой GUI → C ABI → свой порт отображения или вывода
```
| Файл | Роль / зависимости |
|---|---|
| `include/set_signal.h`, `src/set_signal.c` | Все четыре численных метода и квантование ЦАП; C99, без Qt, heap и файловой системы |
| `python/set_devices/signal_reconstruction.py` | Тонкий вызов C через ctypes, без второго алгоритма |
| `python/set_devices/plot_processing.py` | Неизменяемые модели, выбор канала и диапазона, явные единицы, CSV; без Qt |
| `python/set_devices/qt_ports/plot_processing.py` | Общая панель, фоновый расчёт, проверка актуальности, слой отрисовки; PySide6 или PySide2 |
| `python/set_devices/waveform.py` | Периодическая сетка генератора и DAC12 через то же ядро |
Перед вызовом расчёта потребитель задаёт `SETPROTOCOL_LIBRARY` либо устанавливает
собранную библиотеку в штатный каталог `protocan/native`. Никакие соседние
репозитории или каталоги приложений автоматически не подключаются.
## Контракт графика
- `Series(key, label, points, visible, discrete, y_unit)` копирует пары X/Y.
Ключи уникальны внутри снимка. Расчёт доступен только видимым аналоговым каналам.
- `Axis(label, unit, encoding)` явно задаёт область X. `numeric` сохраняет числа,
`unix_ms` сохраняет UTC ISO timestamp. Большое число само по себе не является датой.
- `Snapshot(series, axis, source, x_range, blocked_reason)` описывает снимок источника.
`x_range=(left, right)` включает точки на обеих границах; `None` означает весь снимок.
`source` должен различать файлы/источники, если переключение между ними требует
сброса результата даже при совпадающих числах. `blocked_reason` запрещает расчёт.
- `prepare(snapshot, key, method, output_count, degree)` создаёт сравнимый запрос.
`process(request)` возвращает `Curve` с точками, числом измерений/уникальных X и СКО.
- `write_csv(curve, stream)` сохраняет отдельный результат, подпись метода и единицы.
Исходные данные не изменяются. При визуальном множителе Y адаптер передаёт
отображаемые значения и указывает множитель в `y_unit`, например `В (×2)`.
Нативное ядро допускает до 100000 входных и 10000 выходных точек. UI задаёт
2–10000 точек результата. После обрезки по X должно остаться достаточно узлов
для выбранного метода. Расчёт не добавляет искусственные узлы на границах окна.
## Пример без GUI
```python
from set_devices.plot_processing import Axis, Series, Snapshot, prepare, process, write_csv
samples = [(0, 0), (10, 2), (20, 1), (30, 0)]
snapshot = Snapshot((Series("voltage", "Напряжение", samples, y_unit="В"),),
Axis("Время", "мс"), source="bench-1")
request = prepare(snapshot, "voltage", method="pchip", output_count=301)
curve = process(request)
with open("calculated.csv", "w", encoding="utf-8-sig", newline="") as stream:
write_csv(curve, stream)
```
Рабочий CLI-пример для всех методов:
`python python/examples/plot_processing.py --method spline --output curve.csv`.
Добавьте `templates/python` в `PYTHONPATH` или установите пакет из этого каталога.
## Подключение нового Qt-графика
`PlotProcessingAttachment(parent, snapshot, repaint)` принимает два callback:
```python
def snapshot() -> Snapshot: ... # текущие отображаемые данные и видимые границы X
def repaint() -> None: ... # обычно QWidget.update
```
1. Создайте attachment и добавьте его `button` в панель графика.
2. После изменения данных, каналов, единиц или окна вызывайте `source_changed()`.
Пока панель не открывали, снимки не строятся. Уведомления одного прохода
event loop объединяются; устаревшая линия сразу скрывается.
3. После исходных кривых вызывайте `attachment.paint(painter, analog_rect, project)`.
`project(x, y, rect) -> QPointF` использует ту же проекцию, что исходные данные.
4. Вызов `open()` открывает немодальное окно с выбором всех четырёх методов,
числа точек, степени полинома, расчётом, удалением и экспортом.
```python
self.processing = PlotProcessingAttachment(self, self.processing_snapshot, self.update)
self.toolbar.layout().addWidget(self.processing.button)
# после обновления источника/масштаба:
self.processing.source_changed()
# внутри paintEvent, после исходных линий:
self.processing.paint(painter, self.analog_rect(), self.project)
```
Для встроенной панели используйте `SignalProcessingPanel.set_snapshot(snapshot)`
и сигнал `changed` для перерисовки. Передавайте `panel.curve` слою через
`set_external_curve(curve, x_offset=0)`. В этом варианте владелец обновляет снимок
и слой вместе. `x_offset` используется только при рисовании, например при сдвиге
epoch на графике наносекундного масштаба; исходные X и CSV сохраняются.
Панель сравнивает снимок выбранного канала, единицы, источник, диапазон и параметры.
При изменении результат убирается, запоздалый ответ worker игнорируется.
Для обработки живого потока остановите его обновление. Y-масштаб не меняет запрос,
если X и данные остались прежними. Кривая обрезается текущей областью Y; при выбросе
сплайна можно увеличить диапазон Y. Панель не меняет историю, autoscale, курсоры,
FFT и цифровые дорожки графика.
## Порты и расширение
SETGUI использует компонент в «Логах и графиках», SignalPlot (CAN, температуры,
УМП), TrendPlot и SpectrumPlot. В спектре методы обрабатывают зависимость уровня
от частоты в Гц; восстановление временного сигнала из амплитудного спектра этим
не выполняется. Временная панель «Логов» блокируется при включённом FFT.
Генератор использует те же методы через `waveform.generate`; для циклической
таблицы последний отсчёт периода не дублируется.
Для нового графика достаточно адаптера `Snapshot` и существующей панели/слоя.
Для другого GUI используйте модель без Qt или непосредственно C ABI.
Для MCU вызывайте `set_signal_reconstruct` с буфером `14 * count + 128` double,
выделенным вызывающей стороной; рабочая память должна соответствовать RAM платы.
Контракт вывода ЦАП и готовые порты F407/G474 описаны в [SIGNAL_GENERATOR.md](SIGNAL_GENERATOR.md).
Для нового численного метода сначала расширьте C API, C-тесты и соответствие
`METHODS` в ctypes-порте; затем добавьте эталон в `test_plot_processing.py`.
Алгоритмы в обработчиках отдельных графиков не дублируются.
Проверки библиотеки:
```text
python -m unittest discover -s python/tests -p test_plot_processing*.py
python -m unittest discover -s python/tests -p test_shared_library_boundary.py
```
Для тестов Qt нужен PySide6/PySide2; для headless-прогона задайте
`QT_QPA_PLATFORM=offscreen`. Численные тесты `test_plot_processing.py` Qt не требуют.

View File

@@ -0,0 +1,146 @@
# Генератор произвольного сигнала WG v1/v2
## Расширение карты v2: F407 до 1 000 000 отсчётов/с
Версия карты в статусе — 2, `set_wave_state.rate` — uint32_t.
Для записи частоты сначала остановить выход, записать старшие 16 бит в
`0x1308`, затем младшие 16 бит в `0x1303`. Вторая запись проверяет диапазон
1…1 000 000 и применяет частоту; обе записи снимают ready. Чтение `0x1308`
возвращает старшие биты применённой частоты, а слово 3 статуса — младшие.
Host ABI: операция 9 — запись старшего слова, 8 — его чтение, 2 — запись
младшего слова (включая ноль). Остальные команды не меняются.
Это версия карты регистров, а не транспорт: SETGUI использует SET v2
через EmulatorSerialPort, с резервным RTU для старой прошивки. Новый клиент
понимает статус карт v1/v2; для v1 предел остаётся 50 000. Старый клиент
отклоняет незнакомую версию 2 до записи. Код F407 и GUI поддерживают 1 МГц;
порт G474 пока сохраняет прежний аппаратный лимит 50 кГц.
Ниже описание исходной карты v1; пределы и регистры частоты заменены этим
расширением для v2. На высоких частотах аналоговое установление зависит
от величины скачка и нагрузки; аппаратная проверка осциллографом обязательна
для оценки точности конкретного сигнала.
Общее C99-ядро строит кривые по точкам, преобразует напряжения в коды ЦАП и
принимает таблицы через RTU поверх USB CDC/COM. Оно не зависит от Qt, HAL или ОС.
Память предоставляет вызывающий; скрытого heap и глобального состояния нет.
```text
SETGUI: точки -> Python/ctypes -> set_signal.c -> график / CSV / C
-> set_wavegen.c -> USB CDC
MCU: USB stream -> set_wave_rtu -> set_wave_port -> TIM6/DMA/DAC
```
| Файл | Зависимости и назначение |
|---|---|
| `include/set_signal.h`, `src/set_signal.c` | C99/math: МНК 1…5, linear, PCHIP, natural cubic spline, 12-bit DAC |
| `include/set_wavegen.h`, `src/set_wavegen.c` | C99 + `set_crc.c`: транзакционная загрузка и wire codec |
| `ports/stm32f407-wavegen` | CMSIS F407: PA4/DAC1, TIM6, DMA1 Stream5 Channel7 |
| `ports/stm32g474-wavegen` | STM32G4 HAL: готовые DAC/TIM/DMA handles из CubeMX |
| `python/set_devices/signal_reconstruction.py`, `waveform.py` | Модели, JSON/экспорт, ctypes; алгоритмов на Python нет |
| `python/set_devices/wavegen_protocol.py`, `qt_ports/wavegen.py` | ctypes-кодек и Qt COM worker с проверкой чтением обратно |
## Интерполяция и таблица
`set_signal_reconstruct` принимает раздельные X/Y и workspace `14*N+128`
элементов double. Сортировка, усреднение одинаковых времён, нормализация,
МНК через QR и интерполяция выполняются в C. Максимум 100000 входных/10000
выходных точек. Переданный workspace может повторно использоваться.
Большие расчёты предназначены для хоста; на MCU можно строить малые таблицы
или принимать готовую таблицу без затрат на интерполяцию.
`endpoint=1` включает последний X (анализ логов); `endpoint=0` строит один
период `[0,T)` без дублированного отсчёта стыка. Частота Fs и период T задают
целое `N=Fs*T`; для WG максимум N=4096, Fs=1…50000 Гц, один канал, циклический
выход. При разных значениях первой и последней точки на стыке будет скачок.
Напряжения 0…Vref округляются к ближайшему коду 0…4095; выход за диапазон
отклоняется целиком. Это относится и к выбросам сплайна. Vref должен
соответствовать фактическому VDDA/VREF+ платы; это не программируемое питание.
Пример хоста (DLL передаётся через `SETPROTOCOL_LIBRARY`):
```python
from set_devices.waveform import generate, c_header
points = [(0, 0), (10, 3.3), (20, 0)]
wave = generate(points, sample_rate=10000, vref=3.3, method="linear")
assert len(wave.codes) == 200
with open("waveform.h", "w", encoding="utf-8") as output:
output.write(c_header(wave))
```
## Контракт порта
`start(context, samples, count, sample_rate)` возвращает 0 при успехе;
`stop(context)` синхронно прекращает DMA и устанавливает нулевой выход.
Память таблицы принадлежит `set_wave_state`, её нельзя менять до stop.
Вызывайте RTU и обслуживание состояния из одного основного потока, не из IRQ.
IRQ USB помещает байты в очередь; USB-пакеты не являются границами RTU.
```c
static uint16_t samples[SET_WAVE_MAX]; /* DMA-accessible SRAM, not CCM */
static set_wave_state wave;
static set_wave_f407 hardware = {72000000};
void application_init(void) {
set_wave_port port = set_wave_f407_port(&hardware);
set_wave_init(&wave, samples, SET_WAVE_MAX, &port);
}
size_t on_rtu(const uint8_t *frame, size_t n, uint8_t reply[256]) {
return set_wave_rtu(&wave, 16, frame, n, reply, 256);
}
```
Для F407 DAC_CH1 — PA4, TIM6 TRGO_UPDATE, DMA1 Stream5/Channel7. Порт использует
только нижние 16 бит DAC_CR и не меняет канал 2. Таблица лежит в SRAM1/2
`0x20000000…0x2001FFFF`, не в CCM. Перед первым отсчётом есть один такт
предзагрузки последнего значения предыдущего периода. Для гарантированной
целой частоты порт принимает только делители входной частоты TIM6; при
72 МГц подходят, например, 100, 1000, 5000, 10000, 20000 Гц.
Частоту таймера передавайте с учётом удвоения при делителе APB1 > 1.
Ошибку DMA/underrun проверяйте через `set_wave_f407_fault()` и останавливайте
состояние. На F407-проекте этот вызов включён в главный цикл.
Для G474 настройте CubeMX: DAC1 channel1 (PA4), output buffer enabled,
TIM6 TRGO_UPDATE, DMA memory-to-peripheral, circular, halfword/halfword,
memory increment, запрос `DMA_REQUEST_DAC1_CHANNEL1`; подключите HAL IRQ
для выбранного канала DMA и DAC. Передайте handles и timer_hz в
`set_wave_g474`. Обработчики ошибок DMA/DAC в приложении должны вызвать stop
и сбросить `wave.running/ready`. Порт проверен компиляцией с CubeG4 1.6.1;
конкретная плата G474 здесь не прошита.
## USB / Modbus RTU
Адрес по умолчанию 16, FC03/FC06, CRC16 Modbus. Максимальный запрос — 8 байт,
ответ — 21 байт; существующий потоковый USB RTU parser F407 подходит без
изменения формата. Все поля строит/проверяет C-кодек, GUI не пакует байты.
| Регистр | Значение |
|---|---|
| `0x1300`, FC03, 8 слов | `0x5747`, версия 1, running, Fs, count, received, ready, capacity |
| `0x1302`, FC06 | 0: stop и нулевой выход; 1: start только после commit |
| `0x1303`, FC06 | Частота 1…50000; сбрасывает ready |
| `0x1304`, FC06 | Начать загрузку, число отсчётов 2…4096; сброс received/ready |
| `0xA000+i`, FC06 | Последовательная загрузка отсчёта 0…4095; повтор того же значения допустим |
| `0xA000+i`, FC03, 1 слово | Чтение загруженного отсчёта для проверки |
| `0x1306`, FC06 | `0xA55A`: commit, только если все отсчёты загружены |
Во время running разрешены чтение и stop; запись/перезагрузка дают исключение
6. Неполная таблица не запускается. Повреждённый CRC и чужой адрес не меняют
состояние. При reset ready/running=0, таблица не сохраняется во Flash.
Профиль и регистры генератора не являются регистратором УМП или протоколом 2812.
Хост выполняет probe → stop → Fs → count → samples → readback каждого
отсчёта → commit → status. Пуск — отдельная пользовательская команда.
После потери USB уже запущенный DAC продолжает автономное воспроизведение;
при таймауте состояние неизвестно до повторного status/stop. Одновременно
используйте одного клиента, не открывайте этот же COM во вкладке подключения.
## Потребители и проверки
- SETGUI: «Логи и графики → Генератор», JSON, CSV и C header.
- `home/407vet6_emul_TMS_Periph`: USB CDC и UART, штатный адрес настройки STM,
отдельный PA4 DAC выход; HAL/CMSIS и настройки платы остаются в проекте.
- G474: переносимый HAL-порт; board init остаётся в целевой прошивке.
- `tests/test_signal_wave.c`: C-векторы интерполяции, DAC, CRC, неполной
загрузки, readback и блокировки running. Общие байты проверены Python
ctypes и фактическим USB stream parser эмулятора.
Аппаратная проверка амплитуды, периода и формы осциллографом не выполнена.

View File

@@ -0,0 +1,24 @@
#ifndef SET_FIRMWARE_IMAGE_H
#define SET_FIRMWARE_IMAGE_H
#include "pcan_abi.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Caller-owned workspace; the parser never allocates memory or accesses files. */
typedef struct { uint32_t address; uint8_t value; } firmware_hex_cell_t;
enum { FW_HEX_OK, FW_HEX_FORMAT, FW_HEX_CHECKSUM, FW_HEX_RECORD,
FW_HEX_EOF, FW_HEX_OVERLAP, FW_HEX_RANGE, FW_HEX_CAPACITY,
FW_HEX_PREFIX, FW_HEX_DIGITS, FW_HEX_LENGTH };
/* Strict HEX: reject any overlap and any non-whitespace after EOF. On failure
* count is zero. Cells are sorted by address only on successful completion. */
PCAN_ABI_API int firmware_hex_parse(const char *text, size_t length,
firmware_hex_cell_t *cells, size_t capacity, size_t max_records,
size_t *count, size_t *error_line);
/* Return one contiguous segment starting at offset in a successfully parsed
* workspace; the caller uses its address and copies its values into its model. */
PCAN_ABI_API size_t firmware_hex_segment_size(const firmware_hex_cell_t *cells,
size_t count, size_t offset);
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,24 @@
/** @file set_signal.h Portable interpolation and DAC waveform preparation. */
#ifndef SET_SIGNAL_H
#define SET_SIGNAL_H
#include "pcan_abi.h"
#ifdef __cplusplus
extern "C" {
#endif
enum { SET_SIGNAL_POLYNOMIAL, SET_SIGNAL_LINEAR, SET_SIGNAL_PCHIP, SET_SIGNAL_SPLINE };
/* Caller-owned workspace: at least 14*count+128 doubles. No heap or globals.
* Inputs may be unsorted; duplicate times are averaged. No extrapolation.
* meta[0..2] = original count, unique count, RMSE at original measurements.
* endpoint=1 includes the last time; 0 samples a cyclic period [first,last).
* Return: 0 success, 1 bounds, 2 nonfinite/degenerate data, 3 rank deficient. */
PCAN_ABI_API int set_signal_reconstruct(const double *x, const double *y, size_t count,
int method, unsigned degree, size_t output_count, int endpoint,
double *out_x, double *out_y, double *meta, double *work, size_t work_count);
/* Reject out-of-range voltages, never silently clip. vref must be positive.
* All input is validated before any output is written. */
PCAN_ABI_API int set_signal_dac12(const double *volts, size_t count, double vref,
uint16_t *codes);
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,59 @@
/** @file set_wavegen.h Transactional table upload and cyclic DAC playback. */
#ifndef SET_WAVEGEN_H
#define SET_WAVEGEN_H
#include "pcan_abi.h"
#ifdef __cplusplus
extern "C" {
#endif
#define SET_WAVE_MAX 4096u
#define SET_WAVE_MAX_RATE 1000000u
#define SET_WAVE_BLOCK_MAX 120u
#define SET_WAVE_BASE 0x1300u
#define SET_WAVE_DATA 0xA000u
typedef struct {
void *context;
/* Return 0 on success. Samples remain owned by state until stop returns.
* start must arrange periodic output, typically DAC + timer + DMA.
* stop must quiesce DMA before returning and set output to zero. */
int (*start)(void *, const uint16_t *, size_t, uint32_t);
void (*stop)(void *);
} set_wave_port;
typedef struct {
uint16_t *samples;
size_t capacity;
uint16_t count, received, ready, running, rate_high;
uint32_t rate;
set_wave_port port;
} set_wave_state;
void set_wave_init(set_wave_state *, uint16_t *, size_t, const set_wave_port *);
/* Atomic sample-block transfer. values=NULL builds a read; otherwise a write.
* At most 120 words: request <=249 bytes, response <=245 bytes. */
PCAN_ABI_API size_t set_wave_block_request(unsigned device,unsigned index,
const uint16_t *values,size_t count,uint8_t *out,size_t capacity);
PCAN_ABI_API int set_wave_block_response(const uint8_t *request,size_t request_size,
const uint8_t *reply,size_t reply_size,uint16_t *words,size_t capacity);
size_t set_wave_block_rtu(set_wave_state *,unsigned device,const uint8_t *,size_t,
uint8_t *,size_t);
/* Relative register callbacks for set_regmap; status=8 words, samples=4096.
* These are the same state/validation used by the legacy RTU wrapper. */
PCAN_ABI_API unsigned set_wave_read(void *,uint16_t,uint16_t,uint16_t *);
PCAN_ABI_API unsigned set_wave_write(void *,uint16_t,uint16_t);
PCAN_ABI_API unsigned set_wave_samples_read(void *,uint16_t,uint16_t,uint16_t *);
PCAN_ABI_API unsigned set_wave_samples_write(void *,uint16_t,uint16_t);
/* Caller serializes calls in the main loop. Returns complete reply size,
* 0 for unrelated/invalid frames. CRC verified before all state changes. */
size_t set_wave_rtu(set_wave_state *, unsigned device, const uint8_t *, size_t,
uint8_t *, size_t);
/* Thin host transport ABI. Operations: 0=status, 1=stop, 2=sample rate,
* 3=begin upload(count), 4=sample(index,value), 5=commit, 6=start.
* 7=read a sample(index) for verification. Return 8 or 0. */
PCAN_ABI_API size_t set_wave_request(unsigned device,unsigned operation,unsigned index,
unsigned value,uint8_t *out,size_t capacity);
/* Return 0=need more bytes, positive=word count, negative=invalid/exception.
* Validates exact echo/read shape, CRC, address and signature on status. */
PCAN_ABI_API int set_wave_response(const uint8_t *request,const uint8_t *reply,
size_t size,uint16_t *words,size_t capacity);
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,53 @@
#include "set_wave_f407.h"
#include "stm32f4xx.h"
static void stop(void *context) {
unsigned limit=100000; (void)context;
RCC->AHB1ENR|=RCC_AHB1ENR_GPIOAEN|RCC_AHB1ENR_DMA1EN;
RCC->APB1ENR|=RCC_APB1ENR_DACEN|RCC_APB1ENR_TIM6EN;
(void)RCC->APB1ENR;
TIM6->CR1=0;
DAC->CR&=~(DAC_CR_DMAEN1|DAC_CR_TEN1);
DMA1_Stream5->CR&=~DMA_SxCR_EN;
while((DMA1_Stream5->CR&DMA_SxCR_EN)&&--limit) {}
/* Leave other DAC channel untouched. Channel 1 outputs zero on stop. */
DAC->DHR12R1=0;
DMA1->HIFCR=0xF40u;
DAC->SR=DAC_SR_DMAUDR1;
}
static int start(void *context,const uint16_t *samples,size_t count,uint32_t rate) {
set_wave_f407 *c=(set_wave_f407 *)context;
uint32_t ticks,psc,arr; uintptr_t address=(uintptr_t)samples;
if(!c||!rate||rate>SET_WAVE_MAX_RATE||count<2||count>SET_WAVE_MAX||c->timer_hz%rate
||address<0x20000000u||address+count*2>0x20020000u) return 1;
ticks=c->timer_hz/rate;
for(psc=1;psc<=65536;++psc) if(ticks%psc==0&&ticks/psc<=65536) break;
if(psc>65536||ticks<psc) return 1;
arr=ticks/psc;
stop(context);
if(DMA1_Stream5->CR&DMA_SxCR_EN) return 1;
GPIOA->MODER|=3u<<8;
GPIOA->PUPDR&=~(3u<<8);
TIM6->PSC=psc-1;TIM6->ARR=arr-1;TIM6->CNT=0;
TIM6->CR2=0;TIM6->EGR=TIM_EGR_UG;TIM6->SR=0;
TIM6->CR2=TIM_CR2_MMS_1; /* update -> TRGO */
DMA1_Stream5->PAR=(uint32_t)&DAC->DHR12R1;
DMA1_Stream5->M0AR=(uint32_t)samples;DMA1_Stream5->NDTR=(uint32_t)count;
DMA1_Stream5->FCR=0;
DMA1_Stream5->CR=(7u<<25)|DMA_SxCR_DIR_0|DMA_SxCR_MINC|DMA_SxCR_CIRC
|DMA_SxCR_PSIZE_0|DMA_SxCR_MSIZE_0|DMA_SxCR_PL_1;
/* Prime the previous period's last sample; DMA supplies the next value
* after each trigger. Cyclic order is last,0,1,... with one priming tick. */
DAC->CR&=~0xFFFFu;
DAC->DHR12R1=samples[count-1];
DAC->CR|=DAC_CR_EN1|DAC_CR_TEN1|DAC_CR_DMAEN1; /* TSEL1=0: TIM6 */
DMA1_Stream5->CR|=DMA_SxCR_EN;
TIM6->CR1=TIM_CR1_CEN;
return 0;
}
set_wave_port set_wave_f407_port(set_wave_f407 *context) {
set_wave_port port={context,start,stop};return port;
}
int set_wave_f407_fault(void) {
return (DMA1->HISR&(DMA_HISR_TEIF5|DMA_HISR_DMEIF5|DMA_HISR_FEIF5))
||(DAC->SR&DAC_SR_DMAUDR1);
}

View File

@@ -0,0 +1,12 @@
#ifndef SET_WAVE_F407_H
#define SET_WAVE_F407_H
#include "../../include/set_wavegen.h"
/* Owns PA4, DAC channel 1, TIM6 and DMA1 stream 5/channel 7.
* timer_hz is TIM6 input clock (twice APB1 when APB1 prescaler !=1).
* Rate: 1..SET_WAVE_MAX_RATE (1 MS/s), exact divisors of timer_hz only.
* Context and sample buffer must outlive playback; buffer must be in SRAM. */
typedef struct { uint32_t timer_hz; } set_wave_f407;
set_wave_port set_wave_f407_port(set_wave_f407 *context);
/* Nonzero on DMA error or DAC underrun; caller stops state on failure. */
int set_wave_f407_fault(void);
#endif

View File

@@ -0,0 +1,37 @@
#include "set_wave_g474.h"
static void stop(void *context) {
set_wave_g474 *c=(set_wave_g474 *)context;
HAL_TIM_Base_Stop(c->timer);
HAL_DAC_Stop_DMA(c->dac,DAC_CHANNEL_1);
/* Disable triggering so zero is applied without another timer edge. */
CLEAR_BIT(c->dac->Instance->CR,DAC_CR_TEN1);
HAL_DAC_SetValue(c->dac,DAC_CHANNEL_1,DAC_ALIGN_12B_R,0);
HAL_DAC_Start(c->dac,DAC_CHANNEL_1);
}
static int start(void *context,const uint16_t *samples,size_t count,uint32_t rate) {
set_wave_g474 *c=(set_wave_g474 *)context;
uint32_t ticks,psc; DMA_HandleTypeDef *dma;
if(!c||!c->dac||!c->timer||c->dac->Instance!=DAC1||c->timer->Instance!=TIM6
||!rate||rate>50000||count<2||count>SET_WAVE_MAX||c->timer_hz%rate) return 1;
dma=c->dac->DMA_Handle1;
if(!dma||dma->Init.Mode!=DMA_CIRCULAR||dma->Init.MemDataAlignment!=DMA_MDATAALIGN_HALFWORD
||dma->Init.PeriphDataAlignment!=DMA_PDATAALIGN_HALFWORD
||dma->Init.Request!=DMA_REQUEST_DAC1_CHANNEL1||dma->Init.MemInc!=DMA_MINC_ENABLE) return 1;
ticks=c->timer_hz/rate;
for(psc=1;psc<=65536;++psc) if(ticks%psc==0&&ticks/psc<=65536) break;
if(psc>65536||ticks<psc) return 1;
stop(context);
__HAL_TIM_SET_PRESCALER(c->timer,psc-1);
__HAL_TIM_SET_AUTORELOAD(c->timer,ticks/psc-1);
__HAL_TIM_SET_COUNTER(c->timer,0);
c->timer->Instance->EGR=TIM_EGR_UG;
__HAL_TIM_CLEAR_FLAG(c->timer,TIM_FLAG_UPDATE);
MODIFY_REG(c->dac->Instance->CR,DAC_CR_TSEL1|DAC_CR_TEN1,DAC_TRIGGER_T6_TRGO);
HAL_DAC_SetValue(c->dac,DAC_CHANNEL_1,DAC_ALIGN_12B_R,samples[count-1]);
if(HAL_DAC_Start_DMA(c->dac,DAC_CHANNEL_1,(uint32_t *)samples,(uint32_t)count,DAC_ALIGN_12B_R)!=HAL_OK) return 1;
if(HAL_TIM_Base_Start(c->timer)!=HAL_OK) {stop(context);return 1;}
return 0;
}
set_wave_port set_wave_g474_port(set_wave_g474 *context) {
set_wave_port port={context,start,stop};return port;
}

View File

@@ -0,0 +1,10 @@
#ifndef SET_WAVE_G474_H
#define SET_WAVE_G474_H
#include "stm32g4xx_hal.h"
#include "../../include/set_wavegen.h"
/* CubeMX owns initialization: DAC1 channel1 PA4, TIM6 TRGO_UPDATE,
* DMA_REQUEST_DAC1_CHANNEL1, circular, halfword/halfword, memory increment.
* Supply TIM6 input clock, not APB bus clock. DMA buffer in SRAM. */
typedef struct { DAC_HandleTypeDef *dac; TIM_HandleTypeDef *timer; uint32_t timer_hz; } set_wave_g474;
set_wave_port set_wave_g474_port(set_wave_g474 *context);
#endif

View File

@@ -0,0 +1,88 @@
#include "firmware_image.h"
#include <stdlib.h>
static int hex_digit(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
}
static int space(char c) { return c == ' ' || c == '\t' || c == '\r'; }
static int compare_cells(const void *a, const void *b)
{
uint32_t x = ((const firmware_hex_cell_t *)a)->address;
uint32_t y = ((const firmware_hex_cell_t *)b)->address;
return (x > y) - (x < y);
}
int firmware_hex_parse(const char *text, size_t length,
firmware_hex_cell_t *cells, size_t capacity, size_t max_records,
size_t *count, size_t *error_line)
{
size_t pos = 0, line = 0, used = 0, records = 0, i;
uint32_t upper = 0;
int eof = 0;
if (count == NULL || error_line == NULL) return FW_HEX_FORMAT;
*count = 0; *error_line = 0;
if (text == NULL || cells == NULL) return FW_HEX_FORMAT;
while (pos < length) {
size_t begin = pos, end, n;
uint8_t bytes[260];
unsigned sum = 0, size, kind, address;
while (pos < length && text[pos] != '\n') ++pos;
end = pos;
if (pos < length) ++pos;
++line;
while (begin < end && space(text[begin])) ++begin;
while (end > begin && space(text[end-1])) --end;
if (begin == end) continue;
*error_line = line;
if (eof) return FW_HEX_EOF;
if (++records > max_records) return FW_HEX_CAPACITY;
if (text[begin++] != ':') return FW_HEX_PREFIX;
if ((end-begin) & 1U) return FW_HEX_DIGITS;
n = (end-begin)/2;
if (n < 5 || n > sizeof(bytes)) return FW_HEX_FORMAT;
for (i = 0; i < n; ++i) {
int hi = hex_digit(text[begin+2*i]), lo = hex_digit(text[begin+2*i+1]);
if (hi < 0 || lo < 0) return FW_HEX_DIGITS;
bytes[i] = (uint8_t)(hi*16+lo); sum += bytes[i];
}
size = bytes[0]; address = ((unsigned)bytes[1]<<8) | bytes[2]; kind = bytes[3];
if (n != size+5U) return FW_HEX_LENGTH;
if (sum & 255U) return FW_HEX_CHECKSUM;
if (kind == 0) {
uint64_t absolute = (uint64_t)upper + address;
if (size && absolute + size - 1 > UINT32_MAX) return FW_HEX_RANGE;
if (size > capacity-used) return FW_HEX_CAPACITY;
for (i = 0; i < size; ++i) {
cells[used].address = (uint32_t)(absolute+i);
cells[used++].value = bytes[4+i];
}
} else if (kind == 1) {
if (size || address) return FW_HEX_RECORD;
eof = 1;
} else if (kind == 2 || kind == 4) {
if (size != 2 || address) return FW_HEX_RECORD;
upper = ((uint32_t)bytes[4]<<8) | bytes[5];
upper <<= kind == 2 ? 4 : 16;
} else if (kind == 3 || kind == 5) {
if (size != 4 || address) return FW_HEX_RECORD;
} else return FW_HEX_RECORD;
}
if (!eof || !used) return FW_HEX_EOF;
qsort(cells, used, sizeof(*cells), compare_cells);
for (i = 1; i < used; ++i)
if (cells[i-1].address == cells[i].address) { *error_line = 0; return FW_HEX_OVERLAP; }
*count = used; *error_line = 0;
return FW_HEX_OK;
}
size_t firmware_hex_segment_size(const firmware_hex_cell_t *cells, size_t count, size_t offset)
{
size_t end;
if (cells == NULL || offset >= count) return 0;
end = offset+1;
while (end < count && cells[end-1].address != UINT32_MAX &&
cells[end].address == cells[end-1].address+1U) ++end;
return end-offset;
}

View File

@@ -0,0 +1,115 @@
#include "set_signal.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
typedef struct { double x, y; } point;
static int compare_point(const void *a, const void *b) {
double x=((const point *)a)->x, y=((const point *)b)->x;
return (x>y)-(x<y);
}
static double edge(double h0,double h1,double d0,double d1) {
double d=((2*h0+h1)*d0-h0*d1)/(h0+h1);
if(d0==0 || (d>0)!=(d0>0)) return 0;
if((d0>0)!=(d1>0) && fabs(d)>3*fabs(d0)) return 3*d0;
return d;
}
static double evaluate(double t,const double *x,const double *y,size_t n,
int method,const double *d,const double *coef,unsigned degree) {
size_t lo=0,hi=n-1,i; double u,a,b,h,value=0;
if(method==SET_SIGNAL_POLYNOMIAL) {
int j; for(j=(int)degree;j>=0;--j) value=value*(2*t-1)+coef[j]; return value;
}
while(hi-lo>1) { i=(hi+lo)/2; if(t<x[i]) hi=i; else lo=i; }
i=lo; h=x[i+1]-x[i]; u=(t-x[i])/h; a=1-u; b=u;
if(method==SET_SIGNAL_LINEAR) return a*y[i]+b*y[i+1];
if(method==SET_SIGNAL_SPLINE)
return a*y[i]+b*y[i+1]+h*h/6*((a*a*a-a)*d[i]+(b*b*b-b)*d[i+1]);
return (2*u*u*u-3*u*u+1)*y[i]+(u*u*u-2*u*u+u)*h*d[i]
+(-2*u*u*u+3*u*u)*y[i+1]+(u*u*u-u*u)*h*d[i+1];
}
int set_signal_reconstruct(const double *input_x,const double *input_y,size_t count,
int method,unsigned degree,size_t output_count,int endpoint,double *out_x,
double *out_y,double *meta,double *work,size_t work_count) {
point *pairs; double *x,*y,*d,*h,*s,*q,*column;
double coef[6]={0},r[6][6]={{0}},origin,span,scale=1,rmse=0;
size_t i,j,k,n=0; unsigned pass;
if(!input_x||!input_y||!out_x||!out_y||!meta||!work || count<2||count>100000
||output_count<2||output_count>10000||work_count<14*count+128
||method<0||method>3||(endpoint!=0&&endpoint!=1)) return 1;
pairs=(point *)work; x=work+2*count; y=x+count; d=y+count; h=d+count;
s=h+count; q=s+count; column=q+6*count;
for(i=0;i<count;++i) {
if(!isfinite(input_x[i])||!isfinite(input_y[i])) return 2;
pairs[i].x=input_x[i]; pairs[i].y=input_y[i];
}
qsort(pairs,count,sizeof(point),compare_point);
for(i=0;i<count;i=j) {
double sum=0; for(j=i+1;j<count&&pairs[j].x==pairs[i].x;++j) {}
for(k=i;k<j;++k) sum+=pairs[k].y/(double)(j-i);
if(!isfinite(sum)) return 2;
x[n]=pairs[i].x; y[n++]=sum; if(fabs(sum)>scale) scale=fabs(sum);
}
if(n<2) return 2;
origin=x[0]; span=x[n-1]-origin;
if(!isfinite(span)||span<=0) return 2;
for(i=0;i<n;++i) { x[i]=(x[i]-origin)/span; y[i]/=scale; d[i]=0; }
for(i=0;i<n-1;++i) {
h[i]=x[i+1]-x[i]; if(h[i]<=0) return 2;
s[i]=(y[i+1]-y[i])/h[i]; if(!isfinite(s[i])) return 2;
}
if(method==SET_SIGNAL_POLYNOMIAL) {
if(degree<1||degree>5||n<=degree) return 1;
for(j=0;j<=degree;++j) {
double norm=0;
for(i=0;i<n;++i) column[i]=pow(2*x[i]-1,(double)j);
for(pass=0;pass<2;++pass) for(k=0;k<j;++k) {
double c=0; for(i=0;i<n;++i) c+=column[i]*q[k*count+i];
r[k][j]+=c; for(i=0;i<n;++i) column[i]-=c*q[k*count+i];
}
for(i=0;i<n;++i) norm+=column[i]*column[i]; norm=sqrt(norm);
if(norm<1e-12*sqrt((double)n)) return 3;
r[j][j]=norm;
for(i=0;i<n;++i) { q[j*count+i]=column[i]/norm; coef[j]+=q[j*count+i]*y[i]; }
}
for(i=degree+1;i>0;--i) {
j=i-1; for(k=j+1;k<=degree;++k) coef[j]-=r[j][k]*coef[k];
coef[j]/=r[j][j];
}
} else if(method==SET_SIGNAL_PCHIP) {
d[0]=d[n-1]=s[0];
if(n>2) {
d[0]=edge(h[0],h[1],s[0],s[1]);
d[n-1]=edge(h[n-2],h[n-3],s[n-2],s[n-3]);
for(i=1;i<n-1;++i) if(s[i-1]!=0&&s[i]!=0&&(s[i-1]>0)==(s[i]>0)) {
double w1=2*h[i]+h[i-1],w2=h[i]+2*h[i-1];
d[i]=(w1+w2)/(w1/s[i-1]+w2/s[i]);
}
}
} else if(method==SET_SIGNAL_SPLINE) {
q[0]=column[0]=0;
for(i=1;i<n-1;++i) {
double diagonal=2*(h[i-1]+h[i])-h[i-1]*q[i-1];
q[i]=h[i]/diagonal;
column[i]=(6*(s[i]-s[i-1])-h[i-1]*column[i-1])/diagonal;
}
for(i=n-2;i>0;--i) d[i]=column[i]-q[i]*d[i+1];
}
for(i=0;i<output_count;++i) {
double t=(double)i/(double)(output_count-(endpoint?1:0));
out_x[i]=origin+span*t; out_y[i]=evaluate(t,x,y,n,method,d,coef,degree)*scale;
if(!isfinite(out_y[i])) return 2;
}
for(i=0;i<count;++i) {
double delta=evaluate((input_x[i]-origin)/span,x,y,n,method,d,coef,degree)-input_y[i]/scale;
rmse+=delta*delta/(double)count;
}
meta[0]=(double)count; meta[1]=(double)n; meta[2]=sqrt(rmse)*scale;
return isfinite(meta[2])?0:2;
}
int set_signal_dac12(const double *volts,size_t count,double vref,uint16_t *codes) {
size_t i;
if(!volts||!codes||count<2||count>10000||!isfinite(vref)||vref<=0) return 1;
for(i=0;i<count;++i) if(!isfinite(volts[i])||volts[i]<0||volts[i]>vref) return 2;
for(i=0;i<count;++i) codes[i]=(uint16_t)floor(volts[i]/vref*4095+0.5);
return 0;
}

View File

@@ -0,0 +1,214 @@
#include "set_wavegen.h"
#include "set_crc.h"
#include <string.h>
static uint16_t read16(const uint8_t *p) {return (uint16_t)((p[0]<<8)|p[1]);}
static void write16(uint8_t *p,unsigned value) {p[0]=(uint8_t)(value>>8);p[1]=(uint8_t)value;}
static size_t seal(uint8_t *p,size_t n) {
uint16_t crc=set_crc16_modbus(p,n);p[n]=(uint8_t)crc;p[n+1]=(uint8_t)(crc>>8);return n+2;
}
void set_wave_init(set_wave_state *s,uint16_t *samples,size_t capacity,const set_wave_port *port) {
memset(s,0,sizeof(*s));s->samples=samples;s->capacity=capacity;s->rate=1000;
if(port) s->port=*port;
}
static unsigned write_register(set_wave_state *s,unsigned reg,unsigned value) {
if(reg==SET_WAVE_BASE+2 && value==0) {
if(s->port.stop) s->port.stop(s->port.context);
s->running=0; return 0;
}
if(s->running) return 6;
if(reg==SET_WAVE_BASE+2 && value==1) {
if(!s->ready||!s->port.start) return 3;
if(s->port.start(s->port.context,s->samples,s->count,s->rate)) return 4;
s->running=1;return 0;
}
if(reg==SET_WAVE_BASE+3) {
uint32_t rate=((uint32_t)s->rate_high<<16)|value;
if(rate<1||rate>SET_WAVE_MAX_RATE) return 3;
s->rate=rate;s->ready=0;return 0;
}
if(reg==SET_WAVE_BASE+8) {
if(value>(SET_WAVE_MAX_RATE>>16)) return 3;
s->rate_high=(uint16_t)value;s->ready=0;return 0;
}
if(reg==SET_WAVE_BASE+4) {
if(value<2||value>SET_WAVE_MAX||value>s->capacity) return 3;
s->count=(uint16_t)value;s->received=s->ready=0;return 0;
}
if(reg>=SET_WAVE_DATA&&reg<SET_WAVE_DATA+SET_WAVE_MAX) {
unsigned index=reg-SET_WAVE_DATA;
if(index>=s->count||index>s->received||value>4095||s->ready) return 3;
if(index<s->received) return s->samples[index]==value?0:3;
s->samples[index]=(uint16_t)value;++s->received;return 0;
}
if(reg==SET_WAVE_BASE+6) {
if(value!=0xA55A||s->count<2||s->received!=s->count) return 3;
s->ready=1;return 0;
}
return 2;
}
unsigned set_wave_read(void *ctx,uint16_t off,uint16_t count,uint16_t *out) {
set_wave_state *s=(set_wave_state *)ctx;uint16_t w[9];unsigned i;
if(!s||!s->samples||!out||!count||(uint32_t)off+count>9)return 2;
w[0]=0x5747;w[1]=3;w[2]=s->running;w[3]=(uint16_t)s->rate;
w[4]=s->count;w[5]=s->received;w[6]=s->ready;w[7]=(uint16_t)s->capacity;w[8]=(uint16_t)(s->rate>>16);
for(i=0;i<count;++i)out[i]=w[off+i];
return 0;
}
unsigned set_wave_write(void *ctx,uint16_t off,uint16_t value) {
set_wave_state *s=(set_wave_state *)ctx;
if(!s||!s->samples||off>=9)return 2;
return write_register(s,SET_WAVE_BASE+off,value);
}
unsigned set_wave_samples_read(void *ctx,uint16_t off,uint16_t count,uint16_t *out) {
set_wave_state *s=(set_wave_state *)ctx;unsigned i;
if(!s||!s->samples||!out||!count||(uint32_t)off+count>s->received)return 2;
for(i=0;i<count;++i)out[i]=s->samples[off+i];
return 0;
}
unsigned set_wave_samples_write(void *ctx,uint16_t off,uint16_t value) {
set_wave_state *s=(set_wave_state *)ctx;
if(!s||!s->samples||off>=SET_WAVE_MAX)return 2;
return write_register(s,SET_WAVE_DATA+off,value);
}
size_t set_wave_rtu(set_wave_state *s,unsigned device,const uint8_t *req,size_t n,uint8_t *out,size_t cap) {
unsigned reg,value,error=0,i;uint16_t status[8];size_t length=0;
if(!s||!s->samples||!req||!out||cap<21||n!=8||device<1||device>247
||req[0]!=device||set_crc16_modbus(req,n)) return 0;
reg=read16(req+2);value=read16(req+4);
if(!((reg>=SET_WAVE_BASE&&reg<SET_WAVE_BASE+9)||(reg>=SET_WAVE_DATA&&reg<SET_WAVE_DATA+SET_WAVE_MAX))) return 0;
out[0]=(uint8_t)device;out[1]=req[1];
if(req[1]==6) {error=write_register(s,reg,value);memcpy(out,req,6);length=6;}
else if(req[1]==3) {
(void)set_wave_read(s,0,8,status);
if(reg==SET_WAVE_BASE&&value==8) {
out[2]=16;for(i=0;i<8;++i) write16(out+3+2*i,status[i]);length=19;
} else if(reg==SET_WAVE_BASE+8&&value==1) {
out[2]=2;write16(out+3,s->rate>>16);length=5;
} else if(reg>=SET_WAVE_DATA&&value==1&&reg-SET_WAVE_DATA<s->received) {
out[2]=2;write16(out+3,s->samples[reg-SET_WAVE_DATA]);length=5;
} else error=2;
} else error=1;
if(error) {out[1]|=0x80;out[2]=(uint8_t)error;length=3;}
return seal(out,length);
}
size_t set_wave_request(unsigned device,unsigned op,unsigned index,unsigned value,uint8_t *out,size_t cap) {
unsigned reg=SET_WAVE_BASE,fn=6;
if(!out||cap<8||device<1||device>247) return 0;
switch(op) {
case 0: fn=3;value=8;break;
case 1: reg+=2;value=0;break;
case 2: reg+=3;if(value>65535)return 0;break;
case 3: reg+=4;if(value<2||value>SET_WAVE_MAX)return 0;break;
case 4: if(index>=SET_WAVE_MAX||value>4095)return 0;reg=SET_WAVE_DATA+index;break;
case 5: reg+=6;value=0xA55A;break;
case 6: reg+=2;value=1;break;
case 7: if(index>=SET_WAVE_MAX)return 0;reg=SET_WAVE_DATA+index;fn=3;value=1;break;
case 8: reg+=8;fn=3;value=1;break;
case 9: reg+=8;if(value>(SET_WAVE_MAX_RATE>>16))return 0;break;
default:return 0;
}
out[0]=(uint8_t)device;out[1]=(uint8_t)fn;write16(out+2,reg);write16(out+4,value);return seal(out,6);
}
int set_wave_response(const uint8_t *req,const uint8_t *reply,size_t size,uint16_t *words,size_t cap) {
size_t expected,i;
if(!req||!reply||!words||set_crc16_modbus(req,8))return -1;
if(size<2)return 0;
if(reply[0]!=req[0])return -1;
if(reply[1]==(req[1]|0x80)) {
if(size<5)return 0;
return size==5&&!set_crc16_modbus(reply,5)?-(100+(int)reply[2]):-1;
}
if(reply[1]!=req[1])return -1;
expected=req[1]==6?8:5+2*(size_t)read16(req+4);
if(size<expected)return 0;
if(size!=expected||set_crc16_modbus(reply,size))return -1;
if(req[1]==6) {if(memcmp(req,reply,8)||cap<1)return -1;words[0]=read16(req+4);return 1;}
if(reply[2]!=expected-5||cap<(expected-5)/2)return -1;
for(i=0;i<(expected-5)/2;++i)words[i]=read16(reply+3+2*i);
if(read16(req+2)==SET_WAVE_BASE&&(words[0]!=0x5747||(words[1]<1||words[1]>3)))return -2;
return (int)((expected-5)/2);
}
size_t set_wave_block_request(unsigned device,unsigned index,const uint16_t *values,
size_t count,uint8_t *out,size_t cap) {
size_t i,n=values?9+2*count:8;
if(!out||device<1||device>247||!count||count>SET_WAVE_BLOCK_MAX||
index>=SET_WAVE_MAX||count>SET_WAVE_MAX-index||cap<n)return 0;
if(values)for(i=0;i<count;++i)if(values[i]>4095)return 0;
out[0]=(uint8_t)device;out[1]=values?16:3;
write16(out+2,SET_WAVE_DATA+index);write16(out+4,(unsigned)count);
if(values) {
out[6]=(uint8_t)(count*2);
for(i=0;i<count;++i)write16(out+7+2*i,values[i]);
}
return seal(out,n-2);
}
size_t set_wave_block_rtu(set_wave_state *s,unsigned device,const uint8_t *req,
size_t n,uint8_t *out,size_t cap) {
unsigned reg,index,count,error=0,i;size_t length=3;
if(!s||!s->samples||!req||!out||cap<5||n<8||device<1||device>247||
req[0]!=device||set_crc16_modbus(req,n))return 0;
reg=read16(req+2);count=read16(req+4);
if(reg<SET_WAVE_DATA||reg>=SET_WAVE_DATA+SET_WAVE_MAX)return 0;
index=reg-SET_WAVE_DATA;out[0]=req[0];out[1]=req[1];
if(!count||count>SET_WAVE_BLOCK_MAX||count>SET_WAVE_MAX-index||
index+count>s->count||index+count>s->capacity)error=3;
else if(req[1]==16) {
if(n!=9+2*(size_t)count||req[6]!=2*count)error=3;
else if(s->running)error=6;
else if(s->ready||index>s->received)error=3;
else {
/* Preflight the whole block, including replayed prefix, before writing. */
for(i=0;i<count;++i) {
unsigned value=read16(req+7+2*i);
if(value>4095||(index+i<s->received&&s->samples[index+i]!=value)) {
error=3;break;
}
}
if(!error) {
if(cap<8)return 0;
for(i=0;i<count;++i)s->samples[index+i]=read16(req+7+2*i);
if(index+count>s->received)s->received=(uint16_t)(index+count);
memcpy(out,req,6);length=6;
}
}
} else if(req[1]==3) {
if(n!=8||index+count>s->received)error=3;
else {
if(cap<5+2*(size_t)count)return 0;
out[2]=(uint8_t)(2*count);
for(i=0;i<count;++i)write16(out+3+2*i,s->samples[index+i]);
length=3+2*(size_t)count;
}
} else error=1;
if(error) {out[1]|=0x80;out[2]=(uint8_t)error;length=3;}
return seal(out,length);
}
int set_wave_block_response(const uint8_t *req,size_t n,const uint8_t *reply,
size_t size,uint16_t *words,size_t cap) {
size_t expected,i;unsigned count;
if(!req||!reply||!words||n<8||set_crc16_modbus(req,n))return -1;
count=read16(req+4);
if(!count||count>SET_WAVE_BLOCK_MAX||read16(req+2)<SET_WAVE_DATA||
(unsigned)read16(req+2)+count>SET_WAVE_DATA+SET_WAVE_MAX)return -1;
if((req[1]==16&&(n!=9+2*(size_t)count||req[6]!=2*count))||
(req[1]==3&&n!=8)||(req[1]!=3&&req[1]!=16))return -1;
if(size<2)return 0;
if(reply[0]!=req[0])return -1;
if(reply[1]==(req[1]|0x80)) {
if(size<5)return 0;
return size==5&&!set_crc16_modbus(reply,size)?-(100+(int)reply[2]):-1;
}
if(reply[1]!=req[1])return -1;
expected=req[1]==16?8:5+2*(size_t)count;
if(size<expected)return 0;
if(size!=expected||set_crc16_modbus(reply,size))return -1;
if(req[1]==16) {
if(cap<1||memcmp(req,reply,6))return -1;
words[0]=(uint16_t)count;return 1;
}
if(cap<count||reply[2]!=2*count)return -1;
for(i=0;i<count;++i)words[i]=read16(reply+3+2*i);
return (int)count;
}

View File

@@ -0,0 +1,22 @@
#include "firmware_image.h"
#include <assert.h>
#include <string.h>
static int parse(const char *text, firmware_hex_cell_t *cells, size_t cap, size_t *count)
{ size_t line; return firmware_hex_parse(text, strlen(text), cells, cap, 100, count, &line); }
int main(void)
{
firmware_hex_cell_t cells[32]; size_t count;
const char *good = ":020000040800F2\n:0400100001020304E2\n:00000001FF\n";
assert(parse(good,cells,32,&count)==0 && count==4);
assert(cells[0].address==0x08000010U && cells[3].value==4);
assert(firmware_hex_segment_size(cells,count,0)==4);
assert(parse(good,cells,3,&count)==FW_HEX_CAPACITY && count==0);
assert(parse(":0100000001FE\n:0100000001FE\n:00000001FF",cells,32,&count)==FW_HEX_OVERLAP);
assert(parse(":0100000001FF\n:00000001FF",cells,32,&count)==FW_HEX_CHECKSUM);
assert(parse(":0100000001FE",cells,32,&count)==FW_HEX_EOF);
assert(parse(":0100000001FE\n:00000001FF\n:0100010002FC",cells,32,&count)==FW_HEX_EOF);
assert(parse(":0100010002FC\n:0100000001FE\n:00000001FF",cells,32,&count)==0);
assert(cells[0].value==1 && cells[1].value==2);
assert(parse(":02000004FFFFFC\n:02FFFF000102FD\n:00000001FF",cells,32,&count)==FW_HEX_RANGE);
return 0;
}

View File

@@ -0,0 +1,51 @@
#include "set_signal.h"
#include "set_wavegen.h"
#include <assert.h>
#include <math.h>
#include <string.h>
static int starts,stops;
static int start(void *ctx,const uint16_t *p,size_t n,uint32_t rate) {
(void)ctx;assert(n==3&&rate==1000&&p[2]==4095);++starts;return 0;
}
static void stop(void *ctx) {(void)ctx;++stops;}
static int exchange(set_wave_state *s,unsigned op,unsigned index,unsigned value,uint16_t *out) {
uint8_t req[8],reply[32];size_t n;
assert(set_wave_request(16,op,index,value,req,8)==8);
n=set_wave_rtu(s,16,req,8,reply,sizeof(reply));assert(n>0);
assert(set_wave_response(req,reply,n-1,out,8)==0);
return set_wave_response(req,reply,n,out,8);
}
int main(void) {
double x[3]={0,1,2},y[3]={0,1,0},ox[5],oy[5],meta[3],work[170];
uint16_t samples[8],out[8],codes[3];set_wave_state state;
set_wave_port port={0,start,stop};uint8_t req[8],reply[32];
assert(!set_signal_reconstruct(x,y,3,SET_SIGNAL_SPLINE,2,5,1,ox,oy,meta,work,170));
assert(fabs(oy[1]-.6875)<1e-12&&fabs(oy[3]-.6875)<1e-12&&meta[2]<1e-12);
y[1]=1.65;y[2]=3.3;assert(!set_signal_dac12(y,3,3.3,codes));
assert(codes[0]==0&&codes[1]==2048&&codes[2]==4095);
y[0]=-1;codes[0]=123;assert(set_signal_dac12(y,3,3.3,codes)&&codes[0]==123);
set_wave_init(&state,samples,8,&port);
assert(exchange(&state,0,0,0,out)==8&&out[0]==0x5747&&out[7]==8);
assert(exchange(&state,6,0,0,out)<0&&starts==0);
assert(exchange(&state,1,0,0,out)==1&&stops==1);
assert(exchange(&state,2,0,1000,out)==1);
assert(exchange(&state,3,0,3,out)==1);
assert(exchange(&state,4,1,10,out)<0); /* no holes */
assert(exchange(&state,4,0,0,out)==1);
assert(exchange(&state,4,0,0,out)==1&&state.received==1); /* idempotent replay */
assert(exchange(&state,4,0,5,out)<0);
assert(exchange(&state,5,0,0,out)<0); /* incomplete upload cannot commit */
assert(exchange(&state,4,1,2048,out)==1);
assert(exchange(&state,4,2,4095,out)==1);
assert(exchange(&state,7,1,0,out)==1&&out[0]==2048);
assert(exchange(&state,5,0,0,out)==1&&state.ready);
assert(exchange(&state,6,0,0,out)==1&&state.running&&starts==1);
assert(exchange(&state,3,0,2,out)<0&&state.count==3);
assert(exchange(&state,4,0,2,out)<0&&samples[0]==0);
set_wave_request(16,1,0,0,req,8);req[7]^=1;
assert(!set_wave_rtu(&state,16,req,8,reply,32)&&state.running);
set_wave_request(17,1,0,0,req,8);
assert(!set_wave_rtu(&state,16,req,8,reply,32)&&state.running);
assert(exchange(&state,1,0,0,out)==1&&!state.running);
return 0;
}

View File

@@ -20,9 +20,9 @@ INCLUDE = ROOT / "include"
JNI_INCLUDES: list[Path] = []
SOURCES = [
ROOT / "src" / name for name in (
"altera_logic.c",
"firmware_image.c", "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",
"set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c", "set_signal.c", "set_wavegen.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",
"pcan_gas.c", "pcan_modbus_server.c",

View File

@@ -13,12 +13,16 @@
| `altera_logic` | C FFI Altera, SETCAN stream, модели и Qt-порты |
| `set_devices` | Кодеки, каталоги, прошивки, EEPROM/DS18B20, TMS, UMP, CAN485, спектр, демо-модели |
| `set_devices.qt_ports` | UART/mock, SLCAN, Candle/WinUSB, STM/TMS boot, STM settings, UMP CAN |
| `set_devices.plot_processing` | Общий контракт графиков: аппроксимация, интерполяция, восстановление, единицы и CSV через C99 |
| `set_devices.qt_ports.plot_processing` | Переиспользуемая панель всех методов, фоновый расчёт и отдельный слой кривой |
| `protocan`, `setprotocol` | Существующие обёртки общего C99-ядра и SET v2 |
Импорт `logic_analyzer` / `set_devices` не загружает Qt и не зависит от GUI.
Qt подключается только при импорте конкретного `qt_ports` (PySide6 или PySide2).
Saleae SDK подключается при обращении к устройству: `pip install -e './python[saleae]'`.
Qt-виджеты, пользовательские настройки и управление сессией находятся в приложении.
Предметные Qt-виджеты, пользовательские настройки и управление сессией находятся
в приложении. Общая панель обработки графиков размещена в `qt_ports.plot_processing`:
[контракт, подключение нового графика и примеры](../c/set-protocol/docs/PLOT_PROCESSING.md).
Пути к нативным библиотекам задаёт потребитель перед импортом:
`SETPROTOCOL_LIBRARY`, при необходимости `ALTERA_LOGIC_LIBRARY`, `CANDLE_LIBRARY`.

View File

@@ -0,0 +1,27 @@
"""Sparse sine reconstruction using the public contract, independent of a GUI."""
import argparse
import math
from pathlib import Path
from set_devices.plot_processing import Axis, Series, Snapshot, prepare, process, write_csv
from set_devices.signal_reconstruction import METHODS
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--method", choices=METHODS, default="spline")
parser.add_argument("--degree", type=int, default=3)
parser.add_argument("--points", type=int, default=201)
parser.add_argument("--output", type=Path, default=Path("curve.csv"))
args = parser.parse_args()
samples = [(i * 125, math.sin(i * math.pi / 4)) for i in range(9)]
snapshot = Snapshot((Series("sine", "Синусоида: 9 отсчётов", samples, y_unit="В"),),
Axis("Время", "мс"), "sparse-sine")
curve = process(prepare(snapshot, "sine", args.method, args.points, args.degree))
with args.output.open("w", encoding="utf-8-sig", newline="") as stream:
write_csv(curve, stream)
print(f"{args.output}: {curve.input_count} -> {len(curve.points)}; RMSE={curve.rmse:.6g}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,31 @@
# Общие образы прошивки
SETGUI и климатический bridge используют один пакет и один C99-парсер
`c/set-protocol/src/firmware_image.c`. Python преобразует результат C в
`HexSegment`; алгоритмического fallback нет. Карта Flash, план записи,
заполнение промежутков FF и стандартные CRC32/SHA256 доступны через `core`.
Запуск программатора, файловый allowlist и журнал остаются в приложении.
Строгая политика HEX одинакова для обоих потребителей: checksum, обязательный
EOF, запрет данных после EOF, запрет любых перекрытий, проверка длины и адресов.
Непрерывный образ имеет заданный предел **до** выделения его буфера.
Профиль STM32F407VE сохраняется для совместимости; другой MCU передаёт свой
`FlashLayout` в функции планирования.
Из корня templates:
```powershell
python c/set-protocol/tools/build_host.py --output python/protocan/native/setprotocol.dll
python -m pip install -e ./python
python -m unittest discover -s python/tests -p test_firmware_image_shared.py
```
На Linux/macOS имя результата — `libsetprotocol.so`/`libsetprotocol.dylib`.
Также поддерживается явный путь `SETPROTOCOL_LIBRARY`. Старая DLL без API
образов выдаёт понятную ошибку с требованием пересборки.
```python
from firmware_image.core import parse_intel_hex, contiguous_image
segments = parse_intel_hex(':0100000001FE\n:00000001FF')
image, base = contiguous_image(':0100000001FE\n:00000001FF', maximum_size=1024)
```

View File

@@ -0,0 +1,2 @@
"""Shared firmware images and flash layout plans."""
from .core import *

View File

@@ -0,0 +1,373 @@
"""Переносимое ядро проверки образа прошивки STM32.
Модуль не знает ни про HTTP, ни про Modbus, ни про файловую систему, ни про
конкретный программатор: на вход подаются уже прочитанные байты, на выходе
получается проверенный план записи. Благодаря этому те же правила проверяются
host-тестом без платы и без bridge.
Основные границы:
* :func:`parse_intel_hex` — полный разбор Intel HEX с проверкой checksum каждой
записи, поддержкой расширенной адресации и поиском пересечений;
* :func:`build_raw_plan` / :func:`build_hex_plan` — план записи с проверкой
выравнивания, границ приложения и защищённых секторов;
* :func:`image_digest` — CRC32 и SHA-256 фактически записываемых байтов.
Геометрия Flash задаётся объектом :class:`FlashLayout`, поэтому ядро не привязано
к STM32F407: значения по умолчанию описывают текущую плату и берутся из
``MDK-ARM/flash_layout.sct`` и ``EEPROM_Emul/lib/flash_ring.h``.
"""
from __future__ import annotations
import hashlib
import re
import zlib
from dataclasses import dataclass, field
from typing import Iterable, Optional, Sequence
__all__ = [
"FirmwareImageError",
"FlashLayout",
"FlashRegion",
"FlashSector",
"HexSegment",
"ImagePlan",
"STM32F407VE_LAYOUT",
"RAW_EXTENSIONS",
"HEX_EXTENSIONS",
"build_hex_plan",
"build_raw_plan",
"detect_format",
"format_address",
"image_digest",
"parse_flash_address",
"parse_intel_hex",
]
class FirmwareImageError(ValueError):
"""Единственный тип ошибки ядра: сообщение пригодно для показа оператору."""
# ---------------------------------------------------------------------------
# Геометрия Flash
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FlashSector:
index: int
start: int
size: int
@property
def end(self) -> int:
"""Последний байт сектора включительно."""
return self.start + self.size - 1
@dataclass(frozen=True)
class FlashRegion:
name: str
start: int
end: int
def overlaps(self, start: int, end: int) -> bool:
return start <= self.end and end >= self.start
@dataclass(frozen=True)
class FlashLayout:
"""Полное описание внутренней Flash целевого МК."""
name: str
sectors: Sequence[FlashSector]
application: FlashRegion
reserved: Sequence[FlashRegion] = field(default_factory=tuple)
write_alignment: int = 4
min_image_bytes: int = 8
@property
def max_image_bytes(self) -> int:
return self.application.end - self.application.start + 1
def sector_at(self, address: int) -> Optional[FlashSector]:
for sector in self.sectors:
if sector.start <= address <= sector.end:
return sector
return None
def sectors_for_range(self, start: int, end: int) -> list[FlashSector]:
return [sector for sector in self.sectors if start <= sector.end and end >= sector.start]
def reserved_hit(self, start: int, end: int) -> Optional[FlashRegion]:
for region in self.reserved:
if region.overlaps(start, end):
return region
return None
# Секторы STM32F407VE: 4x16 КиБ, 64 КиБ, 3x128 КиБ.
STM32F407VE_LAYOUT = FlashLayout(
name="STM32F407VET6",
sectors=(
FlashSector(0, 0x08000000, 16 * 1024),
FlashSector(1, 0x08004000, 16 * 1024),
FlashSector(2, 0x08008000, 16 * 1024),
FlashSector(3, 0x0800C000, 16 * 1024),
FlashSector(4, 0x08010000, 64 * 1024),
FlashSector(5, 0x08020000, 128 * 1024),
FlashSector(6, 0x08040000, 128 * 1024),
FlashSector(7, 0x08060000, 128 * 1024),
),
application=FlashRegion("Приложение", 0x08000000, 0x0803FFFF),
# Секторы 6-7 отданы кольцу EEPROM: их стирание уничтожило бы настройки.
reserved=(FlashRegion("Кольцо EEPROM (секторы 6-7)", 0x08040000, 0x0807FFFF),),
)
RAW_EXTENSIONS = (".bin", ".fw")
HEX_EXTENSIONS = (".hex",)
def detect_format(name: str) -> str:
"""Формат образа по расширению; исключение для всего остального."""
lower = str(name or "").lower()
if lower.endswith(RAW_EXTENSIONS):
return "bin"
if lower.endswith(HEX_EXTENSIONS):
return "hex"
raise FirmwareImageError("Поддерживаются только .bin/.hex/.fw")
def format_address(address: int) -> str:
return f"0x{int(address):08X}"
def parse_flash_address(value: object) -> int:
"""Адрес принимается только как явный decimal или HEX с префиксом 0x."""
text = str(value if value is not None else "").strip().replace(" ", "")
if not text:
raise FirmwareImageError("Базовый адрес Flash не задан")
if not re.fullmatch(r"0[xX][0-9a-fA-F]{1,8}|\d+", text):
raise FirmwareImageError("Адрес должен быть decimal или HEX с префиксом 0x")
address = int(text, 16 if text.lower().startswith("0x") else 10)
if not 0 <= address <= 0xFFFFFFFF:
raise FirmwareImageError("Адрес выходит за диапазон uint32")
return address
# ---------------------------------------------------------------------------
# Intel HEX
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class HexSegment:
"""Непрерывный участок данных Intel HEX."""
start: int
data: bytes
@property
def end(self) -> int:
return self.start + len(self.data) - 1
RECORD_DATA = 0x00
RECORD_EOF = 0x01
RECORD_EXTENDED_SEGMENT = 0x02
RECORD_START_SEGMENT = 0x03
RECORD_EXTENDED_LINEAR = 0x04
RECORD_START_LINEAR = 0x05
_KNOWN_RECORDS = (RECORD_DATA, RECORD_EOF, RECORD_EXTENDED_SEGMENT,
RECORD_START_SEGMENT, RECORD_EXTENDED_LINEAR, RECORD_START_LINEAR)
# Лимит защищает bridge от файла, который «раздувается» адресными записями.
MAX_HEX_RECORDS = 1_000_000
def parse_intel_hex(text: object, *, max_records: int = MAX_HEX_RECORDS) -> list[HexSegment]:
"""Validate HEX with the shared C99 parser and return contiguous segments."""
from .native import parse_segments
return parse_segments(text, max_records, FirmwareImageError, HexSegment)
def contiguous_image(text, maximum_size=2 * 1024 * 1024):
"""Convert validated segments to a bounded image, filling gaps with FF."""
segments = parse_intel_hex(text)
base, end = segments[0].start, segments[-1].end + 1
if end - base > maximum_size:
raise FirmwareImageError('Размер образа превышает %d байт' % maximum_size)
image = bytearray(b'\xff' * (end - base))
for segment in segments:
offset = segment.start - base
image[offset:offset + len(segment.data)] = segment.data
return bytes(image), base
@dataclass(frozen=True)
class ImagePlan:
"""Проверенный план: что именно и куда будет записано."""
image_format: str
start: int
end: int
payload_bytes: int
sectors: tuple[int, ...]
erased_bytes: int
segments: tuple[HexSegment, ...]
gaps: tuple[tuple[int, int], ...]
crc32: str
sha256: str
@property
def text(self) -> str:
return f"{format_address(self.start)}..{format_address(self.end)}"
def as_dict(self) -> dict:
"""Представление для JSON API и GUI."""
return {
"format": self.image_format,
"start": format_address(self.start),
"end": format_address(self.end),
"targetRange": self.text,
"payloadBytes": self.payload_bytes,
"spanBytes": self.end - self.start + 1,
"sectors": list(self.sectors),
"erasedBytes": self.erased_bytes,
"segments": [
{"start": format_address(item.start), "end": format_address(item.end),
"size": len(item.data)}
for item in self.segments
],
"gaps": [
{"start": format_address(low), "end": format_address(high), "size": high - low + 1}
for low, high in self.gaps
],
"crc32": self.crc32,
"sha256": self.sha256,
}
def image_digest(data: bytes) -> tuple[str, str]:
"""CRC32 и SHA-256 фактически записываемых байтов."""
return (
f"{zlib.crc32(bytes(data)) & 0xFFFFFFFF:08X}",
hashlib.sha256(bytes(data)).hexdigest().upper(),
)
def _check_range(layout: FlashLayout, start: int, end: int) -> None:
reserved = layout.reserved_hit(start, end)
if reserved is not None:
raise FirmwareImageError(f"Диапазон пересекает защищённую область: {reserved.name}")
if start < layout.application.start or end > layout.application.end:
raise FirmwareImageError(
"Диапазон должен помещаться в "
f"{format_address(layout.application.start)}.."
f"{format_address(layout.application.end)}"
)
def _sector_summary(layout: FlashLayout, start: int, end: int) -> tuple[tuple[int, ...], int]:
sectors = layout.sectors_for_range(start, end)
return tuple(item.index for item in sectors), sum(item.size for item in sectors)
def build_raw_plan(size: object, base_address: object, *,
layout: FlashLayout = STM32F407VE_LAYOUT,
data: Optional[bytes] = None) -> ImagePlan:
"""План для сырого образа.
Базовый адрес обязан совпадать с началом сектора: стирание STM32F4 идёт
только целыми секторами, поэтому запись с середины сектора уничтожила бы
соседние данные. ``data`` необязателен: список файлов SD знает лишь размер,
и контрольные суммы считаются только тогда, когда содержимое действительно
прочитано.
"""
try:
length = int(size)
except (TypeError, ValueError) as exc:
raise FirmwareImageError("Размер образа должен быть целым числом") from exc
if data is not None and len(data) != length:
raise FirmwareImageError("Размер образа не совпадает с числом прочитанных байт")
if length < layout.min_image_bytes:
raise FirmwareImageError(f"Размер образа меньше {layout.min_image_bytes} байт")
if length > layout.max_image_bytes:
raise FirmwareImageError(
f"Образ больше области приложения ({layout.max_image_bytes // 1024} КиБ)"
)
start = parse_flash_address(base_address)
if start % layout.write_alignment:
raise FirmwareImageError(
f"Базовый адрес должен быть выровнен на {layout.write_alignment} байта"
)
sector = layout.sector_at(start)
if sector is None:
raise FirmwareImageError(f"Базовый адрес вне внутренней Flash {layout.name}")
if sector.start != start:
raise FirmwareImageError(
f"Базовый адрес должен совпадать с началом сектора ({format_address(sector.start)})"
)
end = start + length - 1
_check_range(layout, start, end)
sectors, erased = _sector_summary(layout, start, end)
crc32, sha256 = image_digest(data) if data is not None else ("", "")
segments = (HexSegment(start, bytes(data)),) if data is not None else ()
return ImagePlan("bin", start, end, length, sectors, erased, segments, (), crc32, sha256)
def build_hex_plan(segments: Iterable[HexSegment], *,
layout: FlashLayout = STM32F407VE_LAYOUT) -> ImagePlan:
"""План для Intel HEX: адреса берутся из файла, оператор их не задаёт.
Проверяется каждый сегмент отдельно, поэтому файл с одним «правильным» и
одним посторонним диапазоном отклоняется целиком. Разрывы между сегментами
сохраняются в плане: при записи они заполняются 0xFF, и оператор должен
видеть, какая часть Flash будет стёрта без данных из файла.
"""
ordered = sorted(segments, key=lambda item: item.start)
if not ordered:
raise FirmwareImageError("Файл Intel HEX не содержит данных")
for segment in ordered:
if not segment.data:
raise FirmwareImageError("Сегмент Intel HEX пуст")
_check_range(layout, segment.start, segment.end)
start = ordered[0].start
end = ordered[-1].end
if start % layout.write_alignment:
raise FirmwareImageError(
f"Начальный адрес Intel HEX не выровнен на {layout.write_alignment} байта"
)
sector = layout.sector_at(start)
if sector is None or sector.start != start:
raise FirmwareImageError(
"Первый адрес Intel HEX должен совпадать с началом сектора "
f"({format_address(sector.start) if sector else 'вне Flash'})"
)
# Пустоты заполняются 0xFF — стёртым состоянием NOR Flash.
image = bytearray(b"\xFF" * (end - start + 1))
gaps: list[tuple[int, int]] = []
previous_end = None
payload = 0
for segment in ordered:
if previous_end is not None and segment.start > previous_end + 1:
gaps.append((previous_end + 1, segment.start - 1))
image[segment.start - start:segment.end - start + 1] = segment.data
payload += len(segment.data)
previous_end = segment.end
sectors, erased = _sector_summary(layout, start, end)
crc32, sha256 = image_digest(bytes(image))
return ImagePlan("hex", start, end, payload, sectors, erased,
tuple(ordered), tuple(gaps), crc32, sha256)
def build_plan(image_format: str, *, data: Optional[bytes] = None, size: Optional[int] = None,
base_address: object = None,
layout: FlashLayout = STM32F407VE_LAYOUT) -> ImagePlan:
"""Единая точка входа: выбрать ветку разбора по формату образа."""
if image_format == "hex":
if data is None:
raise FirmwareImageError("Для Intel HEX требуется содержимое файла")
return build_hex_plan(parse_intel_hex(data), layout=layout)
if image_format == "bin":
length = len(data) if data is not None else size
return build_raw_plan(length, base_address, layout=layout, data=data)
raise FirmwareImageError("Неизвестный формат образа")

View File

@@ -0,0 +1,48 @@
"""Models for the C99 Intel HEX parser. No Python parser fallback."""
import ctypes as ct
from functools import lru_cache
from protocan.native import _load_library
class Cell(ct.Structure):
_fields_ = [('address', ct.c_uint32), ('value', ct.c_uint8)]
@lru_cache(maxsize=1)
def library():
lib = _load_library()
try:
lib.firmware_hex_parse.argtypes = [ct.c_char_p, ct.c_size_t, ct.POINTER(Cell),
ct.c_size_t, ct.c_size_t, ct.POINTER(ct.c_size_t), ct.POINTER(ct.c_size_t)]
lib.firmware_hex_parse.restype = ct.c_int
lib.firmware_hex_segment_size.argtypes = [ct.POINTER(Cell), ct.c_size_t, ct.c_size_t]
lib.firmware_hex_segment_size.restype = ct.c_size_t
except AttributeError as exc:
raise RuntimeError('Rebuild SETProtocol: native firmware image API is missing') from exc
return lib
def parse_segments(text, max_records, error_type, segment_type):
try:
raw = bytes(text) if isinstance(text, (bytes, bytearray)) else str(text or '').encode('ascii')
except UnicodeError as exc:
raise error_type('Intel HEX должен состоять из ASCII') from exc
if max_records < 1 or len(raw) > 64 * 1024 * 1024:
raise error_type('Файл Intel HEX превышает допустимый размер')
cells = (Cell * max(1, len(raw) // 2))()
count, line = ct.c_size_t(), ct.c_size_t()
lib = library()
status = lib.firmware_hex_parse(raw, len(raw), cells, len(cells), max_records,
ct.byref(count), ct.byref(line))
if status:
messages = {1: 'неверный формат', 2: 'неверная checksum записи',
3: 'неизвестный тип или неверная длина записи', 4: 'нет данных/EOF или данные после записи EOF',
5: 'адрес описан повторно', 6: 'адрес выходит за uint32',
7: 'превышено допустимое число записей',
8: "запись должна начинаться с ':'", 9: 'запись содержит не пары HEX-символов',
10: 'длина записи не совпадает с полем count'}
raise error_type('Intel HEX, строка %d: %s' % (line.value, messages.get(status, 'ошибка')))
result, offset = [], 0
while offset < count.value:
size = lib.firmware_hex_segment_size(cells, count.value, offset)
result.append(segment_type(cells[offset].address,
bytes(cells[i].value for i in range(offset, offset + size))))
offset += size
return result

View File

@@ -14,4 +14,7 @@ saleae = ["logic2-automation>=1.0.7,<2"]
[tool.setuptools.packages.find]
where = ["."]
include = ["altera_logic*", "logic_analyzer*", "set_devices*", "protocan*", "setprotocol*"]
include = ["firmware_image*", "altera_logic*", "logic_analyzer*", "set_devices*", "protocan*", "setprotocol*"]
[tool.setuptools.package-data]
protocan = ["native/*.dll", "native/*.so", "native/*.dylib"]

View File

@@ -7,15 +7,12 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from set_devices.protocol import crc32_ieee
class FirmwareImageError(ValueError):
"""@brief Файл нельзя безопасно представить как непрерывный бинарный образ."""
from firmware_image.core import FirmwareImageError, contiguous_image, image_digest
@dataclass(frozen=True)
@@ -34,18 +31,18 @@ class FirmwareImage:
@property
def crc32(self) -> int:
return crc32_ieee(self.data)
return int(image_digest(self.data)[0], 16)
@property
def sha256(self) -> str:
return hashlib.sha256(self.data).hexdigest()
return image_digest(self.data)[1].lower()
@classmethod
def load(cls, path: str | Path, maximum_size: int = 2 * 1024 * 1024) -> "FirmwareImage":
file_path = Path(path)
raw = file_path.read_bytes()
if file_path.suffix.lower() == ".hex":
data, base = _parse_intel_hex(raw.decode("ascii"))
data, base = _parse_intel_hex(raw, maximum_size)
elif file_path.suffix.lower() == ".bin":
data, base = raw, 0
else:
@@ -57,49 +54,8 @@ class FirmwareImage:
return cls(file_path, data, base)
def _parse_intel_hex(text: str) -> tuple[bytes, int]:
memory: dict[int, int] = {}
upper = 0
eof = False
for line_number, source in enumerate(text.splitlines(), 1):
line = source.strip()
if not line:
continue
if not line.startswith(":"):
raise FirmwareImageError(f"Intel HEX: строка {line_number} без ':'")
try:
record = bytes.fromhex(line[1:])
except ValueError as error:
raise FirmwareImageError(f"Intel HEX: неверный HEX в строке {line_number}") from error
if len(record) < 5 or len(record) != record[0] + 5 or sum(record) & 0xFF:
raise FirmwareImageError(f"Intel HEX: неверная длина/CRC строки {line_number}")
count = record[0]
address = int.from_bytes(record[1:3], "big")
kind = record[3]
payload = record[4 : 4 + count]
if kind == 0x00:
absolute = upper + address
for index, value in enumerate(payload):
key = absolute + index
if key in memory and memory[key] != value:
raise FirmwareImageError("Intel HEX содержит конфликтующие диапазоны")
memory[key] = value
elif kind == 0x01:
eof = True
break
elif kind == 0x04 and count == 2:
upper = int.from_bytes(payload, "big") << 16
elif kind == 0x02 and count == 2:
upper = int.from_bytes(payload, "big") << 4
elif kind not in (0x03, 0x05):
raise FirmwareImageError(f"Intel HEX: тип записи {kind:#x} не поддержан")
if not eof or not memory:
raise FirmwareImageError("Intel HEX не содержит данных или EOF")
start, end = min(memory), max(memory) + 1
image = bytearray(b"\xFF" * (end - start))
for address, value in memory.items():
image[address - start] = value
return bytes(image), start
def _parse_intel_hex(text: str, maximum_size=2 * 1024 * 1024) -> tuple[bytes, int]:
return contiguous_image(text, maximum_size)
@dataclass

View File

@@ -0,0 +1,117 @@
"""Renderer-independent processing contract. Numerical work stays in C99.
Adapters publish immutable snapshots in the displayed units. Calculated curves
are separate objects, never channels in the acquisition or measurement model.
"""
from __future__ import annotations
import csv
from dataclasses import dataclass
from datetime import datetime, timezone
from .signal_reconstruction import METHODS, reconstruct
@dataclass(frozen=True)
class Axis:
label: str = "Время"
unit: str = "мс"
encoding: str = "numeric" # numeric or unix_ms; never infer from magnitude
def __post_init__(self):
if self.encoding not in ("numeric", "unix_ms"):
raise ValueError("Неизвестное представление оси X")
@dataclass(frozen=True)
class Series:
key: str
label: str
points: tuple
visible: bool = True
discrete: bool = False
y_unit: str = ""
def __post_init__(self):
# A source may reuse mutable buffers immediately after publication.
object.__setattr__(self, "points", tuple((float(x), float(y)) for x, y in self.points))
@dataclass(frozen=True)
class Snapshot:
series: tuple = ()
axis: Axis = Axis()
source: str = ""
x_range: tuple | None = None
blocked_reason: str = ""
def __post_init__(self):
object.__setattr__(self, "series", tuple(self.series))
if len({s.key for s in self.series}) != len(self.series):
raise ValueError("Ключи каналов должны быть уникальны")
if self.x_range is not None:
left, right = self.x_range
if not left <= right:
raise ValueError("Неверные границы окна")
object.__setattr__(self, "x_range", (left, right))
@property
def analogs(self):
return tuple(s for s in self.series if s.visible and not s.discrete)
@dataclass(frozen=True)
class Request:
series: Series
axis: Axis
source: str
method: str
output_count: int
degree: int
x_range: tuple | None
@dataclass(frozen=True)
class Curve:
request: Request
points: tuple
input_count: int
unique_count: int
rmse: float
@property
def label(self):
return f"{self.request.series.label} · расчёт ({METHODS[self.request.method]})"
def prepare(snapshot, key, method="pchip", output_count=1000, degree=2):
"""Return a comparable request, or None when the source is not processable."""
if snapshot.blocked_reason:
return None
selected = next((s for s in snapshot.analogs if s.key == key), None)
if selected is None:
return None
if snapshot.x_range is not None:
left, right = snapshot.x_range
selected = Series(selected.key, selected.label,
tuple((x, y) for x, y in selected.points if left <= x <= right),
y_unit=selected.y_unit)
return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range)
def process(request):
if request is None:
raise ValueError("Нет доступного аналогового канала")
result = reconstruct(request.series.points, request.method, request.output_count, request.degree)
return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse)
def write_csv(curve, stream):
"""CSV preserves the X domain. Relative time/frequency are numeric, not dates."""
axis = curve.request.axis
writer = csv.writer(stream)
x_title = "timestamp" if axis.encoding == "unix_ms" else axis.label + (f" [{axis.unit}]" if axis.unit else "")
y_title = curve.label + (f" [{curve.request.series.y_unit}]" if curve.request.series.y_unit else "")
writer.writerow([x_title, y_title])
for x, y in curve.points:
value = (datetime.fromtimestamp(x / 1000, timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
if axis.encoding == "unix_ms" else x)
writer.writerow([value, y])

View File

@@ -0,0 +1,183 @@
"""Asynchronous SET v2 USB updater for the STM32F407VE single-slot port."""
from __future__ import annotations
import struct
from .qt_compat import QObject, QTimer, Signal, QSerialPort
from setprotocol.core import (Frame, FrameFlag, FrameParser, build_frame,
decode_response, DeviceInfo, FirmwareBegin, FirmwareFlag, encode_firmware_data)
APP_BASE = 0x08010000
APP_SIZE = 0x70000
BOOT_MODEL = "STM32F407-EMU-USB-BOOT"
def validate_image(image):
if image.base_address != APP_BASE or not 8 <= len(image.data) <= APP_SIZE:
raise ValueError("Нужен образ приложения для 0x08010000, размером до 448 КБ")
sp, pc = struct.unpack_from("<II", image.data)
if sp & 7 or not (0x20000000 < sp <= 0x20020000 or 0x10000000 < sp <= 0x10010000):
raise ValueError("Неверный начальный указатель стека")
if not pc & 1 or not APP_BASE <= (pc & ~1) < APP_BASE + len(image.data):
raise ValueError("Образ собран не для USB-загрузчика (нужен адрес 0x08010000)")
class F407UsbBootloader(QObject):
progress = Signal(int, str)
finished = Signal(bool, str)
def __init__(self, parent=None):
super().__init__(parent)
self._serial = QSerialPort(self)
self._serial.readyRead.connect(self._read)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._timeout)
self._reconnect = QTimer(self)
self._reconnect.setSingleShot(True)
self._reconnect.timeout.connect(self._open_boot)
self._active = False
self._sequence = 0
self._pending = None
self._parser = FrameParser()
self._attempts = 0
def start(self, image, port_name, baud_rate=115200):
if self._active:
return
try:
validate_image(image)
if not port_name:
raise ValueError("Выберите COM-порт USB платы")
except ValueError as error:
self.finished.emit(False, str(error))
return
self._active = True
self._image = image
self._port_name = port_name
self._offset = 0
self._attempts = 0
self._parser = FrameParser()
if not self._open():
self._finish(False, "Не удалось открыть USB COM-порт: " + self._serial.errorString())
return
self.progress.emit(0, "Определение USB-загрузчика")
self._request(2, b"", "identify", 2500)
def _open(self):
self._serial.close()
self._serial.setPortName(self._port_name)
self._serial.setBaudRate(115200)
self._serial.setDataBits(QSerialPort.DataBits.Data8)
self._serial.setParity(QSerialPort.Parity.NoParity)
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
self._parser = FrameParser()
return self._serial.open(QSerialPort.OpenModeFlag.ReadWrite)
def _request(self, kind, payload, phase, timeout=20000):
self._sequence = (self._sequence + 1) & 0xffff
self._pending = (kind, self._sequence, phase)
packet = build_frame(Frame(kind, self._sequence, payload, FrameFlag.ACK_REQUIRED, 42, 0))
if self._serial.write(packet) != len(packet):
self._finish(False, "Ошибка передачи USB")
return
self._timer.start(timeout)
def _read(self):
for frame in self._parser.feed(bytes(self._serial.readAll())):
if not self._active or self._pending is None:
continue
kind, sequence, phase = self._pending
if (frame.message_type != kind or frame.sequence != sequence or
frame.source != 16 or frame.destination != 42 or
not frame.flags & FrameFlag.RESPONSE):
continue
self._timer.stop()
self._pending = None
try:
status, body = decode_response(frame)
except ValueError as error:
self._finish(False, "Неверный ответ загрузчика: " + str(error))
return
if status:
self._finish(False, f"Загрузчик отклонил {phase}: код {status}. Повторите загрузку целиком.")
return
try:
self._advance(phase, body)
except (ValueError, struct.error) as error:
self._finish(False, "Неверный ответ загрузчика: " + str(error))
return
def _advance(self, phase, body):
if phase in ("identify", "reconnect"):
model = DeviceInfo.decode(body).model
if model == BOOT_MODEL:
self._attempts = 0
image = self._image
begin = FirmwareBegin(len(image.data), image.crc32, image.version,
APP_BASE, 0, 256, bytes(32), FirmwareFlag.ERASE_SLOT)
self.progress.emit(1, "Стирание области приложения")
self._request(0x100, begin.encode(), "begin")
elif model == "STM32F407-EMU-EXPERIMENT" and phase == "identify":
self._request(0x1003, b"", "enter")
elif phase == "reconnect" and model == "STM32F407-EMU-EXPERIMENT":
self._retry_open()
else:
self._finish(False, "Выбрано другое устройство: " + model)
elif phase == "enter":
self.progress.emit(0, "Ожидание переподключения USB")
self._serial.close()
self._attempts = 40
self._reconnect.start(1000)
elif phase in ("begin", "data"):
if phase == "data":
self._offset += self._sent
if self._offset == len(self._image.data):
self.progress.emit(98, "Проверка CRC содержимого Flash")
self._request(0x102, struct.pack("<II32s", len(self._image.data),
self._image.crc32, bytes(32)), "end")
else:
block = self._image.data[self._offset:self._offset+256]
self._sent = len(block)
self.progress.emit(2 + 95*self._offset//len(self._image.data),
f"Запись {self._offset}/{len(self._image.data)} байт")
self._request(0x101, encode_firmware_data(self._offset, block), "data")
elif phase == "end":
self._request(0x105, b"", "activate")
elif phase == "activate":
self.progress.emit(100, "CRC проверена, команда запуска подтверждена")
self._finish(True, "Прошивка записана. Плата перезапускается; подключитесь к ней снова.")
def _retry_open(self):
self._serial.close()
if self._attempts <= 0:
self._finish(False, "USB не появился. Выберите COM-порт загрузчика и повторите загрузку.")
else:
self._reconnect.start(500)
def _open_boot(self):
if not self._active:
return
self._attempts -= 1
if self._open():
self._request(2, b"", "reconnect", 500)
else:
self._retry_open()
def _timeout(self):
if self._pending and self._pending[2] == "reconnect":
self._pending = None
self._retry_open()
else:
self._finish(False, "Нет ответа USB. Проверьте порт и повторите полную загрузку.")
def cancel(self):
if self._active:
self._finish(False, "Загрузка отменена. Незавершённый образ не запустится; повторите загрузку.")
def _finish(self, ok, text):
self._active = False
self._pending = None
self._timer.stop()
self._reconnect.stop()
self._serial.close()
self.finished.emit(ok, text)

View File

@@ -0,0 +1,293 @@
"""Reusable Qt processing panel and plot attachment; no SETGUI dependency."""
from __future__ import annotations
from dataclasses import replace
try:
from PySide6.QtCore import QObject, QPointF, QRunnable, QThreadPool, QSignalBlocker, QTimer, Qt, Signal, Slot
from PySide6.QtGui import QColor, QPainterPath, QPen
from PySide6.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSpinBox,
QVBoxLayout, QWidget,
)
except ImportError:
from PySide2.QtCore import QObject, QPointF, QRunnable, QThreadPool, QSignalBlocker, QTimer, Qt, Signal, Slot
from PySide2.QtGui import QColor, QPainterPath, QPen
from PySide2.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSpinBox,
QVBoxLayout, QWidget,
)
from set_devices.signal_reconstruction import METHODS
from set_devices.plot_processing import Snapshot, prepare, process, write_csv
class _ResultSignals(QObject):
done = Signal(object, object, str)
class _Calculation(QRunnable):
def __init__(self, signature):
super().__init__()
self.signature = signature
self.signals = _ResultSignals()
def run(self):
try:
result = process(self.signature)
except Exception as error:
self.signals.done.emit(self.signature, None, str(error))
else:
self.signals.done.emit(self.signature, result, "")
class SignalProcessingPanel(QWidget):
changed = Signal()
RESULT_KEY = "__calculated_trend__"
def __init__(self, parent=None):
super().__init__(parent)
self._snapshot = Snapshot()
self._result = None
self._result_signature = None
self._job = None
self._revision = 0
self._job_revision = 0
self.preserve_on_view_change = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
row = QHBoxLayout()
row.addWidget(QLabel("Обработка"))
self.channel = QComboBox()
self.channel.setMinimumWidth(140)
row.addWidget(self.channel)
self.method = QComboBox()
for key, title in METHODS.items():
self.method.addItem(title, key)
self.method.setCurrentIndex(2)
row.addWidget(self.method)
row.addStretch(1)
layout.addLayout(row)
row = QHBoxLayout()
row.addWidget(QLabel("Степень"))
self.degree = QSpinBox()
self.degree.setRange(1, 5)
self.degree.setValue(2)
row.addWidget(self.degree)
row.addWidget(QLabel("Точек результата"))
self.count = QSpinBox()
self.count.setRange(2, 10000)
self.count.setValue(1000)
row.addWidget(self.count)
self.apply_button = QPushButton("Рассчитать")
self.apply_button.clicked.connect(self.calculate)
row.addWidget(self.apply_button)
self.clear_button = QPushButton("Убрать")
self.clear_button.clicked.connect(self.clear)
row.addWidget(self.clear_button)
self.export_button = QPushButton("Результат CSV…")
self.export_button.clicked.connect(self.export_csv)
row.addWidget(self.export_button)
row.addStretch(1)
layout.addLayout(row)
self.status = QLabel("Выберите аналоговый канал и метод. Расчёт по переданным точкам, без экстраполяции.")
self.status.setWordWrap(True)
layout.addWidget(self.status)
self.method.setToolTip(
"Полином МНК — сглаживание шумных данных. Линейная — отрезки между точками.\n"
"PCHIP — сохранение формы без выбросов. Сплайн — гладкая кривая, возможны выбросы.\n"
"Восстановление — оценка между измерениями; утраченные детали не определяются однозначно.")
for widget in (self.channel, self.method):
widget.currentIndexChanged.connect(self.clear)
for widget in (self.degree, self.count):
widget.valueChanged.connect(self.clear)
self._update_actions()
def _signature(self):
return prepare(self._snapshot, self.channel.currentData(), self.method.currentData(),
self.count.value(), self.degree.value())
def _current_request_for(self, signature):
if not self.preserve_on_view_change or signature is None:
return self._signature()
# Validate the original calculation domain, independently of navigation.
return prepare(replace(self._snapshot, x_range=signature.x_range),
self.channel.currentData(), self.method.currentData(),
self.count.value(), self.degree.value())
def set_snapshot(self, snapshot):
previous = ((self._result_signature or (self._job.signature if self._job is not None else None))
if self.preserve_on_view_change else None) or self._signature()
was_blocked = bool(self._snapshot.blocked_reason)
choices = [(signal.key, signal.label) for signal in snapshot.analogs]
current = [(self.channel.itemData(i), self.channel.itemText(i)) for i in range(self.channel.count())]
if choices != current:
selected = self.channel.currentData()
blocker = QSignalBlocker(self.channel)
self.channel.clear()
for key, title in choices:
self.channel.addItem(title, key)
index = self.channel.findData(selected)
if index >= 0:
self.channel.setCurrentIndex(index)
del blocker
self._snapshot = snapshot
if previous != self._current_request_for(previous):
self._revision += 1
if self._result is not None:
self._result = self._result_signature = None
self.status.setText("Данные или окно изменились — нажмите «Рассчитать» повторно.")
if snapshot.blocked_reason:
self.status.setText(snapshot.blocked_reason)
elif not choices:
self.status.setText("Нет видимых аналоговых каналов для обработки.")
elif was_blocked:
self.status.setText("Выберите канал и метод, затем нажмите «Рассчитать».")
self._update_actions()
def _update_actions(self):
self.degree.setEnabled(self.method.currentData() == "polynomial")
request = self._signature()
self.apply_button.setEnabled(self._job is None and request is not None and
len(request.series.points) >= 2)
self.export_button.setEnabled(self._result is not None and not self._snapshot.blocked_reason)
self.clear_button.setEnabled(self._result is not None or self._job is not None)
self.apply_button.setToolTip("Обработка выбранной кривой в её единицах X/Y. Для живого потока остановите сбор.")
def clear(self, *_):
self._revision += 1
self._result = self._result_signature = None
self.status.setText("Расчёт по текущему окну. Исходные измерения сохраняются; кривая — оценка между точками.")
self._update_actions()
self.changed.emit()
def calculate(self):
if self._job is not None or self._snapshot.blocked_reason:
return
signature = self._signature()
if signature is None or len(signature.series.points) < 2:
self.status.setText("Нужны минимум две точки аналогового канала.")
return
self._job = _Calculation(signature)
self._job_revision = self._revision
self._job.signals.done.connect(self._finished)
self.status.setText("Расчёт…")
self._update_actions()
QThreadPool.globalInstance().start(self._job)
@Slot(object, object, str)
def _finished(self, signature, result, error):
self._job = None
if signature != self._current_request_for(signature) or self._job_revision != self._revision:
self.status.setText("Данные изменились во время расчёта. Остановите сбор и повторите расчёт.")
elif error:
self.status.setText("Расчёт не выполнен: " + error)
else:
self._result, self._result_signature = result, signature
duplicates = result.input_count - result.unique_count
self.status.setText(
f"{self.channel.currentText()}: {result.unique_count} исходных → {len(result.points)} расчётных точек; "
f"СКО на измерениях: {result.rmse:.6g}. "
+ (f"Повторы X усреднены: {duplicates}. " if duplicates else "")
+ "Розовая линия — расчётная оценка; исходные данные сохранены.")
self._update_actions()
self.changed.emit()
def overlay(self):
if self._result is None or self._snapshot.blocked_reason:
return [], {}
return [(self.RESULT_KEY, self._result.label, False, "#FF70D0", True)], {self.RESULT_KEY: self._result.points}
@property
def curve(self):
return self._result
def export_csv(self):
if self._result is None:
return
path, _ = QFileDialog.getSaveFileName(self, "Сохранить расчётную кривую", "calculated-signal.csv", "CSV (*.csv)")
if not path:
return
try:
with open(path, "w", newline="", encoding="utf-8-sig") as stream:
write_csv(self._result, stream)
except (OSError, ValueError, OverflowError) as error:
self.status.setText("Не удалось сохранить CSV: " + str(error))
class PlotProcessingAttachment(QObject):
"""Attach to any Qt plot via snapshot/project/repaint callbacks.
Call source_changed when raw data, units or visible channel selection change.
Paint after raw analog curves with the same rect/projection. No channel or
history mutation, no dependency on the caller's renderer or acquisition API.
"""
def __init__(self, parent, snapshot, repaint):
super().__init__(parent)
self.snapshot = snapshot
self.repaint = repaint
self.dialog = None
self.panel = None
self._external_curve = None
self._external_offset = 0.0
self.button = QPushButton("Обработка…", parent)
self.button.setToolTip("Аппроксимация, интерполяция и восстановление аналоговой кривой")
self.button.clicked.connect(self.open)
self._refresh_timer = QTimer(self)
self._refresh_timer.setSingleShot(True)
self._refresh_timer.timeout.connect(self.refresh)
self._dirty = False
@property
def curve(self):
# Hide stale geometry immediately, before coalesced refresh runs.
if self._external_curve is not None:
return self._external_curve
return self.panel.curve if self.panel is not None and not self._dirty else None
def set_external_curve(self, curve, x_offset=0.0):
"""Embedded panels own snapshot invalidation; display rebasing is explicit."""
self._external_curve = curve
self._external_offset = x_offset
self.repaint()
def source_changed(self):
if self.panel is not None:
self._dirty = True
self._refresh_timer.start(0)
def refresh(self):
if self.panel is not None:
self.panel.set_snapshot(self.snapshot())
self._dirty = False
self.repaint()
def open(self):
if self.dialog is None:
self.dialog = QDialog(self.parent())
self.dialog.setWindowTitle("Обработка графика")
layout = QVBoxLayout(self.dialog)
self.panel = SignalProcessingPanel(self.dialog)
self.panel.changed.connect(self.repaint)
layout.addWidget(self.panel)
self.dialog.resize(760, 240)
self.refresh()
self.dialog.show()
self.dialog.raise_()
self.dialog.activateWindow()
def paint(self, painter, rect, project):
curve = self.curve
if curve is None:
return
painter.save()
painter.setClipRect(rect)
path = QPainterPath()
for index, (x, y) in enumerate(curve.points):
point = project(x - (self._external_offset if self._external_curve is not None else 0), y, rect)
path.moveTo(point) if index == 0 else path.lineTo(point)
painter.setPen(QPen(QColor("#FF70D0"), 2, Qt.DashLine))
painter.setBrush(Qt.NoBrush)
painter.drawPath(path)
painter.setPen(QColor("#FF70D0"))
painter.drawText(QPointF(rect.left() + 6, rect.bottom() - 8), curve.label)
painter.restore()

View File

@@ -17,10 +17,10 @@ def decode_config(raw):
if len(raw) != 25:
raise ValueError("Нужна прошивка STM с выбором режима эмуляции")
words = struct.unpack(">10H", raw[3:-2])
if words[:2] != (0x5343, 2):
if words[0] != 0x5343 or words[1] not in (2, 3):
raise ValueError("Прошивка STM не поддерживает настройку связи (нужна новая версия)")
address, tms, rate = words[2:5]
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] > 1 or words[9] > 1:
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] > (3 if words[1] == 3 else 1) or words[9] > (3 if words[1] == 3 else 1):
raise ValueError("STM вернула неверные параметры связи")
return words
@@ -28,9 +28,9 @@ def decode_config(raw):
class StmSettingsClient(QObject):
finished = Signal(bool, str, object)
def __init__(self, parent=None):
def __init__(self, parent=None, serial_factory=None):
super().__init__(parent)
self.serial = QSerialPort(self)
self.serial = (serial_factory or QSerialPort)(self)
self.serial.readyRead.connect(self._receive)
self.serial.errorOccurred.connect(self._error)
self.timeout = QTimer(self)
@@ -48,7 +48,7 @@ class StmSettingsClient(QObject):
return
if desired is not None:
addr, tms, rate, mode = desired
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in (0, 1):
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in (0, 1, 2, 3):
self.finished.emit(False, "Адреса УМП и 2812 должны различаться; проверьте диапазоны и скорость", None)
return
self.unit, self.baud, self.timeout_ms = unit, baud, timeout
@@ -69,7 +69,8 @@ class StmSettingsClient(QObject):
def _send(self, function, register, value):
self.rx.clear()
self.packet = request(self.unit, function, register, value)
self.timeout.start(self.timeout_ms)
budget = getattr(self.serial, 'response_timeout', lambda ms: ms)(self.timeout_ms)
self.timeout.start(budget)
if self.stage == "commit":
# If ACK is lost, the board may nevertheless switch settings.
self.committed = True
@@ -114,6 +115,8 @@ class StmSettingsClient(QObject):
self._finish(True, "Настройки STM прочитаны", (words[2], words[3], BAUDRATES[words[4]], words[8]))
return
addr, tms, baud, mode = self.desired
if mode >= 2 and words[1] < 3:
raise ValueError("Обновите прошивку STM: для отдельных режимов IGBT и ЦАП нужна версия настроек 3")
self.staged = (addr, tms, BAUDRATES.index(baud), mode)
self.steps = [(6, 0x1218, 0), (6, 0x1215, addr), (6, 0x1216, tms),
(6, 0x1217, self.staged[2]), (6, 0x1219, mode), (3, 0x1210, 10)]
@@ -166,5 +169,5 @@ class StmSettingsClient(QObject):
if self.serial.isOpen():
self.serial.close()
if not ok and self.committed:
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812). Повторите чтение по ним или перезагрузите плату." % self.desired
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812, 2=IGBT, 3=ЦАП). Повторите чтение по ним или перезагрузите плату." % self.desired
self.finished.emit(ok, message, values)

View File

@@ -0,0 +1,106 @@
"""USB CDC/COM worker for WG v1/v2/v3, with SET v2 block uploads. All waits run in a worker, never the GUI thread."""
import time
from PySide6.QtCore import QObject, QRunnable, Signal, QIODevice
from PySide6.QtSerialPort import QSerialPort
from set_devices.wavegen_protocol import WaveProtocol
class _Signals(QObject):
finished = Signal(object, str)
progress = Signal(int)
class WaveTransfer(QRunnable):
def __init__(self, port_name, address, action, waveform=None, port_factory=None):
super().__init__()
self.port_name, self.address, self.action, self.waveform = port_name, address, action, waveform
self.signals = _Signals()
self.port_factory = port_factory or QSerialPort
def run(self):
port = self.port_factory()
try:
protocol = WaveProtocol(self.address)
port.setPortName(self.port_name)
port.setBaudRate(115200)
port.setDataBits(QSerialPort.Data8)
port.setParity(QSerialPort.NoParity)
port.setStopBits(QSerialPort.OneStop)
port.setFlowControl(QSerialPort.NoFlowControl)
if not port.open(QIODevice.ReadWrite):
raise OSError(port.errorString())
port.clear()
def transfer(request, decode):
if port.write(request) != len(request):
raise OSError(port.errorString())
if port.bytesToWrite() and not port.waitForBytesWritten(1000):
raise OSError("Таймаут передачи USB")
reply = bytearray()
budget = getattr(port, 'response_timeout', lambda ms: ms)(1500)
deadline = time.monotonic() + budget / 1000
while time.monotonic() < deadline:
if port.bytesAvailable() or port.waitForReadyRead(50):
reply.extend(bytes(port.readAll()))
response = decode(request, reply)
if response is not None:
return response
raise OSError("Нет подтверждения USB. Состояние выхода неизвестно; проверьте связь и нажмите Стоп.")
def exchange(operation, index=0, value=0):
return transfer(protocol.request(operation, index, value), protocol.response)
def read_status():
words = exchange(0)
result = protocol.status(words)
if result['version'] >= 2:
result = protocol.status(words, exchange(8)[0])
return result
status = read_status() # Positive device identification before any write.
if self.action == "upload":
wave = self.waveform
if wave is None or len(wave.codes) > status["capacity"]:
raise ValueError("Таблица не помещается в память устройства")
if status['version'] == 1 and wave.sample_rate > 50000:
raise ValueError("Для частоты выше 50 000 отсчётов/с обновите прошивку МК: нужна карта генератора версии 2")
exchange(1)
if status['version'] >= 2:
exchange(9, value=wave.sample_rate >> 16)
exchange(2, value=wave.sample_rate & 0xffff)
exchange(3, value=len(wave.codes))
blocks = status['version'] >= 3 and getattr(port, 'supports_wave_blocks', False)
step = 120 if blocks else 1
for index in range(0, len(wave.codes), step):
values = wave.codes[index:index + step]
if blocks:
transfer(protocol.block_request(index, values), protocol.block_response)
else:
exchange(4, index, values[0])
self.signals.progress.emit(int((index + len(values)) * 50 / len(wave.codes)))
for index in range(0, len(wave.codes), step):
values = wave.codes[index:index + step]
actual = (transfer(protocol.block_request(index, count=len(values)), protocol.block_response)
if blocks else exchange(7, index))
if tuple(actual) != tuple(values):
raise ValueError(f"Проверка таблицы не пройдена: отсчёты с {index}")
self.signals.progress.emit(50 + int((index + len(values)) * 50 / len(wave.codes)))
exchange(5)
elif self.action == "start":
if not status["ready"]:
raise ValueError("Сначала загрузите и проверьте таблицу")
exchange(6)
elif self.action == "stop":
exchange(1)
elif self.action != "status":
raise ValueError("Неизвестное действие")
result = read_status()
if self.action == "upload" and (not result["ready"] or result["running"] or
result["count"] != len(self.waveform.codes) or result["rate"] != self.waveform.sample_rate):
raise ValueError("Устройство не подтвердило загруженную таблицу")
if self.action in ("start", "stop") and result["running"] != (self.action == "start"):
raise ValueError("Устройство не подтвердило состояние выхода")
self.signals.progress.emit(100)
self.signals.finished.emit(result, "")
except Exception as error:
self.signals.finished.emit(None, str(error))
finally:
port.close()

View File

@@ -0,0 +1,54 @@
"""Thin host adapter for the shared C99 reconstruction/DAC algorithms."""
from __future__ import annotations
import ctypes as C
from dataclasses import dataclass
from functools import lru_cache
from protocan.native import NativeProtocol
METHODS = {"polynomial": "Аппроксимация: полином МНК", "linear": "Интерполяция: линейная",
"pchip": "Интерполяция: PCHIP", "spline": "Восстановление: кубический сплайн"}
@dataclass
class Reconstruction:
points: list
input_count: int
unique_count: int
rmse: float
@lru_cache(maxsize=1)
def library():
try:
lib = NativeProtocol().lib
pointer = C.POINTER(C.c_double)
lib.set_signal_reconstruct.argtypes = [pointer, pointer, C.c_size_t, C.c_int, C.c_uint,
C.c_size_t, C.c_int, pointer, pointer, pointer, pointer, C.c_size_t]
lib.set_signal_reconstruct.restype = C.c_int
lib.set_signal_dac12.argtypes = [pointer, C.c_size_t, C.c_double, C.POINTER(C.c_uint16)]
lib.set_signal_dac12.restype = C.c_int
return lib
except (AttributeError, OSError, RuntimeError) as error:
raise ValueError("Пересоберите SETProtocol с set_signal.c и set_wavegen.c") from error
def reconstruct(points, method="pchip", output_count=1000, degree=2, *, endpoint=True):
if method not in METHODS or type(output_count) is not int or not 2 <= output_count <= 10000:
raise ValueError("Неизвестный метод или число выходных точек вне 2…10000")
count = len(points)
if not 2 <= count <= 100000 or type(degree) is not int or not 1 <= degree <= 5:
raise ValueError("Нужно 2…100000 исходных точек, степень 1…5")
x, y = (C.c_double * count)(*(p[0] for p in points)), (C.c_double * count)(*(p[1] for p in points))
ox, oy, meta = (C.c_double * output_count)(), (C.c_double * output_count)(), (C.c_double * 3)()
work = (C.c_double * (14 * count + 128))()
code = library().set_signal_reconstruct(x, y, count, list(METHODS).index(method), degree,
output_count, int(endpoint), ox, oy, meta, work, len(work))
if code:
raise ValueError({1: "Недостаточно точек для выбранной степени или неверные параметры",
2: "Нужны конечные значения и минимум две различные временные точки",
3: "Неустойчивая аппроксимация: уменьшите степень"}.get(code, "Ошибка расчёта"))
return Reconstruction(list(zip(ox, oy)), int(meta[0]), int(meta[1]), meta[2])
def dac12(volts, vref=3.3):
data = (C.c_double * len(volts))(*volts)
codes = (C.c_uint16 * len(volts))()
if library().set_signal_dac12(data, len(data), vref, codes):
raise ValueError("Расчётная кривая выходит за диапазон ЦАП 0…Vref; измените точки или метод")
return tuple(codes)

View File

@@ -0,0 +1,61 @@
"""Reusable recipes and tables; interpolation/quantization live in C."""
from __future__ import annotations
import math
import json
from dataclasses import dataclass
from .signal_reconstruction import reconstruct, dac12
@dataclass(frozen=True)
class Waveform:
points: tuple
codes: tuple
sample_rate: int
vref: float
period_ms: float
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
"""One cyclic period, last endpoint excluded (no duplicated seam sample)."""
if type(sample_rate) is not int or not 1 <= sample_rate <= 1000000:
raise ValueError("Частота отсчётов должна быть 1…1000000 Гц")
if not math.isfinite(vref) or not 0 < vref <= 3.6:
raise ValueError("Vref должен быть в диапазоне 0…3,6 В")
if not 2 <= len(points) <= 4096 or points[0][0] != 0:
raise ValueError("Нужно 2…4096 точек; первая временная метка — 0 мс")
if any(not math.isfinite(x) or not math.isfinite(y) or not 0 <= y <= vref for x, y in points):
raise ValueError("В точках нужны конечные числа, напряжение 0…Vref")
if any(b[0] <= a[0] for a, b in zip(points, points[1:])):
raise ValueError("Время точек должно строго возрастать")
period = points[-1][0]
exact_count = period * sample_rate / 1000
count = round(exact_count)
if not 2 <= count <= 4096:
raise ValueError("Период × частота должны давать от 2 до 4096 отсчётов")
if abs(exact_count - count) > 1e-7:
raise ValueError("Период должен содержать целое число отсчётов при выбранной частоте")
result = reconstruct(points, method, count, degree, endpoint=False)
# Check the endpoint too: polynomial fitting can move its value.
checked = reconstruct(points, method, count + 1, degree)
dac12([p[1] for p in checked.points], vref)
codes = dac12([p[1] for p in result.points], vref)
return Waveform(tuple(result.points), codes, sample_rate, vref, period)
def recipe(points, sample_rate, vref, method, degree):
return {"version": 1, "points_ms_volts": [list(pair) for pair in points], "sample_rate": sample_rate,
"vref": vref, "method": method, "degree": degree}
def load_recipe(text):
data = json.loads(text)
if not isinstance(data, dict) or data.get("version") != 1:
raise ValueError("Неизвестная версия задания генератора")
points = tuple((float(x), float(y)) for x, y in data["points_ms_volts"])
generate(points, data["sample_rate"], data["vref"], data["method"], data["degree"])
return points, data
def c_header(wave):
values = [", ".join(str(v) for v in wave.codes[i:i+16]) for i in range(0, len(wave.codes), 16)]
return ("/* Generated cyclic 12-bit DAC table. DMA-accessible SRAM, not CCM. */\n"
"#pragma once\n#include <stdint.h>\n"
f"#define WAVE_SAMPLE_RATE_HZ {wave.sample_rate}u\n"
f"#define WAVE_SAMPLE_COUNT {len(wave.codes)}u\n"
f"/* Vref = {wave.vref:g} V; period = {wave.period_ms:g} ms. */\n"
"static uint16_t wave_samples[WAVE_SAMPLE_COUNT] = {\n " + ",\n ".join(values) + "\n};\n")

View File

@@ -0,0 +1,67 @@
"""ctypes port of the shared wave generator RTU codec."""
import ctypes as C
from .signal_reconstruction import library
class WaveProtocol:
def __init__(self, address=16):
if not 1 <= address <= 247:
raise ValueError("Адрес должен быть 1…247")
self.address = address
self.lib = library()
p = C.POINTER(C.c_uint8)
self.lib.set_wave_request.argtypes = [C.c_uint, C.c_uint, C.c_uint, C.c_uint, p, C.c_size_t]
self.lib.set_wave_request.restype = C.c_size_t
self.lib.set_wave_response.argtypes = [p, p, C.c_size_t, C.POINTER(C.c_uint16), C.c_size_t]
self.lib.set_wave_response.restype = C.c_int
self.lib.set_wave_block_request.argtypes = [C.c_uint, C.c_uint, C.POINTER(C.c_uint16), C.c_size_t, p, C.c_size_t]
self.lib.set_wave_block_request.restype = C.c_size_t
self.lib.set_wave_block_response.argtypes = [p, C.c_size_t, p, C.c_size_t, C.POINTER(C.c_uint16), C.c_size_t]
self.lib.set_wave_block_response.restype = C.c_int
def block_request(self, index, values=None, count=None):
count = len(values) if values is not None else count
if count is None or not 1 <= count <= 120:
raise ValueError("Блок должен содержать 1…120 отсчётов")
if values is not None and any(not 0 <= value <= 4095 for value in values):
raise ValueError("Коды ЦАП должны быть 0…4095")
samples = (C.c_uint16 * count)(*values) if values is not None else None
output = (C.c_uint8 * 249)()
size = self.lib.set_wave_block_request(self.address, index, samples, count, output, len(output))
if not size:
raise ValueError("Недопустимый блок генератора")
return bytes(output[:size])
def block_response(self, request, reply):
words = (C.c_uint16 * 120)()
code = self.lib.set_wave_block_response((C.c_uint8 * len(request)).from_buffer_copy(request), len(request),
(C.c_uint8 * len(reply)).from_buffer_copy(reply), len(reply), words, len(words))
if code == 0:
return None
if code < 0:
raise ValueError(f"Неверный ответ блока генератора ({code})")
return tuple(words[:code])
def request(self, operation, index=0, value=0):
output = (C.c_uint8 * 8)()
if self.lib.set_wave_request(self.address, operation, index, value, output, 8) != 8:
raise ValueError("Недопустимая команда генератора")
return bytes(output)
def response(self, request, reply):
if len(request) != 8:
raise ValueError("Неверный запрос")
words = (C.c_uint16 * 8)()
code = self.lib.set_wave_response((C.c_uint8 * 8).from_buffer_copy(request),
(C.c_uint8 * len(reply)).from_buffer_copy(reply), len(reply), words, len(words))
if code == 0:
return None
if code < 0:
raise ValueError(f"Неверный ответ генератора или исключение устройства ({code})")
return tuple(words[:code])
@staticmethod
def status(words, rate_high=0):
if len(words) != 8 or words[0] != 0x5747 or words[1] not in (1, 2, 3):
raise ValueError("Устройство не поддерживает генератор WG v1/v2/v3")
return dict(version=words[1], running=bool(words[2]), rate=(rate_high << 16) | words[3], count=words[4],
received=words[5], ready=bool(words[6]), capacity=words[7])

View File

@@ -0,0 +1,87 @@
"""Portable processing contract: no Qt or application imports."""
import csv
import io
import math
import unittest
from dataclasses import replace
from set_devices.plot_processing import Axis, Series, Snapshot, prepare, process, write_csv
from set_devices.signal_reconstruction import METHODS
class PlotProcessingTests(unittest.TestCase):
def test_snapshot_copies_mutable_samples_and_excludes_hidden_and_digital(self):
points = [[0, 0], [1, 1]]
channel = Series("a", "Analog", points)
snapshot = Snapshot([channel, Series("hidden", "Hidden", points, visible=False),
Series("bit", "Bit", points, discrete=True)])
points[0][1] = 9
points.append([2, 3])
self.assertEqual(((0., 0.), (1., 1.)), prepare(snapshot, "a").series.points)
self.assertEqual((channel,), snapshot.analogs)
self.assertIsNone(prepare(snapshot, "hidden"))
self.assertIsNone(prepare(snapshot, "bit"))
self.assertIsNone(prepare(replace(snapshot, blocked_reason="Pause capture"), "a"))
with self.assertRaises(ValueError):
Snapshot([channel, channel])
def test_all_methods_use_same_contract_and_keep_source(self):
source = Series("v", "Voltage", [(100, 5), (300, 9)], y_unit="V")
snapshot = Snapshot([source], Axis("Time", "ms"), "scope")
for method in METHODS:
with self.subTest(method=method):
request = prepare(snapshot, "v", method, 5, 1)
curve = process(request)
self.assertEqual([100, 150, 200, 250, 300], [x for x, y in curve.points])
for (_, value), expected in zip(curve.points, [5, 6, 7, 8, 9]):
self.assertAlmostEqual(expected, value)
self.assertEqual((2, 2), (curve.input_count, curve.unique_count))
self.assertEqual(request, curve.request)
self.assertEqual(((100., 5.), (300., 9.)), source.points)
def test_window_is_inclusive_and_never_extrapolates(self):
snapshot = Snapshot([Series("a", "A", [(0, 0), (1, 2), (2, 4), (3, 6)])],
x_range=(.5, 2))
curve = process(prepare(snapshot, "a", "linear", 3))
self.assertEqual(((1., 2.), (1.5, 3.), (2., 4.)), curve.points)
self.assertEqual(2, curve.input_count)
with self.assertRaises(ValueError):
process(prepare(replace(snapshot, x_range=(.5, .9)), "a"))
def test_sparse_spline_and_noisy_polynomial_work_in_shared_pipeline(self):
sparse = Snapshot([Series("a", "Sine", [(i * math.pi / 4, math.sin(i * math.pi / 4))
for i in range(9)])])
curve = process(prepare(sparse, "a", "spline", 201))
self.assertLess(max(abs(y - math.sin(x)) for x, y in curve.points), .002)
noisy = Snapshot([Series("a", "Ramp", [(i, 2 * i + (1 if i % 2 else -1)) for i in range(11)])])
curve = process(prepare(noisy, "a", "polynomial", 21, 1))
self.assertLess(max(abs(y - 2 * x) for x, y in curve.points), .1)
self.assertGreater(curve.rmse, .9)
def test_csv_respects_explicit_axis_domain_and_units(self):
for axis, start, expected in ((Axis("Time", "ms"), 1000, "1000.0"),
(Axis("Frequency", "Hz"), 1.8e12, "1800000000000.0"),
(Axis("Time", "ms", "unix_ms"), 1000, "1970-01-01T00:00:01.000000Z")):
with self.subTest(axis=axis):
snapshot = Snapshot([Series("v", "Voltage", [(start, 0), (start + 1000, 1)], y_unit="V")], axis)
output = io.StringIO()
write_csv(process(prepare(snapshot, "v", "linear", 3)), output)
rows = list(csv.reader(io.StringIO(output.getvalue())))
self.assertEqual(expected, rows[1][0])
self.assertTrue(rows[0][1].endswith("[V]"))
self.assertEqual("timestamp" if axis.encoding == "unix_ms" else f"{axis.label} [{axis.unit}]", rows[0][0])
def test_request_tracks_selected_channel_units_source_and_window(self):
channel = Series("a", "A", [(0, 0), (1, 1)])
other = Series("b", "B", [(0, 3), (1, 4)])
snapshot = Snapshot([channel, other], source="file1")
request = prepare(snapshot, "a")
self.assertEqual(request, prepare(replace(snapshot, series=[channel, replace(other, points=[(0, 9)])]), "a"))
for changed in (replace(snapshot, source="file2"), replace(snapshot, axis=Axis("Frequency", "Hz")),
replace(snapshot, x_range=(0, 1)),
replace(snapshot, series=[replace(channel, y_unit="V")])):
self.assertNotEqual(request, prepare(changed, "a"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,93 @@
"""Reusable Qt adapter tests, runnable with PySide2 or PySide6, without a GUI app repo."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import time
import unittest
from dataclasses import replace
from unittest.mock import patch
from set_devices.plot_processing import Axis, Series, Snapshot, process
from set_devices.qt_ports.plot_processing import SignalProcessingPanel, PlotProcessingAttachment, QWidget
try:
from PySide6.QtWidgets import QApplication
except ImportError:
from PySide2.QtWidgets import QApplication
class ProcessingQtTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def setUp(self):
self.panel = SignalProcessingPanel()
self.snapshot = Snapshot([Series("a", "A", [(0, 0), (1, 1), (2, 0)])])
self.panel.set_snapshot(self.snapshot)
def tearDown(self):
self.panel.close()
def calculate(self):
self.panel.calculate()
deadline = time.monotonic() + 5
while self.panel._job is not None and time.monotonic() < deadline:
self.app.processEvents()
time.sleep(.002)
self.assertIsNone(self.panel._job)
self.assertIsNotNone(self.panel.curve, self.panel.status.text())
def test_all_methods_are_available_and_parameter_changes_clear_result(self):
for method in ("polynomial", "linear", "pchip", "spline"):
self.panel.method.setCurrentIndex(self.panel.method.findData(method))
self.calculate()
self.assertEqual(method, self.panel.curve.request.method)
self.panel.count.setValue(self.panel.count.value() + 1)
self.assertIsNone(self.panel.curve)
self.assertFalse(self.panel.export_button.isEnabled())
def test_source_units_and_blocking_invalidate_result(self):
for changed in (replace(self.snapshot, axis=Axis("Frequency", "Hz")),
replace(self.snapshot, source="new file"),
replace(self.snapshot, x_range=(0, 1)),
replace(self.snapshot, blocked_reason="FFT")):
self.panel.set_snapshot(self.snapshot)
self.calculate()
self.panel.set_snapshot(changed)
self.assertIsNone(self.panel.curve)
self.assertFalse(self.panel.export_button.isEnabled())
self.assertFalse(self.panel.apply_button.isEnabled())
def test_late_worker_result_is_rejected_even_after_source_returns(self):
# Capture, but do not schedule, the real worker. Deliver its answer after
# the source changes away and back to the same numerical values.
with patch("set_devices.qt_ports.plot_processing.QThreadPool"):
self.panel.calculate()
job = self.panel._job
self.panel.set_snapshot(replace(self.snapshot, source="another"))
self.panel.set_snapshot(self.snapshot)
self.panel._finished(job.signature, process(job.signature), "")
self.assertIsNone(self.panel.curve)
self.assertFalse(self.panel.export_button.isEnabled())
def test_attachment_is_lazy_and_coalesces_source_notifications(self):
widget = QWidget()
calls = []
attachment = PlotProcessingAttachment(widget, lambda: calls.append(1) or self.snapshot, widget.update)
try:
for _ in range(10):
attachment.source_changed()
self.app.processEvents()
self.assertEqual([], calls)
self.assertIsNone(attachment.panel)
attachment.open()
self.assertEqual([1], calls)
for _ in range(10):
attachment.source_changed()
self.app.processEvents()
self.assertEqual([1, 1], calls)
finally:
widget.close()
if __name__ == "__main__":
unittest.main()