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,162 @@
"""Модель нейтральных сигналов для шаблона без привязки к ТЗ."""
from __future__ import annotations
from dataclasses import dataclass, field
from math import sin
@dataclass(frozen=True)
class BitDefinition:
"""@brief Описание битового индикатора или управляющей дискреты.
@param key Стабильный ключ обмена, не зависящий от подписи в UI.
@param title Отображаемое имя сигнала.
@param writable Разрешено ли пользователю изменять значение.
@param alarm Активное состояние отображается аварийным цветом.
"""
key: str
title: str
writable: bool = False
alarm: bool = False
@dataclass(frozen=True)
class NumericDefinition:
"""@brief Метаданные цифрового поля или измерительного индикатора.
Границы и точность принадлежат catalog, поэтому UI не содержит сведений
о конкретной карте объектов контроллера.
"""
key: str
title: str
unit: str = ""
minimum: float = 0.0
maximum: float = 100.0
decimals: int = 1
writable: bool = False
@dataclass(frozen=True)
class SignalCatalog:
"""@brief Полный неизменяемый набор отображаемых сигналов.
Catalog является переносимой границей между данными устройства и Qt UI.
Для реального изделия заменяются определения, а не код вкладок.
"""
controller_fields: tuple[NumericDefinition, ...]
indicators: tuple[NumericDefinition, ...]
status_bits: tuple[BitDefinition, ...]
input_discretes: tuple[BitDefinition, ...]
output_discretes: tuple[BitDefinition, ...]
@classmethod
def template(cls) -> "SignalCatalog":
"""@brief Создаёт согласованный catalog с нейтральными именами.
@return Шаблон для обсуждения компоновки без зависимости от финального ТЗ.
"""
return cls(
controller_fields=tuple(
NumericDefinition(
key=f"controller_{index:02d}",
title=f"Параметр {index:02d}",
minimum=-9999,
maximum=9999,
decimals=0,
writable=True,
)
for index in range(1, 9)
),
indicators=tuple(
NumericDefinition(
key=f"indicator_{index:02d}",
title=f"Индикатор {index:02d}",
unit=("В" if index <= 4 else "%"),
maximum=(400.0 if index <= 4 else 100.0),
)
for index in range(1, 9)
),
status_bits=tuple(
BitDefinition(
key=f"status_{index:02d}",
title=f"Статус {index:02d}",
alarm=index in (7, 8),
)
for index in range(1, 9)
),
input_discretes=tuple(
BitDefinition(
key=f"input_{index:02d}",
title=f"Вход {index:02d}",
)
for index in range(1, 17)
),
output_discretes=tuple(
BitDefinition(
key=f"output_{index:02d}",
title=f"Выход {index:02d}",
writable=True,
)
for index in range(1, 17)
),
)
@dataclass
class SignalSnapshot:
"""@brief Атомарный снимок значений от одного момента времени.
Словари заменяются целиком при публикации, чтобы вкладки не видели смесь
данных от двух циклов опроса.
"""
timestamp_s: float = 0.0
numeric: dict[str, float] = field(default_factory=dict)
bits: dict[str, bool] = field(default_factory=dict)
class MockSignalSource:
"""@brief Детерминированный генератор данных без контроллера.
Не зависит от Qt и используется как переносимое ядро в host-тестах.
"""
def __init__(self, catalog: SignalCatalog) -> None:
self._catalog = catalog
self._outputs = {item.key: False for item in catalog.output_discretes}
def set_output(self, key: str, value: bool) -> None:
"""@brief Сохраняет состояние существующего template-выхода.
@param key Ключ из ``SignalCatalog.output_discretes``.
@param value Новое логическое состояние.
@raises KeyError Если ключ отсутствует в разрешённом наборе.
"""
if key not in self._outputs:
raise KeyError(f"Неизвестная управляющая дискрета: {key}")
self._outputs[key] = bool(value)
def snapshot(self, timestamp_s: float) -> SignalSnapshot:
"""@brief Генерирует согласованный снимок для заданного времени.
@param timestamp_s Монотонное время от запуска mock в секундах.
@return Новый независимый ``SignalSnapshot``.
"""
numeric: dict[str, float] = {}
bits: dict[str, bool] = {}
for index, item in enumerate(self._catalog.controller_fields):
numeric[item.key] = float((index + 1) * 10)
for index, item in enumerate(self._catalog.indicators):
center = item.maximum * 0.55
amplitude = item.maximum * 0.18
numeric[item.key] = center + amplitude * sin(timestamp_s * 0.7 + index)
for index, item in enumerate(self._catalog.status_bits):
bits[item.key] = int(timestamp_s + index) % (index + 2) == 0
for index, item in enumerate(self._catalog.input_discretes):
bits[item.key] = int(timestamp_s * 2 + index) % 5 < 2
bits.update(self._outputs)
return SignalSnapshot(timestamp_s=timestamp_s, numeric=numeric, bits=bits)