Add shared waveform processing, generation and firmware image support
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,3 +24,6 @@ __pycache__/
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.codex-build/
|
||||
|
||||
# Generated native library (built before packaging)
|
||||
python/protocan/native/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
146
c/set-protocol/docs/PLOT_PROCESSING.md
Normal file
146
c/set-protocol/docs/PLOT_PROCESSING.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Общая обработка графиков и сигналов
|
||||
|
||||
Аппроксимация, интерполяция и восстановление кривой по редким отсчётам имеют
|
||||
один расчётный API. Источник может быть временным графиком, журналом, спектром
|
||||
или таблицей ручного генератора. Выбор устройства и отрисовка остаются в порте.
|
||||
|
||||
| Метод | Назначение | Ограничения |
|
||||
|---|---|---|
|
||||
| `polynomial` | Аппроксимация полиномом МНК, степени 1–5; сглаживание шумных отсчётов | Нужно не меньше `degree + 1` различных X; степень задаёт пользователь |
|
||||
| `linear` | Линейная интерполяция | Минимум два различных X; изломы в узлах |
|
||||
| `pchip` | Интерполяция с сохранением формы | Минимум два различных X; подходит для фронтов и монотонных участков |
|
||||
| `spline` | Восстановление естественным кубическим сплайном | Минимум два различных X; возможны выбросы между узлами |
|
||||
|
||||
Все методы строят сетку только между крайними выбранными отсчётами.
|
||||
Восстановление по малому числу точек — оценка, зависящая от метода; утраченные
|
||||
высокочастотные детали не определяются однозначно. СКО считается на исходных
|
||||
измерениях, поэтому нулевая СКО интерполяции не означает нулевую ошибку между ними.
|
||||
Одинаковые X усредняются, нечисловые и бесконечные значения отклоняются.
|
||||
|
||||
## Слои и зависимости
|
||||
|
||||
```text
|
||||
График / журнал → Snapshot → Request → set_signal_reconstruct (C99) → Curve
|
||||
Ручные точки генератора → waveform.generate → то же C99-ядро → таблица ЦАП
|
||||
Qt-панель → worker → Curve → отдельный слой графика / CSV
|
||||
МК / Android / другой GUI → C ABI → свой порт отображения или вывода
|
||||
```
|
||||
|
||||
| Файл | Роль / зависимости |
|
||||
|---|---|
|
||||
| `include/set_signal.h`, `src/set_signal.c` | Все четыре численных метода и квантование ЦАП; C99, без Qt, heap и файловой системы |
|
||||
| `python/set_devices/signal_reconstruction.py` | Тонкий вызов C через ctypes, без второго алгоритма |
|
||||
| `python/set_devices/plot_processing.py` | Неизменяемые модели, выбор канала и диапазона, явные единицы, CSV; без Qt |
|
||||
| `python/set_devices/qt_ports/plot_processing.py` | Общая панель, фоновый расчёт, проверка актуальности, слой отрисовки; PySide6 или PySide2 |
|
||||
| `python/set_devices/waveform.py` | Периодическая сетка генератора и DAC12 через то же ядро |
|
||||
|
||||
Перед вызовом расчёта потребитель задаёт `SETPROTOCOL_LIBRARY` либо устанавливает
|
||||
собранную библиотеку в штатный каталог `protocan/native`. Никакие соседние
|
||||
репозитории или каталоги приложений автоматически не подключаются.
|
||||
|
||||
## Контракт графика
|
||||
|
||||
- `Series(key, label, points, visible, discrete, y_unit)` копирует пары X/Y.
|
||||
Ключи уникальны внутри снимка. Расчёт доступен только видимым аналоговым каналам.
|
||||
- `Axis(label, unit, encoding)` явно задаёт область X. `numeric` сохраняет числа,
|
||||
`unix_ms` сохраняет UTC ISO timestamp. Большое число само по себе не является датой.
|
||||
- `Snapshot(series, axis, source, x_range, blocked_reason)` описывает снимок источника.
|
||||
`x_range=(left, right)` включает точки на обеих границах; `None` означает весь снимок.
|
||||
`source` должен различать файлы/источники, если переключение между ними требует
|
||||
сброса результата даже при совпадающих числах. `blocked_reason` запрещает расчёт.
|
||||
- `prepare(snapshot, key, method, output_count, degree)` создаёт сравнимый запрос.
|
||||
`process(request)` возвращает `Curve` с точками, числом измерений/уникальных X и СКО.
|
||||
- `write_csv(curve, stream)` сохраняет отдельный результат, подпись метода и единицы.
|
||||
Исходные данные не изменяются. При визуальном множителе Y адаптер передаёт
|
||||
отображаемые значения и указывает множитель в `y_unit`, например `В (×2)`.
|
||||
|
||||
Нативное ядро допускает до 100000 входных и 10000 выходных точек. UI задаёт
|
||||
2–10000 точек результата. После обрезки по X должно остаться достаточно узлов
|
||||
для выбранного метода. Расчёт не добавляет искусственные узлы на границах окна.
|
||||
|
||||
## Пример без GUI
|
||||
|
||||
```python
|
||||
from set_devices.plot_processing import Axis, Series, Snapshot, prepare, process, write_csv
|
||||
|
||||
samples = [(0, 0), (10, 2), (20, 1), (30, 0)]
|
||||
snapshot = Snapshot((Series("voltage", "Напряжение", samples, y_unit="В"),),
|
||||
Axis("Время", "мс"), source="bench-1")
|
||||
request = prepare(snapshot, "voltage", method="pchip", output_count=301)
|
||||
curve = process(request)
|
||||
with open("calculated.csv", "w", encoding="utf-8-sig", newline="") as stream:
|
||||
write_csv(curve, stream)
|
||||
```
|
||||
|
||||
Рабочий CLI-пример для всех методов:
|
||||
`python python/examples/plot_processing.py --method spline --output curve.csv`.
|
||||
Добавьте `templates/python` в `PYTHONPATH` или установите пакет из этого каталога.
|
||||
|
||||
## Подключение нового Qt-графика
|
||||
|
||||
`PlotProcessingAttachment(parent, snapshot, repaint)` принимает два callback:
|
||||
|
||||
```python
|
||||
def snapshot() -> Snapshot: ... # текущие отображаемые данные и видимые границы X
|
||||
def repaint() -> None: ... # обычно QWidget.update
|
||||
```
|
||||
|
||||
1. Создайте attachment и добавьте его `button` в панель графика.
|
||||
2. После изменения данных, каналов, единиц или окна вызывайте `source_changed()`.
|
||||
Пока панель не открывали, снимки не строятся. Уведомления одного прохода
|
||||
event loop объединяются; устаревшая линия сразу скрывается.
|
||||
3. После исходных кривых вызывайте `attachment.paint(painter, analog_rect, project)`.
|
||||
`project(x, y, rect) -> QPointF` использует ту же проекцию, что исходные данные.
|
||||
4. Вызов `open()` открывает немодальное окно с выбором всех четырёх методов,
|
||||
числа точек, степени полинома, расчётом, удалением и экспортом.
|
||||
|
||||
```python
|
||||
self.processing = PlotProcessingAttachment(self, self.processing_snapshot, self.update)
|
||||
self.toolbar.layout().addWidget(self.processing.button)
|
||||
# после обновления источника/масштаба:
|
||||
self.processing.source_changed()
|
||||
# внутри paintEvent, после исходных линий:
|
||||
self.processing.paint(painter, self.analog_rect(), self.project)
|
||||
```
|
||||
|
||||
Для встроенной панели используйте `SignalProcessingPanel.set_snapshot(snapshot)`
|
||||
и сигнал `changed` для перерисовки. Передавайте `panel.curve` слою через
|
||||
`set_external_curve(curve, x_offset=0)`. В этом варианте владелец обновляет снимок
|
||||
и слой вместе. `x_offset` используется только при рисовании, например при сдвиге
|
||||
epoch на графике наносекундного масштаба; исходные X и CSV сохраняются.
|
||||
|
||||
Панель сравнивает снимок выбранного канала, единицы, источник, диапазон и параметры.
|
||||
При изменении результат убирается, запоздалый ответ worker игнорируется.
|
||||
Для обработки живого потока остановите его обновление. Y-масштаб не меняет запрос,
|
||||
если X и данные остались прежними. Кривая обрезается текущей областью Y; при выбросе
|
||||
сплайна можно увеличить диапазон Y. Панель не меняет историю, autoscale, курсоры,
|
||||
FFT и цифровые дорожки графика.
|
||||
|
||||
## Порты и расширение
|
||||
|
||||
SETGUI использует компонент в «Логах и графиках», SignalPlot (CAN, температуры,
|
||||
УМП), TrendPlot и SpectrumPlot. В спектре методы обрабатывают зависимость уровня
|
||||
от частоты в Гц; восстановление временного сигнала из амплитудного спектра этим
|
||||
не выполняется. Временная панель «Логов» блокируется при включённом FFT.
|
||||
Генератор использует те же методы через `waveform.generate`; для циклической
|
||||
таблицы последний отсчёт периода не дублируется.
|
||||
|
||||
Для нового графика достаточно адаптера `Snapshot` и существующей панели/слоя.
|
||||
Для другого GUI используйте модель без Qt или непосредственно C ABI.
|
||||
Для MCU вызывайте `set_signal_reconstruct` с буфером `14 * count + 128` double,
|
||||
выделенным вызывающей стороной; рабочая память должна соответствовать RAM платы.
|
||||
Контракт вывода ЦАП и готовые порты F407/G474 описаны в [SIGNAL_GENERATOR.md](SIGNAL_GENERATOR.md).
|
||||
|
||||
Для нового численного метода сначала расширьте C API, C-тесты и соответствие
|
||||
`METHODS` в ctypes-порте; затем добавьте эталон в `test_plot_processing.py`.
|
||||
Алгоритмы в обработчиках отдельных графиков не дублируются.
|
||||
|
||||
Проверки библиотеки:
|
||||
|
||||
```text
|
||||
python -m unittest discover -s python/tests -p test_plot_processing*.py
|
||||
python -m unittest discover -s python/tests -p test_shared_library_boundary.py
|
||||
```
|
||||
|
||||
Для тестов Qt нужен PySide6/PySide2; для headless-прогона задайте
|
||||
`QT_QPA_PLATFORM=offscreen`. Численные тесты `test_plot_processing.py` Qt не требуют.
|
||||
146
c/set-protocol/docs/SIGNAL_GENERATOR.md
Normal file
146
c/set-protocol/docs/SIGNAL_GENERATOR.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Генератор произвольного сигнала WG v1/v2
|
||||
|
||||
## Расширение карты v2: F407 до 1 000 000 отсчётов/с
|
||||
|
||||
Версия карты в статусе — 2, `set_wave_state.rate` — uint32_t.
|
||||
Для записи частоты сначала остановить выход, записать старшие 16 бит в
|
||||
`0x1308`, затем младшие 16 бит в `0x1303`. Вторая запись проверяет диапазон
|
||||
1…1 000 000 и применяет частоту; обе записи снимают ready. Чтение `0x1308`
|
||||
возвращает старшие биты применённой частоты, а слово 3 статуса — младшие.
|
||||
Host ABI: операция 9 — запись старшего слова, 8 — его чтение, 2 — запись
|
||||
младшего слова (включая ноль). Остальные команды не меняются.
|
||||
|
||||
Это версия карты регистров, а не транспорт: SETGUI использует SET v2
|
||||
через EmulatorSerialPort, с резервным RTU для старой прошивки. Новый клиент
|
||||
понимает статус карт v1/v2; для v1 предел остаётся 50 000. Старый клиент
|
||||
отклоняет незнакомую версию 2 до записи. Код F407 и GUI поддерживают 1 МГц;
|
||||
порт G474 пока сохраняет прежний аппаратный лимит 50 кГц.
|
||||
Ниже описание исходной карты v1; пределы и регистры частоты заменены этим
|
||||
расширением для v2. На высоких частотах аналоговое установление зависит
|
||||
от величины скачка и нагрузки; аппаратная проверка осциллографом обязательна
|
||||
для оценки точности конкретного сигнала.
|
||||
|
||||
Общее C99-ядро строит кривые по точкам, преобразует напряжения в коды ЦАП и
|
||||
принимает таблицы через RTU поверх USB CDC/COM. Оно не зависит от Qt, HAL или ОС.
|
||||
Память предоставляет вызывающий; скрытого heap и глобального состояния нет.
|
||||
|
||||
```text
|
||||
SETGUI: точки -> Python/ctypes -> set_signal.c -> график / CSV / C
|
||||
-> set_wavegen.c -> USB CDC
|
||||
MCU: USB stream -> set_wave_rtu -> set_wave_port -> TIM6/DMA/DAC
|
||||
```
|
||||
|
||||
| Файл | Зависимости и назначение |
|
||||
|---|---|
|
||||
| `include/set_signal.h`, `src/set_signal.c` | C99/math: МНК 1…5, linear, PCHIP, natural cubic spline, 12-bit DAC |
|
||||
| `include/set_wavegen.h`, `src/set_wavegen.c` | C99 + `set_crc.c`: транзакционная загрузка и wire codec |
|
||||
| `ports/stm32f407-wavegen` | CMSIS F407: PA4/DAC1, TIM6, DMA1 Stream5 Channel7 |
|
||||
| `ports/stm32g474-wavegen` | STM32G4 HAL: готовые DAC/TIM/DMA handles из CubeMX |
|
||||
| `python/set_devices/signal_reconstruction.py`, `waveform.py` | Модели, JSON/экспорт, ctypes; алгоритмов на Python нет |
|
||||
| `python/set_devices/wavegen_protocol.py`, `qt_ports/wavegen.py` | ctypes-кодек и Qt COM worker с проверкой чтением обратно |
|
||||
|
||||
## Интерполяция и таблица
|
||||
|
||||
`set_signal_reconstruct` принимает раздельные X/Y и workspace `14*N+128`
|
||||
элементов double. Сортировка, усреднение одинаковых времён, нормализация,
|
||||
МНК через QR и интерполяция выполняются в C. Максимум 100000 входных/10000
|
||||
выходных точек. Переданный workspace может повторно использоваться.
|
||||
Большие расчёты предназначены для хоста; на MCU можно строить малые таблицы
|
||||
или принимать готовую таблицу без затрат на интерполяцию.
|
||||
|
||||
`endpoint=1` включает последний X (анализ логов); `endpoint=0` строит один
|
||||
период `[0,T)` без дублированного отсчёта стыка. Частота Fs и период T задают
|
||||
целое `N=Fs*T`; для WG максимум N=4096, Fs=1…50000 Гц, один канал, циклический
|
||||
выход. При разных значениях первой и последней точки на стыке будет скачок.
|
||||
Напряжения 0…Vref округляются к ближайшему коду 0…4095; выход за диапазон
|
||||
отклоняется целиком. Это относится и к выбросам сплайна. Vref должен
|
||||
соответствовать фактическому VDDA/VREF+ платы; это не программируемое питание.
|
||||
|
||||
Пример хоста (DLL передаётся через `SETPROTOCOL_LIBRARY`):
|
||||
|
||||
```python
|
||||
from set_devices.waveform import generate, c_header
|
||||
points = [(0, 0), (10, 3.3), (20, 0)]
|
||||
wave = generate(points, sample_rate=10000, vref=3.3, method="linear")
|
||||
assert len(wave.codes) == 200
|
||||
with open("waveform.h", "w", encoding="utf-8") as output:
|
||||
output.write(c_header(wave))
|
||||
```
|
||||
|
||||
## Контракт порта
|
||||
|
||||
`start(context, samples, count, sample_rate)` возвращает 0 при успехе;
|
||||
`stop(context)` синхронно прекращает DMA и устанавливает нулевой выход.
|
||||
Память таблицы принадлежит `set_wave_state`, её нельзя менять до stop.
|
||||
Вызывайте RTU и обслуживание состояния из одного основного потока, не из IRQ.
|
||||
IRQ USB помещает байты в очередь; USB-пакеты не являются границами RTU.
|
||||
|
||||
```c
|
||||
static uint16_t samples[SET_WAVE_MAX]; /* DMA-accessible SRAM, not CCM */
|
||||
static set_wave_state wave;
|
||||
static set_wave_f407 hardware = {72000000};
|
||||
void application_init(void) {
|
||||
set_wave_port port = set_wave_f407_port(&hardware);
|
||||
set_wave_init(&wave, samples, SET_WAVE_MAX, &port);
|
||||
}
|
||||
size_t on_rtu(const uint8_t *frame, size_t n, uint8_t reply[256]) {
|
||||
return set_wave_rtu(&wave, 16, frame, n, reply, 256);
|
||||
}
|
||||
```
|
||||
|
||||
Для F407 DAC_CH1 — PA4, TIM6 TRGO_UPDATE, DMA1 Stream5/Channel7. Порт использует
|
||||
только нижние 16 бит DAC_CR и не меняет канал 2. Таблица лежит в SRAM1/2
|
||||
`0x20000000…0x2001FFFF`, не в CCM. Перед первым отсчётом есть один такт
|
||||
предзагрузки последнего значения предыдущего периода. Для гарантированной
|
||||
целой частоты порт принимает только делители входной частоты TIM6; при
|
||||
72 МГц подходят, например, 100, 1000, 5000, 10000, 20000 Гц.
|
||||
Частоту таймера передавайте с учётом удвоения при делителе APB1 > 1.
|
||||
Ошибку DMA/underrun проверяйте через `set_wave_f407_fault()` и останавливайте
|
||||
состояние. На F407-проекте этот вызов включён в главный цикл.
|
||||
|
||||
Для G474 настройте CubeMX: DAC1 channel1 (PA4), output buffer enabled,
|
||||
TIM6 TRGO_UPDATE, DMA memory-to-peripheral, circular, halfword/halfword,
|
||||
memory increment, запрос `DMA_REQUEST_DAC1_CHANNEL1`; подключите HAL IRQ
|
||||
для выбранного канала DMA и DAC. Передайте handles и timer_hz в
|
||||
`set_wave_g474`. Обработчики ошибок DMA/DAC в приложении должны вызвать stop
|
||||
и сбросить `wave.running/ready`. Порт проверен компиляцией с CubeG4 1.6.1;
|
||||
конкретная плата G474 здесь не прошита.
|
||||
|
||||
## USB / Modbus RTU
|
||||
|
||||
Адрес по умолчанию 16, FC03/FC06, CRC16 Modbus. Максимальный запрос — 8 байт,
|
||||
ответ — 21 байт; существующий потоковый USB RTU parser F407 подходит без
|
||||
изменения формата. Все поля строит/проверяет C-кодек, GUI не пакует байты.
|
||||
|
||||
| Регистр | Значение |
|
||||
|---|---|
|
||||
| `0x1300`, FC03, 8 слов | `0x5747`, версия 1, running, Fs, count, received, ready, capacity |
|
||||
| `0x1302`, FC06 | 0: stop и нулевой выход; 1: start только после commit |
|
||||
| `0x1303`, FC06 | Частота 1…50000; сбрасывает ready |
|
||||
| `0x1304`, FC06 | Начать загрузку, число отсчётов 2…4096; сброс received/ready |
|
||||
| `0xA000+i`, FC06 | Последовательная загрузка отсчёта 0…4095; повтор того же значения допустим |
|
||||
| `0xA000+i`, FC03, 1 слово | Чтение загруженного отсчёта для проверки |
|
||||
| `0x1306`, FC06 | `0xA55A`: commit, только если все отсчёты загружены |
|
||||
|
||||
Во время running разрешены чтение и stop; запись/перезагрузка дают исключение
|
||||
6. Неполная таблица не запускается. Повреждённый CRC и чужой адрес не меняют
|
||||
состояние. При reset ready/running=0, таблица не сохраняется во Flash.
|
||||
Профиль и регистры генератора не являются регистратором УМП или протоколом 2812.
|
||||
|
||||
Хост выполняет probe → stop → Fs → count → samples → readback каждого
|
||||
отсчёта → commit → status. Пуск — отдельная пользовательская команда.
|
||||
После потери USB уже запущенный DAC продолжает автономное воспроизведение;
|
||||
при таймауте состояние неизвестно до повторного status/stop. Одновременно
|
||||
используйте одного клиента, не открывайте этот же COM во вкладке подключения.
|
||||
|
||||
## Потребители и проверки
|
||||
|
||||
- SETGUI: «Логи и графики → Генератор», JSON, CSV и C header.
|
||||
- `home/407vet6_emul_TMS_Periph`: USB CDC и UART, штатный адрес настройки STM,
|
||||
отдельный PA4 DAC выход; HAL/CMSIS и настройки платы остаются в проекте.
|
||||
- G474: переносимый HAL-порт; board init остаётся в целевой прошивке.
|
||||
- `tests/test_signal_wave.c`: C-векторы интерполяции, DAC, CRC, неполной
|
||||
загрузки, readback и блокировки running. Общие байты проверены Python
|
||||
ctypes и фактическим USB stream parser эмулятора.
|
||||
|
||||
Аппаратная проверка амплитуды, периода и формы осциллографом не выполнена.
|
||||
24
c/set-protocol/include/firmware_image.h
Normal file
24
c/set-protocol/include/firmware_image.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#ifndef SET_FIRMWARE_IMAGE_H
|
||||
#define SET_FIRMWARE_IMAGE_H
|
||||
#include "pcan_abi.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
/* Caller-owned workspace; the parser never allocates memory or accesses files. */
|
||||
typedef struct { uint32_t address; uint8_t value; } firmware_hex_cell_t;
|
||||
enum { FW_HEX_OK, FW_HEX_FORMAT, FW_HEX_CHECKSUM, FW_HEX_RECORD,
|
||||
FW_HEX_EOF, FW_HEX_OVERLAP, FW_HEX_RANGE, FW_HEX_CAPACITY,
|
||||
FW_HEX_PREFIX, FW_HEX_DIGITS, FW_HEX_LENGTH };
|
||||
/* Strict HEX: reject any overlap and any non-whitespace after EOF. On failure
|
||||
* count is zero. Cells are sorted by address only on successful completion. */
|
||||
PCAN_ABI_API int firmware_hex_parse(const char *text, size_t length,
|
||||
firmware_hex_cell_t *cells, size_t capacity, size_t max_records,
|
||||
size_t *count, size_t *error_line);
|
||||
/* Return one contiguous segment starting at offset in a successfully parsed
|
||||
* workspace; the caller uses its address and copies its values into its model. */
|
||||
PCAN_ABI_API size_t firmware_hex_segment_size(const firmware_hex_cell_t *cells,
|
||||
size_t count, size_t offset);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
24
c/set-protocol/include/set_signal.h
Normal file
24
c/set-protocol/include/set_signal.h
Normal file
@@ -0,0 +1,24 @@
|
||||
/** @file set_signal.h Portable interpolation and DAC waveform preparation. */
|
||||
#ifndef SET_SIGNAL_H
|
||||
#define SET_SIGNAL_H
|
||||
#include "pcan_abi.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
enum { SET_SIGNAL_POLYNOMIAL, SET_SIGNAL_LINEAR, SET_SIGNAL_PCHIP, SET_SIGNAL_SPLINE };
|
||||
/* Caller-owned workspace: at least 14*count+128 doubles. No heap or globals.
|
||||
* Inputs may be unsorted; duplicate times are averaged. No extrapolation.
|
||||
* meta[0..2] = original count, unique count, RMSE at original measurements.
|
||||
* endpoint=1 includes the last time; 0 samples a cyclic period [first,last).
|
||||
* Return: 0 success, 1 bounds, 2 nonfinite/degenerate data, 3 rank deficient. */
|
||||
PCAN_ABI_API int set_signal_reconstruct(const double *x, const double *y, size_t count,
|
||||
int method, unsigned degree, size_t output_count, int endpoint,
|
||||
double *out_x, double *out_y, double *meta, double *work, size_t work_count);
|
||||
/* Reject out-of-range voltages, never silently clip. vref must be positive.
|
||||
* All input is validated before any output is written. */
|
||||
PCAN_ABI_API int set_signal_dac12(const double *volts, size_t count, double vref,
|
||||
uint16_t *codes);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
59
c/set-protocol/include/set_wavegen.h
Normal file
59
c/set-protocol/include/set_wavegen.h
Normal file
@@ -0,0 +1,59 @@
|
||||
/** @file set_wavegen.h Transactional table upload and cyclic DAC playback. */
|
||||
#ifndef SET_WAVEGEN_H
|
||||
#define SET_WAVEGEN_H
|
||||
#include "pcan_abi.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define SET_WAVE_MAX 4096u
|
||||
#define SET_WAVE_MAX_RATE 1000000u
|
||||
#define SET_WAVE_BLOCK_MAX 120u
|
||||
#define SET_WAVE_BASE 0x1300u
|
||||
#define SET_WAVE_DATA 0xA000u
|
||||
typedef struct {
|
||||
void *context;
|
||||
/* Return 0 on success. Samples remain owned by state until stop returns.
|
||||
* start must arrange periodic output, typically DAC + timer + DMA.
|
||||
* stop must quiesce DMA before returning and set output to zero. */
|
||||
int (*start)(void *, const uint16_t *, size_t, uint32_t);
|
||||
void (*stop)(void *);
|
||||
} set_wave_port;
|
||||
typedef struct {
|
||||
uint16_t *samples;
|
||||
size_t capacity;
|
||||
uint16_t count, received, ready, running, rate_high;
|
||||
uint32_t rate;
|
||||
set_wave_port port;
|
||||
} set_wave_state;
|
||||
void set_wave_init(set_wave_state *, uint16_t *, size_t, const set_wave_port *);
|
||||
/* Atomic sample-block transfer. values=NULL builds a read; otherwise a write.
|
||||
* At most 120 words: request <=249 bytes, response <=245 bytes. */
|
||||
PCAN_ABI_API size_t set_wave_block_request(unsigned device,unsigned index,
|
||||
const uint16_t *values,size_t count,uint8_t *out,size_t capacity);
|
||||
PCAN_ABI_API int set_wave_block_response(const uint8_t *request,size_t request_size,
|
||||
const uint8_t *reply,size_t reply_size,uint16_t *words,size_t capacity);
|
||||
size_t set_wave_block_rtu(set_wave_state *,unsigned device,const uint8_t *,size_t,
|
||||
uint8_t *,size_t);
|
||||
/* Relative register callbacks for set_regmap; status=8 words, samples=4096.
|
||||
* These are the same state/validation used by the legacy RTU wrapper. */
|
||||
PCAN_ABI_API unsigned set_wave_read(void *,uint16_t,uint16_t,uint16_t *);
|
||||
PCAN_ABI_API unsigned set_wave_write(void *,uint16_t,uint16_t);
|
||||
PCAN_ABI_API unsigned set_wave_samples_read(void *,uint16_t,uint16_t,uint16_t *);
|
||||
PCAN_ABI_API unsigned set_wave_samples_write(void *,uint16_t,uint16_t);
|
||||
/* Caller serializes calls in the main loop. Returns complete reply size,
|
||||
* 0 for unrelated/invalid frames. CRC verified before all state changes. */
|
||||
size_t set_wave_rtu(set_wave_state *, unsigned device, const uint8_t *, size_t,
|
||||
uint8_t *, size_t);
|
||||
/* Thin host transport ABI. Operations: 0=status, 1=stop, 2=sample rate,
|
||||
* 3=begin upload(count), 4=sample(index,value), 5=commit, 6=start.
|
||||
* 7=read a sample(index) for verification. Return 8 or 0. */
|
||||
PCAN_ABI_API size_t set_wave_request(unsigned device,unsigned operation,unsigned index,
|
||||
unsigned value,uint8_t *out,size_t capacity);
|
||||
/* Return 0=need more bytes, positive=word count, negative=invalid/exception.
|
||||
* Validates exact echo/read shape, CRC, address and signature on status. */
|
||||
PCAN_ABI_API int set_wave_response(const uint8_t *request,const uint8_t *reply,
|
||||
size_t size,uint16_t *words,size_t capacity);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
53
c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.c
Normal file
53
c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.c
Normal file
@@ -0,0 +1,53 @@
|
||||
#include "set_wave_f407.h"
|
||||
#include "stm32f4xx.h"
|
||||
static void stop(void *context) {
|
||||
unsigned limit=100000; (void)context;
|
||||
RCC->AHB1ENR|=RCC_AHB1ENR_GPIOAEN|RCC_AHB1ENR_DMA1EN;
|
||||
RCC->APB1ENR|=RCC_APB1ENR_DACEN|RCC_APB1ENR_TIM6EN;
|
||||
(void)RCC->APB1ENR;
|
||||
TIM6->CR1=0;
|
||||
DAC->CR&=~(DAC_CR_DMAEN1|DAC_CR_TEN1);
|
||||
DMA1_Stream5->CR&=~DMA_SxCR_EN;
|
||||
while((DMA1_Stream5->CR&DMA_SxCR_EN)&&--limit) {}
|
||||
/* Leave other DAC channel untouched. Channel 1 outputs zero on stop. */
|
||||
DAC->DHR12R1=0;
|
||||
DMA1->HIFCR=0xF40u;
|
||||
DAC->SR=DAC_SR_DMAUDR1;
|
||||
}
|
||||
static int start(void *context,const uint16_t *samples,size_t count,uint32_t rate) {
|
||||
set_wave_f407 *c=(set_wave_f407 *)context;
|
||||
uint32_t ticks,psc,arr; uintptr_t address=(uintptr_t)samples;
|
||||
if(!c||!rate||rate>SET_WAVE_MAX_RATE||count<2||count>SET_WAVE_MAX||c->timer_hz%rate
|
||||
||address<0x20000000u||address+count*2>0x20020000u) return 1;
|
||||
ticks=c->timer_hz/rate;
|
||||
for(psc=1;psc<=65536;++psc) if(ticks%psc==0&&ticks/psc<=65536) break;
|
||||
if(psc>65536||ticks<psc) return 1;
|
||||
arr=ticks/psc;
|
||||
stop(context);
|
||||
if(DMA1_Stream5->CR&DMA_SxCR_EN) return 1;
|
||||
GPIOA->MODER|=3u<<8;
|
||||
GPIOA->PUPDR&=~(3u<<8);
|
||||
TIM6->PSC=psc-1;TIM6->ARR=arr-1;TIM6->CNT=0;
|
||||
TIM6->CR2=0;TIM6->EGR=TIM_EGR_UG;TIM6->SR=0;
|
||||
TIM6->CR2=TIM_CR2_MMS_1; /* update -> TRGO */
|
||||
DMA1_Stream5->PAR=(uint32_t)&DAC->DHR12R1;
|
||||
DMA1_Stream5->M0AR=(uint32_t)samples;DMA1_Stream5->NDTR=(uint32_t)count;
|
||||
DMA1_Stream5->FCR=0;
|
||||
DMA1_Stream5->CR=(7u<<25)|DMA_SxCR_DIR_0|DMA_SxCR_MINC|DMA_SxCR_CIRC
|
||||
|DMA_SxCR_PSIZE_0|DMA_SxCR_MSIZE_0|DMA_SxCR_PL_1;
|
||||
/* Prime the previous period's last sample; DMA supplies the next value
|
||||
* after each trigger. Cyclic order is last,0,1,... with one priming tick. */
|
||||
DAC->CR&=~0xFFFFu;
|
||||
DAC->DHR12R1=samples[count-1];
|
||||
DAC->CR|=DAC_CR_EN1|DAC_CR_TEN1|DAC_CR_DMAEN1; /* TSEL1=0: TIM6 */
|
||||
DMA1_Stream5->CR|=DMA_SxCR_EN;
|
||||
TIM6->CR1=TIM_CR1_CEN;
|
||||
return 0;
|
||||
}
|
||||
set_wave_port set_wave_f407_port(set_wave_f407 *context) {
|
||||
set_wave_port port={context,start,stop};return port;
|
||||
}
|
||||
int set_wave_f407_fault(void) {
|
||||
return (DMA1->HISR&(DMA_HISR_TEIF5|DMA_HISR_DMEIF5|DMA_HISR_FEIF5))
|
||||
||(DAC->SR&DAC_SR_DMAUDR1);
|
||||
}
|
||||
12
c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.h
Normal file
12
c/set-protocol/ports/stm32f407-wavegen/set_wave_f407.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#ifndef SET_WAVE_F407_H
|
||||
#define SET_WAVE_F407_H
|
||||
#include "../../include/set_wavegen.h"
|
||||
/* Owns PA4, DAC channel 1, TIM6 and DMA1 stream 5/channel 7.
|
||||
* timer_hz is TIM6 input clock (twice APB1 when APB1 prescaler !=1).
|
||||
* Rate: 1..SET_WAVE_MAX_RATE (1 MS/s), exact divisors of timer_hz only.
|
||||
* Context and sample buffer must outlive playback; buffer must be in SRAM. */
|
||||
typedef struct { uint32_t timer_hz; } set_wave_f407;
|
||||
set_wave_port set_wave_f407_port(set_wave_f407 *context);
|
||||
/* Nonzero on DMA error or DAC underrun; caller stops state on failure. */
|
||||
int set_wave_f407_fault(void);
|
||||
#endif
|
||||
37
c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.c
Normal file
37
c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.c
Normal file
@@ -0,0 +1,37 @@
|
||||
#include "set_wave_g474.h"
|
||||
static void stop(void *context) {
|
||||
set_wave_g474 *c=(set_wave_g474 *)context;
|
||||
HAL_TIM_Base_Stop(c->timer);
|
||||
HAL_DAC_Stop_DMA(c->dac,DAC_CHANNEL_1);
|
||||
/* Disable triggering so zero is applied without another timer edge. */
|
||||
CLEAR_BIT(c->dac->Instance->CR,DAC_CR_TEN1);
|
||||
HAL_DAC_SetValue(c->dac,DAC_CHANNEL_1,DAC_ALIGN_12B_R,0);
|
||||
HAL_DAC_Start(c->dac,DAC_CHANNEL_1);
|
||||
}
|
||||
static int start(void *context,const uint16_t *samples,size_t count,uint32_t rate) {
|
||||
set_wave_g474 *c=(set_wave_g474 *)context;
|
||||
uint32_t ticks,psc; DMA_HandleTypeDef *dma;
|
||||
if(!c||!c->dac||!c->timer||c->dac->Instance!=DAC1||c->timer->Instance!=TIM6
|
||||
||!rate||rate>50000||count<2||count>SET_WAVE_MAX||c->timer_hz%rate) return 1;
|
||||
dma=c->dac->DMA_Handle1;
|
||||
if(!dma||dma->Init.Mode!=DMA_CIRCULAR||dma->Init.MemDataAlignment!=DMA_MDATAALIGN_HALFWORD
|
||||
||dma->Init.PeriphDataAlignment!=DMA_PDATAALIGN_HALFWORD
|
||||
||dma->Init.Request!=DMA_REQUEST_DAC1_CHANNEL1||dma->Init.MemInc!=DMA_MINC_ENABLE) return 1;
|
||||
ticks=c->timer_hz/rate;
|
||||
for(psc=1;psc<=65536;++psc) if(ticks%psc==0&&ticks/psc<=65536) break;
|
||||
if(psc>65536||ticks<psc) return 1;
|
||||
stop(context);
|
||||
__HAL_TIM_SET_PRESCALER(c->timer,psc-1);
|
||||
__HAL_TIM_SET_AUTORELOAD(c->timer,ticks/psc-1);
|
||||
__HAL_TIM_SET_COUNTER(c->timer,0);
|
||||
c->timer->Instance->EGR=TIM_EGR_UG;
|
||||
__HAL_TIM_CLEAR_FLAG(c->timer,TIM_FLAG_UPDATE);
|
||||
MODIFY_REG(c->dac->Instance->CR,DAC_CR_TSEL1|DAC_CR_TEN1,DAC_TRIGGER_T6_TRGO);
|
||||
HAL_DAC_SetValue(c->dac,DAC_CHANNEL_1,DAC_ALIGN_12B_R,samples[count-1]);
|
||||
if(HAL_DAC_Start_DMA(c->dac,DAC_CHANNEL_1,(uint32_t *)samples,(uint32_t)count,DAC_ALIGN_12B_R)!=HAL_OK) return 1;
|
||||
if(HAL_TIM_Base_Start(c->timer)!=HAL_OK) {stop(context);return 1;}
|
||||
return 0;
|
||||
}
|
||||
set_wave_port set_wave_g474_port(set_wave_g474 *context) {
|
||||
set_wave_port port={context,start,stop};return port;
|
||||
}
|
||||
10
c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.h
Normal file
10
c/set-protocol/ports/stm32g474-wavegen/set_wave_g474.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#ifndef SET_WAVE_G474_H
|
||||
#define SET_WAVE_G474_H
|
||||
#include "stm32g4xx_hal.h"
|
||||
#include "../../include/set_wavegen.h"
|
||||
/* CubeMX owns initialization: DAC1 channel1 PA4, TIM6 TRGO_UPDATE,
|
||||
* DMA_REQUEST_DAC1_CHANNEL1, circular, halfword/halfword, memory increment.
|
||||
* Supply TIM6 input clock, not APB bus clock. DMA buffer in SRAM. */
|
||||
typedef struct { DAC_HandleTypeDef *dac; TIM_HandleTypeDef *timer; uint32_t timer_hz; } set_wave_g474;
|
||||
set_wave_port set_wave_g474_port(set_wave_g474 *context);
|
||||
#endif
|
||||
88
c/set-protocol/src/firmware_image.c
Normal file
88
c/set-protocol/src/firmware_image.c
Normal file
@@ -0,0 +1,88 @@
|
||||
#include "firmware_image.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
static int hex_digit(char c)
|
||||
{
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
return -1;
|
||||
}
|
||||
static int space(char c) { return c == ' ' || c == '\t' || c == '\r'; }
|
||||
static int compare_cells(const void *a, const void *b)
|
||||
{
|
||||
uint32_t x = ((const firmware_hex_cell_t *)a)->address;
|
||||
uint32_t y = ((const firmware_hex_cell_t *)b)->address;
|
||||
return (x > y) - (x < y);
|
||||
}
|
||||
int firmware_hex_parse(const char *text, size_t length,
|
||||
firmware_hex_cell_t *cells, size_t capacity, size_t max_records,
|
||||
size_t *count, size_t *error_line)
|
||||
{
|
||||
size_t pos = 0, line = 0, used = 0, records = 0, i;
|
||||
uint32_t upper = 0;
|
||||
int eof = 0;
|
||||
if (count == NULL || error_line == NULL) return FW_HEX_FORMAT;
|
||||
*count = 0; *error_line = 0;
|
||||
if (text == NULL || cells == NULL) return FW_HEX_FORMAT;
|
||||
while (pos < length) {
|
||||
size_t begin = pos, end, n;
|
||||
uint8_t bytes[260];
|
||||
unsigned sum = 0, size, kind, address;
|
||||
while (pos < length && text[pos] != '\n') ++pos;
|
||||
end = pos;
|
||||
if (pos < length) ++pos;
|
||||
++line;
|
||||
while (begin < end && space(text[begin])) ++begin;
|
||||
while (end > begin && space(text[end-1])) --end;
|
||||
if (begin == end) continue;
|
||||
*error_line = line;
|
||||
if (eof) return FW_HEX_EOF;
|
||||
if (++records > max_records) return FW_HEX_CAPACITY;
|
||||
if (text[begin++] != ':') return FW_HEX_PREFIX;
|
||||
if ((end-begin) & 1U) return FW_HEX_DIGITS;
|
||||
n = (end-begin)/2;
|
||||
if (n < 5 || n > sizeof(bytes)) return FW_HEX_FORMAT;
|
||||
for (i = 0; i < n; ++i) {
|
||||
int hi = hex_digit(text[begin+2*i]), lo = hex_digit(text[begin+2*i+1]);
|
||||
if (hi < 0 || lo < 0) return FW_HEX_DIGITS;
|
||||
bytes[i] = (uint8_t)(hi*16+lo); sum += bytes[i];
|
||||
}
|
||||
size = bytes[0]; address = ((unsigned)bytes[1]<<8) | bytes[2]; kind = bytes[3];
|
||||
if (n != size+5U) return FW_HEX_LENGTH;
|
||||
if (sum & 255U) return FW_HEX_CHECKSUM;
|
||||
if (kind == 0) {
|
||||
uint64_t absolute = (uint64_t)upper + address;
|
||||
if (size && absolute + size - 1 > UINT32_MAX) return FW_HEX_RANGE;
|
||||
if (size > capacity-used) return FW_HEX_CAPACITY;
|
||||
for (i = 0; i < size; ++i) {
|
||||
cells[used].address = (uint32_t)(absolute+i);
|
||||
cells[used++].value = bytes[4+i];
|
||||
}
|
||||
} else if (kind == 1) {
|
||||
if (size || address) return FW_HEX_RECORD;
|
||||
eof = 1;
|
||||
} else if (kind == 2 || kind == 4) {
|
||||
if (size != 2 || address) return FW_HEX_RECORD;
|
||||
upper = ((uint32_t)bytes[4]<<8) | bytes[5];
|
||||
upper <<= kind == 2 ? 4 : 16;
|
||||
} else if (kind == 3 || kind == 5) {
|
||||
if (size != 4 || address) return FW_HEX_RECORD;
|
||||
} else return FW_HEX_RECORD;
|
||||
}
|
||||
if (!eof || !used) return FW_HEX_EOF;
|
||||
qsort(cells, used, sizeof(*cells), compare_cells);
|
||||
for (i = 1; i < used; ++i)
|
||||
if (cells[i-1].address == cells[i].address) { *error_line = 0; return FW_HEX_OVERLAP; }
|
||||
*count = used; *error_line = 0;
|
||||
return FW_HEX_OK;
|
||||
}
|
||||
size_t firmware_hex_segment_size(const firmware_hex_cell_t *cells, size_t count, size_t offset)
|
||||
{
|
||||
size_t end;
|
||||
if (cells == NULL || offset >= count) return 0;
|
||||
end = offset+1;
|
||||
while (end < count && cells[end-1].address != UINT32_MAX &&
|
||||
cells[end].address == cells[end-1].address+1U) ++end;
|
||||
return end-offset;
|
||||
}
|
||||
115
c/set-protocol/src/set_signal.c
Normal file
115
c/set-protocol/src/set_signal.c
Normal file
@@ -0,0 +1,115 @@
|
||||
#include "set_signal.h"
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
typedef struct { double x, y; } point;
|
||||
static int compare_point(const void *a, const void *b) {
|
||||
double x=((const point *)a)->x, y=((const point *)b)->x;
|
||||
return (x>y)-(x<y);
|
||||
}
|
||||
static double edge(double h0,double h1,double d0,double d1) {
|
||||
double d=((2*h0+h1)*d0-h0*d1)/(h0+h1);
|
||||
if(d0==0 || (d>0)!=(d0>0)) return 0;
|
||||
if((d0>0)!=(d1>0) && fabs(d)>3*fabs(d0)) return 3*d0;
|
||||
return d;
|
||||
}
|
||||
static double evaluate(double t,const double *x,const double *y,size_t n,
|
||||
int method,const double *d,const double *coef,unsigned degree) {
|
||||
size_t lo=0,hi=n-1,i; double u,a,b,h,value=0;
|
||||
if(method==SET_SIGNAL_POLYNOMIAL) {
|
||||
int j; for(j=(int)degree;j>=0;--j) value=value*(2*t-1)+coef[j]; return value;
|
||||
}
|
||||
while(hi-lo>1) { i=(hi+lo)/2; if(t<x[i]) hi=i; else lo=i; }
|
||||
i=lo; h=x[i+1]-x[i]; u=(t-x[i])/h; a=1-u; b=u;
|
||||
if(method==SET_SIGNAL_LINEAR) return a*y[i]+b*y[i+1];
|
||||
if(method==SET_SIGNAL_SPLINE)
|
||||
return a*y[i]+b*y[i+1]+h*h/6*((a*a*a-a)*d[i]+(b*b*b-b)*d[i+1]);
|
||||
return (2*u*u*u-3*u*u+1)*y[i]+(u*u*u-2*u*u+u)*h*d[i]
|
||||
+(-2*u*u*u+3*u*u)*y[i+1]+(u*u*u-u*u)*h*d[i+1];
|
||||
}
|
||||
int set_signal_reconstruct(const double *input_x,const double *input_y,size_t count,
|
||||
int method,unsigned degree,size_t output_count,int endpoint,double *out_x,
|
||||
double *out_y,double *meta,double *work,size_t work_count) {
|
||||
point *pairs; double *x,*y,*d,*h,*s,*q,*column;
|
||||
double coef[6]={0},r[6][6]={{0}},origin,span,scale=1,rmse=0;
|
||||
size_t i,j,k,n=0; unsigned pass;
|
||||
if(!input_x||!input_y||!out_x||!out_y||!meta||!work || count<2||count>100000
|
||||
||output_count<2||output_count>10000||work_count<14*count+128
|
||||
||method<0||method>3||(endpoint!=0&&endpoint!=1)) return 1;
|
||||
pairs=(point *)work; x=work+2*count; y=x+count; d=y+count; h=d+count;
|
||||
s=h+count; q=s+count; column=q+6*count;
|
||||
for(i=0;i<count;++i) {
|
||||
if(!isfinite(input_x[i])||!isfinite(input_y[i])) return 2;
|
||||
pairs[i].x=input_x[i]; pairs[i].y=input_y[i];
|
||||
}
|
||||
qsort(pairs,count,sizeof(point),compare_point);
|
||||
for(i=0;i<count;i=j) {
|
||||
double sum=0; for(j=i+1;j<count&&pairs[j].x==pairs[i].x;++j) {}
|
||||
for(k=i;k<j;++k) sum+=pairs[k].y/(double)(j-i);
|
||||
if(!isfinite(sum)) return 2;
|
||||
x[n]=pairs[i].x; y[n++]=sum; if(fabs(sum)>scale) scale=fabs(sum);
|
||||
}
|
||||
if(n<2) return 2;
|
||||
origin=x[0]; span=x[n-1]-origin;
|
||||
if(!isfinite(span)||span<=0) return 2;
|
||||
for(i=0;i<n;++i) { x[i]=(x[i]-origin)/span; y[i]/=scale; d[i]=0; }
|
||||
for(i=0;i<n-1;++i) {
|
||||
h[i]=x[i+1]-x[i]; if(h[i]<=0) return 2;
|
||||
s[i]=(y[i+1]-y[i])/h[i]; if(!isfinite(s[i])) return 2;
|
||||
}
|
||||
if(method==SET_SIGNAL_POLYNOMIAL) {
|
||||
if(degree<1||degree>5||n<=degree) return 1;
|
||||
for(j=0;j<=degree;++j) {
|
||||
double norm=0;
|
||||
for(i=0;i<n;++i) column[i]=pow(2*x[i]-1,(double)j);
|
||||
for(pass=0;pass<2;++pass) for(k=0;k<j;++k) {
|
||||
double c=0; for(i=0;i<n;++i) c+=column[i]*q[k*count+i];
|
||||
r[k][j]+=c; for(i=0;i<n;++i) column[i]-=c*q[k*count+i];
|
||||
}
|
||||
for(i=0;i<n;++i) norm+=column[i]*column[i]; norm=sqrt(norm);
|
||||
if(norm<1e-12*sqrt((double)n)) return 3;
|
||||
r[j][j]=norm;
|
||||
for(i=0;i<n;++i) { q[j*count+i]=column[i]/norm; coef[j]+=q[j*count+i]*y[i]; }
|
||||
}
|
||||
for(i=degree+1;i>0;--i) {
|
||||
j=i-1; for(k=j+1;k<=degree;++k) coef[j]-=r[j][k]*coef[k];
|
||||
coef[j]/=r[j][j];
|
||||
}
|
||||
} else if(method==SET_SIGNAL_PCHIP) {
|
||||
d[0]=d[n-1]=s[0];
|
||||
if(n>2) {
|
||||
d[0]=edge(h[0],h[1],s[0],s[1]);
|
||||
d[n-1]=edge(h[n-2],h[n-3],s[n-2],s[n-3]);
|
||||
for(i=1;i<n-1;++i) if(s[i-1]!=0&&s[i]!=0&&(s[i-1]>0)==(s[i]>0)) {
|
||||
double w1=2*h[i]+h[i-1],w2=h[i]+2*h[i-1];
|
||||
d[i]=(w1+w2)/(w1/s[i-1]+w2/s[i]);
|
||||
}
|
||||
}
|
||||
} else if(method==SET_SIGNAL_SPLINE) {
|
||||
q[0]=column[0]=0;
|
||||
for(i=1;i<n-1;++i) {
|
||||
double diagonal=2*(h[i-1]+h[i])-h[i-1]*q[i-1];
|
||||
q[i]=h[i]/diagonal;
|
||||
column[i]=(6*(s[i]-s[i-1])-h[i-1]*column[i-1])/diagonal;
|
||||
}
|
||||
for(i=n-2;i>0;--i) d[i]=column[i]-q[i]*d[i+1];
|
||||
}
|
||||
for(i=0;i<output_count;++i) {
|
||||
double t=(double)i/(double)(output_count-(endpoint?1:0));
|
||||
out_x[i]=origin+span*t; out_y[i]=evaluate(t,x,y,n,method,d,coef,degree)*scale;
|
||||
if(!isfinite(out_y[i])) return 2;
|
||||
}
|
||||
for(i=0;i<count;++i) {
|
||||
double delta=evaluate((input_x[i]-origin)/span,x,y,n,method,d,coef,degree)-input_y[i]/scale;
|
||||
rmse+=delta*delta/(double)count;
|
||||
}
|
||||
meta[0]=(double)count; meta[1]=(double)n; meta[2]=sqrt(rmse)*scale;
|
||||
return isfinite(meta[2])?0:2;
|
||||
}
|
||||
int set_signal_dac12(const double *volts,size_t count,double vref,uint16_t *codes) {
|
||||
size_t i;
|
||||
if(!volts||!codes||count<2||count>10000||!isfinite(vref)||vref<=0) return 1;
|
||||
for(i=0;i<count;++i) if(!isfinite(volts[i])||volts[i]<0||volts[i]>vref) return 2;
|
||||
for(i=0;i<count;++i) codes[i]=(uint16_t)floor(volts[i]/vref*4095+0.5);
|
||||
return 0;
|
||||
}
|
||||
214
c/set-protocol/src/set_wavegen.c
Normal file
214
c/set-protocol/src/set_wavegen.c
Normal file
@@ -0,0 +1,214 @@
|
||||
#include "set_wavegen.h"
|
||||
#include "set_crc.h"
|
||||
#include <string.h>
|
||||
static uint16_t read16(const uint8_t *p) {return (uint16_t)((p[0]<<8)|p[1]);}
|
||||
static void write16(uint8_t *p,unsigned value) {p[0]=(uint8_t)(value>>8);p[1]=(uint8_t)value;}
|
||||
static size_t seal(uint8_t *p,size_t n) {
|
||||
uint16_t crc=set_crc16_modbus(p,n);p[n]=(uint8_t)crc;p[n+1]=(uint8_t)(crc>>8);return n+2;
|
||||
}
|
||||
void set_wave_init(set_wave_state *s,uint16_t *samples,size_t capacity,const set_wave_port *port) {
|
||||
memset(s,0,sizeof(*s));s->samples=samples;s->capacity=capacity;s->rate=1000;
|
||||
if(port) s->port=*port;
|
||||
}
|
||||
static unsigned write_register(set_wave_state *s,unsigned reg,unsigned value) {
|
||||
if(reg==SET_WAVE_BASE+2 && value==0) {
|
||||
if(s->port.stop) s->port.stop(s->port.context);
|
||||
s->running=0; return 0;
|
||||
}
|
||||
if(s->running) return 6;
|
||||
if(reg==SET_WAVE_BASE+2 && value==1) {
|
||||
if(!s->ready||!s->port.start) return 3;
|
||||
if(s->port.start(s->port.context,s->samples,s->count,s->rate)) return 4;
|
||||
s->running=1;return 0;
|
||||
}
|
||||
if(reg==SET_WAVE_BASE+3) {
|
||||
uint32_t rate=((uint32_t)s->rate_high<<16)|value;
|
||||
if(rate<1||rate>SET_WAVE_MAX_RATE) return 3;
|
||||
s->rate=rate;s->ready=0;return 0;
|
||||
}
|
||||
if(reg==SET_WAVE_BASE+8) {
|
||||
if(value>(SET_WAVE_MAX_RATE>>16)) return 3;
|
||||
s->rate_high=(uint16_t)value;s->ready=0;return 0;
|
||||
}
|
||||
if(reg==SET_WAVE_BASE+4) {
|
||||
if(value<2||value>SET_WAVE_MAX||value>s->capacity) return 3;
|
||||
s->count=(uint16_t)value;s->received=s->ready=0;return 0;
|
||||
}
|
||||
if(reg>=SET_WAVE_DATA&®<SET_WAVE_DATA+SET_WAVE_MAX) {
|
||||
unsigned index=reg-SET_WAVE_DATA;
|
||||
if(index>=s->count||index>s->received||value>4095||s->ready) return 3;
|
||||
if(index<s->received) return s->samples[index]==value?0:3;
|
||||
s->samples[index]=(uint16_t)value;++s->received;return 0;
|
||||
}
|
||||
if(reg==SET_WAVE_BASE+6) {
|
||||
if(value!=0xA55A||s->count<2||s->received!=s->count) return 3;
|
||||
s->ready=1;return 0;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
unsigned set_wave_read(void *ctx,uint16_t off,uint16_t count,uint16_t *out) {
|
||||
set_wave_state *s=(set_wave_state *)ctx;uint16_t w[9];unsigned i;
|
||||
if(!s||!s->samples||!out||!count||(uint32_t)off+count>9)return 2;
|
||||
w[0]=0x5747;w[1]=3;w[2]=s->running;w[3]=(uint16_t)s->rate;
|
||||
w[4]=s->count;w[5]=s->received;w[6]=s->ready;w[7]=(uint16_t)s->capacity;w[8]=(uint16_t)(s->rate>>16);
|
||||
for(i=0;i<count;++i)out[i]=w[off+i];
|
||||
return 0;
|
||||
}
|
||||
unsigned set_wave_write(void *ctx,uint16_t off,uint16_t value) {
|
||||
set_wave_state *s=(set_wave_state *)ctx;
|
||||
if(!s||!s->samples||off>=9)return 2;
|
||||
return write_register(s,SET_WAVE_BASE+off,value);
|
||||
}
|
||||
unsigned set_wave_samples_read(void *ctx,uint16_t off,uint16_t count,uint16_t *out) {
|
||||
set_wave_state *s=(set_wave_state *)ctx;unsigned i;
|
||||
if(!s||!s->samples||!out||!count||(uint32_t)off+count>s->received)return 2;
|
||||
for(i=0;i<count;++i)out[i]=s->samples[off+i];
|
||||
return 0;
|
||||
}
|
||||
unsigned set_wave_samples_write(void *ctx,uint16_t off,uint16_t value) {
|
||||
set_wave_state *s=(set_wave_state *)ctx;
|
||||
if(!s||!s->samples||off>=SET_WAVE_MAX)return 2;
|
||||
return write_register(s,SET_WAVE_DATA+off,value);
|
||||
}
|
||||
size_t set_wave_rtu(set_wave_state *s,unsigned device,const uint8_t *req,size_t n,uint8_t *out,size_t cap) {
|
||||
unsigned reg,value,error=0,i;uint16_t status[8];size_t length=0;
|
||||
if(!s||!s->samples||!req||!out||cap<21||n!=8||device<1||device>247
|
||||
||req[0]!=device||set_crc16_modbus(req,n)) return 0;
|
||||
reg=read16(req+2);value=read16(req+4);
|
||||
if(!((reg>=SET_WAVE_BASE&®<SET_WAVE_BASE+9)||(reg>=SET_WAVE_DATA&®<SET_WAVE_DATA+SET_WAVE_MAX))) return 0;
|
||||
out[0]=(uint8_t)device;out[1]=req[1];
|
||||
if(req[1]==6) {error=write_register(s,reg,value);memcpy(out,req,6);length=6;}
|
||||
else if(req[1]==3) {
|
||||
(void)set_wave_read(s,0,8,status);
|
||||
if(reg==SET_WAVE_BASE&&value==8) {
|
||||
out[2]=16;for(i=0;i<8;++i) write16(out+3+2*i,status[i]);length=19;
|
||||
} else if(reg==SET_WAVE_BASE+8&&value==1) {
|
||||
out[2]=2;write16(out+3,s->rate>>16);length=5;
|
||||
} else if(reg>=SET_WAVE_DATA&&value==1&®-SET_WAVE_DATA<s->received) {
|
||||
out[2]=2;write16(out+3,s->samples[reg-SET_WAVE_DATA]);length=5;
|
||||
} else error=2;
|
||||
} else error=1;
|
||||
if(error) {out[1]|=0x80;out[2]=(uint8_t)error;length=3;}
|
||||
return seal(out,length);
|
||||
}
|
||||
size_t set_wave_request(unsigned device,unsigned op,unsigned index,unsigned value,uint8_t *out,size_t cap) {
|
||||
unsigned reg=SET_WAVE_BASE,fn=6;
|
||||
if(!out||cap<8||device<1||device>247) return 0;
|
||||
switch(op) {
|
||||
case 0: fn=3;value=8;break;
|
||||
case 1: reg+=2;value=0;break;
|
||||
case 2: reg+=3;if(value>65535)return 0;break;
|
||||
case 3: reg+=4;if(value<2||value>SET_WAVE_MAX)return 0;break;
|
||||
case 4: if(index>=SET_WAVE_MAX||value>4095)return 0;reg=SET_WAVE_DATA+index;break;
|
||||
case 5: reg+=6;value=0xA55A;break;
|
||||
case 6: reg+=2;value=1;break;
|
||||
case 7: if(index>=SET_WAVE_MAX)return 0;reg=SET_WAVE_DATA+index;fn=3;value=1;break;
|
||||
case 8: reg+=8;fn=3;value=1;break;
|
||||
case 9: reg+=8;if(value>(SET_WAVE_MAX_RATE>>16))return 0;break;
|
||||
default:return 0;
|
||||
}
|
||||
out[0]=(uint8_t)device;out[1]=(uint8_t)fn;write16(out+2,reg);write16(out+4,value);return seal(out,6);
|
||||
}
|
||||
int set_wave_response(const uint8_t *req,const uint8_t *reply,size_t size,uint16_t *words,size_t cap) {
|
||||
size_t expected,i;
|
||||
if(!req||!reply||!words||set_crc16_modbus(req,8))return -1;
|
||||
if(size<2)return 0;
|
||||
if(reply[0]!=req[0])return -1;
|
||||
if(reply[1]==(req[1]|0x80)) {
|
||||
if(size<5)return 0;
|
||||
return size==5&&!set_crc16_modbus(reply,5)?-(100+(int)reply[2]):-1;
|
||||
}
|
||||
if(reply[1]!=req[1])return -1;
|
||||
expected=req[1]==6?8:5+2*(size_t)read16(req+4);
|
||||
if(size<expected)return 0;
|
||||
if(size!=expected||set_crc16_modbus(reply,size))return -1;
|
||||
if(req[1]==6) {if(memcmp(req,reply,8)||cap<1)return -1;words[0]=read16(req+4);return 1;}
|
||||
if(reply[2]!=expected-5||cap<(expected-5)/2)return -1;
|
||||
for(i=0;i<(expected-5)/2;++i)words[i]=read16(reply+3+2*i);
|
||||
if(read16(req+2)==SET_WAVE_BASE&&(words[0]!=0x5747||(words[1]<1||words[1]>3)))return -2;
|
||||
return (int)((expected-5)/2);
|
||||
}
|
||||
|
||||
|
||||
size_t set_wave_block_request(unsigned device,unsigned index,const uint16_t *values,
|
||||
size_t count,uint8_t *out,size_t cap) {
|
||||
size_t i,n=values?9+2*count:8;
|
||||
if(!out||device<1||device>247||!count||count>SET_WAVE_BLOCK_MAX||
|
||||
index>=SET_WAVE_MAX||count>SET_WAVE_MAX-index||cap<n)return 0;
|
||||
if(values)for(i=0;i<count;++i)if(values[i]>4095)return 0;
|
||||
out[0]=(uint8_t)device;out[1]=values?16:3;
|
||||
write16(out+2,SET_WAVE_DATA+index);write16(out+4,(unsigned)count);
|
||||
if(values) {
|
||||
out[6]=(uint8_t)(count*2);
|
||||
for(i=0;i<count;++i)write16(out+7+2*i,values[i]);
|
||||
}
|
||||
return seal(out,n-2);
|
||||
}
|
||||
size_t set_wave_block_rtu(set_wave_state *s,unsigned device,const uint8_t *req,
|
||||
size_t n,uint8_t *out,size_t cap) {
|
||||
unsigned reg,index,count,error=0,i;size_t length=3;
|
||||
if(!s||!s->samples||!req||!out||cap<5||n<8||device<1||device>247||
|
||||
req[0]!=device||set_crc16_modbus(req,n))return 0;
|
||||
reg=read16(req+2);count=read16(req+4);
|
||||
if(reg<SET_WAVE_DATA||reg>=SET_WAVE_DATA+SET_WAVE_MAX)return 0;
|
||||
index=reg-SET_WAVE_DATA;out[0]=req[0];out[1]=req[1];
|
||||
if(!count||count>SET_WAVE_BLOCK_MAX||count>SET_WAVE_MAX-index||
|
||||
index+count>s->count||index+count>s->capacity)error=3;
|
||||
else if(req[1]==16) {
|
||||
if(n!=9+2*(size_t)count||req[6]!=2*count)error=3;
|
||||
else if(s->running)error=6;
|
||||
else if(s->ready||index>s->received)error=3;
|
||||
else {
|
||||
/* Preflight the whole block, including replayed prefix, before writing. */
|
||||
for(i=0;i<count;++i) {
|
||||
unsigned value=read16(req+7+2*i);
|
||||
if(value>4095||(index+i<s->received&&s->samples[index+i]!=value)) {
|
||||
error=3;break;
|
||||
}
|
||||
}
|
||||
if(!error) {
|
||||
if(cap<8)return 0;
|
||||
for(i=0;i<count;++i)s->samples[index+i]=read16(req+7+2*i);
|
||||
if(index+count>s->received)s->received=(uint16_t)(index+count);
|
||||
memcpy(out,req,6);length=6;
|
||||
}
|
||||
}
|
||||
} else if(req[1]==3) {
|
||||
if(n!=8||index+count>s->received)error=3;
|
||||
else {
|
||||
if(cap<5+2*(size_t)count)return 0;
|
||||
out[2]=(uint8_t)(2*count);
|
||||
for(i=0;i<count;++i)write16(out+3+2*i,s->samples[index+i]);
|
||||
length=3+2*(size_t)count;
|
||||
}
|
||||
} else error=1;
|
||||
if(error) {out[1]|=0x80;out[2]=(uint8_t)error;length=3;}
|
||||
return seal(out,length);
|
||||
}
|
||||
int set_wave_block_response(const uint8_t *req,size_t n,const uint8_t *reply,
|
||||
size_t size,uint16_t *words,size_t cap) {
|
||||
size_t expected,i;unsigned count;
|
||||
if(!req||!reply||!words||n<8||set_crc16_modbus(req,n))return -1;
|
||||
count=read16(req+4);
|
||||
if(!count||count>SET_WAVE_BLOCK_MAX||read16(req+2)<SET_WAVE_DATA||
|
||||
(unsigned)read16(req+2)+count>SET_WAVE_DATA+SET_WAVE_MAX)return -1;
|
||||
if((req[1]==16&&(n!=9+2*(size_t)count||req[6]!=2*count))||
|
||||
(req[1]==3&&n!=8)||(req[1]!=3&&req[1]!=16))return -1;
|
||||
if(size<2)return 0;
|
||||
if(reply[0]!=req[0])return -1;
|
||||
if(reply[1]==(req[1]|0x80)) {
|
||||
if(size<5)return 0;
|
||||
return size==5&&!set_crc16_modbus(reply,size)?-(100+(int)reply[2]):-1;
|
||||
}
|
||||
if(reply[1]!=req[1])return -1;
|
||||
expected=req[1]==16?8:5+2*(size_t)count;
|
||||
if(size<expected)return 0;
|
||||
if(size!=expected||set_crc16_modbus(reply,size))return -1;
|
||||
if(req[1]==16) {
|
||||
if(cap<1||memcmp(req,reply,6))return -1;
|
||||
words[0]=(uint16_t)count;return 1;
|
||||
}
|
||||
if(cap<count||reply[2]!=2*count)return -1;
|
||||
for(i=0;i<count;++i)words[i]=read16(reply+3+2*i);
|
||||
return (int)count;
|
||||
}
|
||||
22
c/set-protocol/tests/test_firmware_image.c
Normal file
22
c/set-protocol/tests/test_firmware_image.c
Normal file
@@ -0,0 +1,22 @@
|
||||
#include "firmware_image.h"
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
static int parse(const char *text, firmware_hex_cell_t *cells, size_t cap, size_t *count)
|
||||
{ size_t line; return firmware_hex_parse(text, strlen(text), cells, cap, 100, count, &line); }
|
||||
int main(void)
|
||||
{
|
||||
firmware_hex_cell_t cells[32]; size_t count;
|
||||
const char *good = ":020000040800F2\n:0400100001020304E2\n:00000001FF\n";
|
||||
assert(parse(good,cells,32,&count)==0 && count==4);
|
||||
assert(cells[0].address==0x08000010U && cells[3].value==4);
|
||||
assert(firmware_hex_segment_size(cells,count,0)==4);
|
||||
assert(parse(good,cells,3,&count)==FW_HEX_CAPACITY && count==0);
|
||||
assert(parse(":0100000001FE\n:0100000001FE\n:00000001FF",cells,32,&count)==FW_HEX_OVERLAP);
|
||||
assert(parse(":0100000001FF\n:00000001FF",cells,32,&count)==FW_HEX_CHECKSUM);
|
||||
assert(parse(":0100000001FE",cells,32,&count)==FW_HEX_EOF);
|
||||
assert(parse(":0100000001FE\n:00000001FF\n:0100010002FC",cells,32,&count)==FW_HEX_EOF);
|
||||
assert(parse(":0100010002FC\n:0100000001FE\n:00000001FF",cells,32,&count)==0);
|
||||
assert(cells[0].value==1 && cells[1].value==2);
|
||||
assert(parse(":02000004FFFFFC\n:02FFFF000102FD\n:00000001FF",cells,32,&count)==FW_HEX_RANGE);
|
||||
return 0;
|
||||
}
|
||||
51
c/set-protocol/tests/test_signal_wave.c
Normal file
51
c/set-protocol/tests/test_signal_wave.c
Normal file
@@ -0,0 +1,51 @@
|
||||
#include "set_signal.h"
|
||||
#include "set_wavegen.h"
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
static int starts,stops;
|
||||
static int start(void *ctx,const uint16_t *p,size_t n,uint32_t rate) {
|
||||
(void)ctx;assert(n==3&&rate==1000&&p[2]==4095);++starts;return 0;
|
||||
}
|
||||
static void stop(void *ctx) {(void)ctx;++stops;}
|
||||
static int exchange(set_wave_state *s,unsigned op,unsigned index,unsigned value,uint16_t *out) {
|
||||
uint8_t req[8],reply[32];size_t n;
|
||||
assert(set_wave_request(16,op,index,value,req,8)==8);
|
||||
n=set_wave_rtu(s,16,req,8,reply,sizeof(reply));assert(n>0);
|
||||
assert(set_wave_response(req,reply,n-1,out,8)==0);
|
||||
return set_wave_response(req,reply,n,out,8);
|
||||
}
|
||||
int main(void) {
|
||||
double x[3]={0,1,2},y[3]={0,1,0},ox[5],oy[5],meta[3],work[170];
|
||||
uint16_t samples[8],out[8],codes[3];set_wave_state state;
|
||||
set_wave_port port={0,start,stop};uint8_t req[8],reply[32];
|
||||
assert(!set_signal_reconstruct(x,y,3,SET_SIGNAL_SPLINE,2,5,1,ox,oy,meta,work,170));
|
||||
assert(fabs(oy[1]-.6875)<1e-12&&fabs(oy[3]-.6875)<1e-12&&meta[2]<1e-12);
|
||||
y[1]=1.65;y[2]=3.3;assert(!set_signal_dac12(y,3,3.3,codes));
|
||||
assert(codes[0]==0&&codes[1]==2048&&codes[2]==4095);
|
||||
y[0]=-1;codes[0]=123;assert(set_signal_dac12(y,3,3.3,codes)&&codes[0]==123);
|
||||
set_wave_init(&state,samples,8,&port);
|
||||
assert(exchange(&state,0,0,0,out)==8&&out[0]==0x5747&&out[7]==8);
|
||||
assert(exchange(&state,6,0,0,out)<0&&starts==0);
|
||||
assert(exchange(&state,1,0,0,out)==1&&stops==1);
|
||||
assert(exchange(&state,2,0,1000,out)==1);
|
||||
assert(exchange(&state,3,0,3,out)==1);
|
||||
assert(exchange(&state,4,1,10,out)<0); /* no holes */
|
||||
assert(exchange(&state,4,0,0,out)==1);
|
||||
assert(exchange(&state,4,0,0,out)==1&&state.received==1); /* idempotent replay */
|
||||
assert(exchange(&state,4,0,5,out)<0);
|
||||
assert(exchange(&state,5,0,0,out)<0); /* incomplete upload cannot commit */
|
||||
assert(exchange(&state,4,1,2048,out)==1);
|
||||
assert(exchange(&state,4,2,4095,out)==1);
|
||||
assert(exchange(&state,7,1,0,out)==1&&out[0]==2048);
|
||||
assert(exchange(&state,5,0,0,out)==1&&state.ready);
|
||||
assert(exchange(&state,6,0,0,out)==1&&state.running&&starts==1);
|
||||
assert(exchange(&state,3,0,2,out)<0&&state.count==3);
|
||||
assert(exchange(&state,4,0,2,out)<0&&samples[0]==0);
|
||||
set_wave_request(16,1,0,0,req,8);req[7]^=1;
|
||||
assert(!set_wave_rtu(&state,16,req,8,reply,32)&&state.running);
|
||||
set_wave_request(17,1,0,0,req,8);
|
||||
assert(!set_wave_rtu(&state,16,req,8,reply,32)&&state.running);
|
||||
assert(exchange(&state,1,0,0,out)==1&&!state.running);
|
||||
return 0;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
27
python/examples/plot_processing.py
Normal file
27
python/examples/plot_processing.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Sparse sine reconstruction using the public contract, independent of a GUI."""
|
||||
import argparse
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from set_devices.plot_processing import Axis, Series, Snapshot, prepare, process, write_csv
|
||||
from set_devices.signal_reconstruction import METHODS
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--method", choices=METHODS, default="spline")
|
||||
parser.add_argument("--degree", type=int, default=3)
|
||||
parser.add_argument("--points", type=int, default=201)
|
||||
parser.add_argument("--output", type=Path, default=Path("curve.csv"))
|
||||
args = parser.parse_args()
|
||||
samples = [(i * 125, math.sin(i * math.pi / 4)) for i in range(9)]
|
||||
snapshot = Snapshot((Series("sine", "Синусоида: 9 отсчётов", samples, y_unit="В"),),
|
||||
Axis("Время", "мс"), "sparse-sine")
|
||||
curve = process(prepare(snapshot, "sine", args.method, args.points, args.degree))
|
||||
with args.output.open("w", encoding="utf-8-sig", newline="") as stream:
|
||||
write_csv(curve, stream)
|
||||
print(f"{args.output}: {curve.input_count} -> {len(curve.points)}; RMSE={curve.rmse:.6g}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
31
python/firmware_image/README.md
Normal file
31
python/firmware_image/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Общие образы прошивки
|
||||
|
||||
SETGUI и климатический bridge используют один пакет и один C99-парсер
|
||||
`c/set-protocol/src/firmware_image.c`. Python преобразует результат C в
|
||||
`HexSegment`; алгоритмического fallback нет. Карта Flash, план записи,
|
||||
заполнение промежутков FF и стандартные CRC32/SHA256 доступны через `core`.
|
||||
Запуск программатора, файловый allowlist и журнал остаются в приложении.
|
||||
|
||||
Строгая политика HEX одинакова для обоих потребителей: checksum, обязательный
|
||||
EOF, запрет данных после EOF, запрет любых перекрытий, проверка длины и адресов.
|
||||
Непрерывный образ имеет заданный предел **до** выделения его буфера.
|
||||
Профиль STM32F407VE сохраняется для совместимости; другой MCU передаёт свой
|
||||
`FlashLayout` в функции планирования.
|
||||
|
||||
Из корня templates:
|
||||
|
||||
```powershell
|
||||
python c/set-protocol/tools/build_host.py --output python/protocan/native/setprotocol.dll
|
||||
python -m pip install -e ./python
|
||||
python -m unittest discover -s python/tests -p test_firmware_image_shared.py
|
||||
```
|
||||
|
||||
На Linux/macOS имя результата — `libsetprotocol.so`/`libsetprotocol.dylib`.
|
||||
Также поддерживается явный путь `SETPROTOCOL_LIBRARY`. Старая DLL без API
|
||||
образов выдаёт понятную ошибку с требованием пересборки.
|
||||
|
||||
```python
|
||||
from firmware_image.core import parse_intel_hex, contiguous_image
|
||||
segments = parse_intel_hex(':0100000001FE\n:00000001FF')
|
||||
image, base = contiguous_image(':0100000001FE\n:00000001FF', maximum_size=1024)
|
||||
```
|
||||
2
python/firmware_image/__init__.py
Normal file
2
python/firmware_image/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Shared firmware images and flash layout plans."""
|
||||
from .core import *
|
||||
373
python/firmware_image/core.py
Normal file
373
python/firmware_image/core.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""Переносимое ядро проверки образа прошивки STM32.
|
||||
|
||||
Модуль не знает ни про HTTP, ни про Modbus, ни про файловую систему, ни про
|
||||
конкретный программатор: на вход подаются уже прочитанные байты, на выходе
|
||||
получается проверенный план записи. Благодаря этому те же правила проверяются
|
||||
host-тестом без платы и без bridge.
|
||||
|
||||
Основные границы:
|
||||
|
||||
* :func:`parse_intel_hex` — полный разбор Intel HEX с проверкой checksum каждой
|
||||
записи, поддержкой расширенной адресации и поиском пересечений;
|
||||
* :func:`build_raw_plan` / :func:`build_hex_plan` — план записи с проверкой
|
||||
выравнивания, границ приложения и защищённых секторов;
|
||||
* :func:`image_digest` — CRC32 и SHA-256 фактически записываемых байтов.
|
||||
|
||||
Геометрия Flash задаётся объектом :class:`FlashLayout`, поэтому ядро не привязано
|
||||
к STM32F407: значения по умолчанию описывают текущую плату и берутся из
|
||||
``MDK-ARM/flash_layout.sct`` и ``EEPROM_Emul/lib/flash_ring.h``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import zlib
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Optional, Sequence
|
||||
|
||||
__all__ = [
|
||||
"FirmwareImageError",
|
||||
"FlashLayout",
|
||||
"FlashRegion",
|
||||
"FlashSector",
|
||||
"HexSegment",
|
||||
"ImagePlan",
|
||||
"STM32F407VE_LAYOUT",
|
||||
"RAW_EXTENSIONS",
|
||||
"HEX_EXTENSIONS",
|
||||
"build_hex_plan",
|
||||
"build_raw_plan",
|
||||
"detect_format",
|
||||
"format_address",
|
||||
"image_digest",
|
||||
"parse_flash_address",
|
||||
"parse_intel_hex",
|
||||
]
|
||||
|
||||
|
||||
class FirmwareImageError(ValueError):
|
||||
"""Единственный тип ошибки ядра: сообщение пригодно для показа оператору."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Геометрия Flash
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass(frozen=True)
|
||||
class FlashSector:
|
||||
index: int
|
||||
start: int
|
||||
size: int
|
||||
|
||||
@property
|
||||
def end(self) -> int:
|
||||
"""Последний байт сектора включительно."""
|
||||
return self.start + self.size - 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlashRegion:
|
||||
name: str
|
||||
start: int
|
||||
end: int
|
||||
|
||||
def overlaps(self, start: int, end: int) -> bool:
|
||||
return start <= self.end and end >= self.start
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlashLayout:
|
||||
"""Полное описание внутренней Flash целевого МК."""
|
||||
|
||||
name: str
|
||||
sectors: Sequence[FlashSector]
|
||||
application: FlashRegion
|
||||
reserved: Sequence[FlashRegion] = field(default_factory=tuple)
|
||||
write_alignment: int = 4
|
||||
min_image_bytes: int = 8
|
||||
|
||||
@property
|
||||
def max_image_bytes(self) -> int:
|
||||
return self.application.end - self.application.start + 1
|
||||
|
||||
def sector_at(self, address: int) -> Optional[FlashSector]:
|
||||
for sector in self.sectors:
|
||||
if sector.start <= address <= sector.end:
|
||||
return sector
|
||||
return None
|
||||
|
||||
def sectors_for_range(self, start: int, end: int) -> list[FlashSector]:
|
||||
return [sector for sector in self.sectors if start <= sector.end and end >= sector.start]
|
||||
|
||||
def reserved_hit(self, start: int, end: int) -> Optional[FlashRegion]:
|
||||
for region in self.reserved:
|
||||
if region.overlaps(start, end):
|
||||
return region
|
||||
return None
|
||||
|
||||
|
||||
# Секторы STM32F407VE: 4x16 КиБ, 64 КиБ, 3x128 КиБ.
|
||||
STM32F407VE_LAYOUT = FlashLayout(
|
||||
name="STM32F407VET6",
|
||||
sectors=(
|
||||
FlashSector(0, 0x08000000, 16 * 1024),
|
||||
FlashSector(1, 0x08004000, 16 * 1024),
|
||||
FlashSector(2, 0x08008000, 16 * 1024),
|
||||
FlashSector(3, 0x0800C000, 16 * 1024),
|
||||
FlashSector(4, 0x08010000, 64 * 1024),
|
||||
FlashSector(5, 0x08020000, 128 * 1024),
|
||||
FlashSector(6, 0x08040000, 128 * 1024),
|
||||
FlashSector(7, 0x08060000, 128 * 1024),
|
||||
),
|
||||
application=FlashRegion("Приложение", 0x08000000, 0x0803FFFF),
|
||||
# Секторы 6-7 отданы кольцу EEPROM: их стирание уничтожило бы настройки.
|
||||
reserved=(FlashRegion("Кольцо EEPROM (секторы 6-7)", 0x08040000, 0x0807FFFF),),
|
||||
)
|
||||
|
||||
RAW_EXTENSIONS = (".bin", ".fw")
|
||||
HEX_EXTENSIONS = (".hex",)
|
||||
|
||||
|
||||
def detect_format(name: str) -> str:
|
||||
"""Формат образа по расширению; исключение для всего остального."""
|
||||
lower = str(name or "").lower()
|
||||
if lower.endswith(RAW_EXTENSIONS):
|
||||
return "bin"
|
||||
if lower.endswith(HEX_EXTENSIONS):
|
||||
return "hex"
|
||||
raise FirmwareImageError("Поддерживаются только .bin/.hex/.fw")
|
||||
|
||||
|
||||
def format_address(address: int) -> str:
|
||||
return f"0x{int(address):08X}"
|
||||
|
||||
|
||||
def parse_flash_address(value: object) -> int:
|
||||
"""Адрес принимается только как явный decimal или HEX с префиксом 0x."""
|
||||
text = str(value if value is not None else "").strip().replace(" ", "")
|
||||
if not text:
|
||||
raise FirmwareImageError("Базовый адрес Flash не задан")
|
||||
if not re.fullmatch(r"0[xX][0-9a-fA-F]{1,8}|\d+", text):
|
||||
raise FirmwareImageError("Адрес должен быть decimal или HEX с префиксом 0x")
|
||||
address = int(text, 16 if text.lower().startswith("0x") else 10)
|
||||
if not 0 <= address <= 0xFFFFFFFF:
|
||||
raise FirmwareImageError("Адрес выходит за диапазон uint32")
|
||||
return address
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intel HEX
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass(frozen=True)
|
||||
class HexSegment:
|
||||
"""Непрерывный участок данных Intel HEX."""
|
||||
|
||||
start: int
|
||||
data: bytes
|
||||
|
||||
@property
|
||||
def end(self) -> int:
|
||||
return self.start + len(self.data) - 1
|
||||
|
||||
|
||||
RECORD_DATA = 0x00
|
||||
RECORD_EOF = 0x01
|
||||
RECORD_EXTENDED_SEGMENT = 0x02
|
||||
RECORD_START_SEGMENT = 0x03
|
||||
RECORD_EXTENDED_LINEAR = 0x04
|
||||
RECORD_START_LINEAR = 0x05
|
||||
_KNOWN_RECORDS = (RECORD_DATA, RECORD_EOF, RECORD_EXTENDED_SEGMENT,
|
||||
RECORD_START_SEGMENT, RECORD_EXTENDED_LINEAR, RECORD_START_LINEAR)
|
||||
|
||||
# Лимит защищает bridge от файла, который «раздувается» адресными записями.
|
||||
MAX_HEX_RECORDS = 1_000_000
|
||||
|
||||
|
||||
def parse_intel_hex(text: object, *, max_records: int = MAX_HEX_RECORDS) -> list[HexSegment]:
|
||||
"""Validate HEX with the shared C99 parser and return contiguous segments."""
|
||||
from .native import parse_segments
|
||||
return parse_segments(text, max_records, FirmwareImageError, HexSegment)
|
||||
|
||||
|
||||
def contiguous_image(text, maximum_size=2 * 1024 * 1024):
|
||||
"""Convert validated segments to a bounded image, filling gaps with FF."""
|
||||
segments = parse_intel_hex(text)
|
||||
base, end = segments[0].start, segments[-1].end + 1
|
||||
if end - base > maximum_size:
|
||||
raise FirmwareImageError('Размер образа превышает %d байт' % maximum_size)
|
||||
image = bytearray(b'\xff' * (end - base))
|
||||
for segment in segments:
|
||||
offset = segment.start - base
|
||||
image[offset:offset + len(segment.data)] = segment.data
|
||||
return bytes(image), base
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImagePlan:
|
||||
"""Проверенный план: что именно и куда будет записано."""
|
||||
|
||||
image_format: str
|
||||
start: int
|
||||
end: int
|
||||
payload_bytes: int
|
||||
sectors: tuple[int, ...]
|
||||
erased_bytes: int
|
||||
segments: tuple[HexSegment, ...]
|
||||
gaps: tuple[tuple[int, int], ...]
|
||||
crc32: str
|
||||
sha256: str
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return f"{format_address(self.start)}..{format_address(self.end)}"
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
"""Представление для JSON API и GUI."""
|
||||
return {
|
||||
"format": self.image_format,
|
||||
"start": format_address(self.start),
|
||||
"end": format_address(self.end),
|
||||
"targetRange": self.text,
|
||||
"payloadBytes": self.payload_bytes,
|
||||
"spanBytes": self.end - self.start + 1,
|
||||
"sectors": list(self.sectors),
|
||||
"erasedBytes": self.erased_bytes,
|
||||
"segments": [
|
||||
{"start": format_address(item.start), "end": format_address(item.end),
|
||||
"size": len(item.data)}
|
||||
for item in self.segments
|
||||
],
|
||||
"gaps": [
|
||||
{"start": format_address(low), "end": format_address(high), "size": high - low + 1}
|
||||
for low, high in self.gaps
|
||||
],
|
||||
"crc32": self.crc32,
|
||||
"sha256": self.sha256,
|
||||
}
|
||||
|
||||
|
||||
def image_digest(data: bytes) -> tuple[str, str]:
|
||||
"""CRC32 и SHA-256 фактически записываемых байтов."""
|
||||
return (
|
||||
f"{zlib.crc32(bytes(data)) & 0xFFFFFFFF:08X}",
|
||||
hashlib.sha256(bytes(data)).hexdigest().upper(),
|
||||
)
|
||||
|
||||
|
||||
def _check_range(layout: FlashLayout, start: int, end: int) -> None:
|
||||
reserved = layout.reserved_hit(start, end)
|
||||
if reserved is not None:
|
||||
raise FirmwareImageError(f"Диапазон пересекает защищённую область: {reserved.name}")
|
||||
if start < layout.application.start or end > layout.application.end:
|
||||
raise FirmwareImageError(
|
||||
"Диапазон должен помещаться в "
|
||||
f"{format_address(layout.application.start)}.."
|
||||
f"{format_address(layout.application.end)}"
|
||||
)
|
||||
|
||||
|
||||
def _sector_summary(layout: FlashLayout, start: int, end: int) -> tuple[tuple[int, ...], int]:
|
||||
sectors = layout.sectors_for_range(start, end)
|
||||
return tuple(item.index for item in sectors), sum(item.size for item in sectors)
|
||||
|
||||
|
||||
def build_raw_plan(size: object, base_address: object, *,
|
||||
layout: FlashLayout = STM32F407VE_LAYOUT,
|
||||
data: Optional[bytes] = None) -> ImagePlan:
|
||||
"""План для сырого образа.
|
||||
|
||||
Базовый адрес обязан совпадать с началом сектора: стирание STM32F4 идёт
|
||||
только целыми секторами, поэтому запись с середины сектора уничтожила бы
|
||||
соседние данные. ``data`` необязателен: список файлов SD знает лишь размер,
|
||||
и контрольные суммы считаются только тогда, когда содержимое действительно
|
||||
прочитано.
|
||||
"""
|
||||
try:
|
||||
length = int(size)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FirmwareImageError("Размер образа должен быть целым числом") from exc
|
||||
if data is not None and len(data) != length:
|
||||
raise FirmwareImageError("Размер образа не совпадает с числом прочитанных байт")
|
||||
if length < layout.min_image_bytes:
|
||||
raise FirmwareImageError(f"Размер образа меньше {layout.min_image_bytes} байт")
|
||||
if length > layout.max_image_bytes:
|
||||
raise FirmwareImageError(
|
||||
f"Образ больше области приложения ({layout.max_image_bytes // 1024} КиБ)"
|
||||
)
|
||||
start = parse_flash_address(base_address)
|
||||
if start % layout.write_alignment:
|
||||
raise FirmwareImageError(
|
||||
f"Базовый адрес должен быть выровнен на {layout.write_alignment} байта"
|
||||
)
|
||||
sector = layout.sector_at(start)
|
||||
if sector is None:
|
||||
raise FirmwareImageError(f"Базовый адрес вне внутренней Flash {layout.name}")
|
||||
if sector.start != start:
|
||||
raise FirmwareImageError(
|
||||
f"Базовый адрес должен совпадать с началом сектора ({format_address(sector.start)})"
|
||||
)
|
||||
end = start + length - 1
|
||||
_check_range(layout, start, end)
|
||||
sectors, erased = _sector_summary(layout, start, end)
|
||||
crc32, sha256 = image_digest(data) if data is not None else ("", "")
|
||||
segments = (HexSegment(start, bytes(data)),) if data is not None else ()
|
||||
return ImagePlan("bin", start, end, length, sectors, erased, segments, (), crc32, sha256)
|
||||
|
||||
|
||||
def build_hex_plan(segments: Iterable[HexSegment], *,
|
||||
layout: FlashLayout = STM32F407VE_LAYOUT) -> ImagePlan:
|
||||
"""План для Intel HEX: адреса берутся из файла, оператор их не задаёт.
|
||||
|
||||
Проверяется каждый сегмент отдельно, поэтому файл с одним «правильным» и
|
||||
одним посторонним диапазоном отклоняется целиком. Разрывы между сегментами
|
||||
сохраняются в плане: при записи они заполняются 0xFF, и оператор должен
|
||||
видеть, какая часть Flash будет стёрта без данных из файла.
|
||||
"""
|
||||
ordered = sorted(segments, key=lambda item: item.start)
|
||||
if not ordered:
|
||||
raise FirmwareImageError("Файл Intel HEX не содержит данных")
|
||||
for segment in ordered:
|
||||
if not segment.data:
|
||||
raise FirmwareImageError("Сегмент Intel HEX пуст")
|
||||
_check_range(layout, segment.start, segment.end)
|
||||
start = ordered[0].start
|
||||
end = ordered[-1].end
|
||||
if start % layout.write_alignment:
|
||||
raise FirmwareImageError(
|
||||
f"Начальный адрес Intel HEX не выровнен на {layout.write_alignment} байта"
|
||||
)
|
||||
sector = layout.sector_at(start)
|
||||
if sector is None or sector.start != start:
|
||||
raise FirmwareImageError(
|
||||
"Первый адрес Intel HEX должен совпадать с началом сектора "
|
||||
f"({format_address(sector.start) if sector else 'вне Flash'})"
|
||||
)
|
||||
# Пустоты заполняются 0xFF — стёртым состоянием NOR Flash.
|
||||
image = bytearray(b"\xFF" * (end - start + 1))
|
||||
gaps: list[tuple[int, int]] = []
|
||||
previous_end = None
|
||||
payload = 0
|
||||
for segment in ordered:
|
||||
if previous_end is not None and segment.start > previous_end + 1:
|
||||
gaps.append((previous_end + 1, segment.start - 1))
|
||||
image[segment.start - start:segment.end - start + 1] = segment.data
|
||||
payload += len(segment.data)
|
||||
previous_end = segment.end
|
||||
sectors, erased = _sector_summary(layout, start, end)
|
||||
crc32, sha256 = image_digest(bytes(image))
|
||||
return ImagePlan("hex", start, end, payload, sectors, erased,
|
||||
tuple(ordered), tuple(gaps), crc32, sha256)
|
||||
|
||||
|
||||
def build_plan(image_format: str, *, data: Optional[bytes] = None, size: Optional[int] = None,
|
||||
base_address: object = None,
|
||||
layout: FlashLayout = STM32F407VE_LAYOUT) -> ImagePlan:
|
||||
"""Единая точка входа: выбрать ветку разбора по формату образа."""
|
||||
if image_format == "hex":
|
||||
if data is None:
|
||||
raise FirmwareImageError("Для Intel HEX требуется содержимое файла")
|
||||
return build_hex_plan(parse_intel_hex(data), layout=layout)
|
||||
if image_format == "bin":
|
||||
length = len(data) if data is not None else size
|
||||
return build_raw_plan(length, base_address, layout=layout, data=data)
|
||||
raise FirmwareImageError("Неизвестный формат образа")
|
||||
48
python/firmware_image/native.py
Normal file
48
python/firmware_image/native.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Models for the C99 Intel HEX parser. No Python parser fallback."""
|
||||
import ctypes as ct
|
||||
from functools import lru_cache
|
||||
from protocan.native import _load_library
|
||||
|
||||
class Cell(ct.Structure):
|
||||
_fields_ = [('address', ct.c_uint32), ('value', ct.c_uint8)]
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def library():
|
||||
lib = _load_library()
|
||||
try:
|
||||
lib.firmware_hex_parse.argtypes = [ct.c_char_p, ct.c_size_t, ct.POINTER(Cell),
|
||||
ct.c_size_t, ct.c_size_t, ct.POINTER(ct.c_size_t), ct.POINTER(ct.c_size_t)]
|
||||
lib.firmware_hex_parse.restype = ct.c_int
|
||||
lib.firmware_hex_segment_size.argtypes = [ct.POINTER(Cell), ct.c_size_t, ct.c_size_t]
|
||||
lib.firmware_hex_segment_size.restype = ct.c_size_t
|
||||
except AttributeError as exc:
|
||||
raise RuntimeError('Rebuild SETProtocol: native firmware image API is missing') from exc
|
||||
return lib
|
||||
|
||||
def parse_segments(text, max_records, error_type, segment_type):
|
||||
try:
|
||||
raw = bytes(text) if isinstance(text, (bytes, bytearray)) else str(text or '').encode('ascii')
|
||||
except UnicodeError as exc:
|
||||
raise error_type('Intel HEX должен состоять из ASCII') from exc
|
||||
if max_records < 1 or len(raw) > 64 * 1024 * 1024:
|
||||
raise error_type('Файл Intel HEX превышает допустимый размер')
|
||||
cells = (Cell * max(1, len(raw) // 2))()
|
||||
count, line = ct.c_size_t(), ct.c_size_t()
|
||||
lib = library()
|
||||
status = lib.firmware_hex_parse(raw, len(raw), cells, len(cells), max_records,
|
||||
ct.byref(count), ct.byref(line))
|
||||
if status:
|
||||
messages = {1: 'неверный формат', 2: 'неверная checksum записи',
|
||||
3: 'неизвестный тип или неверная длина записи', 4: 'нет данных/EOF или данные после записи EOF',
|
||||
5: 'адрес описан повторно', 6: 'адрес выходит за uint32',
|
||||
7: 'превышено допустимое число записей',
|
||||
8: "запись должна начинаться с ':'", 9: 'запись содержит не пары HEX-символов',
|
||||
10: 'длина записи не совпадает с полем count'}
|
||||
raise error_type('Intel HEX, строка %d: %s' % (line.value, messages.get(status, 'ошибка')))
|
||||
result, offset = [], 0
|
||||
while offset < count.value:
|
||||
size = lib.firmware_hex_segment_size(cells, count.value, offset)
|
||||
result.append(segment_type(cells[offset].address,
|
||||
bytes(cells[i].value for i in range(offset, offset + size))))
|
||||
offset += size
|
||||
return result
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
117
python/set_devices/plot_processing.py
Normal file
117
python/set_devices/plot_processing.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Renderer-independent processing contract. Numerical work stays in C99.
|
||||
|
||||
Adapters publish immutable snapshots in the displayed units. Calculated curves
|
||||
are separate objects, never channels in the acquisition or measurement model.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from .signal_reconstruction import METHODS, reconstruct
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Axis:
|
||||
label: str = "Время"
|
||||
unit: str = "мс"
|
||||
encoding: str = "numeric" # numeric or unix_ms; never infer from magnitude
|
||||
|
||||
def __post_init__(self):
|
||||
if self.encoding not in ("numeric", "unix_ms"):
|
||||
raise ValueError("Неизвестное представление оси X")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Series:
|
||||
key: str
|
||||
label: str
|
||||
points: tuple
|
||||
visible: bool = True
|
||||
discrete: bool = False
|
||||
y_unit: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
# A source may reuse mutable buffers immediately after publication.
|
||||
object.__setattr__(self, "points", tuple((float(x), float(y)) for x, y in self.points))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Snapshot:
|
||||
series: tuple = ()
|
||||
axis: Axis = Axis()
|
||||
source: str = ""
|
||||
x_range: tuple | None = None
|
||||
blocked_reason: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(self, "series", tuple(self.series))
|
||||
if len({s.key for s in self.series}) != len(self.series):
|
||||
raise ValueError("Ключи каналов должны быть уникальны")
|
||||
if self.x_range is not None:
|
||||
left, right = self.x_range
|
||||
if not left <= right:
|
||||
raise ValueError("Неверные границы окна")
|
||||
object.__setattr__(self, "x_range", (left, right))
|
||||
|
||||
@property
|
||||
def analogs(self):
|
||||
return tuple(s for s in self.series if s.visible and not s.discrete)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Request:
|
||||
series: Series
|
||||
axis: Axis
|
||||
source: str
|
||||
method: str
|
||||
output_count: int
|
||||
degree: int
|
||||
x_range: tuple | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Curve:
|
||||
request: Request
|
||||
points: tuple
|
||||
input_count: int
|
||||
unique_count: int
|
||||
rmse: float
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return f"{self.request.series.label} · расчёт ({METHODS[self.request.method]})"
|
||||
|
||||
|
||||
def prepare(snapshot, key, method="pchip", output_count=1000, degree=2):
|
||||
"""Return a comparable request, or None when the source is not processable."""
|
||||
if snapshot.blocked_reason:
|
||||
return None
|
||||
selected = next((s for s in snapshot.analogs if s.key == key), None)
|
||||
if selected is None:
|
||||
return None
|
||||
if snapshot.x_range is not None:
|
||||
left, right = snapshot.x_range
|
||||
selected = Series(selected.key, selected.label,
|
||||
tuple((x, y) for x, y in selected.points if left <= x <= right),
|
||||
y_unit=selected.y_unit)
|
||||
return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range)
|
||||
|
||||
|
||||
def process(request):
|
||||
if request is None:
|
||||
raise ValueError("Нет доступного аналогового канала")
|
||||
result = reconstruct(request.series.points, request.method, request.output_count, request.degree)
|
||||
return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse)
|
||||
|
||||
|
||||
def write_csv(curve, stream):
|
||||
"""CSV preserves the X domain. Relative time/frequency are numeric, not dates."""
|
||||
axis = curve.request.axis
|
||||
writer = csv.writer(stream)
|
||||
x_title = "timestamp" if axis.encoding == "unix_ms" else axis.label + (f" [{axis.unit}]" if axis.unit else "")
|
||||
y_title = curve.label + (f" [{curve.request.series.y_unit}]" if curve.request.series.y_unit else "")
|
||||
writer.writerow([x_title, y_title])
|
||||
for x, y in curve.points:
|
||||
value = (datetime.fromtimestamp(x / 1000, timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
||||
if axis.encoding == "unix_ms" else x)
|
||||
writer.writerow([value, y])
|
||||
183
python/set_devices/qt_ports/f407_usb_bootloader.py
Normal file
183
python/set_devices/qt_ports/f407_usb_bootloader.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Asynchronous SET v2 USB updater for the STM32F407VE single-slot port."""
|
||||
from __future__ import annotations
|
||||
import struct
|
||||
from .qt_compat import QObject, QTimer, Signal, QSerialPort
|
||||
from setprotocol.core import (Frame, FrameFlag, FrameParser, build_frame,
|
||||
decode_response, DeviceInfo, FirmwareBegin, FirmwareFlag, encode_firmware_data)
|
||||
|
||||
APP_BASE = 0x08010000
|
||||
APP_SIZE = 0x70000
|
||||
BOOT_MODEL = "STM32F407-EMU-USB-BOOT"
|
||||
|
||||
|
||||
def validate_image(image):
|
||||
if image.base_address != APP_BASE or not 8 <= len(image.data) <= APP_SIZE:
|
||||
raise ValueError("Нужен образ приложения для 0x08010000, размером до 448 КБ")
|
||||
sp, pc = struct.unpack_from("<II", image.data)
|
||||
if sp & 7 or not (0x20000000 < sp <= 0x20020000 or 0x10000000 < sp <= 0x10010000):
|
||||
raise ValueError("Неверный начальный указатель стека")
|
||||
if not pc & 1 or not APP_BASE <= (pc & ~1) < APP_BASE + len(image.data):
|
||||
raise ValueError("Образ собран не для USB-загрузчика (нужен адрес 0x08010000)")
|
||||
|
||||
|
||||
class F407UsbBootloader(QObject):
|
||||
progress = Signal(int, str)
|
||||
finished = Signal(bool, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._serial.readyRead.connect(self._read)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._timeout)
|
||||
self._reconnect = QTimer(self)
|
||||
self._reconnect.setSingleShot(True)
|
||||
self._reconnect.timeout.connect(self._open_boot)
|
||||
self._active = False
|
||||
self._sequence = 0
|
||||
self._pending = None
|
||||
self._parser = FrameParser()
|
||||
self._attempts = 0
|
||||
|
||||
def start(self, image, port_name, baud_rate=115200):
|
||||
if self._active:
|
||||
return
|
||||
try:
|
||||
validate_image(image)
|
||||
if not port_name:
|
||||
raise ValueError("Выберите COM-порт USB платы")
|
||||
except ValueError as error:
|
||||
self.finished.emit(False, str(error))
|
||||
return
|
||||
self._active = True
|
||||
self._image = image
|
||||
self._port_name = port_name
|
||||
self._offset = 0
|
||||
self._attempts = 0
|
||||
self._parser = FrameParser()
|
||||
if not self._open():
|
||||
self._finish(False, "Не удалось открыть USB COM-порт: " + self._serial.errorString())
|
||||
return
|
||||
self.progress.emit(0, "Определение USB-загрузчика")
|
||||
self._request(2, b"", "identify", 2500)
|
||||
|
||||
def _open(self):
|
||||
self._serial.close()
|
||||
self._serial.setPortName(self._port_name)
|
||||
self._serial.setBaudRate(115200)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
self._parser = FrameParser()
|
||||
return self._serial.open(QSerialPort.OpenModeFlag.ReadWrite)
|
||||
|
||||
def _request(self, kind, payload, phase, timeout=20000):
|
||||
self._sequence = (self._sequence + 1) & 0xffff
|
||||
self._pending = (kind, self._sequence, phase)
|
||||
packet = build_frame(Frame(kind, self._sequence, payload, FrameFlag.ACK_REQUIRED, 42, 0))
|
||||
if self._serial.write(packet) != len(packet):
|
||||
self._finish(False, "Ошибка передачи USB")
|
||||
return
|
||||
self._timer.start(timeout)
|
||||
|
||||
def _read(self):
|
||||
for frame in self._parser.feed(bytes(self._serial.readAll())):
|
||||
if not self._active or self._pending is None:
|
||||
continue
|
||||
kind, sequence, phase = self._pending
|
||||
if (frame.message_type != kind or frame.sequence != sequence or
|
||||
frame.source != 16 or frame.destination != 42 or
|
||||
not frame.flags & FrameFlag.RESPONSE):
|
||||
continue
|
||||
self._timer.stop()
|
||||
self._pending = None
|
||||
try:
|
||||
status, body = decode_response(frame)
|
||||
except ValueError as error:
|
||||
self._finish(False, "Неверный ответ загрузчика: " + str(error))
|
||||
return
|
||||
if status:
|
||||
self._finish(False, f"Загрузчик отклонил {phase}: код {status}. Повторите загрузку целиком.")
|
||||
return
|
||||
try:
|
||||
self._advance(phase, body)
|
||||
except (ValueError, struct.error) as error:
|
||||
self._finish(False, "Неверный ответ загрузчика: " + str(error))
|
||||
return
|
||||
|
||||
def _advance(self, phase, body):
|
||||
if phase in ("identify", "reconnect"):
|
||||
model = DeviceInfo.decode(body).model
|
||||
if model == BOOT_MODEL:
|
||||
self._attempts = 0
|
||||
image = self._image
|
||||
begin = FirmwareBegin(len(image.data), image.crc32, image.version,
|
||||
APP_BASE, 0, 256, bytes(32), FirmwareFlag.ERASE_SLOT)
|
||||
self.progress.emit(1, "Стирание области приложения")
|
||||
self._request(0x100, begin.encode(), "begin")
|
||||
elif model == "STM32F407-EMU-EXPERIMENT" and phase == "identify":
|
||||
self._request(0x1003, b"", "enter")
|
||||
elif phase == "reconnect" and model == "STM32F407-EMU-EXPERIMENT":
|
||||
self._retry_open()
|
||||
else:
|
||||
self._finish(False, "Выбрано другое устройство: " + model)
|
||||
elif phase == "enter":
|
||||
self.progress.emit(0, "Ожидание переподключения USB")
|
||||
self._serial.close()
|
||||
self._attempts = 40
|
||||
self._reconnect.start(1000)
|
||||
elif phase in ("begin", "data"):
|
||||
if phase == "data":
|
||||
self._offset += self._sent
|
||||
if self._offset == len(self._image.data):
|
||||
self.progress.emit(98, "Проверка CRC содержимого Flash")
|
||||
self._request(0x102, struct.pack("<II32s", len(self._image.data),
|
||||
self._image.crc32, bytes(32)), "end")
|
||||
else:
|
||||
block = self._image.data[self._offset:self._offset+256]
|
||||
self._sent = len(block)
|
||||
self.progress.emit(2 + 95*self._offset//len(self._image.data),
|
||||
f"Запись {self._offset}/{len(self._image.data)} байт")
|
||||
self._request(0x101, encode_firmware_data(self._offset, block), "data")
|
||||
elif phase == "end":
|
||||
self._request(0x105, b"", "activate")
|
||||
elif phase == "activate":
|
||||
self.progress.emit(100, "CRC проверена, команда запуска подтверждена")
|
||||
self._finish(True, "Прошивка записана. Плата перезапускается; подключитесь к ней снова.")
|
||||
|
||||
def _retry_open(self):
|
||||
self._serial.close()
|
||||
if self._attempts <= 0:
|
||||
self._finish(False, "USB не появился. Выберите COM-порт загрузчика и повторите загрузку.")
|
||||
else:
|
||||
self._reconnect.start(500)
|
||||
|
||||
def _open_boot(self):
|
||||
if not self._active:
|
||||
return
|
||||
self._attempts -= 1
|
||||
if self._open():
|
||||
self._request(2, b"", "reconnect", 500)
|
||||
else:
|
||||
self._retry_open()
|
||||
|
||||
def _timeout(self):
|
||||
if self._pending and self._pending[2] == "reconnect":
|
||||
self._pending = None
|
||||
self._retry_open()
|
||||
else:
|
||||
self._finish(False, "Нет ответа USB. Проверьте порт и повторите полную загрузку.")
|
||||
|
||||
def cancel(self):
|
||||
if self._active:
|
||||
self._finish(False, "Загрузка отменена. Незавершённый образ не запустится; повторите загрузку.")
|
||||
|
||||
def _finish(self, ok, text):
|
||||
self._active = False
|
||||
self._pending = None
|
||||
self._timer.stop()
|
||||
self._reconnect.stop()
|
||||
self._serial.close()
|
||||
self.finished.emit(ok, text)
|
||||
293
python/set_devices/qt_ports/plot_processing.py
Normal file
293
python/set_devices/qt_ports/plot_processing.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Reusable Qt processing panel and plot attachment; no SETGUI dependency."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import replace
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import QObject, QPointF, QRunnable, QThreadPool, QSignalBlocker, QTimer, Qt, Signal, Slot
|
||||
from PySide6.QtGui import QColor, QPainterPath, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSpinBox,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QObject, QPointF, QRunnable, QThreadPool, QSignalBlocker, QTimer, Qt, Signal, Slot
|
||||
from PySide2.QtGui import QColor, QPainterPath, QPen
|
||||
from PySide2.QtWidgets import (
|
||||
QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSpinBox,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from set_devices.signal_reconstruction import METHODS
|
||||
from set_devices.plot_processing import Snapshot, prepare, process, write_csv
|
||||
|
||||
|
||||
class _ResultSignals(QObject):
|
||||
done = Signal(object, object, str)
|
||||
|
||||
|
||||
class _Calculation(QRunnable):
|
||||
def __init__(self, signature):
|
||||
super().__init__()
|
||||
self.signature = signature
|
||||
self.signals = _ResultSignals()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
result = process(self.signature)
|
||||
except Exception as error:
|
||||
self.signals.done.emit(self.signature, None, str(error))
|
||||
else:
|
||||
self.signals.done.emit(self.signature, result, "")
|
||||
|
||||
|
||||
class SignalProcessingPanel(QWidget):
|
||||
changed = Signal()
|
||||
RESULT_KEY = "__calculated_trend__"
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._snapshot = Snapshot()
|
||||
self._result = None
|
||||
self._result_signature = None
|
||||
self._job = None
|
||||
self._revision = 0
|
||||
self._job_revision = 0
|
||||
self.preserve_on_view_change = False
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(QLabel("Обработка"))
|
||||
self.channel = QComboBox()
|
||||
self.channel.setMinimumWidth(140)
|
||||
row.addWidget(self.channel)
|
||||
self.method = QComboBox()
|
||||
for key, title in METHODS.items():
|
||||
self.method.addItem(title, key)
|
||||
self.method.setCurrentIndex(2)
|
||||
row.addWidget(self.method)
|
||||
row.addStretch(1)
|
||||
layout.addLayout(row)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(QLabel("Степень"))
|
||||
self.degree = QSpinBox()
|
||||
self.degree.setRange(1, 5)
|
||||
self.degree.setValue(2)
|
||||
row.addWidget(self.degree)
|
||||
row.addWidget(QLabel("Точек результата"))
|
||||
self.count = QSpinBox()
|
||||
self.count.setRange(2, 10000)
|
||||
self.count.setValue(1000)
|
||||
row.addWidget(self.count)
|
||||
self.apply_button = QPushButton("Рассчитать")
|
||||
self.apply_button.clicked.connect(self.calculate)
|
||||
row.addWidget(self.apply_button)
|
||||
self.clear_button = QPushButton("Убрать")
|
||||
self.clear_button.clicked.connect(self.clear)
|
||||
row.addWidget(self.clear_button)
|
||||
self.export_button = QPushButton("Результат CSV…")
|
||||
self.export_button.clicked.connect(self.export_csv)
|
||||
row.addWidget(self.export_button)
|
||||
row.addStretch(1)
|
||||
layout.addLayout(row)
|
||||
self.status = QLabel("Выберите аналоговый канал и метод. Расчёт по переданным точкам, без экстраполяции.")
|
||||
self.status.setWordWrap(True)
|
||||
layout.addWidget(self.status)
|
||||
self.method.setToolTip(
|
||||
"Полином МНК — сглаживание шумных данных. Линейная — отрезки между точками.\n"
|
||||
"PCHIP — сохранение формы без выбросов. Сплайн — гладкая кривая, возможны выбросы.\n"
|
||||
"Восстановление — оценка между измерениями; утраченные детали не определяются однозначно.")
|
||||
for widget in (self.channel, self.method):
|
||||
widget.currentIndexChanged.connect(self.clear)
|
||||
for widget in (self.degree, self.count):
|
||||
widget.valueChanged.connect(self.clear)
|
||||
self._update_actions()
|
||||
|
||||
def _signature(self):
|
||||
return prepare(self._snapshot, self.channel.currentData(), self.method.currentData(),
|
||||
self.count.value(), self.degree.value())
|
||||
|
||||
def _current_request_for(self, signature):
|
||||
if not self.preserve_on_view_change or signature is None:
|
||||
return self._signature()
|
||||
# Validate the original calculation domain, independently of navigation.
|
||||
return prepare(replace(self._snapshot, x_range=signature.x_range),
|
||||
self.channel.currentData(), self.method.currentData(),
|
||||
self.count.value(), self.degree.value())
|
||||
|
||||
def set_snapshot(self, snapshot):
|
||||
previous = ((self._result_signature or (self._job.signature if self._job is not None else None))
|
||||
if self.preserve_on_view_change else None) or self._signature()
|
||||
was_blocked = bool(self._snapshot.blocked_reason)
|
||||
choices = [(signal.key, signal.label) for signal in snapshot.analogs]
|
||||
current = [(self.channel.itemData(i), self.channel.itemText(i)) for i in range(self.channel.count())]
|
||||
if choices != current:
|
||||
selected = self.channel.currentData()
|
||||
blocker = QSignalBlocker(self.channel)
|
||||
self.channel.clear()
|
||||
for key, title in choices:
|
||||
self.channel.addItem(title, key)
|
||||
index = self.channel.findData(selected)
|
||||
if index >= 0:
|
||||
self.channel.setCurrentIndex(index)
|
||||
del blocker
|
||||
self._snapshot = snapshot
|
||||
if previous != self._current_request_for(previous):
|
||||
self._revision += 1
|
||||
if self._result is not None:
|
||||
self._result = self._result_signature = None
|
||||
self.status.setText("Данные или окно изменились — нажмите «Рассчитать» повторно.")
|
||||
if snapshot.blocked_reason:
|
||||
self.status.setText(snapshot.blocked_reason)
|
||||
elif not choices:
|
||||
self.status.setText("Нет видимых аналоговых каналов для обработки.")
|
||||
elif was_blocked:
|
||||
self.status.setText("Выберите канал и метод, затем нажмите «Рассчитать».")
|
||||
self._update_actions()
|
||||
|
||||
def _update_actions(self):
|
||||
self.degree.setEnabled(self.method.currentData() == "polynomial")
|
||||
request = self._signature()
|
||||
self.apply_button.setEnabled(self._job is None and request is not None and
|
||||
len(request.series.points) >= 2)
|
||||
self.export_button.setEnabled(self._result is not None and not self._snapshot.blocked_reason)
|
||||
self.clear_button.setEnabled(self._result is not None or self._job is not None)
|
||||
self.apply_button.setToolTip("Обработка выбранной кривой в её единицах X/Y. Для живого потока остановите сбор.")
|
||||
|
||||
def clear(self, *_):
|
||||
self._revision += 1
|
||||
self._result = self._result_signature = None
|
||||
self.status.setText("Расчёт по текущему окну. Исходные измерения сохраняются; кривая — оценка между точками.")
|
||||
self._update_actions()
|
||||
self.changed.emit()
|
||||
|
||||
def calculate(self):
|
||||
if self._job is not None or self._snapshot.blocked_reason:
|
||||
return
|
||||
signature = self._signature()
|
||||
if signature is None or len(signature.series.points) < 2:
|
||||
self.status.setText("Нужны минимум две точки аналогового канала.")
|
||||
return
|
||||
self._job = _Calculation(signature)
|
||||
self._job_revision = self._revision
|
||||
self._job.signals.done.connect(self._finished)
|
||||
self.status.setText("Расчёт…")
|
||||
self._update_actions()
|
||||
QThreadPool.globalInstance().start(self._job)
|
||||
|
||||
@Slot(object, object, str)
|
||||
def _finished(self, signature, result, error):
|
||||
self._job = None
|
||||
if signature != self._current_request_for(signature) or self._job_revision != self._revision:
|
||||
self.status.setText("Данные изменились во время расчёта. Остановите сбор и повторите расчёт.")
|
||||
elif error:
|
||||
self.status.setText("Расчёт не выполнен: " + error)
|
||||
else:
|
||||
self._result, self._result_signature = result, signature
|
||||
duplicates = result.input_count - result.unique_count
|
||||
self.status.setText(
|
||||
f"{self.channel.currentText()}: {result.unique_count} исходных → {len(result.points)} расчётных точек; "
|
||||
f"СКО на измерениях: {result.rmse:.6g}. "
|
||||
+ (f"Повторы X усреднены: {duplicates}. " if duplicates else "")
|
||||
+ "Розовая линия — расчётная оценка; исходные данные сохранены.")
|
||||
self._update_actions()
|
||||
self.changed.emit()
|
||||
|
||||
def overlay(self):
|
||||
if self._result is None or self._snapshot.blocked_reason:
|
||||
return [], {}
|
||||
return [(self.RESULT_KEY, self._result.label, False, "#FF70D0", True)], {self.RESULT_KEY: self._result.points}
|
||||
|
||||
@property
|
||||
def curve(self):
|
||||
return self._result
|
||||
|
||||
def export_csv(self):
|
||||
if self._result is None:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Сохранить расчётную кривую", "calculated-signal.csv", "CSV (*.csv)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, "w", newline="", encoding="utf-8-sig") as stream:
|
||||
write_csv(self._result, stream)
|
||||
except (OSError, ValueError, OverflowError) as error:
|
||||
self.status.setText("Не удалось сохранить CSV: " + str(error))
|
||||
|
||||
|
||||
class PlotProcessingAttachment(QObject):
|
||||
"""Attach to any Qt plot via snapshot/project/repaint callbacks.
|
||||
|
||||
Call source_changed when raw data, units or visible channel selection change.
|
||||
Paint after raw analog curves with the same rect/projection. No channel or
|
||||
history mutation, no dependency on the caller's renderer or acquisition API.
|
||||
"""
|
||||
def __init__(self, parent, snapshot, repaint):
|
||||
super().__init__(parent)
|
||||
self.snapshot = snapshot
|
||||
self.repaint = repaint
|
||||
self.dialog = None
|
||||
self.panel = None
|
||||
self._external_curve = None
|
||||
self._external_offset = 0.0
|
||||
self.button = QPushButton("Обработка…", parent)
|
||||
self.button.setToolTip("Аппроксимация, интерполяция и восстановление аналоговой кривой")
|
||||
self.button.clicked.connect(self.open)
|
||||
self._refresh_timer = QTimer(self)
|
||||
self._refresh_timer.setSingleShot(True)
|
||||
self._refresh_timer.timeout.connect(self.refresh)
|
||||
self._dirty = False
|
||||
|
||||
@property
|
||||
def curve(self):
|
||||
# Hide stale geometry immediately, before coalesced refresh runs.
|
||||
if self._external_curve is not None:
|
||||
return self._external_curve
|
||||
return self.panel.curve if self.panel is not None and not self._dirty else None
|
||||
|
||||
def set_external_curve(self, curve, x_offset=0.0):
|
||||
"""Embedded panels own snapshot invalidation; display rebasing is explicit."""
|
||||
self._external_curve = curve
|
||||
self._external_offset = x_offset
|
||||
self.repaint()
|
||||
|
||||
def source_changed(self):
|
||||
if self.panel is not None:
|
||||
self._dirty = True
|
||||
self._refresh_timer.start(0)
|
||||
|
||||
def refresh(self):
|
||||
if self.panel is not None:
|
||||
self.panel.set_snapshot(self.snapshot())
|
||||
self._dirty = False
|
||||
self.repaint()
|
||||
|
||||
def open(self):
|
||||
if self.dialog is None:
|
||||
self.dialog = QDialog(self.parent())
|
||||
self.dialog.setWindowTitle("Обработка графика")
|
||||
layout = QVBoxLayout(self.dialog)
|
||||
self.panel = SignalProcessingPanel(self.dialog)
|
||||
self.panel.changed.connect(self.repaint)
|
||||
layout.addWidget(self.panel)
|
||||
self.dialog.resize(760, 240)
|
||||
self.refresh()
|
||||
self.dialog.show()
|
||||
self.dialog.raise_()
|
||||
self.dialog.activateWindow()
|
||||
|
||||
def paint(self, painter, rect, project):
|
||||
curve = self.curve
|
||||
if curve is None:
|
||||
return
|
||||
painter.save()
|
||||
painter.setClipRect(rect)
|
||||
path = QPainterPath()
|
||||
for index, (x, y) in enumerate(curve.points):
|
||||
point = project(x - (self._external_offset if self._external_curve is not None else 0), y, rect)
|
||||
path.moveTo(point) if index == 0 else path.lineTo(point)
|
||||
painter.setPen(QPen(QColor("#FF70D0"), 2, Qt.DashLine))
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.drawPath(path)
|
||||
painter.setPen(QColor("#FF70D0"))
|
||||
painter.drawText(QPointF(rect.left() + 6, rect.bottom() - 8), curve.label)
|
||||
painter.restore()
|
||||
@@ -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)
|
||||
|
||||
106
python/set_devices/qt_ports/wavegen.py
Normal file
106
python/set_devices/qt_ports/wavegen.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""USB CDC/COM worker for WG v1/v2/v3, with SET v2 block uploads. All waits run in a worker, never the GUI thread."""
|
||||
import time
|
||||
from PySide6.QtCore import QObject, QRunnable, Signal, QIODevice
|
||||
from PySide6.QtSerialPort import QSerialPort
|
||||
from set_devices.wavegen_protocol import WaveProtocol
|
||||
|
||||
class _Signals(QObject):
|
||||
finished = Signal(object, str)
|
||||
progress = Signal(int)
|
||||
|
||||
class WaveTransfer(QRunnable):
|
||||
def __init__(self, port_name, address, action, waveform=None, port_factory=None):
|
||||
super().__init__()
|
||||
self.port_name, self.address, self.action, self.waveform = port_name, address, action, waveform
|
||||
self.signals = _Signals()
|
||||
self.port_factory = port_factory or QSerialPort
|
||||
|
||||
def run(self):
|
||||
port = self.port_factory()
|
||||
try:
|
||||
protocol = WaveProtocol(self.address)
|
||||
port.setPortName(self.port_name)
|
||||
port.setBaudRate(115200)
|
||||
port.setDataBits(QSerialPort.Data8)
|
||||
port.setParity(QSerialPort.NoParity)
|
||||
port.setStopBits(QSerialPort.OneStop)
|
||||
port.setFlowControl(QSerialPort.NoFlowControl)
|
||||
if not port.open(QIODevice.ReadWrite):
|
||||
raise OSError(port.errorString())
|
||||
port.clear()
|
||||
|
||||
def transfer(request, decode):
|
||||
if port.write(request) != len(request):
|
||||
raise OSError(port.errorString())
|
||||
if port.bytesToWrite() and not port.waitForBytesWritten(1000):
|
||||
raise OSError("Таймаут передачи USB")
|
||||
reply = bytearray()
|
||||
budget = getattr(port, 'response_timeout', lambda ms: ms)(1500)
|
||||
deadline = time.monotonic() + budget / 1000
|
||||
while time.monotonic() < deadline:
|
||||
if port.bytesAvailable() or port.waitForReadyRead(50):
|
||||
reply.extend(bytes(port.readAll()))
|
||||
response = decode(request, reply)
|
||||
if response is not None:
|
||||
return response
|
||||
raise OSError("Нет подтверждения USB. Состояние выхода неизвестно; проверьте связь и нажмите Стоп.")
|
||||
|
||||
def exchange(operation, index=0, value=0):
|
||||
return transfer(protocol.request(operation, index, value), protocol.response)
|
||||
|
||||
def read_status():
|
||||
words = exchange(0)
|
||||
result = protocol.status(words)
|
||||
if result['version'] >= 2:
|
||||
result = protocol.status(words, exchange(8)[0])
|
||||
return result
|
||||
|
||||
status = read_status() # Positive device identification before any write.
|
||||
if self.action == "upload":
|
||||
wave = self.waveform
|
||||
if wave is None or len(wave.codes) > status["capacity"]:
|
||||
raise ValueError("Таблица не помещается в память устройства")
|
||||
if status['version'] == 1 and wave.sample_rate > 50000:
|
||||
raise ValueError("Для частоты выше 50 000 отсчётов/с обновите прошивку МК: нужна карта генератора версии 2")
|
||||
exchange(1)
|
||||
if status['version'] >= 2:
|
||||
exchange(9, value=wave.sample_rate >> 16)
|
||||
exchange(2, value=wave.sample_rate & 0xffff)
|
||||
exchange(3, value=len(wave.codes))
|
||||
blocks = status['version'] >= 3 and getattr(port, 'supports_wave_blocks', False)
|
||||
step = 120 if blocks else 1
|
||||
for index in range(0, len(wave.codes), step):
|
||||
values = wave.codes[index:index + step]
|
||||
if blocks:
|
||||
transfer(protocol.block_request(index, values), protocol.block_response)
|
||||
else:
|
||||
exchange(4, index, values[0])
|
||||
self.signals.progress.emit(int((index + len(values)) * 50 / len(wave.codes)))
|
||||
for index in range(0, len(wave.codes), step):
|
||||
values = wave.codes[index:index + step]
|
||||
actual = (transfer(protocol.block_request(index, count=len(values)), protocol.block_response)
|
||||
if blocks else exchange(7, index))
|
||||
if tuple(actual) != tuple(values):
|
||||
raise ValueError(f"Проверка таблицы не пройдена: отсчёты с {index}")
|
||||
self.signals.progress.emit(50 + int((index + len(values)) * 50 / len(wave.codes)))
|
||||
exchange(5)
|
||||
elif self.action == "start":
|
||||
if not status["ready"]:
|
||||
raise ValueError("Сначала загрузите и проверьте таблицу")
|
||||
exchange(6)
|
||||
elif self.action == "stop":
|
||||
exchange(1)
|
||||
elif self.action != "status":
|
||||
raise ValueError("Неизвестное действие")
|
||||
result = read_status()
|
||||
if self.action == "upload" and (not result["ready"] or result["running"] or
|
||||
result["count"] != len(self.waveform.codes) or result["rate"] != self.waveform.sample_rate):
|
||||
raise ValueError("Устройство не подтвердило загруженную таблицу")
|
||||
if self.action in ("start", "stop") and result["running"] != (self.action == "start"):
|
||||
raise ValueError("Устройство не подтвердило состояние выхода")
|
||||
self.signals.progress.emit(100)
|
||||
self.signals.finished.emit(result, "")
|
||||
except Exception as error:
|
||||
self.signals.finished.emit(None, str(error))
|
||||
finally:
|
||||
port.close()
|
||||
54
python/set_devices/signal_reconstruction.py
Normal file
54
python/set_devices/signal_reconstruction.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Thin host adapter for the shared C99 reconstruction/DAC algorithms."""
|
||||
from __future__ import annotations
|
||||
import ctypes as C
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from protocan.native import NativeProtocol
|
||||
|
||||
METHODS = {"polynomial": "Аппроксимация: полином МНК", "linear": "Интерполяция: линейная",
|
||||
"pchip": "Интерполяция: PCHIP", "spline": "Восстановление: кубический сплайн"}
|
||||
|
||||
@dataclass
|
||||
class Reconstruction:
|
||||
points: list
|
||||
input_count: int
|
||||
unique_count: int
|
||||
rmse: float
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def library():
|
||||
try:
|
||||
lib = NativeProtocol().lib
|
||||
pointer = C.POINTER(C.c_double)
|
||||
lib.set_signal_reconstruct.argtypes = [pointer, pointer, C.c_size_t, C.c_int, C.c_uint,
|
||||
C.c_size_t, C.c_int, pointer, pointer, pointer, pointer, C.c_size_t]
|
||||
lib.set_signal_reconstruct.restype = C.c_int
|
||||
lib.set_signal_dac12.argtypes = [pointer, C.c_size_t, C.c_double, C.POINTER(C.c_uint16)]
|
||||
lib.set_signal_dac12.restype = C.c_int
|
||||
return lib
|
||||
except (AttributeError, OSError, RuntimeError) as error:
|
||||
raise ValueError("Пересоберите SETProtocol с set_signal.c и set_wavegen.c") from error
|
||||
|
||||
def reconstruct(points, method="pchip", output_count=1000, degree=2, *, endpoint=True):
|
||||
if method not in METHODS or type(output_count) is not int or not 2 <= output_count <= 10000:
|
||||
raise ValueError("Неизвестный метод или число выходных точек вне 2…10000")
|
||||
count = len(points)
|
||||
if not 2 <= count <= 100000 or type(degree) is not int or not 1 <= degree <= 5:
|
||||
raise ValueError("Нужно 2…100000 исходных точек, степень 1…5")
|
||||
x, y = (C.c_double * count)(*(p[0] for p in points)), (C.c_double * count)(*(p[1] for p in points))
|
||||
ox, oy, meta = (C.c_double * output_count)(), (C.c_double * output_count)(), (C.c_double * 3)()
|
||||
work = (C.c_double * (14 * count + 128))()
|
||||
code = library().set_signal_reconstruct(x, y, count, list(METHODS).index(method), degree,
|
||||
output_count, int(endpoint), ox, oy, meta, work, len(work))
|
||||
if code:
|
||||
raise ValueError({1: "Недостаточно точек для выбранной степени или неверные параметры",
|
||||
2: "Нужны конечные значения и минимум две различные временные точки",
|
||||
3: "Неустойчивая аппроксимация: уменьшите степень"}.get(code, "Ошибка расчёта"))
|
||||
return Reconstruction(list(zip(ox, oy)), int(meta[0]), int(meta[1]), meta[2])
|
||||
|
||||
def dac12(volts, vref=3.3):
|
||||
data = (C.c_double * len(volts))(*volts)
|
||||
codes = (C.c_uint16 * len(volts))()
|
||||
if library().set_signal_dac12(data, len(data), vref, codes):
|
||||
raise ValueError("Расчётная кривая выходит за диапазон ЦАП 0…Vref; измените точки или метод")
|
||||
return tuple(codes)
|
||||
61
python/set_devices/waveform.py
Normal file
61
python/set_devices/waveform.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Reusable recipes and tables; interpolation/quantization live in C."""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from .signal_reconstruction import reconstruct, dac12
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Waveform:
|
||||
points: tuple
|
||||
codes: tuple
|
||||
sample_rate: int
|
||||
vref: float
|
||||
period_ms: float
|
||||
|
||||
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
|
||||
"""One cyclic period, last endpoint excluded (no duplicated seam sample)."""
|
||||
if type(sample_rate) is not int or not 1 <= sample_rate <= 1000000:
|
||||
raise ValueError("Частота отсчётов должна быть 1…1000000 Гц")
|
||||
if not math.isfinite(vref) or not 0 < vref <= 3.6:
|
||||
raise ValueError("Vref должен быть в диапазоне 0…3,6 В")
|
||||
if not 2 <= len(points) <= 4096 or points[0][0] != 0:
|
||||
raise ValueError("Нужно 2…4096 точек; первая временная метка — 0 мс")
|
||||
if any(not math.isfinite(x) or not math.isfinite(y) or not 0 <= y <= vref for x, y in points):
|
||||
raise ValueError("В точках нужны конечные числа, напряжение 0…Vref")
|
||||
if any(b[0] <= a[0] for a, b in zip(points, points[1:])):
|
||||
raise ValueError("Время точек должно строго возрастать")
|
||||
period = points[-1][0]
|
||||
exact_count = period * sample_rate / 1000
|
||||
count = round(exact_count)
|
||||
if not 2 <= count <= 4096:
|
||||
raise ValueError("Период × частота должны давать от 2 до 4096 отсчётов")
|
||||
if abs(exact_count - count) > 1e-7:
|
||||
raise ValueError("Период должен содержать целое число отсчётов при выбранной частоте")
|
||||
result = reconstruct(points, method, count, degree, endpoint=False)
|
||||
# Check the endpoint too: polynomial fitting can move its value.
|
||||
checked = reconstruct(points, method, count + 1, degree)
|
||||
dac12([p[1] for p in checked.points], vref)
|
||||
codes = dac12([p[1] for p in result.points], vref)
|
||||
return Waveform(tuple(result.points), codes, sample_rate, vref, period)
|
||||
|
||||
def recipe(points, sample_rate, vref, method, degree):
|
||||
return {"version": 1, "points_ms_volts": [list(pair) for pair in points], "sample_rate": sample_rate,
|
||||
"vref": vref, "method": method, "degree": degree}
|
||||
|
||||
def load_recipe(text):
|
||||
data = json.loads(text)
|
||||
if not isinstance(data, dict) or data.get("version") != 1:
|
||||
raise ValueError("Неизвестная версия задания генератора")
|
||||
points = tuple((float(x), float(y)) for x, y in data["points_ms_volts"])
|
||||
generate(points, data["sample_rate"], data["vref"], data["method"], data["degree"])
|
||||
return points, data
|
||||
|
||||
def c_header(wave):
|
||||
values = [", ".join(str(v) for v in wave.codes[i:i+16]) for i in range(0, len(wave.codes), 16)]
|
||||
return ("/* Generated cyclic 12-bit DAC table. DMA-accessible SRAM, not CCM. */\n"
|
||||
"#pragma once\n#include <stdint.h>\n"
|
||||
f"#define WAVE_SAMPLE_RATE_HZ {wave.sample_rate}u\n"
|
||||
f"#define WAVE_SAMPLE_COUNT {len(wave.codes)}u\n"
|
||||
f"/* Vref = {wave.vref:g} V; period = {wave.period_ms:g} ms. */\n"
|
||||
"static uint16_t wave_samples[WAVE_SAMPLE_COUNT] = {\n " + ",\n ".join(values) + "\n};\n")
|
||||
67
python/set_devices/wavegen_protocol.py
Normal file
67
python/set_devices/wavegen_protocol.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""ctypes port of the shared wave generator RTU codec."""
|
||||
import ctypes as C
|
||||
from .signal_reconstruction import library
|
||||
|
||||
class WaveProtocol:
|
||||
def __init__(self, address=16):
|
||||
if not 1 <= address <= 247:
|
||||
raise ValueError("Адрес должен быть 1…247")
|
||||
self.address = address
|
||||
self.lib = library()
|
||||
p = C.POINTER(C.c_uint8)
|
||||
self.lib.set_wave_request.argtypes = [C.c_uint, C.c_uint, C.c_uint, C.c_uint, p, C.c_size_t]
|
||||
self.lib.set_wave_request.restype = C.c_size_t
|
||||
self.lib.set_wave_response.argtypes = [p, p, C.c_size_t, C.POINTER(C.c_uint16), C.c_size_t]
|
||||
self.lib.set_wave_response.restype = C.c_int
|
||||
self.lib.set_wave_block_request.argtypes = [C.c_uint, C.c_uint, C.POINTER(C.c_uint16), C.c_size_t, p, C.c_size_t]
|
||||
self.lib.set_wave_block_request.restype = C.c_size_t
|
||||
self.lib.set_wave_block_response.argtypes = [p, C.c_size_t, p, C.c_size_t, C.POINTER(C.c_uint16), C.c_size_t]
|
||||
self.lib.set_wave_block_response.restype = C.c_int
|
||||
|
||||
def block_request(self, index, values=None, count=None):
|
||||
count = len(values) if values is not None else count
|
||||
if count is None or not 1 <= count <= 120:
|
||||
raise ValueError("Блок должен содержать 1…120 отсчётов")
|
||||
if values is not None and any(not 0 <= value <= 4095 for value in values):
|
||||
raise ValueError("Коды ЦАП должны быть 0…4095")
|
||||
samples = (C.c_uint16 * count)(*values) if values is not None else None
|
||||
output = (C.c_uint8 * 249)()
|
||||
size = self.lib.set_wave_block_request(self.address, index, samples, count, output, len(output))
|
||||
if not size:
|
||||
raise ValueError("Недопустимый блок генератора")
|
||||
return bytes(output[:size])
|
||||
|
||||
def block_response(self, request, reply):
|
||||
words = (C.c_uint16 * 120)()
|
||||
code = self.lib.set_wave_block_response((C.c_uint8 * len(request)).from_buffer_copy(request), len(request),
|
||||
(C.c_uint8 * len(reply)).from_buffer_copy(reply), len(reply), words, len(words))
|
||||
if code == 0:
|
||||
return None
|
||||
if code < 0:
|
||||
raise ValueError(f"Неверный ответ блока генератора ({code})")
|
||||
return tuple(words[:code])
|
||||
|
||||
def request(self, operation, index=0, value=0):
|
||||
output = (C.c_uint8 * 8)()
|
||||
if self.lib.set_wave_request(self.address, operation, index, value, output, 8) != 8:
|
||||
raise ValueError("Недопустимая команда генератора")
|
||||
return bytes(output)
|
||||
|
||||
def response(self, request, reply):
|
||||
if len(request) != 8:
|
||||
raise ValueError("Неверный запрос")
|
||||
words = (C.c_uint16 * 8)()
|
||||
code = self.lib.set_wave_response((C.c_uint8 * 8).from_buffer_copy(request),
|
||||
(C.c_uint8 * len(reply)).from_buffer_copy(reply), len(reply), words, len(words))
|
||||
if code == 0:
|
||||
return None
|
||||
if code < 0:
|
||||
raise ValueError(f"Неверный ответ генератора или исключение устройства ({code})")
|
||||
return tuple(words[:code])
|
||||
|
||||
@staticmethod
|
||||
def status(words, rate_high=0):
|
||||
if len(words) != 8 or words[0] != 0x5747 or words[1] not in (1, 2, 3):
|
||||
raise ValueError("Устройство не поддерживает генератор WG v1/v2/v3")
|
||||
return dict(version=words[1], running=bool(words[2]), rate=(rate_high << 16) | words[3], count=words[4],
|
||||
received=words[5], ready=bool(words[6]), capacity=words[7])
|
||||
87
python/tests/test_plot_processing.py
Normal file
87
python/tests/test_plot_processing.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Portable processing contract: no Qt or application imports."""
|
||||
import csv
|
||||
import io
|
||||
import math
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from set_devices.plot_processing import Axis, Series, Snapshot, prepare, process, write_csv
|
||||
from set_devices.signal_reconstruction import METHODS
|
||||
|
||||
|
||||
class PlotProcessingTests(unittest.TestCase):
|
||||
def test_snapshot_copies_mutable_samples_and_excludes_hidden_and_digital(self):
|
||||
points = [[0, 0], [1, 1]]
|
||||
channel = Series("a", "Analog", points)
|
||||
snapshot = Snapshot([channel, Series("hidden", "Hidden", points, visible=False),
|
||||
Series("bit", "Bit", points, discrete=True)])
|
||||
points[0][1] = 9
|
||||
points.append([2, 3])
|
||||
self.assertEqual(((0., 0.), (1., 1.)), prepare(snapshot, "a").series.points)
|
||||
self.assertEqual((channel,), snapshot.analogs)
|
||||
self.assertIsNone(prepare(snapshot, "hidden"))
|
||||
self.assertIsNone(prepare(snapshot, "bit"))
|
||||
self.assertIsNone(prepare(replace(snapshot, blocked_reason="Pause capture"), "a"))
|
||||
with self.assertRaises(ValueError):
|
||||
Snapshot([channel, channel])
|
||||
|
||||
def test_all_methods_use_same_contract_and_keep_source(self):
|
||||
source = Series("v", "Voltage", [(100, 5), (300, 9)], y_unit="V")
|
||||
snapshot = Snapshot([source], Axis("Time", "ms"), "scope")
|
||||
for method in METHODS:
|
||||
with self.subTest(method=method):
|
||||
request = prepare(snapshot, "v", method, 5, 1)
|
||||
curve = process(request)
|
||||
self.assertEqual([100, 150, 200, 250, 300], [x for x, y in curve.points])
|
||||
for (_, value), expected in zip(curve.points, [5, 6, 7, 8, 9]):
|
||||
self.assertAlmostEqual(expected, value)
|
||||
self.assertEqual((2, 2), (curve.input_count, curve.unique_count))
|
||||
self.assertEqual(request, curve.request)
|
||||
self.assertEqual(((100., 5.), (300., 9.)), source.points)
|
||||
|
||||
def test_window_is_inclusive_and_never_extrapolates(self):
|
||||
snapshot = Snapshot([Series("a", "A", [(0, 0), (1, 2), (2, 4), (3, 6)])],
|
||||
x_range=(.5, 2))
|
||||
curve = process(prepare(snapshot, "a", "linear", 3))
|
||||
self.assertEqual(((1., 2.), (1.5, 3.), (2., 4.)), curve.points)
|
||||
self.assertEqual(2, curve.input_count)
|
||||
with self.assertRaises(ValueError):
|
||||
process(prepare(replace(snapshot, x_range=(.5, .9)), "a"))
|
||||
|
||||
def test_sparse_spline_and_noisy_polynomial_work_in_shared_pipeline(self):
|
||||
sparse = Snapshot([Series("a", "Sine", [(i * math.pi / 4, math.sin(i * math.pi / 4))
|
||||
for i in range(9)])])
|
||||
curve = process(prepare(sparse, "a", "spline", 201))
|
||||
self.assertLess(max(abs(y - math.sin(x)) for x, y in curve.points), .002)
|
||||
noisy = Snapshot([Series("a", "Ramp", [(i, 2 * i + (1 if i % 2 else -1)) for i in range(11)])])
|
||||
curve = process(prepare(noisy, "a", "polynomial", 21, 1))
|
||||
self.assertLess(max(abs(y - 2 * x) for x, y in curve.points), .1)
|
||||
self.assertGreater(curve.rmse, .9)
|
||||
|
||||
def test_csv_respects_explicit_axis_domain_and_units(self):
|
||||
for axis, start, expected in ((Axis("Time", "ms"), 1000, "1000.0"),
|
||||
(Axis("Frequency", "Hz"), 1.8e12, "1800000000000.0"),
|
||||
(Axis("Time", "ms", "unix_ms"), 1000, "1970-01-01T00:00:01.000000Z")):
|
||||
with self.subTest(axis=axis):
|
||||
snapshot = Snapshot([Series("v", "Voltage", [(start, 0), (start + 1000, 1)], y_unit="V")], axis)
|
||||
output = io.StringIO()
|
||||
write_csv(process(prepare(snapshot, "v", "linear", 3)), output)
|
||||
rows = list(csv.reader(io.StringIO(output.getvalue())))
|
||||
self.assertEqual(expected, rows[1][0])
|
||||
self.assertTrue(rows[0][1].endswith("[V]"))
|
||||
self.assertEqual("timestamp" if axis.encoding == "unix_ms" else f"{axis.label} [{axis.unit}]", rows[0][0])
|
||||
|
||||
def test_request_tracks_selected_channel_units_source_and_window(self):
|
||||
channel = Series("a", "A", [(0, 0), (1, 1)])
|
||||
other = Series("b", "B", [(0, 3), (1, 4)])
|
||||
snapshot = Snapshot([channel, other], source="file1")
|
||||
request = prepare(snapshot, "a")
|
||||
self.assertEqual(request, prepare(replace(snapshot, series=[channel, replace(other, points=[(0, 9)])]), "a"))
|
||||
for changed in (replace(snapshot, source="file2"), replace(snapshot, axis=Axis("Frequency", "Hz")),
|
||||
replace(snapshot, x_range=(0, 1)),
|
||||
replace(snapshot, series=[replace(channel, y_unit="V")])):
|
||||
self.assertNotEqual(request, prepare(changed, "a"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
93
python/tests/test_plot_processing_qt.py
Normal file
93
python/tests/test_plot_processing_qt.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Reusable Qt adapter tests, runnable with PySide2 or PySide6, without a GUI app repo."""
|
||||
import os
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from unittest.mock import patch
|
||||
|
||||
from set_devices.plot_processing import Axis, Series, Snapshot, process
|
||||
from set_devices.qt_ports.plot_processing import SignalProcessingPanel, PlotProcessingAttachment, QWidget
|
||||
try:
|
||||
from PySide6.QtWidgets import QApplication
|
||||
except ImportError:
|
||||
from PySide2.QtWidgets import QApplication
|
||||
|
||||
|
||||
class ProcessingQtTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def setUp(self):
|
||||
self.panel = SignalProcessingPanel()
|
||||
self.snapshot = Snapshot([Series("a", "A", [(0, 0), (1, 1), (2, 0)])])
|
||||
self.panel.set_snapshot(self.snapshot)
|
||||
|
||||
def tearDown(self):
|
||||
self.panel.close()
|
||||
|
||||
def calculate(self):
|
||||
self.panel.calculate()
|
||||
deadline = time.monotonic() + 5
|
||||
while self.panel._job is not None and time.monotonic() < deadline:
|
||||
self.app.processEvents()
|
||||
time.sleep(.002)
|
||||
self.assertIsNone(self.panel._job)
|
||||
self.assertIsNotNone(self.panel.curve, self.panel.status.text())
|
||||
|
||||
def test_all_methods_are_available_and_parameter_changes_clear_result(self):
|
||||
for method in ("polynomial", "linear", "pchip", "spline"):
|
||||
self.panel.method.setCurrentIndex(self.panel.method.findData(method))
|
||||
self.calculate()
|
||||
self.assertEqual(method, self.panel.curve.request.method)
|
||||
self.panel.count.setValue(self.panel.count.value() + 1)
|
||||
self.assertIsNone(self.panel.curve)
|
||||
self.assertFalse(self.panel.export_button.isEnabled())
|
||||
|
||||
def test_source_units_and_blocking_invalidate_result(self):
|
||||
for changed in (replace(self.snapshot, axis=Axis("Frequency", "Hz")),
|
||||
replace(self.snapshot, source="new file"),
|
||||
replace(self.snapshot, x_range=(0, 1)),
|
||||
replace(self.snapshot, blocked_reason="FFT")):
|
||||
self.panel.set_snapshot(self.snapshot)
|
||||
self.calculate()
|
||||
self.panel.set_snapshot(changed)
|
||||
self.assertIsNone(self.panel.curve)
|
||||
self.assertFalse(self.panel.export_button.isEnabled())
|
||||
self.assertFalse(self.panel.apply_button.isEnabled())
|
||||
|
||||
def test_late_worker_result_is_rejected_even_after_source_returns(self):
|
||||
# Capture, but do not schedule, the real worker. Deliver its answer after
|
||||
# the source changes away and back to the same numerical values.
|
||||
with patch("set_devices.qt_ports.plot_processing.QThreadPool"):
|
||||
self.panel.calculate()
|
||||
job = self.panel._job
|
||||
self.panel.set_snapshot(replace(self.snapshot, source="another"))
|
||||
self.panel.set_snapshot(self.snapshot)
|
||||
self.panel._finished(job.signature, process(job.signature), "")
|
||||
self.assertIsNone(self.panel.curve)
|
||||
self.assertFalse(self.panel.export_button.isEnabled())
|
||||
|
||||
def test_attachment_is_lazy_and_coalesces_source_notifications(self):
|
||||
widget = QWidget()
|
||||
calls = []
|
||||
attachment = PlotProcessingAttachment(widget, lambda: calls.append(1) or self.snapshot, widget.update)
|
||||
try:
|
||||
for _ in range(10):
|
||||
attachment.source_changed()
|
||||
self.app.processEvents()
|
||||
self.assertEqual([], calls)
|
||||
self.assertIsNone(attachment.panel)
|
||||
attachment.open()
|
||||
self.assertEqual([1], calls)
|
||||
for _ in range(10):
|
||||
attachment.source_changed()
|
||||
self.app.processEvents()
|
||||
self.assertEqual([1, 1], calls)
|
||||
finally:
|
||||
widget.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user