Add shared waveform processing, generation and firmware image support

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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