diff --git a/.gitignore b/.gitignore index 4e99bfd..e27995e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ __pycache__/ Thumbs.db Desktop.ini .codex-build/ + +# Generated native library (built before packaging) +python/protocan/native/ diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt index 5165838..00cc283 100644 --- a/c/set-protocol/CMakeLists.txt +++ b/c/set-protocol/CMakeLists.txt @@ -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) diff --git a/c/set-protocol/README.md b/c/set-protocol/README.md index 8842a43..44c0cb3 100644 --- a/c/set-protocol/README.md +++ b/c/set-protocol/README.md @@ -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). diff --git a/c/set-protocol/docs/PLOT_PROCESSING.md b/c/set-protocol/docs/PLOT_PROCESSING.md new file mode 100644 index 0000000..88c8476 --- /dev/null +++ b/c/set-protocol/docs/PLOT_PROCESSING.md @@ -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 не требуют. diff --git a/c/set-protocol/docs/SIGNAL_GENERATOR.md b/c/set-protocol/docs/SIGNAL_GENERATOR.md new file mode 100644 index 0000000..3be4099 --- /dev/null +++ b/c/set-protocol/docs/SIGNAL_GENERATOR.md @@ -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 эмулятора. + +Аппаратная проверка амплитуды, периода и формы осциллографом не выполнена. diff --git a/c/set-protocol/include/firmware_image.h b/c/set-protocol/include/firmware_image.h new file mode 100644 index 0000000..418b6d7 --- /dev/null +++ b/c/set-protocol/include/firmware_image.h @@ -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 diff --git a/c/set-protocol/include/set_signal.h b/c/set-protocol/include/set_signal.h new file mode 100644 index 0000000..0816e77 --- /dev/null +++ b/c/set-protocol/include/set_signal.h @@ -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 diff --git a/c/set-protocol/include/set_wavegen.h b/c/set-protocol/include/set_wavegen.h new file mode 100644 index 0000000..06bbce7 --- /dev/null +++ b/c/set-protocol/include/set_wavegen.h @@ -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 diff --git a/c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.c b/c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.c new file mode 100644 index 0000000..0e354af --- /dev/null +++ b/c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.c @@ -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||ticksCR&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); +} diff --git a/c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.h b/c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.h new file mode 100644 index 0000000..b535770 --- /dev/null +++ b/c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.h @@ -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 diff --git a/c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.c b/c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.c new file mode 100644 index 0000000..aa79594 --- /dev/null +++ b/c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.c @@ -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||tickstimer,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; +} diff --git a/c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.h b/c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.h new file mode 100644 index 0000000..908500e --- /dev/null +++ b/c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.h @@ -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 diff --git a/c/set-protocol/src/firmware_image.c b/c/set-protocol/src/firmware_image.c new file mode 100644 index 0000000..6070efd --- /dev/null +++ b/c/set-protocol/src/firmware_image.c @@ -0,0 +1,88 @@ +#include "firmware_image.h" +#include + +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; +} diff --git a/c/set-protocol/src/set_signal.c b/c/set-protocol/src/set_signal.c new file mode 100644 index 0000000..9f17bd6 --- /dev/null +++ b/c/set-protocol/src/set_signal.c @@ -0,0 +1,115 @@ +#include "set_signal.h" +#include +#include +#include +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)-(x0)!=(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(t100000 + ||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;iscale) 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;i5||n<=degree) return 1; + for(j=0;j<=degree;++j) { + double norm=0; + for(i=0;i0;--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;i0)==(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;i0;--i) d[i]=column[i]-q[i]*d[i+1]; + } + for(i=0;i10000||!isfinite(vref)||vref<=0) return 1; + for(i=0;ivref) return 2; + for(i=0;i +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&®=s->count||index>s->received||value>4095||s->ready) return 3; + if(indexreceived) 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;isamples||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;isamples[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&®=SET_WAVE_DATA&®rate>>16);length=5; + } else if(reg>=SET_WAVE_DATA&&value==1&®-SET_WAVE_DATAreceived) { + 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(size3)))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||cap4095)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;isamples||!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+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;i4095||(index+ireceived&&s->samples[index+i]!=value)) { + error=3;break; + } + } + if(!error) { + if(cap<8)return 0; + for(i=0;isamples[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;isamples[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+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 +#include +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; +} diff --git a/c/set-protocol/tests/test_signal_wave.c b/c/set-protocol/tests/test_signal_wave.c new file mode 100644 index 0000000..0b99c8c --- /dev/null +++ b/c/set-protocol/tests/test_signal_wave.c @@ -0,0 +1,51 @@ +#include "set_signal.h" +#include "set_wavegen.h" +#include +#include +#include +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; +} diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py index 428a488..f2452e5 100644 --- a/c/set-protocol/tools/build_host.py +++ b/c/set-protocol/tools/build_host.py @@ -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", diff --git a/python/README.md b/python/README.md index 9c52d3f..4e2b50d 100644 --- a/python/README.md +++ b/python/README.md @@ -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`. diff --git a/python/examples/plot_processing.py b/python/examples/plot_processing.py new file mode 100644 index 0000000..f76057e --- /dev/null +++ b/python/examples/plot_processing.py @@ -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() diff --git a/python/firmware_image/README.md b/python/firmware_image/README.md new file mode 100644 index 0000000..ae6867e --- /dev/null +++ b/python/firmware_image/README.md @@ -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) +``` diff --git a/python/firmware_image/__init__.py b/python/firmware_image/__init__.py new file mode 100644 index 0000000..9c962cc --- /dev/null +++ b/python/firmware_image/__init__.py @@ -0,0 +1,2 @@ +"""Shared firmware images and flash layout plans.""" +from .core import * diff --git a/python/firmware_image/core.py b/python/firmware_image/core.py new file mode 100644 index 0000000..ffa3c9d --- /dev/null +++ b/python/firmware_image/core.py @@ -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("Неизвестный формат образа") diff --git a/python/firmware_image/native.py b/python/firmware_image/native.py new file mode 100644 index 0000000..c68ddc3 --- /dev/null +++ b/python/firmware_image/native.py @@ -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 diff --git a/python/pyproject.toml b/python/pyproject.toml index 2255f3b..64fb8f6 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -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"] diff --git a/python/set_devices/firmware.py b/python/set_devices/firmware.py index 0384014..760a686 100644 --- a/python/set_devices/firmware.py +++ b/python/set_devices/firmware.py @@ -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 diff --git a/python/set_devices/plot_processing.py b/python/set_devices/plot_processing.py new file mode 100644 index 0000000..1b626cc --- /dev/null +++ b/python/set_devices/plot_processing.py @@ -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]) diff --git a/python/set_devices/qt_ports/f407_usb_bootloader.py b/python/set_devices/qt_ports/f407_usb_bootloader.py new file mode 100644 index 0000000..3243f92 --- /dev/null +++ b/python/set_devices/qt_ports/f407_usb_bootloader.py @@ -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("= 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() diff --git a/python/set_devices/qt_ports/stm_settings.py b/python/set_devices/qt_ports/stm_settings.py index f64b3f9..bbb5bac 100644 --- a/python/set_devices/qt_ports/stm_settings.py +++ b/python/set_devices/qt_ports/stm_settings.py @@ -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) diff --git a/python/set_devices/qt_ports/wavegen.py b/python/set_devices/qt_ports/wavegen.py new file mode 100644 index 0000000..b45b920 --- /dev/null +++ b/python/set_devices/qt_ports/wavegen.py @@ -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() diff --git a/python/set_devices/signal_reconstruction.py b/python/set_devices/signal_reconstruction.py new file mode 100644 index 0000000..776d6ee --- /dev/null +++ b/python/set_devices/signal_reconstruction.py @@ -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) diff --git a/python/set_devices/waveform.py b/python/set_devices/waveform.py new file mode 100644 index 0000000..09abdb0 --- /dev/null +++ b/python/set_devices/waveform.py @@ -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 \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") diff --git a/python/set_devices/wavegen_protocol.py b/python/set_devices/wavegen_protocol.py new file mode 100644 index 0000000..18f57b3 --- /dev/null +++ b/python/set_devices/wavegen_protocol.py @@ -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]) diff --git a/python/tests/test_plot_processing.py b/python/tests/test_plot_processing.py new file mode 100644 index 0000000..5a3f7bf --- /dev/null +++ b/python/tests/test_plot_processing.py @@ -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() diff --git a/python/tests/test_plot_processing_qt.py b/python/tests/test_plot_processing_qt.py new file mode 100644 index 0000000..fac867e --- /dev/null +++ b/python/tests/test_plot_processing_qt.py @@ -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()