Extract reusable device protocols, ports and logic analyzers from SETGUI

This commit is contained in:
2026-09-23 20:11:37 +03:00
parent 80ba17d77d
commit 795a1279b1
63 changed files with 10181 additions and 6 deletions

View File

@@ -0,0 +1,420 @@
"""@file spectrum_stream.py
@brief Разбор потока спектра и гармоник от прибора на STM32.
Прибор снимает сигнал вибродатчика или микрофона, считает быстрое
преобразование Фурье прямо на месте и передаёт готовый результат: сам спектр
и параметры найденных гармоник. Задача этого модуля — превратить поток байтов
в структуры, с которыми работает вкладка.
Модуль не зависит от Qt и от последовательного порта: на вход подаются байты,
на выход идут кадры. Так его можно проверить тестами без прибора и без окна.
Формат строки повторяет привычный по навигационным приборам вид: доллар,
поля через запятую, звёздочка и контрольная сумма::
$SPEC,<seq>,<fs>,<fft>,<bins>,<top>,<range>,<hex>*<cs>
$HARM,<seq>,<f0>,<a0>,<thd>,<snr>,<rms>,<n>[,<f>,<a>,<db>]...*<cs>
Текстовый формат выбран намеренно, хотя двоичный вышел бы компактнее.
Прибор отлаживается через обычный терминал, и возможность увидеть поток
глазами дороже экономии канала. Спектр при этом ужат до одного байта
на элемент, поэтому кадр укладывается примерно в 290 байт и на скорости
115200 бод спокойно идёт пять раз в секунду.
"""
from __future__ import annotations
from dataclasses import dataclass, field
#: Начальный символ строки.
LINE_START = "$"
#: Разделитель контрольной суммы.
CHECKSUM_MARK = "*"
#: Тип строки со спектром.
TAG_SPECTRUM = "SPEC"
#: Тип строки с гармониками.
TAG_HARMONICS = "HARM"
#: Наибольшее число гармоник в кадре; совпадает с пределом прошивки.
MAX_HARMONICS = 8
#: Наибольшая длина строки. Защита от мусора в канале: без неё обрыв связи
#: посреди передачи привёл бы к бесконечному накоплению в буфере.
MAX_LINE_LENGTH = 4096
def checksum(payload: str) -> int:
"""@brief Считает контрольную сумму строки.
@param payload Содержимое между ``$`` и ``*``.
@return Побайтовое исключающее ИЛИ, 0..255.
Сумма именно такая простая не от небрежности: канал короткий и
проводной, а задача суммы здесь — отсеять строку, склеенную из двух
обрывков, а не защитить от искажения одного бита.
"""
value = 0
for char in payload.encode("ascii", errors="replace"):
value ^= char
return value & 0xFF
@dataclass(frozen=True)
class Harmonic:
"""@brief Одна найденная гармоника."""
index: int
"""Номер гармоники: 2 — вторая, 3 — третья и так далее."""
frequency_hz: float
"""Измеренная частота."""
amplitude_mv: float
"""Амплитуда."""
relative_db: float
"""Уровень относительно основного тона, децибелы. Всегда отрицателен
у исправного тракта: гармоника слабее основного тона."""
@dataclass(frozen=True)
class HarmonicsFrame:
"""@brief Параметры основного тона и его гармоник."""
seq: int
fundamental_hz: float
fundamental_mv: float
thd_percent: float
"""Коэффициент гармонических искажений."""
snr_db: float
rms_mv: float
harmonics: tuple[Harmonic, ...] = ()
@dataclass(frozen=True)
class SpectrumFrame:
"""@brief Спектр целиком."""
seq: int
sample_rate_hz: int
fft_size: int
top_dbmv: float
"""Верхняя граница шкалы уровней, децибелы относительно милливольта."""
range_db: float
"""Полный размах шкалы уровней."""
levels_db: tuple[float, ...] = field(default=())
"""Уровни элементов спектра в децибелах относительно милливольта."""
@property
def bin_width_hz(self) -> float:
"""@brief Шаг по частоте между элементами переданного спектра.
Учитывает, что прибор мог проредить спектр перед передачей: делится
не на размер преобразования, а на фактическое число элементов.
"""
if not self.levels_db:
return 0.0
top_frequency = self.sample_rate_hz / 2.0
return top_frequency / len(self.levels_db)
def frequency_of(self, index: int) -> float:
"""@brief Частота, соответствующая элементу спектра.
@param index Номер элемента.
@return Частота в герцах.
"""
return index * self.bin_width_hz
class SpectrumStreamError(Exception):
"""@brief Строка не разобрана. Несёт причину для журнала."""
def _split_line(line: str) -> tuple[str, list[str]]:
"""@brief Проверяет обрамление и контрольную сумму, возвращает поля.
@param line Одна строка без перевода строки.
@return Кортеж «тип строки, список полей».
@throws SpectrumStreamError При нарушении формата или суммы.
"""
if not line.startswith(LINE_START):
raise SpectrumStreamError("строка не начинается с '$'")
star = line.rfind(CHECKSUM_MARK)
if star < 0:
raise SpectrumStreamError("нет контрольной суммы")
payload = line[1:star]
tail = line[star + 1 :].strip()
if len(tail) != 2:
raise SpectrumStreamError("контрольная сумма не из двух знаков")
try:
expected = int(tail, 16)
except ValueError as exc:
raise SpectrumStreamError("контрольная сумма не шестнадцатеричная") from exc
actual = checksum(payload)
if actual != expected:
raise SpectrumStreamError(
f"контрольная сумма не сошлась: получено {expected:02X}, "
f"посчитано {actual:02X}"
)
fields = payload.split(",")
if not fields:
raise SpectrumStreamError("пустая строка")
return fields[0], fields[1:]
def _decode_levels(hex_data: str, top_dbmv: float, range_db: float) -> tuple[float, ...]:
"""@brief Разворачивает упакованный спектр в уровни.
@param hex_data Элементы спектра, по два знака на элемент.
@param top_dbmv Верхняя граница шкалы.
@param range_db Размах шкалы.
@return Уровни в децибелах.
Прибор передаёт каждый элемент одним байтом: ноль означает нижнюю
границу шкалы, 255 — верхнюю. Такое сжатие теряет доли децибела,
что для картины спектра несущественно, зато уменьшает кадр вчетверо
относительно передачи чисел текстом.
"""
if len(hex_data) % 2 != 0:
raise SpectrumStreamError("нечётное число знаков в данных спектра")
bottom = top_dbmv - range_db
levels: list[float] = []
for pos in range(0, len(hex_data), 2):
chunk = hex_data[pos : pos + 2]
try:
raw = int(chunk, 16)
except ValueError as exc:
raise SpectrumStreamError(f"неверный знак в данных: '{chunk}'") from exc
levels.append(bottom + (raw / 255.0) * range_db)
return tuple(levels)
def parse_line(line: str) -> SpectrumFrame | HarmonicsFrame:
"""@brief Разбирает одну строку.
@param line Строка без перевода строки.
@return Кадр спектра или кадр гармоник.
@throws SpectrumStreamError При любой ошибке разбора.
"""
tag, fields = _split_line(line)
if tag == TAG_SPECTRUM:
if len(fields) != 7:
raise SpectrumStreamError(
f"в строке спектра {len(fields)} полей вместо 7"
)
try:
seq = int(fields[0])
sample_rate = int(fields[1])
fft_size = int(fields[2])
bins = int(fields[3])
top = float(fields[4])
span = float(fields[5])
except ValueError as exc:
raise SpectrumStreamError("нечисловое поле в строке спектра") from exc
if span <= 0.0:
raise SpectrumStreamError("размах шкалы должен быть положительным")
levels = _decode_levels(fields[6], top, span)
# Заявленное число элементов обязано совпасть с переданным: иначе
# шкала частот сдвинется, и оператор увидит тон не на своей частоте.
if len(levels) != bins:
raise SpectrumStreamError(
f"заявлено {bins} элементов, передано {len(levels)}"
)
return SpectrumFrame(
seq=seq,
sample_rate_hz=sample_rate,
fft_size=fft_size,
top_dbmv=top,
range_db=span,
levels_db=levels,
)
if tag == TAG_HARMONICS:
if len(fields) < 6:
raise SpectrumStreamError("слишком короткая строка гармоник")
try:
seq = int(fields[0])
fundamental_hz = float(fields[1])
fundamental_mv = float(fields[2])
thd = float(fields[3])
snr = float(fields[4])
rms = float(fields[5])
count = int(fields[6]) if len(fields) > 6 else 0
except ValueError as exc:
raise SpectrumStreamError("нечисловое поле в строке гармоник") from exc
if count < 0 or count > MAX_HARMONICS:
raise SpectrumStreamError(f"недопустимое число гармоник: {count}")
expected_fields = 7 + count * 3
if len(fields) != expected_fields:
raise SpectrumStreamError(
f"для {count} гармоник нужно {expected_fields} полей, "
f"получено {len(fields)}"
)
harmonics: list[Harmonic] = []
for item in range(count):
base = 7 + item * 3
try:
harmonics.append(
Harmonic(
index=item + 2,
frequency_hz=float(fields[base]),
amplitude_mv=float(fields[base + 1]),
relative_db=float(fields[base + 2]),
)
)
except ValueError as exc:
raise SpectrumStreamError(
f"нечисловое поле у гармоники {item + 2}"
) from exc
return HarmonicsFrame(
seq=seq,
fundamental_hz=fundamental_hz,
fundamental_mv=fundamental_mv,
thd_percent=thd,
snr_db=snr,
rms_mv=rms,
harmonics=tuple(harmonics),
)
raise SpectrumStreamError(f"неизвестный тип строки: '{tag}'")
class SpectrumStream:
"""@brief Накопитель байтов, отдающий разобранные кадры.
Данные из порта приходят кусками произвольного размера: строка может
прийти по частям, а в одном куске может оказаться несколько строк.
Накопитель собирает их обратно.
"""
def __init__(self) -> None:
self._buffer = ""
self.parsed = 0
"""Число успешно разобранных строк."""
self.errors = 0
"""Число отброшенных строк."""
self.last_error: str = ""
"""Причина последней ошибки — для журнала вкладки."""
def reset(self) -> None:
"""@brief Сбрасывает накопитель и счётчики."""
self._buffer = ""
self.parsed = 0
self.errors = 0
self.last_error = ""
def feed(self, chunk: str | bytes) -> list[SpectrumFrame | HarmonicsFrame]:
"""@brief Добавляет очередной кусок потока.
@param chunk Данные из порта.
@return Список кадров, разобранных из накопленного.
Ошибочные строки отбрасываются, но не прерывают разбор: одиночная
помеха в канале не должна ронять поток целиком.
"""
if isinstance(chunk, (bytes, bytearray)):
chunk = chunk.decode("ascii", errors="replace")
self._buffer += chunk
# Защита от мусора без переводов строки: иначе буфер рос бы
# неограниченно и приложение съело бы всю память.
if len(self._buffer) > MAX_LINE_LENGTH:
cut = self._buffer.rfind("\n")
self._buffer = self._buffer[cut + 1 :] if cut >= 0 else ""
self.errors += 1
self.last_error = "строка длиннее допустимой, буфер очищен"
frames: list[SpectrumFrame | HarmonicsFrame] = []
while "\n" in self._buffer:
line, _, self._buffer = self._buffer.partition("\n")
line = line.strip("\r\n \t")
if not line:
continue
try:
frames.append(parse_line(line))
self.parsed += 1
except SpectrumStreamError as exc:
self.errors += 1
self.last_error = str(exc)
return frames
def build_spectrum_line(
seq: int,
sample_rate_hz: int,
fft_size: int,
levels_db: list[float] | tuple[float, ...],
top_dbmv: float,
range_db: float,
) -> str:
"""@brief Собирает строку спектра.
@return Строка с переводом строки в конце.
Нужна для тестов и для демонстрационного источника, но главное — она
задаёт формат в одном месте с разбором. Если формат изменят только
в прошивке, круговой тест это заметит.
"""
bottom = top_dbmv - range_db
parts: list[str] = []
for level in levels_db:
ratio = (level - bottom) / range_db if range_db > 0.0 else 0.0
raw = int(round(max(0.0, min(1.0, ratio)) * 255.0))
parts.append(f"{raw:02X}")
payload = (
f"{TAG_SPECTRUM},{seq},{sample_rate_hz},{fft_size},"
f"{len(parts)},{top_dbmv:.1f},{range_db:.1f},{''.join(parts)}"
)
return f"{LINE_START}{payload}{CHECKSUM_MARK}{checksum(payload):02X}\n"
def build_harmonics_line(frame: HarmonicsFrame) -> str:
"""@brief Собирает строку гармоник.
@param frame Данные для передачи.
@return Строка с переводом строки в конце.
"""
parts = [
TAG_HARMONICS,
str(frame.seq),
f"{frame.fundamental_hz:.1f}",
f"{frame.fundamental_mv:.1f}",
f"{frame.thd_percent:.2f}",
f"{frame.snr_db:.1f}",
f"{frame.rms_mv:.1f}",
str(len(frame.harmonics)),
]
for harmonic in frame.harmonics:
parts.append(f"{harmonic.frequency_hz:.1f}")
parts.append(f"{harmonic.amplitude_mv:.1f}")
parts.append(f"{harmonic.relative_db:.1f}")
payload = ",".join(parts)
return f"{LINE_START}{payload}{CHECKSUM_MARK}{checksum(payload):02X}\n"