Add shared waveform processing, generation and firmware image support
This commit is contained in:
@@ -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