Merge origin/ump-v4 into main
Combine Flash CAN updates, signal processing and HOPE pin fixes with current Modbus/GAS, STM32 ports and CCS release exports. Resolve protocol, documentation and Qt conflicts; deduplicate Android JNI sources and include gauge/IGBT sources in host builds.
This commit is contained in:
@@ -15,6 +15,10 @@
|
||||
| `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` | Переиспользуемая панель всех методов, фоновый расчёт и отдельный слой кривой |
|
||||
| `set_devices.signal_reference` | Сравнение полной записи с CSV-эталоном: совмещение времени, допуски, остаток и показатели ошибки без Qt |
|
||||
| `set_devices.qt_ports.reference_comparison` | Общие встроенная панель и диалог сравнения, фоновое чтение/расчёт, внедряемые график и папки, JSON-отчёт |
|
||||
| `set_devices.qt_ports.reference_overlay` | Компактные параметры и автоматически подготовленный эталон для наложения на существующий canvas без создания второго графика |
|
||||
| `set_devices.qt_ports.reference_results` | Полная таблица отклонений, единое форматирование 14 показателей, флажок кривой Δ и экспорт |
|
||||
| `protocan`, `setprotocol` | Существующие обёртки общего C99-ядра и SET v2 |
|
||||
|
||||
Импорт `logic_analyzer` / `set_devices` не загружает Qt и не зависит от GUI.
|
||||
@@ -24,6 +28,9 @@ Saleae SDK подключается при обращении к устройс
|
||||
в приложении. Общая панель обработки графиков размещена в `qt_ports.plot_processing`:
|
||||
[контракт, подключение нового графика и примеры](../c/set-protocol/docs/PLOT_PROCESSING.md).
|
||||
|
||||
Сравнение с эталоном подключается через фабрики canvas и кривой без
|
||||
зависимости от приложения: [API ядра, контракт Qt-порта и пример](../docs/reference-comparison.md).
|
||||
|
||||
Конструкторы CAN-адаптера, CAN DevBoard и RS485 DevBoard используют общий
|
||||
компонент `set_devices.qt_ports.can_frame_builder.CanFrameFields`. Он задаёт
|
||||
одинаковые поля и исходный режим «Обычный CAN — ID / DLC / Data», сохраняет
|
||||
|
||||
@@ -16,20 +16,9 @@ BOOT = dict(enumerate(('IDENTIFY ENTER_BOOT BEGIN_IMAGE BEGIN_COMPAT ERASE VERIF
|
||||
def legacy(ident, data, profile='protocan'):
|
||||
if not 0 <= ident <= 0x1fffffff or len(data) > 8:
|
||||
raise ValueError('Invalid classic CAN frame')
|
||||
relative = ident - 0xba0000
|
||||
if profile == 'balsam':
|
||||
if not (0 <= relative < 13 or 16 <= relative < 29):
|
||||
return None
|
||||
if len(data) != 8:
|
||||
raise ValueError('Balsam requires DLC=8')
|
||||
header = number(data[:2], 'big')
|
||||
address, mask = header & 0x1fff, header >> 13
|
||||
registers = [(address + i, number(data[2+i*2:4+i*2], 'big'))
|
||||
for i in range(3) if mask & (4 >> i)]
|
||||
return dict(protocol='Balsam', registers=registers, summary=
|
||||
'Balsam node=%d %s mask=%X %s' % ((relative & 15) + 1,
|
||||
'data' if relative >= 16 else 'command', mask,
|
||||
' '.join('R%04X=%04X' % pair for pair in registers)))
|
||||
from .template_protocols import decode_can # Единая реализация templates знает порядок value2/value3/header/value1 и специальные ID пульта.
|
||||
return decode_can(ident, data, 'balsam') # Старый публичный вызов получает исправленную расшифровку без второго варианта wire-формата.
|
||||
msg, body = (ident >> 16) & 15, ident & 0xffff
|
||||
result = dict(protocol='ProtoCAN', priority=(ident >> 28) & 1,
|
||||
route=(ident >> 27) & 1, device_type=(ident >> 24) & 7,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from pathlib import Path # Путь к поставляемым библиотекам вычисляется относительно ядра декодера, независимо от рабочего каталога DSView.
|
||||
import sys # Встроенному Python требуется путь к переносимым пакетам, которые поставляются вместе с декодерами.
|
||||
from types import SimpleNamespace # Общим CAN-парсерам нужен только набор свойств кадра, без подключения транспорта или Qt.
|
||||
|
||||
_vendor = Path(__file__).with_name('templates') # Установщик помещает исходные пакеты templates в закрытый подкаталог общего ядра.
|
||||
if _vendor.is_dir() and str(_vendor) not in sys.path: # При запуске из исходников используются штатные пакеты; установленный DSView получает собственные копии.
|
||||
sys.path.insert(0, str(_vendor)) # Локальные версии пакетов имеют приоритет и не требуют установленного SETGUI или внешнего Python.
|
||||
|
||||
from protocan import balsam, legacycan, protocan as pc # Форматы ID, порядок слов и каталоги проектов берутся из действующих библиотек templates.
|
||||
from set_devices import deif_xdi, ump_can # Профиль DEIF и словарь УМП используются теми же функциями, что в CAN-мониторе.
|
||||
|
||||
CAN_PROFILES = ('protocan', 'auto', 'devboard', 'balsam', 'ump-protocan', 'deif', 'legacy', 'set-v2') # Старые ключи сохранены для ранее настроенных записей DSView.
|
||||
LEGACY_PROJECTS = tuple(project.name for project in legacycan.PROJECTS) # Список выбора обновляется вместе с единым каталогом исторических проектов.
|
||||
|
||||
def _decoded(result): # Преобразует нейтральную модель CAN-монитора в словарь событий анализатора.
|
||||
return dict(protocol=result.protocol, summary=result.summary, fields=list(result.fields), registers=result.registers, warnings=list(result.warnings), device=result.device_label) # Копии списков исключают изменение результата другим слоем декодирования.
|
||||
|
||||
def _balsam(ident, payload): # Разбирает фактический порядок слов Balsam 167 вместо упрощённого заголовка в начале данных.
|
||||
result = balsam.decode(ident, payload, native=pc._SHARED_PROTOCOL) if pc._SHARED_PROTOCOL is not None else balsam._decode_fallback(ident, payload) # Без DLL используется существующий переносимый разбор; повторный поиск DLL на каждом кадре исключён.
|
||||
if result is None: # Чужой идентификатор не должен получать подпись Balsam.
|
||||
return None # Отсутствие подходящего кадра не создаёт ложную ошибку на общей шине.
|
||||
registers = [(item.address, item.value) for item in result.registers] # Общий парсер уже применил маску присутствия и восстановил порядок трёх слов.
|
||||
fields = [('Узел', result.device_name), ('Маршрут', result.route), ('Начальный адрес', '0x%04X' % result.start_address), ('Маска слов', format(result.present_mask, '03b'))] # Адрес имеет ширину 13 бит, а маска содержит три признака из wire-формата Balsam.
|
||||
return dict(protocol='Balsam', summary=result.summary, registers=registers, fields=fields, warnings=[]) # Сохраняет совместимое имя протокола и полную сводку с командами общего парсера.
|
||||
|
||||
def decode_can(ident, payload, profile='protocan', *, extended=True, ump_map='gas167', ump_node=1, project=LEGACY_PROJECTS[0]): # Явные настройки предотвращают угадывание несовместимых карт регистров по содержимому данных.
|
||||
payload = bytes(payload) # Парсеры получают неизменяемые байты вне зависимости от контейнера, переданного sigrok.
|
||||
if not 0 <= ident <= (0x1fffffff if extended else 0x7ff) or len(payload) > 8: # Classic CAN допускает 29-битный EXT либо 11-битный STD ID и не более восьми байт.
|
||||
raise ValueError('Invalid classic CAN frame') # Повреждённый ID или DLC отклоняется до интерпретации прикладных полей.
|
||||
if profile not in CAN_PROFILES or profile == 'set-v2': # Сегментированный SET v2 обрабатывается отдельной машиной состояний с тайм-аутом.
|
||||
raise ValueError('Unsupported CAN profile: ' + profile) # Неизвестный профиль не подменяется ProtoCAN молча.
|
||||
if not extended and profile != 'deif': # Все поставляемые профили SET используют EXT; DEIF работает со стандартными CANopen ID.
|
||||
return None # Стандартный кадр не интерпретируется как адресное поле ProtoCAN.
|
||||
frame = SimpleNamespace(can_id=ident, data=payload, ide=extended, rtr=False, is_error=False) # Минимальный кадр соответствует контракту общих функций CAN-монитора.
|
||||
if profile == 'deif': # CANopen/DEIF использует собственную адресацию и проверку селектора.
|
||||
return _decoded(deif_xdi.decode(frame)) # Поля, физические единицы и замечания полностью определяет имеющийся парсер.
|
||||
if profile == 'balsam' or profile == 'auto' and balsam.is_balsam_id(ident): # В автоматическом режиме Balsam распознаётся только по его документированным идентификаторам.
|
||||
return _balsam(ident, payload) # Включает отдельную пару CAN ID пульта, присутствующую в общей библиотеке.
|
||||
if profile == 'legacy': # Старые проектные форматы требуют явного проекта, поскольку диапазоны ID могут пересекаться.
|
||||
selected = next((item for item in legacycan.PROJECTS if item.name == project), None) # Поиск использует общий каталог без копирования ID в анализатор.
|
||||
if selected is None: # Ошибка выбора не должна давать расшифровку другого проекта.
|
||||
raise ValueError('Unknown legacy CAN project: ' + project) # Сообщение остаётся видимым на дорожке ошибок.
|
||||
node = selected.node_for(ident) # Маршрутизация зависит от формата: точный ID либо старшие биты ID.
|
||||
if node is None: # На общей шине допустимы кадры других устройств и проектов.
|
||||
return None # Чужие ID остаются только в дорожках базового CAN-декодера.
|
||||
packet = legacycan.decode(selected.format, node, ident, payload) # Общая реализация восстанавливает адрес, маску и слова с нужным порядком байтов.
|
||||
if packet is None: # Известный ID с неподходящим DLC обозначает повреждённый кадр выбранного проекта.
|
||||
raise ValueError('Invalid legacy CAN payload') # Некорректные данные не выдаются как достоверные регистры.
|
||||
registers = list(packet.present_values) # Получает только отмеченные маской слова в исходном адресном порядке.
|
||||
commands = ['%s: %s' % ('R%04X' % address, ', '.join(selected.command_names[bit] for bit in range(16) if value & (1 << bit))) for address, value in registers if address == 127] # Регистр 127 хранит 16 командных битов с названиями выбранного проекта.
|
||||
summary = '%s · %s · %s · %s' % (project, node.name, packet.source.value, ' '.join('R%04X=%04X' % pair for pair in registers)) # Краткая подпись связывает проект, устройство, направление и значения слов.
|
||||
return dict(protocol='Legacy CAN', summary=summary, fields=[('Проект', project), ('Устройство', node.name), ('Направление', packet.source.value)] + [('Команды', text) for text in commands], registers=registers, warnings=[]) # Все подробности доступны на самостоятельных дорожках DSView.
|
||||
if profile == 'ump-protocan' and ump_map == 'gas167': # Текущая карта 167/v1 назначается вручную и не смешивается с новыми глобальными ID.
|
||||
if ump_node not in (1, 2): # В профиле имеются два УМП; номер не извлекается из локального адреса регистра.
|
||||
raise ValueError('UMP node must be 1 or 2') # Ошибочный номер узла должен быть исправлен в настройках.
|
||||
return _decoded(ump_can.decode_gas167(frame, ump_node)) # Имена параметров и активные биты берутся из действующей карты templates.
|
||||
if profile == 'ump-protocan' and ump_map != 'assigned': # Назначенная глобальная карта обрабатывается штатным ProtoCAN ниже.
|
||||
raise ValueError('Unknown UMP map: ' + ump_map) # Неизвестную карту нельзя интерпретировать как другую раскладку регистров.
|
||||
from .set_can import legacy # Поздний импорт сохраняет совместимость существующего ядра и предотвращает циклическую загрузку модулей.
|
||||
result = legacy(ident, payload) # Сохраняет строгие проверки DLC GAS/Boot, сегментных длин и старые структурированные поля событий.
|
||||
application = _decoded(pc.decode(ident, payload)) # Общий ProtoCAN добавляет имена устройств, GAS, Settings, дискретов и служебных полей.
|
||||
result.update(fields=application['fields'], warnings=application['warnings'], summary=result['summary'] + ' · ' + application['summary']) # Старые поля сохраняются, а полная прикладная расшифровка становится доступной на осциллограмме.
|
||||
return result # Кадр выдаётся только после проверки wire-формата обоими существующими уровнями.
|
||||
@@ -0,0 +1,29 @@
|
||||
from collections import deque # Кольцевой буфер хранит только координаты одного максимального кадра.
|
||||
from . import template_protocols # Подключает поставляемые пакеты templates в отдельном Python-процессе DSView.
|
||||
from set_devices import tms_terminal as tms # Раскладки, CRC и потоковый поиск границ используются из действующего парсера терминала.
|
||||
|
||||
LAYOUTS = tuple(tms.PRESETS) + ('custom',) # Имена готовых раскладок совпадают с настройками терминала SETGUI.
|
||||
|
||||
class TmsStreamParser: # Адаптирует общий байтовый парсер к событиям с координатами sigrok.
|
||||
def __init__(self, preset=tms.DEFAULT_PRESET, digit_bytes=38, analog_count=34, crc='MODBUS'): # Значения 38/34 соответствуют штатной раскладке message2.h.
|
||||
selected = tms.PRESETS[preset] if preset != 'custom' else tms.Layout(digit_bytes, analog_count, crc) # Индивидуальная раскладка проверяется существующей моделью Layout.
|
||||
self.layout = tms.Layout(selected.digit_bytes, selected.analog_count, crc) # CRC выбирается отдельно, поскольку в источниках есть MODBUS и CCITT-FALSE.
|
||||
self.parser = tms.TmsParser(self.layout) # Поиск границ и подтверждение CRC выполняет общий парсер.
|
||||
self.positions = deque(maxlen=self.layout.frame_size) # Число хранимых координат ограничено длиной одного кадра в байтах.
|
||||
|
||||
def flush(self): # Пауза либо ошибка UART запрещает объединять части разных посылок.
|
||||
self.parser.reset() # Общий парсер отбрасывает недособранные байты и состояние синхронизации.
|
||||
self.positions.clear() # Координаты старого хвоста также исключаются из следующего события.
|
||||
return [] # Формат без сигнатуры не позволяет отличить неполный кадр от произвольного шума.
|
||||
|
||||
def feed(self, value, ss, es): # Один UART-байт приходит вместе с точными границами в отсчётах.
|
||||
self.positions.append((ss, es)) # Последние frame_size позиций соответствуют окну поиска общего парсера.
|
||||
events = [] # За один байт может завершиться один кадр фиксированной длины.
|
||||
for record in self.parser.feed(bytes([value])): # Только CRC-подтверждённые записи выдаются прикладному слою.
|
||||
fields = [('Команда', record.command), ('Адрес', str(record.address)), ('CRC', '%04X' % record.crc_frame)] # Служебные поля берутся из готовой модели TmsRecord.
|
||||
fields += [(tms.analog_name(index), str(word)) for index, word in enumerate(record.analogs)] # Имена аналогов и знаковые int16 совпадают с терминалом SETGUI.
|
||||
fields += [('Дискреты', ', '.join(record.set_bits()) or 'нет активных битов')] # Активные биты выдаются с исходной нумерацией байтов и битов.
|
||||
result = dict(protocol='TMS UART', summary=record.summary, payload=record.raw[2:-3], raw=record.raw, fields=fields, analogs=record.analogs, digits=record.digits) # CRC и завершающий байт исключены из полезной нагрузки по контракту TMS_TO_TERMINAL.
|
||||
events.append((self.positions[0][0], es, result, None)) # Начало подтверждённого окна сохраняется даже после пропущенного шума.
|
||||
self.positions.clear() # Координаты завершённого кадра не удерживаются до следующей посылки.
|
||||
return events # События имеют тот же контракт, что парсер SET UART.
|
||||
+42
-15
@@ -15,6 +15,19 @@ DATA_OFFSET = 0x10
|
||||
NODE_COUNT = 13
|
||||
PULT_COMMAND_ID = 0x00745009
|
||||
PULT_DATA_ID = 0x00745019
|
||||
HEARTBEAT_ADDRESS = 0x0100 # CAN_HEARTBEAT_ADDR в ecan.c периферии 167 начинает три служебных слова вне технологической карты R0..R127.
|
||||
HEARTBEAT_NAMES = { # Назначения слов соответствуют hiword и loword ветки CAN_send для CAN_HEARTBEAT_ADDR.
|
||||
HEARTBEAT_ADDRESS: "Heartbeat: счётчик, с", # Первое слово содержит секунды после запуска по модулю 65536, а не знаковое измерение.
|
||||
HEARTBEAT_ADDRESS + 1: "Heartbeat: Mode", # Второе слово передаёт аппаратный номер Mode платы без преобразования CAN-индекса.
|
||||
HEARTBEAT_ADDRESS + 2: "Heartbeat: тип платы (Desk)", # Третье слово содержит код Desk из прошивки, без подмены его именем CAN-узла.
|
||||
} # Все три адреса общие для поддерживаемых узлов периферии 167.
|
||||
COMMON_REGISTER_NAMES = { # Общие назначения подтверждены package.h; единицы времени зависят от версии планировщика CAN.
|
||||
0x60: "Настройка быстрого потока CAN", # Cancount[m_FAST]: в новом планировщике ноль отключает поток, ненулевое значение включает его.
|
||||
0x61: "Настройка медленного потока CAN", # Cancount[m_SLOW]: исторический период нельзя переводить в миллисекунды без версии прошивки.
|
||||
0x62: "Яркость индикации", # Brightness в package.h содержит исходный уровень яркости, а не подтверждённый процент.
|
||||
0x7E: "Последний режим", # LastMode хранится в предпоследнем слове технологической карты R0–R127.
|
||||
0x7F: "Команды", # Commands — битовая маска, которую дополнительно расшифровывает command_description.
|
||||
} # Таблица доступна с любой версией DLL и при использовании резервного Python-декодера.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -38,10 +51,21 @@ class BalsamFrame:
|
||||
present_mask: int
|
||||
registers: tuple[BalsamRegister, ...]
|
||||
|
||||
@property
|
||||
def route(self) -> str:
|
||||
return ("%s → ПМ" % self.device_name if self.from_device
|
||||
else "ПМ → %s" % self.device_name)
|
||||
@property # Признак доступен адаптерам монитора и общей текстовой расшифровке без повторного разбора байтов.
|
||||
def is_heartbeat(self) -> bool: # Штатный CAN_heartbeat_service передаёт ответ с адресом 0x0100 и всеми тремя словами.
|
||||
return self.from_device and self.start_address == HEARTBEAT_ADDRESS and self.present_mask == 7 # Маска 111 из ecan.c исключает команды и неполные служебные пакеты.
|
||||
|
||||
@property # Имя отправителя определяется направлением кадра, тогда как device_name сохраняет имя периферийного узла.
|
||||
def sender_name(self) -> str: # Все устройства 167 подписываются по источнику кадра, как в предоставленной таблице адресов.
|
||||
return self.device_name if self.from_device else "TMS" # Команды базового диапазона передаёт TMS, а ответы со смещением 0x10 — соответствующий периферийный узел.
|
||||
|
||||
@property # Получатель дополняет отправителя, чтобы подробная расшифровка совпадала с подписью CAN ID.
|
||||
def recipient_name(self) -> str: # Имя адресата нужно для обоих направлений каждой пары устройств.
|
||||
return "TMS" if self.from_device else self.device_name # Периферия возвращает данные в TMS; получателем команды остаётся узел, выбранный CAN ID.
|
||||
|
||||
@property # Краткий маршрут используется также в сводке и истории CAN-монитора.
|
||||
def route(self) -> str: # Собираем направление из тех же имён, которые показаны в подробностях кадра.
|
||||
return "%s → %s" % (self.sender_name, self.recipient_name) # Единые подписи исключают различия между строкой ID и полями отправителя и получателя.
|
||||
|
||||
def command_description(self, value: int) -> str:
|
||||
"""Command register 127; named UMP bits from BALZAM_167 automats.h."""
|
||||
@@ -56,13 +80,16 @@ class BalsamFrame:
|
||||
for bit in range(16) if value & (1 << bit))
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
direction = "данные" if self.from_device else "команда"
|
||||
values = ", ".join(
|
||||
"%s=0x%04X (%d)" % (item.name or "R%04X" % item.address,
|
||||
item.value, item.signed_value)
|
||||
def summary(self) -> str: # Сводка показывает служебный пакет компактно, а технологические регистры — с адресами и известными назначениями.
|
||||
if self.is_heartbeat: # Только полный ответ с адреса 0x0100 содержит три служебных слова в гарантированном порядке.
|
||||
seconds, mode, desk = (item.value for item in self.registers) # Значения heartbeat беззнаковые; счётчик циклически проходит 0–65535 секунд.
|
||||
return "BALZAM · %s · Heartbeat · %d с · Mode=%d · Desk=%d" % (self.route, seconds, mode, desk) # Один heartbeat получает одну подпись; исходные HEX остаются в байтах и подробных полях.
|
||||
direction = "данные" if self.from_device else "команда" # Направление технологического кадра определяется диапазоном CAN ID.
|
||||
values = ", ".join( # Каждое отмеченное маской слово выводится ровно один раз вместе с его назначением.
|
||||
"R%d=0x%04X (%d)" % (item.address, item.value, item.signed_value) # Десятичный адрес совпадает с таблицей R0–R127, HEX сохраняет точное исходное слово.
|
||||
+ (" — " + item.name if item.name else "") # Для неизвестного адреса сохраняем только достоверные номер и значение, не придумывая назначение.
|
||||
+ (" — " + self.command_description(item.value)
|
||||
if not self.from_device and item.address == 0x7F else "")
|
||||
if item.address == 0x7F else "")
|
||||
for item in self.registers
|
||||
) or "нет отмеченных регистров"
|
||||
return "BALZAM · %s · %s · %s" % (self.route, direction, values)
|
||||
@@ -93,7 +120,7 @@ def decode(can_id: int, data: bytes, native=None) -> BalsamFrame | None:
|
||||
device, direction, mask, start, values = decoded
|
||||
registers = tuple(
|
||||
BalsamRegister(start + index, values[index],
|
||||
native.balsam_register_name(device, start + index))
|
||||
HEARTBEAT_NAMES.get(start + index) or COMMON_REGISTER_NAMES.get(start + index) or native.balsam_register_name(device, start + index)) # Общие настройки получают одинаковые подписи со старой DLL; назначения каналов определяет C-ядро.
|
||||
for index in range(3) if mask & (4 >> index)
|
||||
)
|
||||
return BalsamFrame(can_id & 0x1FFFFFFF, device,
|
||||
@@ -107,7 +134,7 @@ def _decode_fallback(can_id: int, data: bytes) -> BalsamFrame | None:
|
||||
if len(data) != 8:
|
||||
raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes")
|
||||
relative = (can_id & 0x1FFFFFFF) - BASE_ID
|
||||
device = (relative & 0x0F) + 1
|
||||
device = (relative & 0x0F) + 1 # Прошивка ecan.c уменьшает DevNum перед сборкой ID; маска выделяет нулевой CAN-индекс, а +1 возвращает номер Mode.
|
||||
from_device = relative >= DATA_OFFSET
|
||||
if can_id & 0x1FFFFFFF in (PULT_COMMAND_ID, PULT_DATA_ID):
|
||||
device = 10
|
||||
@@ -117,12 +144,12 @@ def _decode_fallback(can_id: int, data: bytes) -> BalsamFrame | None:
|
||||
for offset in (6, 0, 2))
|
||||
names = {
|
||||
1: "Трансформатор 1", 2: "Трансформатор 2",
|
||||
3: "Силовой блок 1", 4: "Силовой блок 2", 5: "УМП 1", 6: "УМП 2",
|
||||
7: "Двигатель", 8: "ВЭП", 9: "Задатчик", 10: "Пульт (МПУ)", 13: "Терминал",
|
||||
3: "Силовой 1", 4: "Силовой 2", 5: "УМП 1", 6: "УМП 2", # Названия силовых узлов и УМП соответствуют строкам 3..6 предоставленной таблицы адресов 167.
|
||||
7: "Двигатель", 8: "Задатчик", 9: "ВЭП", 10: "ПМУ", 13: "Терминал", # Таблица задаёт задатчик 8, ВЭП 9 и ПМУ для пары 0xBA0009/0xBA0019; служебный терминал сохраняет номер 13.
|
||||
}
|
||||
start, mask = header & 0x1FFF, (header >> 13) & 7
|
||||
registers = tuple(BalsamRegister(start + i, values[i],
|
||||
"Команды" if start + i == 0x7F else "")
|
||||
HEARTBEAT_NAMES.get(start + i) or COMMON_REGISTER_NAMES.get(start + i, "")) # Без DLL сохраняем подтверждённые назначения heartbeat и общих настроек, оставляя неизвестные каналы без подписи.
|
||||
for i in range(3) if mask & (4 >> i))
|
||||
return BalsamFrame(can_id & 0x1FFFFFFF, device,
|
||||
names.get(device, "Узел %d" % device),
|
||||
|
||||
@@ -20,6 +20,7 @@ class CanBootTarget:
|
||||
hardware_min: int = 0
|
||||
hardware_max: int = 255
|
||||
session_id: int = 1
|
||||
memory: str = "eeprom" # Назначение выбирается явно; старые вызовы сохраняют EEPROM A/B и прежний wire-протокол.
|
||||
|
||||
def __post_init__(self):
|
||||
if not 0 <= self.device_type <= 7 or not 0 <= self.device <= 15:
|
||||
@@ -30,6 +31,10 @@ class CanBootTarget:
|
||||
raise ValueError("Неверный диапазон аппаратных ревизий")
|
||||
if not 1 <= self.session_id <= 255:
|
||||
raise ValueError("Session ID должен быть 1…255")
|
||||
if self.memory not in ("eeprom", "flash", "stm32") or self.memory == "flash" and self.product_type != 0x2833: # F28335 требует отдельного подтверждения выбора Flash; STM32 использует штатный односекционный поток.
|
||||
raise ValueError("Внутренняя Flash по CAN доступна только для F28335 / Product Type 0x2833") # Ошибка назначения обнаруживается до первого CAN-кадра.
|
||||
if self.memory == "stm32" and self.product_type != 0: # BOOT CANdle G474VET6 публикует Product Type 0 в своём аппаратном профиле.
|
||||
raise ValueError("CANdle G474VET6 требует Product Type 0") # Не допускает случайную передачу образа с ранее выбранным типом платы Бальзам.
|
||||
|
||||
|
||||
class CanBootTransfer:
|
||||
@@ -41,6 +46,19 @@ class CanBootTransfer:
|
||||
raise ValueError("F28335 M95M02 A/B: образ не должен превышать 130560 байт")
|
||||
if not 0 <= image.version <= 0xFFFFFFFF:
|
||||
raise ValueError("Версия образа вне диапазона u32")
|
||||
if target.memory == "stm32": # Односекционная Flash G474 проверяется до первой команды ENTER_BOOT.
|
||||
from set_devices.mcu_profiles import CANDLE_G474_BOOT # Размер приложения и адрес векторов берутся из общего профиля платы.
|
||||
profile = CANDLE_G474_BOOT # Границы соответствуют 32 КиБ BOOT и 480 КиБ приложения STM32G474VET6.
|
||||
if not 8 <= len(image.data) <= profile["app_size"]: # Первые восемь байтов содержат SP и Reset_Handler; запись не должна выходить за Flash.
|
||||
raise ValueError("CANdle G474VET6: приложение должно занимать 8…491520 байт") # Ошибка обнаруживается до стирания целевого приложения.
|
||||
sp, pc = struct.unpack_from("<II", image.data) # Таблица векторов ARM Cortex-M хранит два 32-битных little-endian адреса.
|
||||
if sp & 7 or not profile["stack_base"] < sp <= profile["stack_end"]: # MSP выровнен на восемь байтов и входит в те же 80 КиБ SRAM, которые разрешает валидатор штатного BOOT CANdle.
|
||||
raise ValueError("CANdle G474VET6: неверный начальный указатель стека") # Случайный BIN не рассматривается как приложение ARM.
|
||||
if not pc & 1 or not profile["app_base"] <= (pc & ~1) < profile["app_base"] + len(image.data): # Thumb-вектор должен указывать внутрь приложения, слинкованного для 0x08008000.
|
||||
raise ValueError("CANdle G474VET6: нужен образ приложения для 0x08008000") # BOOT-образ 0x08000000 и приложение USB BOOT RB не принимаются.
|
||||
if target.memory == "flash": # Flash принимает тот же подписанный SCI8, что существующий RS Flash, с защищённым сектором A.
|
||||
from .f28335_flash import parse_sci8 # Повторно использует общий валидатор FAB1, адресов и CRC без отдельной реализации в клиенте.
|
||||
parse_sci8(image.data) # Обычный RAM BIN и образ с аппаратным boot отвергаются до входа в загрузчик.
|
||||
self.image, self.target = image, target
|
||||
self.stage = "enter"
|
||||
self.next_block = 0
|
||||
@@ -50,6 +68,8 @@ class CanBootTransfer:
|
||||
|
||||
@property
|
||||
def response_timeout_ms(self):
|
||||
if self.target.memory == "flash" and self.stage == "commit": # TI erase/program/verify выполняются синхронно после полной проверки staging.
|
||||
return 180000 # Даёт до 180 секунд на штатную запись и итоговую CRC, как действующий RS Flash-клиент.
|
||||
return 15000 if self.stage in ("enter", "verify", "commit") else 1500
|
||||
|
||||
@property
|
||||
@@ -88,7 +108,7 @@ class CanBootTransfer:
|
||||
if not self.accepts(frame):
|
||||
return [], ""
|
||||
command = ProtoCanId.parse(frame.can_id).body & 255
|
||||
expected_command = {"enter": 2, "image": 3, "compat": 4, "erase": 5,
|
||||
expected_command = {"enter": 2, "memory": 16, "image": 3, "compat": 4, "erase": 5, # Ответ выбора назначения принимается только на собственном этапе.
|
||||
"data": 0, "verify": 6, "commit": 7, "reboot": 9}.get(self.stage)
|
||||
# Позднее подтверждение предыдущего этапа нельзя применять к новому:
|
||||
# оно не продвигает автомат и не считается ошибкой текущего запроса.
|
||||
@@ -99,7 +119,10 @@ class CanBootTransfer:
|
||||
return [], "Загрузчик занят"
|
||||
if status not in (0, 8) or (status == 8 and self.stage != "data"):
|
||||
raise RuntimeError(f"ProtoCAN Boot: ошибка 0x{status:02X}")
|
||||
if self.stage == "enter":
|
||||
if self.stage == "enter" and self.target.memory == "flash": # Старый загрузчик должен явно подтвердить поддержку новой цели до передачи образа.
|
||||
self.stage = "memory" # Сначала требует ACK SELECT_MEMORY, затем начнёт обычные метаданные.
|
||||
return self._control(16, b"\x01"), "Выбор внутренней Flash F28335" # DLC=1 и значение 1 означают внутреннюю Flash; значение 0 зарезервировано за штатной EEPROM.
|
||||
if self.stage in ("enter", "memory"): # EEPROM сохраняет прежний переход, Flash переходит только после подтверждённого выбора памяти.
|
||||
self.stage = "image"
|
||||
return self._control(3, struct.pack("<II", len(self.image.data), self.image.crc32)), "Передача метаданных"
|
||||
if self.stage == "image":
|
||||
@@ -110,7 +133,7 @@ class CanBootTransfer:
|
||||
if slot not in (0, 1):
|
||||
raise RuntimeError("ProtoCAN Boot: неверный слот")
|
||||
self.slot, self.stage = slot, "erase"
|
||||
return self._control(5), "Стирание неактивного слота"
|
||||
return self._control(5), ("Подготовка временного слота" if self.target.memory == "flash" else "Стирание приложения STM32" if self.target.memory == "stm32" else "Стирание неактивного слота") # F28335 готовит staging, STM32 стирает единственный слот, EEPROM сохраняет штатное поведение A/B.
|
||||
if self.stage in ("erase", "data"):
|
||||
total = (len(self.image.data) + 7) // 8
|
||||
# The final index of a 512 KiB image wraps the u16 status field.
|
||||
@@ -132,11 +155,15 @@ class CanBootTransfer:
|
||||
return self._window(), "Передача блоков ProtoCAN Boot"
|
||||
if self.stage == "verify":
|
||||
self.stage = "commit"
|
||||
return self._control(7), "Активация образа"
|
||||
return self._control(7), "Запись и проверка внутренней Flash" if self.target.memory == "flash" else "Активация образа" # Для Flash COMMIT означает реальный вызов TI API и публикацию FAB1 последней стадией.
|
||||
if self.stage == "commit":
|
||||
self.stage = "reboot"
|
||||
return self._control(9), "Перезапуск прибора"
|
||||
self.stage, self.finished = "finished", True
|
||||
if self.target.memory == "stm32": # У односекционного STM32 нет EEPROM-кандидата и механизма пробного запуска A/B.
|
||||
return [], "Flash STM32 записана и проверена; прибор перезапускается" # Успех сообщается после ACK VERIFY, COMMIT и REBOOT обычного протокола.
|
||||
if self.target.memory == "flash": # Flash не выдаётся за EEPROM-кандидата, которому требуется trial/CONFIRM.
|
||||
return [], "Внутренняя Flash записана и проверена; прибор перезапускается" # Успех возможен только после итогового ACK COMMIT и ACK REBOOT.
|
||||
if self.target.product_type == 0x2833:
|
||||
return [], "Образ принят; после перезапуска приложение подтвердит пробный запуск"
|
||||
return [], "Прошивка ProtoCAN Boot завершена"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""GAS 167/v1 dictionary and assigned UKSS IDs; values remain raw u16."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
REGISTERS = tuple(json.loads(Path(__file__).with_name('gas_167_registers.json').read_text(encoding='utf-8'))['registers'])
|
||||
BY_ADDRESS = {entry['address']: entry for entry in REGISTERS}
|
||||
BY_ASSIGNED_ID = {entry[key]: (node, entry) for node, key in ((5, 'ump1_id'), (6, 'ump2_id')) for entry in REGISTERS}
|
||||
|
||||
COMMAND_NAMES = (
|
||||
'cTestLamp — проверка ламп', 'cDefParam — параметры по умолчанию',
|
||||
'cSaveParam — сохранение параметров', 'cLoadParam — загрузка параметров',
|
||||
'cTermoCal — термокалибровка', 'cKoefCalc — расчёт коэффициентов',
|
||||
'cUMPreset — останов заряда', 'cUMPstart — пуск заряда',
|
||||
'cInitDac — инициализация ЦАП', 'cCalibrDac — калибровка ЦАП',
|
||||
'cSecretBt', 'cLiteFire', 'cRawMeat', 'бит 13 (не назначен)',
|
||||
'бит 14 (не назначен)', 'cReset — сброс',
|
||||
)
|
||||
|
||||
|
||||
def describe_word(offset, value):
|
||||
"""Describe active bits using the selected LOAD register map."""
|
||||
if offset == 127:
|
||||
return ', '.join(name for bit, name in enumerate(COMMAND_NAMES)
|
||||
if value & (1 << bit)) or 'нет активных команд'
|
||||
if offset == 124:
|
||||
names = ('LOAD_FAULT — ошибка пуска', 'нет свежего напряжения ЗПТ',
|
||||
'LOAD_RELOAD — сброс перед пуском', 'LOAD_RUN — работа рампы',
|
||||
'имитация потери ЗПТ', 'имитация 0 В')
|
||||
return ', '.join(names[bit] if bit < len(names) else 'бит %d' % bit
|
||||
for bit in range(16) if value & (1 << bit)) or 'нет активных флагов LOAD'
|
||||
return ''
|
||||
|
||||
|
||||
def metadata(address):
|
||||
"""Metadata for the explicitly selected legacy Modbus profile."""
|
||||
index = address if 0 <= address < 128 else address - 0x5000
|
||||
if not 0 <= index < 128:
|
||||
return {}
|
||||
entry = REGISTERS[index]
|
||||
return {'name': entry['description'], 'gas_id': f"{entry['ump1_id']} / {entry['ump2_id']}"}
|
||||
|
||||
|
||||
def assigned_metadata(address):
|
||||
"""Only assigned global IDs; reserved offsets have no parameter name."""
|
||||
match = BY_ASSIGNED_ID.get(address)
|
||||
if match is None:
|
||||
return {}
|
||||
node, entry = match
|
||||
return {'node': node, 'name': entry['description'], 'access': entry['access'],
|
||||
'modbus': entry['modbus'], 'legacy_address': entry['address'],
|
||||
'status': 'назначен; прошивка 167/v1 ещё не обслуживает'}
|
||||
@@ -0,0 +1,1030 @@
|
||||
{
|
||||
"profile": "167/v1",
|
||||
"source_commit": "378e06a",
|
||||
"registers": [
|
||||
{
|
||||
"modbus": 0,
|
||||
"address": 20480,
|
||||
"ump1_id": 5000,
|
||||
"ump2_id": 6000,
|
||||
"description": "sens_error[0]; InputRep0/1/2 — биты A/B/C",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 1,
|
||||
"address": 20481,
|
||||
"ump1_id": 5001,
|
||||
"ump2_id": 6001,
|
||||
"description": "sens_error[1]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 2,
|
||||
"address": 20482,
|
||||
"ump1_id": 5002,
|
||||
"ump2_id": 6002,
|
||||
"description": "sens_error[2]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 3,
|
||||
"address": 20483,
|
||||
"ump1_id": 5003,
|
||||
"ump2_id": 6003,
|
||||
"description": "sens_error[3]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 4,
|
||||
"address": 20484,
|
||||
"ump1_id": 5004,
|
||||
"ump2_id": 6004,
|
||||
"description": "sens_error[4]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 5,
|
||||
"address": 20485,
|
||||
"ump1_id": 5005,
|
||||
"ump2_id": 6005,
|
||||
"description": "sens_error[5]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 6,
|
||||
"address": 20486,
|
||||
"ump1_id": 5006,
|
||||
"ump2_id": 6006,
|
||||
"description": "sens_error[6]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 7,
|
||||
"address": 20487,
|
||||
"ump1_id": 5007,
|
||||
"ump2_id": 6007,
|
||||
"description": "sens_error[7]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 8,
|
||||
"address": 20488,
|
||||
"ump1_id": 5008,
|
||||
"ump2_id": 6008,
|
||||
"description": "sens_error[8]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 9,
|
||||
"address": 20489,
|
||||
"ump1_id": 5009,
|
||||
"ump2_id": 6009,
|
||||
"description": "sens_error[9]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 10,
|
||||
"address": 20490,
|
||||
"ump1_id": 5010,
|
||||
"ump2_id": 6010,
|
||||
"description": "sens_error[10]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 11,
|
||||
"address": 20491,
|
||||
"ump1_id": 5011,
|
||||
"ump2_id": 6011,
|
||||
"description": "sens_error[11]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 12,
|
||||
"address": 20492,
|
||||
"ump1_id": 5012,
|
||||
"ump2_id": 6012,
|
||||
"description": "sens_error[12]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 13,
|
||||
"address": 20493,
|
||||
"ump1_id": 5013,
|
||||
"ump2_id": 6013,
|
||||
"description": "sens_error[13]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 14,
|
||||
"address": 20494,
|
||||
"ump1_id": 5014,
|
||||
"ump2_id": 6014,
|
||||
"description": "sens_error[14]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 15,
|
||||
"address": 20495,
|
||||
"ump1_id": 5015,
|
||||
"ump2_id": 6015,
|
||||
"description": "sens_error[15]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 16,
|
||||
"address": 20496,
|
||||
"ump1_id": 5016,
|
||||
"ump2_id": 6016,
|
||||
"description": "sens_error[16]; Inputs",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 17,
|
||||
"address": 20497,
|
||||
"ump1_id": 5017,
|
||||
"ump2_id": 6017,
|
||||
"description": "sens_error[17]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 18,
|
||||
"address": 20498,
|
||||
"ump1_id": 5018,
|
||||
"ump2_id": 6018,
|
||||
"description": "sens_error[18]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 19,
|
||||
"address": 20499,
|
||||
"ump1_id": 5019,
|
||||
"ump2_id": 6019,
|
||||
"description": "sens_error[19]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 20,
|
||||
"address": 20500,
|
||||
"ump1_id": 5020,
|
||||
"ump2_id": 6020,
|
||||
"description": "sens_error[20]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 21,
|
||||
"address": 20501,
|
||||
"ump1_id": 5021,
|
||||
"ump2_id": 6021,
|
||||
"description": "sens_error[21]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 22,
|
||||
"address": 20502,
|
||||
"ump1_id": 5022,
|
||||
"ump2_id": 6022,
|
||||
"description": "sens_error[22]; Buttons",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 23,
|
||||
"address": 20503,
|
||||
"ump1_id": 5023,
|
||||
"ump2_id": 6023,
|
||||
"description": "sens_error[23]; Jumpers",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 24,
|
||||
"address": 20504,
|
||||
"ump1_id": 5024,
|
||||
"ump2_id": 6024,
|
||||
"description": "sens_data[0]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 25,
|
||||
"address": 20505,
|
||||
"ump1_id": 5025,
|
||||
"ump2_id": 6025,
|
||||
"description": "sens_data[1]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 26,
|
||||
"address": 20506,
|
||||
"ump1_id": 5026,
|
||||
"ump2_id": 6026,
|
||||
"description": "sens_data[2]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 27,
|
||||
"address": 20507,
|
||||
"ump1_id": 5027,
|
||||
"ump2_id": 6027,
|
||||
"description": "sens_data[3]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 28,
|
||||
"address": 20508,
|
||||
"ump1_id": 5028,
|
||||
"ump2_id": 6028,
|
||||
"description": "sens_data[4]; LOAD: расчётный выход 4–20 мА, raw ×10",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 29,
|
||||
"address": 20509,
|
||||
"ump1_id": 5029,
|
||||
"ump2_id": 6029,
|
||||
"description": "sens_data[5]; LOAD: измеренный вход 4–20 мА, raw ×10",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 30,
|
||||
"address": 20510,
|
||||
"ump1_id": 5030,
|
||||
"ump2_id": 6030,
|
||||
"description": "sens_data[6]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 31,
|
||||
"address": 20511,
|
||||
"ump1_id": 5031,
|
||||
"ump2_id": 6031,
|
||||
"description": "sens_data[7]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 32,
|
||||
"address": 20512,
|
||||
"ump1_id": 5032,
|
||||
"ump2_id": 6032,
|
||||
"description": "sens_data[8]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 33,
|
||||
"address": 20513,
|
||||
"ump1_id": 5033,
|
||||
"ump2_id": 6033,
|
||||
"description": "sens_data[9]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 34,
|
||||
"address": 20514,
|
||||
"ump1_id": 5034,
|
||||
"ump2_id": 6034,
|
||||
"description": "sens_data[10]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 35,
|
||||
"address": 20515,
|
||||
"ump1_id": 5035,
|
||||
"ump2_id": 6035,
|
||||
"description": "sens_data[11]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 36,
|
||||
"address": 20516,
|
||||
"ump1_id": 5036,
|
||||
"ump2_id": 6036,
|
||||
"description": "sens_data[12]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 37,
|
||||
"address": 20517,
|
||||
"ump1_id": 5037,
|
||||
"ump2_id": 6037,
|
||||
"description": "sens_data[13]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 38,
|
||||
"address": 20518,
|
||||
"ump1_id": 5038,
|
||||
"ump2_id": 6038,
|
||||
"description": "sens_data[14]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 39,
|
||||
"address": 20519,
|
||||
"ump1_id": 5039,
|
||||
"ump2_id": 6039,
|
||||
"description": "sens_data[15]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 40,
|
||||
"address": 20520,
|
||||
"ump1_id": 5040,
|
||||
"ump2_id": 6040,
|
||||
"description": "sens_data[16]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 41,
|
||||
"address": 20521,
|
||||
"ump1_id": 5041,
|
||||
"ump2_id": 6041,
|
||||
"description": "sens_data[17]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 42,
|
||||
"address": 20522,
|
||||
"ump1_id": 5042,
|
||||
"ump2_id": 6042,
|
||||
"description": "sens_data[18]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 43,
|
||||
"address": 20523,
|
||||
"ump1_id": 5043,
|
||||
"ump2_id": 6043,
|
||||
"description": "sens_data[19]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 44,
|
||||
"address": 20524,
|
||||
"ump1_id": 5044,
|
||||
"ump2_id": 6044,
|
||||
"description": "sens_data[20]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 45,
|
||||
"address": 20525,
|
||||
"ump1_id": 5045,
|
||||
"ump2_id": 6045,
|
||||
"description": "sens_data[21]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 46,
|
||||
"address": 20526,
|
||||
"ump1_id": 5046,
|
||||
"ump2_id": 6046,
|
||||
"description": "sens_data[22]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 47,
|
||||
"address": 20527,
|
||||
"ump1_id": 5047,
|
||||
"ump2_id": 6047,
|
||||
"description": "sens_data[23]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 48,
|
||||
"address": 20528,
|
||||
"ump1_id": 5048,
|
||||
"ump2_id": 6048,
|
||||
"description": "sens_hi_edge[0]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 49,
|
||||
"address": 20529,
|
||||
"ump1_id": 5049,
|
||||
"ump2_id": 6049,
|
||||
"description": "sens_hi_edge[1]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 50,
|
||||
"address": 20530,
|
||||
"ump1_id": 5050,
|
||||
"ump2_id": 6050,
|
||||
"description": "sens_hi_edge[2]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 51,
|
||||
"address": 20531,
|
||||
"ump1_id": 5051,
|
||||
"ump2_id": 6051,
|
||||
"description": "sens_hi_edge[3]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 52,
|
||||
"address": 20532,
|
||||
"ump1_id": 5052,
|
||||
"ump2_id": 6052,
|
||||
"description": "sens_hi_edge[4]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 53,
|
||||
"address": 20533,
|
||||
"ump1_id": 5053,
|
||||
"ump2_id": 6053,
|
||||
"description": "sens_hi_edge[5]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 54,
|
||||
"address": 20534,
|
||||
"ump1_id": 5054,
|
||||
"ump2_id": 6054,
|
||||
"description": "sens_hi_edge[6]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 55,
|
||||
"address": 20535,
|
||||
"ump1_id": 5055,
|
||||
"ump2_id": 6055,
|
||||
"description": "sens_hi_edge[7]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 56,
|
||||
"address": 20536,
|
||||
"ump1_id": 5056,
|
||||
"ump2_id": 6056,
|
||||
"description": "sens_hi_edge[8]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 57,
|
||||
"address": 20537,
|
||||
"ump1_id": 5057,
|
||||
"ump2_id": 6057,
|
||||
"description": "sens_hi_edge[9]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 58,
|
||||
"address": 20538,
|
||||
"ump1_id": 5058,
|
||||
"ump2_id": 6058,
|
||||
"description": "sens_hi_edge[10]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 59,
|
||||
"address": 20539,
|
||||
"ump1_id": 5059,
|
||||
"ump2_id": 6059,
|
||||
"description": "sens_hi_edge[11]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 60,
|
||||
"address": 20540,
|
||||
"ump1_id": 5060,
|
||||
"ump2_id": 6060,
|
||||
"description": "sens_hi_edge[12]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 61,
|
||||
"address": 20541,
|
||||
"ump1_id": 5061,
|
||||
"ump2_id": 6061,
|
||||
"description": "sens_hi_edge[13]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 62,
|
||||
"address": 20542,
|
||||
"ump1_id": 5062,
|
||||
"ump2_id": 6062,
|
||||
"description": "sens_hi_edge[14]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 63,
|
||||
"address": 20543,
|
||||
"ump1_id": 5063,
|
||||
"ump2_id": 6063,
|
||||
"description": "sens_hi_edge[15]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 64,
|
||||
"address": 20544,
|
||||
"ump1_id": 5064,
|
||||
"ump2_id": 6064,
|
||||
"description": "sens_hi_edge[16]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 65,
|
||||
"address": 20545,
|
||||
"ump1_id": 5065,
|
||||
"ump2_id": 6065,
|
||||
"description": "sens_hi_edge[17]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 66,
|
||||
"address": 20546,
|
||||
"ump1_id": 5066,
|
||||
"ump2_id": 6066,
|
||||
"description": "sens_hi_edge[18]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 67,
|
||||
"address": 20547,
|
||||
"ump1_id": 5067,
|
||||
"ump2_id": 6067,
|
||||
"description": "sens_hi_edge[19]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 68,
|
||||
"address": 20548,
|
||||
"ump1_id": 5068,
|
||||
"ump2_id": 6068,
|
||||
"description": "sens_hi_edge[20]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 69,
|
||||
"address": 20549,
|
||||
"ump1_id": 5069,
|
||||
"ump2_id": 6069,
|
||||
"description": "sens_hi_edge[21]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 70,
|
||||
"address": 20550,
|
||||
"ump1_id": 5070,
|
||||
"ump2_id": 6070,
|
||||
"description": "sens_hi_edge[22]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 71,
|
||||
"address": 20551,
|
||||
"ump1_id": 5071,
|
||||
"ump2_id": 6071,
|
||||
"description": "sens_hi_edge[23]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 72,
|
||||
"address": 20552,
|
||||
"ump1_id": 5072,
|
||||
"ump2_id": 6072,
|
||||
"description": "sens_lo_edge[0]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 73,
|
||||
"address": 20553,
|
||||
"ump1_id": 5073,
|
||||
"ump2_id": 6073,
|
||||
"description": "sens_lo_edge[1]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 74,
|
||||
"address": 20554,
|
||||
"ump1_id": 5074,
|
||||
"ump2_id": 6074,
|
||||
"description": "sens_lo_edge[2]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 75,
|
||||
"address": 20555,
|
||||
"ump1_id": 5075,
|
||||
"ump2_id": 6075,
|
||||
"description": "sens_lo_edge[3]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 76,
|
||||
"address": 20556,
|
||||
"ump1_id": 5076,
|
||||
"ump2_id": 6076,
|
||||
"description": "sens_lo_edge[4]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 77,
|
||||
"address": 20557,
|
||||
"ump1_id": 5077,
|
||||
"ump2_id": 6077,
|
||||
"description": "sens_lo_edge[5]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 78,
|
||||
"address": 20558,
|
||||
"ump1_id": 5078,
|
||||
"ump2_id": 6078,
|
||||
"description": "sens_lo_edge[6]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 79,
|
||||
"address": 20559,
|
||||
"ump1_id": 5079,
|
||||
"ump2_id": 6079,
|
||||
"description": "sens_lo_edge[7]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 80,
|
||||
"address": 20560,
|
||||
"ump1_id": 5080,
|
||||
"ump2_id": 6080,
|
||||
"description": "sens_lo_edge[8]; LOAD: DAC_go — параметр начала рампы",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 81,
|
||||
"address": 20561,
|
||||
"ump1_id": 5081,
|
||||
"ump2_id": 6081,
|
||||
"description": "sens_lo_edge[9]; LOAD: DAC_stop — параметр конца рампы",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 82,
|
||||
"address": 20562,
|
||||
"ump1_id": 5082,
|
||||
"ump2_id": 6082,
|
||||
"description": "sens_lo_edge[10]; LOAD: LOAD_CAN_TIMEOUT, мс",
|
||||
"access": "RW; LOAD: 1…60000"
|
||||
},
|
||||
{
|
||||
"modbus": 83,
|
||||
"address": 20563,
|
||||
"ump1_id": 5083,
|
||||
"ump2_id": 6083,
|
||||
"description": "sens_lo_edge[11]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 84,
|
||||
"address": 20564,
|
||||
"ump1_id": 5084,
|
||||
"ump2_id": 6084,
|
||||
"description": "sens_lo_edge[12]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 85,
|
||||
"address": 20565,
|
||||
"ump1_id": 5085,
|
||||
"ump2_id": 6085,
|
||||
"description": "sens_lo_edge[13]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 86,
|
||||
"address": 20566,
|
||||
"ump1_id": 5086,
|
||||
"ump2_id": 6086,
|
||||
"description": "sens_lo_edge[14]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 87,
|
||||
"address": 20567,
|
||||
"ump1_id": 5087,
|
||||
"ump2_id": 6087,
|
||||
"description": "sens_lo_edge[15]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 88,
|
||||
"address": 20568,
|
||||
"ump1_id": 5088,
|
||||
"ump2_id": 6088,
|
||||
"description": "sens_lo_edge[16]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 89,
|
||||
"address": 20569,
|
||||
"ump1_id": 5089,
|
||||
"ump2_id": 6089,
|
||||
"description": "sens_lo_edge[17]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 90,
|
||||
"address": 20570,
|
||||
"ump1_id": 5090,
|
||||
"ump2_id": 6090,
|
||||
"description": "sens_lo_edge[18]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 91,
|
||||
"address": 20571,
|
||||
"ump1_id": 5091,
|
||||
"ump2_id": 6091,
|
||||
"description": "sens_lo_edge[19]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 92,
|
||||
"address": 20572,
|
||||
"ump1_id": 5092,
|
||||
"ump2_id": 6092,
|
||||
"description": "sens_lo_edge[20]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 93,
|
||||
"address": 20573,
|
||||
"ump1_id": 5093,
|
||||
"ump2_id": 6093,
|
||||
"description": "sens_lo_edge[21]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 94,
|
||||
"address": 20574,
|
||||
"ump1_id": 5094,
|
||||
"ump2_id": 6094,
|
||||
"description": "sens_lo_edge[22]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 95,
|
||||
"address": 20575,
|
||||
"ump1_id": 5095,
|
||||
"ump2_id": 6095,
|
||||
"description": "sens_lo_edge[23]",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 96,
|
||||
"address": 20576,
|
||||
"ump1_id": 5096,
|
||||
"ump2_id": 6096,
|
||||
"description": "Cancount[m_FAST] — пауза быстрых посылок CAN",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 97,
|
||||
"address": 20577,
|
||||
"ump1_id": 5097,
|
||||
"ump2_id": 6097,
|
||||
"description": "Cancount[m_SLOW] — пауза медленных посылок CAN",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 98,
|
||||
"address": 20578,
|
||||
"ump1_id": 5098,
|
||||
"ump2_id": 6098,
|
||||
"description": "Brightness / Bright[0] — яркость",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 99,
|
||||
"address": 20579,
|
||||
"ump1_id": 5099,
|
||||
"ump2_id": 6099,
|
||||
"description": "Owncount / Bright[1]; LOAD: исходное значение АЦП",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 100,
|
||||
"address": 20580,
|
||||
"ump1_id": 5100,
|
||||
"ump2_id": 6100,
|
||||
"description": "Bright[2]; Current_count: исходное значение канала 0",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 101,
|
||||
"address": 20581,
|
||||
"ump1_id": 5101,
|
||||
"ump2_id": 6101,
|
||||
"description": "Bright[3]; Current_count: исходное значение канала 1",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 102,
|
||||
"address": 20582,
|
||||
"ump1_id": 5102,
|
||||
"ump2_id": 6102,
|
||||
"description": "Bright[4]; Current_count: исходное значение канала 2",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 103,
|
||||
"address": 20583,
|
||||
"ump1_id": 5103,
|
||||
"ump2_id": 6103,
|
||||
"description": "Bright[5]; Current_count: исходное значение канала 3",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 104,
|
||||
"address": 20584,
|
||||
"ump1_id": 5104,
|
||||
"ump2_id": 6104,
|
||||
"description": "lev_count[], запись из Current_count; назначение по режиму",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 105,
|
||||
"address": 20585,
|
||||
"ump1_id": 5105,
|
||||
"ump2_id": 6105,
|
||||
"description": "lev_count[], запись из Current_count; назначение по режиму",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 106,
|
||||
"address": 20586,
|
||||
"ump1_id": 5106,
|
||||
"ump2_id": 6106,
|
||||
"description": "lev_count[], запись из Current_count; назначение по режиму",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 107,
|
||||
"address": 20587,
|
||||
"ump1_id": 5107,
|
||||
"ump2_id": 6107,
|
||||
"description": "lev_count[], запись из Current_count; назначение по режиму",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 108,
|
||||
"address": 20588,
|
||||
"ump1_id": 5108,
|
||||
"ump2_id": 6108,
|
||||
"description": "lev_count[], запись из Current_count; назначение по режиму",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 109,
|
||||
"address": 20589,
|
||||
"ump1_id": 5109,
|
||||
"ump2_id": 6109,
|
||||
"description": "lev_count[], запись из Current_count; назначение по режиму",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 110,
|
||||
"address": 20590,
|
||||
"ump1_id": 5110,
|
||||
"ump2_id": 6110,
|
||||
"description": "DAC_20 — калибровка входа 20 мА",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 111,
|
||||
"address": 20591,
|
||||
"ump1_id": 5111,
|
||||
"ump2_id": 6111,
|
||||
"description": "DAC_04 — калибровка входа 4 мА",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 112,
|
||||
"address": 20592,
|
||||
"ump1_id": 5112,
|
||||
"ump2_id": 6112,
|
||||
"description": "Zero_lev[0] — нулевой уровень канала тока 0",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 113,
|
||||
"address": 20593,
|
||||
"ump1_id": 5113,
|
||||
"ump2_id": 6113,
|
||||
"description": "Zero_lev[1] — нулевой уровень канала тока 1",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 114,
|
||||
"address": 20594,
|
||||
"ump1_id": 5114,
|
||||
"ump2_id": 6114,
|
||||
"description": "Zero_lev[2] — нулевой уровень канала тока 2",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 115,
|
||||
"address": 20595,
|
||||
"ump1_id": 5115,
|
||||
"ump2_id": 6115,
|
||||
"description": "Zero_lev[3] — нулевой уровень канала тока 3",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 116,
|
||||
"address": 20596,
|
||||
"ump1_id": 5116,
|
||||
"ump2_id": 6116,
|
||||
"description": "Caliber[0]: K380_1 / KPOW_1",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 117,
|
||||
"address": 20597,
|
||||
"ump1_id": 5117,
|
||||
"ump2_id": 6117,
|
||||
"description": "Caliber[1]: K380_2 / KPOW_2",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 118,
|
||||
"address": 20598,
|
||||
"ump1_id": 5118,
|
||||
"ump2_id": 6118,
|
||||
"description": "Caliber[2]: KPOW_3",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 119,
|
||||
"address": 20599,
|
||||
"ump1_id": 5119,
|
||||
"ump2_id": 6119,
|
||||
"description": "Caliber[3]: KPOW_4",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 120,
|
||||
"address": 20600,
|
||||
"ump1_id": 5120,
|
||||
"ump2_id": 6120,
|
||||
"description": "Caliber[4] / TCaliber[0]; LOAD: DAC_max()",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 121,
|
||||
"address": 20601,
|
||||
"ump1_id": 5121,
|
||||
"ump2_id": 6121,
|
||||
"description": "Caliber[5] / TCaliber[1]; LOAD: DAC_min()",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 122,
|
||||
"address": 20602,
|
||||
"ump1_id": 5122,
|
||||
"ump2_id": 6122,
|
||||
"description": "Caliber[6] / TCaliber[2]; LOAD: DAC_cal()",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 123,
|
||||
"address": 20603,
|
||||
"ump1_id": 5123,
|
||||
"ump2_id": 6123,
|
||||
"description": "Caliber[7] / TCaliber[3]; LOAD: текущий 12-битный код ЦАП",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 124,
|
||||
"address": 20604,
|
||||
"ump1_id": 5124,
|
||||
"ump2_id": 6124,
|
||||
"description": "NormaU = Caliber[8]; LOAD: LOAD_STATUS",
|
||||
"access": "RO для LOAD; иначе RW"
|
||||
},
|
||||
{
|
||||
"modbus": 125,
|
||||
"address": 20605,
|
||||
"ump1_id": 5125,
|
||||
"ump2_id": 6125,
|
||||
"description": "DeltaU = Caliber[9]; LOAD: LOAD_ATTEMPTS",
|
||||
"access": "RO для LOAD; иначе RW"
|
||||
},
|
||||
{
|
||||
"modbus": 126,
|
||||
"address": 20606,
|
||||
"ump1_id": 5126,
|
||||
"ump2_id": 6126,
|
||||
"description": "LastMode",
|
||||
"access": "RW"
|
||||
},
|
||||
{
|
||||
"modbus": 127,
|
||||
"address": 20607,
|
||||
"ump1_id": 5127,
|
||||
"ump2_id": 6127,
|
||||
"description": "Commands — битовая маска команд",
|
||||
"access": "RW"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,8 @@ from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import List, Optional
|
||||
|
||||
from .gas_167 import assigned_metadata, describe_word
|
||||
|
||||
try:
|
||||
from .native import get_native_core
|
||||
_SHARED_PROTOCOL = get_native_core()
|
||||
@@ -662,11 +664,49 @@ def _decode_gas(res: Decoded) -> None:
|
||||
return
|
||||
for adr, v in res.registers:
|
||||
res.fields.append((" Reg 0x%04X" % adr, "0x%04X (%d)" % (v, v)))
|
||||
_append_gas_metadata(res, adr)
|
||||
if not vals:
|
||||
_append_gas_metadata(res, start)
|
||||
if vals:
|
||||
res.summary = "GAS: %d рег. с 0x%04X = %s" % (
|
||||
len(vals), start, " ".join("%04X" % v for v in vals))
|
||||
else:
|
||||
res.summary = "GAS: запрос по адресу 0x%04X" % start
|
||||
# Every monitor, manual decoder and export uses this shared summary.
|
||||
# Keep names in the summary as well as in the expandable field list.
|
||||
addresses = res.registers if vals else [(start, None)]
|
||||
if any(assigned_metadata(address) for address, _value in addresses):
|
||||
words = []
|
||||
for address, value in addresses:
|
||||
entry = assigned_metadata(address)
|
||||
label = "ID %d (0x%04X)" % (address, address)
|
||||
if entry:
|
||||
label = "УМП %d · %s · %s" % (
|
||||
entry['node'] - 4, label, entry['name'])
|
||||
else:
|
||||
label += " · имя не назначено"
|
||||
if value is not None:
|
||||
label += " = %d [0x%04X]" % (value, value)
|
||||
description = describe_word(entry['modbus'], value) if entry else ''
|
||||
if description:
|
||||
label += " — " + description
|
||||
res.fields.append((" GAS ID %d / биты" % address, description))
|
||||
words.append(label)
|
||||
res.summary = ("GAS: " if vals else "GAS запрос: ") + "; ".join(words)
|
||||
|
||||
|
||||
def _append_gas_metadata(res: Decoded, address: int) -> None:
|
||||
entry = assigned_metadata(address)
|
||||
if not entry:
|
||||
return
|
||||
label = " GAS ID %d" % address
|
||||
res.fields += [
|
||||
(label, "УМП %d — %s" % (entry['node'] - 4, entry['name'])),
|
||||
(label + " / статус", entry['status']),
|
||||
(label + " / Modbus 167/v1", "0x%04X" % entry['modbus']),
|
||||
(label + " / GAS 167/v1", "0x%04X" % entry['legacy_address']),
|
||||
(label + " / доступ", entry['access']),
|
||||
]
|
||||
|
||||
|
||||
def _decode_modbus(res: Decoded) -> None: # Единый тип 0x5 разбирается по номеру функции, а не по прежним трём MsgType.
|
||||
|
||||
@@ -73,6 +73,7 @@ class TmsTarget:
|
||||
kind: str = "tms"
|
||||
board: int = 0
|
||||
load_only: bool = False
|
||||
verify: bool = True
|
||||
|
||||
def validate(self, size: int) -> None:
|
||||
if not 0 <= self.controller <= 255:
|
||||
@@ -116,6 +117,8 @@ def programming_steps(data: bytes, target: TmsTarget):
|
||||
raise ValueError(f"Проверка EEPROM не пройдена: код {error}, обработано {words} слов")
|
||||
return
|
||||
yield tflash(t.controller, t.ram, t.flash, len(data)), 6, 180000, "CMD_TFLASH: запись Flash", 85
|
||||
if not t.verify:
|
||||
return
|
||||
for offset in range(0, len(data), 256):
|
||||
block = data[offset:offset + 256]
|
||||
count = (len(block) + 1) & ~1
|
||||
|
||||
@@ -19,4 +19,4 @@ include = ["firmware_image*", "altera_logic*", "logic_analyzer*", "set_devices*"
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
set_devices = ["gas_logger_maps/*.json"]
|
||||
protocan = ["native/*.dll", "native/*.so", "native/*.dylib"]
|
||||
protocan = ["*.json", "native/*.dll", "native/*.so", "native/*.dylib"]
|
||||
|
||||
@@ -86,3 +86,119 @@ self.byte_page.set_status("Чтение завершено")
|
||||
Каждому независимому соединению нужен собственный протокол. Если вкладки
|
||||
используют одно соединение, владелец транспорта должен запрещать одновременные
|
||||
запросы и маршрутизировать ответы. В SETGUI это делает существующая очередь TMS2812.
|
||||
|
||||
## PBUS, SBUS и общие макросы
|
||||
|
||||
В SETGUI, на странице «Работа с байтом → Команды / макросы», слева находится
|
||||
«SBUS (Сериальная шина)», справа — «PBUS (Параллельная шина)». Под обоими
|
||||
блоками расположен единственный общий список макросов на всю ширину.
|
||||
Один макрос может содержать команды обеих шин в произвольном порядке.
|
||||
Создание, редактирование, удаление, запуск и остановка макросов сохранены.
|
||||
Отдельных нижних полей «Ответ контроллера» и «Считанное слово» нет.
|
||||
|
||||
SBUS сохраняет прежние чтение и запись через `0x200A`, `0x200B`, `0x200F`.
|
||||
Старый JSON без поля `bus` исполняется как SBUS, новые команды явно
|
||||
сохраняют `bus: "sbus"` или `bus: "pbus"`. Публичные элементы `bus_*`
|
||||
по-прежнему относятся к SBUS. Таблица результатов доступна как `pbus_table`.
|
||||
|
||||
В PBUS достаточно ввести адрес платы и регистр, затем нажать «Добавить в
|
||||
таблицу» или Enter. Таблица показывает плату, регистр, HEX, DEC и состояние
|
||||
считывания. Повторное добавление того же источника не создаёт дубликат.
|
||||
«Убрать из таблицы» и Delete удаляют выделенные строки; Ctrl и Shift
|
||||
позволяют удалить несколько источников сразу. После удаления оставшиеся
|
||||
регистры уплотняются и заново записываются в аппаратную таблицу с правильными
|
||||
адресами буфера. «Изменить строку» заменяет выбранную строку адресом и
|
||||
регистром из полей выше. После удаления последнего регистра список
|
||||
становится пустым, аппаратный и фоновый опрос остаются остановленными;
|
||||
прежнее содержимое аппаратного буфера не используется как результат.
|
||||
|
||||
В блоке PBUS поле «Порог ошибок (1–15)» задаёт число допустимых ошибок.
|
||||
Начальное значение — 10 (`0xA`), поэтому две записи запускаются словом
|
||||
`0x8A01`. Выбранный порог применяется при добавлении, изменении таблицы
|
||||
или нажатии «Обновить». Обычный фоновый цикл использует уже применённые
|
||||
параметры и передаёт только запросы чтения памяти. Команда макроса может
|
||||
явно задать свой порог; новое редактирование поля во время обмена не
|
||||
меняет снимок уже поставленной команды.
|
||||
|
||||
Перенастройка выполняется одной операцией общей очереди: остановка через
|
||||
`0x200C`, полная запись всех пар индекса `0x200D` и дескриптора `0x200E`,
|
||||
затем запуск через `0x200C`. Остановка сохраняет порог и последний индекс
|
||||
ранее подтверждённой активной таблицы, сбрасывая только бит 15. Запуск
|
||||
содержит новый последний индекс `N-1` в младшем байте и порог в битах 11:8.
|
||||
Для пустого списка выполняется только остановка, без переполнения `N-1`
|
||||
и без команд запуска, включая `0x80FE`.
|
||||
|
||||
Дескриптор равен `(плата << 12) | (регистр << 8) | CD`. Индекс таблицы
|
||||
независим от CD, а результат читается по словному адресу `0x2000 | CD`,
|
||||
без умножения на размер слова. При обычном добавлении CD начинается с
|
||||
`0x40`, поэтому строки читаются из `0x2040`, `0x2041` и далее. Полный
|
||||
снимок настройки может содержать явный `cd` у каждой записи: это назначение
|
||||
сохраняется при редактировании и перестройке таблицы. Максимум — 64 записи.
|
||||
Регистр `0x2012` не читается для проверки состояния или блокировки данных.
|
||||
|
||||
После подтверждения запуска выдерживается поле «После запуска» в
|
||||
миллисекундах; начальное значение — 100 мс. Оно настраивается по времени
|
||||
обновления аппаратного буфера отдельно от периода чтения. До окончания
|
||||
паузы и успешного первого чтения таблица не считается применённой и
|
||||
прежние значения не показываются. Ошибка записи останавливает дальнейшие
|
||||
шаги и запрещает автоматическое применение или повторный запуск.
|
||||
|
||||
Пример двух регистров платы `0xE` с порогом `0xA`:
|
||||
|
||||
```text
|
||||
write(0x200C, 0x0A01)
|
||||
write(0x200D, 0x0000)
|
||||
write(0x200E, 0xE040)
|
||||
write(0x200D, 0x0001)
|
||||
write(0x200E, 0xE141)
|
||||
write(0x200C, 0x8A01)
|
||||
ожидание аппаратного обновления
|
||||
read(0x2040)
|
||||
read(0x2041)
|
||||
```
|
||||
|
||||
«Обновить» получает значения списка. «Циклическое чтение» повторяет чтение
|
||||
PBUS с выбранным периодом в миллисекундах и сохраняет выделение строк.
|
||||
Циклическое чтение передаёт только ReadMemory назначений 20CD, не меняет
|
||||
таблицу и не перезапускает шину. При выключении галочки отменяются будущие
|
||||
повторы и неотправленные чтения; после завершения текущего пакета или
|
||||
логической операции в очередь передаётся остановка `0x200C` с очищенным
|
||||
битом 15 и сохранёнными параметрами активной таблицы. Повторное включение
|
||||
галочки запускает аппаратно остановленную таблицу перед чтением её буфера. При изменённой или неподтверждённой
|
||||
таблице необходимо явное нажатие «Обновить» для полного применения.
|
||||
Изменение адреса контроллера останавливает опрос до явного применения
|
||||
списка к новому устройству. В SBUS собственные галочка и период повторяют
|
||||
чтение устройства и регистра из текущих полей; изменения применяются к
|
||||
следующему запросу, уже отправленные параметры сохраняются.
|
||||
|
||||
Кнопки и поля остаются доступными во время обмена. Нажатия фиксируют
|
||||
параметры и становятся в общую очередь соединения. В одной логической
|
||||
операции подготовка регистра и получение результата выполняются целиком,
|
||||
без перемешивания с другой операцией. Проверка записанного слова чтением
|
||||
также удерживает соединение до завершения. Ручные действия обслуживаются
|
||||
раньше следующего фонового задания. Периодический источник имеет не более
|
||||
одного ожидающего или выполняющегося запуска; пропущенные циклы не копятся.
|
||||
|
||||
Макросы размещаются слева направо с переносом по ширине панели и
|
||||
вертикальной прокруткой, сохраняя прежнюю высоту и естественную ширину.
|
||||
Длинное имя увеличивает ширину кнопки. У каждого макроса есть галочка
|
||||
«Циклически»; поле «Период повторения» в редакторе сохраняется как
|
||||
`period_ms` в его JSON. Старые макросы получают период 1000 мс.
|
||||
Включение сразу запускает макрос, период отсчитывается между реальными
|
||||
началами повторов. Незавершённый запуск того же макроса исключает повтор,
|
||||
а однократная кнопка выполнения остаётся доступной отдельно.
|
||||
|
||||
Общий редактор команд PBUS поддерживает `add`, `remove`, `poll`, `start`
|
||||
и `stop`. Команды и будущий размер списка проверяются до первого запроса.
|
||||
Ошибки порта, CRC, неверный ответ и таймаут прекращают оставшиеся команды
|
||||
соответствующего запуска. Остановка снимает будущие повторения и ещё
|
||||
не отправленные команды выбранного задания; текущий пакет завершается
|
||||
ответом или штатным таймаутом. Отключение порта очищает всю очередь.
|
||||
Уже переданные аппаратные записи не откатываются.
|
||||
|
||||
Рабочие панели SETGUI используют существующие `QDockWidget` и
|
||||
`QMainWindow`: перемещаются влево, вправо, вверх, вниз и в общую группу
|
||||
вкладок со штатной подсветкой области вставки. Разделители меняют размеры.
|
||||
Перенос сохраняет экземпляры панелей, соединение и выполняемые задания.
|
||||
Раскладка сохраняется между запусками; «Сбросить раскладку» возвращает
|
||||
стандартное расположение без пересоздания содержимого.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Операции PBUS по myXilinx/x_parallel_bus.c прошивки TMS2812 / 22220."""
|
||||
|
||||
from .exchange_queue import ExchangeDelay # Пауза после запуска остаётся внутри одной атомарной операции общей очереди.
|
||||
|
||||
PBUS_SETTLE_MS = 100 # Начальная настраиваемая пауза обновления аппаратного буфера после старта, миллисекунды.
|
||||
PBUS_COMMAND = 0x200C # Spartan2E_Adr.h: управление запуском циклического опроса параллельной шины.
|
||||
PBUS_TABLE_INDEX = 0x200D # Spartan2E_Adr.h: выбор изменяемой строки таблицы опроса FPGA.
|
||||
PBUS_TABLE_DATA = 0x200E # Spartan2E_Adr.h: дескриптор строки, содержащий адрес платы, регистр и адрес буфера.
|
||||
PBUS_BUFFER = 0x2040 # Spartan2E_Adr.h: ADR_FIRST_FREE, первое слово буфера результатов циклического опроса.
|
||||
PBUS_CAPACITY = 64 # Spartan2E_Adr.h: буфер 0x2040–0x207F содержит 64 слова, поэтому ограничиваем число строк таблицы.
|
||||
PBUS_ERROR_LIMIT = 0xA # Начальный порог SETGUI соответствует рабочему ручному запуску 0x8Axx; пользователь может задать от 1 до 15 ошибок.
|
||||
|
||||
|
||||
def validate_command(command): # Проверяем все параметры до передачи, чтобы неверный макрос не выполнился частично.
|
||||
if not isinstance(command, dict): # Повреждённый JSON не должен приводить к исключению интерфейса вместо понятного отказа валидации.
|
||||
raise ValueError("Команда макроса должна содержать именованные параметры") # Останавливаем всю последовательность до передачи первого управляющего запроса.
|
||||
bus = command.get("bus", "sbus") # Старые JSON-макросы без поля bus относятся к существовавшей последовательной шине.
|
||||
if bus not in ("pbus", "sbus"): # Не допускаем исполнения повреждённого имени шины другим алгоритмом.
|
||||
raise ValueError("Неизвестная шина макроса") # Сообщаем причину отказа до передачи управляющих данных.
|
||||
operations = ("add", "remove", "poll", "configure", "write", "read", "start", "stop") if bus == "pbus" else ("write", "read") # PBUS управляет общим списком регистров; прежние команды сохраняются для совместимости JSON.
|
||||
if command["type"] not in operations: # Направление операции должно явно поддерживаться выбранной шиной.
|
||||
raise ValueError("Неизвестная команда макроса") # Не превращаем неизвестную команду в чтение.
|
||||
limits = (("tetrad", 15 if bus == "pbus" else 14), ("register", 15), ("data", 0xFFFF)) # PBUS имеет четырёхбитный адрес, прежний SBUS сохраняет диапазон 0–14; данные — 16 бит.
|
||||
if bus == "pbus" and command["type"] in ("write", "read", "start", "stop"): # Только прежние команды явно задают индекс и длину аппаратной таблицы.
|
||||
limits += (("slot", PBUS_CAPACITY - 1), ("count", PBUS_CAPACITY), ("errors", 15)) # Индекс буфера — 0–63, число строк — 1–64, порог — четыре бита.
|
||||
defaults = {"data": 0, "slot": 0, "count": 1, "errors": PBUS_ERROR_LIMIT} # Стабильные значения позволяют сохранять компактные команды PBUS.
|
||||
for name, maximum in limits: # Проверяем все значения одним правилом до упаковки битовых полей.
|
||||
value = command.get(name, defaults[name]) if name in defaults else command[name] # Адрес и регистр обязательны, параметры таблицы имеют штатные значения.
|
||||
minimum = 1 if name in ("count", "errors") else 0 # Пустую таблицу и нулевой порог запрещаем: библиотека считает count_error >= 0 ошибкой.
|
||||
if not isinstance(value, int) or not minimum <= value <= maximum: # Исключаем строки, отрицательные числа и переполнение аппаратных полей.
|
||||
raise ValueError(f"{name}: допустим диапазон {minimum}…{maximum}") # Указываем конкретный неверный параметр и его пределы.
|
||||
if bus == "pbus" and (not isinstance(command.get("errors", PBUS_ERROR_LIMIT), int) or not 1 <= command.get("errors", PBUS_ERROR_LIMIT) <= 15): # Новые команды также сохраняют целочисленный четырёхбитный ненулевой порог ошибки PBUS.
|
||||
raise ValueError("errors: допустим диапазон 1…15") # Нулевой порог иначе сделал бы любой статус аппаратной ошибкой.
|
||||
if bus == "pbus" and command["type"] in ("write", "read", "start", "stop") and command.get("slot", 0) >= command.get("count", 1): # Проверяем явную адресацию только у прежнего формата команды.
|
||||
raise ValueError("PBUS: индекс строки должен быть меньше числа строк таблицы") # Не запускаем опрос, который исключает выбранный результат.
|
||||
if bus == "pbus" and "entries" in command: # Явная замена таблицы валидируется целиком до постановки макроса в очередь.
|
||||
validated_entries(command["entries"]) # Повреждённая поздняя строка исключает передачу начала перенастройки.
|
||||
if bus == "pbus" and "settle_ms" in command and (not isinstance(command["settle_ms"], int) or not 1 <= command["settle_ms"] <= 60000): # Пауза обновления задаётся в положительных миллисекундах.
|
||||
raise ValueError("PBUS: пауза после запуска должна быть 1…60000 мс") # Отказываем до любого WriteMemory контроллеру.
|
||||
return bus # Возвращаем проверенную шину для выбора алгоритма и адресного отображения результата.
|
||||
|
||||
|
||||
def pbus_result_address(entry, index): # Номер записи таблицы и CD её результата являются разными полями аппаратного протокола.
|
||||
cd = entry.get("cd", (PBUS_BUFFER & 0xFF) + index) # При отсутствии явного CD сохраняем обычное размещение результатов с младшего байта 0x40.
|
||||
if not isinstance(cd, int) or not 0 <= cd <= 0xFF: # Поле CD занимает ровно восемь бит дескриптора.
|
||||
raise ValueError("PBUS: CD должен быть в диапазоне 00…FF") # Не допускаем усечения адреса результата при упаковке дескриптора.
|
||||
return 0x2000 | cd # Адрес памяти словный: страница 0x2000 объединяется с CD без умножения на размер слова.
|
||||
|
||||
|
||||
def pbus_descriptor(slave, register, cd): # Формируем дескриптор отдельно от индекса записи, записываемого в 0x200D.
|
||||
return (slave << 12) | (register << 8) | cd # Четыре бита платы, четыре бита регистра и восемь бит CD соответствуют заданному формату 0x200E.
|
||||
|
||||
|
||||
def pbus_control(count, errors): # Управляющее слово содержит параметры таблицы без бита запуска.
|
||||
if not isinstance(count, int) or not 0 <= count <= PBUS_CAPACITY or not isinstance(errors, int) or not 1 <= errors <= 15: # Проверяем ёмкость таблицы и четырёхбитный аппаратный порог до отправки.
|
||||
raise ValueError("PBUS: неверный размер таблицы или порог ошибок") # Неверные параметры не меняют работающую шину.
|
||||
return (errors << 8) | (count - 1 if count else 0) # Для пустой таблицы младший байт равен нулю, выражение N-1 не вычисляется с переполнением.
|
||||
|
||||
|
||||
def validated_entries(entries): # Проверяем полный снимок таблицы до её остановки и возвращаем независимые адреса источников.
|
||||
if not isinstance(entries, (list, tuple)) or len(entries) > PBUS_CAPACITY: # Аппаратный буфер штатной панели ограничен шестьюдесятью четырьмя источниками.
|
||||
raise ValueError("PBUS: в таблице нет места, максимум 64 регистра") # Ошибка поздней строки не должна вызвать частичную перенастройку.
|
||||
result = [] # Снимок не содержит считанных значений и временных состояний интерфейса.
|
||||
for index, entry in enumerate(entries): # Каждый индекс таблицы проверяется независимо от CD результата.
|
||||
validate_command({"bus": "pbus", "type": "add", "tetrad": entry["tetrad"], "register": entry["register"]}) # Плата и регистр должны помещаться в свои четырёхбитные поля.
|
||||
pbus_result_address(entry, index) # Явный CD также проверяется до первой аппаратной записи.
|
||||
result.append({name: entry[name] for name in ("tetrad", "register", "cd") if name in entry}) # Явное отображение CD сохраняется при добавлении и удалении других строк.
|
||||
return result # Полный снимок используется очередью на протяжении всей перенастройки.
|
||||
|
||||
|
||||
def table_entries_after(entries, command): # Вычисляем список регистров до передачи, чтобы целиком проверить общий макрос.
|
||||
result = validated_entries(command.get("entries", entries)) # Сохраняем адреса без устаревших считанных значений и промежуточных статусов.
|
||||
key = (command["tetrad"], command["register"]) # Одна пара плата/регистр однозначно определяет источник PBUS.
|
||||
if command["type"] == "add" and key not in [(entry["tetrad"], entry["register"]) for entry in result]: # Повторное добавление уже наблюдаемого регистра не создаёт дубликат.
|
||||
result.append({"tetrad": key[0], "register": key[1], **({"cd": command["cd"]} if "cd" in command else {})}) # Явный CD сохраняется независимо от индекса новой строки, без него буфер выбирается последовательно.
|
||||
if command["type"] == "remove": # Удаление по адресам не зависит от смещения строк после предыдущих удалений.
|
||||
removed = set(tuple(item) for item in command.get("remove_keys", [key])) # Кнопка и Delete могут удалить сразу несколько выделенных регистров.
|
||||
for slave, register in removed: # Проверяем также параметры множественного удаления, приходящие из JSON.
|
||||
validate_command({"bus": "pbus", "type": "remove", "tetrad": slave, "register": register}) # Недопустимая пара исключает исполнение всей последовательности заранее.
|
||||
result = [entry for entry in result if (entry["tetrad"], entry["register"]) not in removed] # Оставшиеся источники уплотняются, сохраняя порядок отображения.
|
||||
if len(result) > PBUS_CAPACITY: # Ограничиваем число строк реальным буфером 0x2040–0x207F.
|
||||
raise ValueError("PBUS: в таблице нет места, максимум 64 регистра") # Переполнение не должно отправлять частичную аппаратную конфигурацию.
|
||||
return validated_entries(result) # Все CD и адреса новой полной таблицы проверены ещё до постановки аппаратных команд в очередь.
|
||||
|
||||
|
||||
def pbus_table_steps(entries, errors=PBUS_ERROR_LIMIT, *, configure=True, stop_control=None, settle_ms=PBUS_SETTLE_MS, on_control=None): # Перенастройка и ожидание свежего буфера выполняются одной последовательной операцией очереди.
|
||||
entries = validated_entries(entries) # Фиксируем все платы, регистры, порядок и CD до первой записи управления.
|
||||
control = pbus_control(len(entries), errors) # Для новой таблицы младший байт задаёт её последний используемый индекс.
|
||||
if not isinstance(settle_ms, int) or not 1 <= settle_ms <= 60000: # Пауза аппаратного обновления явно задаётся в миллисекундах, от одной до минуты.
|
||||
raise ValueError("PBUS: пауза после запуска должна быть 1…60000 мс") # Некорректное время не допускает запуска таблицы с немедленным чтением старого буфера.
|
||||
if configure: # Изменение любой строки требует полной остановки и переписи всей новой таблицы.
|
||||
stopped = (control if stop_control is None else stop_control) & 0x7FFF # У активной таблицы сохраняем порог и последний индекс, сбрасывая только бит 15.
|
||||
yield PBUS_COMMAND, stopped # Ждём подтверждения остановки прежде, чем менять индекс или дескриптор.
|
||||
if on_control is not None: # Панель отслеживает только уже подтверждённые управляющие записи.
|
||||
on_control(stopped) # После ошибки следующей записи прежняя таблица остаётся известной как остановленная.
|
||||
if not entries: # Пустая таблица не содержит последнего индекса и не должна запускаться для очистки.
|
||||
return {"entries": [], "rows": [], "control": stopped} # Оставляем PBUS остановленной без 0x80FE, переполнения N-1 и чтения прежнего буфера.
|
||||
for index, entry in enumerate(entries): # Переписываем все записи, даже если изменилась только одна из них.
|
||||
yield PBUS_TABLE_INDEX, index # Индекс таблицы нумеруется с нуля и не подменяется адресом результата.
|
||||
cd = pbus_result_address(entry, index) & 0xFF # CD извлекается из фактического адреса чтения этой строки на странице 0x2000.
|
||||
yield PBUS_TABLE_DATA, pbus_descriptor(entry["tetrad"], entry["register"], cd) # Дескриптор хранит плату, регистр и CD выбранной записи в 0x200E.
|
||||
yield PBUS_COMMAND, control | 0x8000 # Только подтверждение всех пар 0x200D/0x200E разрешает запуск новой длины N-1.
|
||||
if on_control is not None: # Успешный ответ запуска фиксирует реальные параметры работающей таблицы.
|
||||
on_control(control | 0x8000) # Последующая остановка сохранит именно этот порог и последний индекс.
|
||||
yield ExchangeDelay(settle_ms) # Не читаем старые слова буфера сразу после подтверждения запуска: ждём настроенный аппаратный интервал.
|
||||
rows = [] # Результаты соответствуют порядку снимка, но их адреса определяются полем CD.
|
||||
for index, entry in enumerate(entries): # Обычное обновление не отправляет ни одной управляющей записи и не перезапускает PBUS.
|
||||
address = pbus_result_address(entry, index) # Полный словный адрес равен 0x2000 | CD независимо от номера платы и регистра.
|
||||
reply = yield address, None # Существующий ReadMemory возвращает одно шестнадцатибитное слово контроллера TMS.
|
||||
if not isinstance(reply, dict) or not isinstance(reply.get("value"), int) or not 0 <= reply["value"] <= 0xFFFF: # Проверяем реальный формат результата чтения, не обращаясь к статусу 0x2012.
|
||||
raise ValueError(f"PBUS: некорректный ответ памяти 0x{address:04X}") # Ошибка указывает фактический адрес CD, а не индекс строки.
|
||||
rows.append(reply) # HEX и DEC каждого результата будут заполнены только после успешного цикла.
|
||||
return {"entries": entries, "rows": rows, "control": control | 0x8000 if entries and configure else stop_control} # Заполненная таблица подтверждается данными после паузы, пустая остаётся остановленной.
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Последовательная очередь логических операций одного соединения UART.""" # Все панели разделяют владение портом на протяжении многошагового действия.
|
||||
from collections import deque # Две очереди обеспечивают приоритет ручных действий без перестановки их порядка.
|
||||
from PySide6.QtCore import QObject, QTimer, Qt # Точный таймер сохраняет аппаратную паузу без преждевременного чтения памяти контроллера.
|
||||
|
||||
|
||||
class ExchangeDelay: # Пауза аппаратного обновления является частью той же операции, а не отдельным обменом UART.
|
||||
def __init__(self, milliseconds): # Время ожидания задаётся явно вызывающим алгоритмом в миллисекундах.
|
||||
self.milliseconds = milliseconds # Очередь сохраняет владение операцией, пока контроллер обновляет буфер результатов.
|
||||
|
||||
|
||||
class ExchangeQueue(QObject): # Один экземпляр привязан к протоколу и используется всеми рабочими панелями.
|
||||
def __init__(self, protocol): # Протокол остаётся ответственным за кодирование, CRC и таймаут отдельного пакета.
|
||||
super().__init__() # Таймеры очереди принадлежат объекту Qt с тем же временем жизни.
|
||||
self.protocol, self.active = protocol, None # Активная операция удерживает порт до завершения всех своих шагов.
|
||||
self.manual, self.background = deque(), deque() # Пользовательские действия выбираются раньше следующего фонового задания.
|
||||
self.timer = QTimer(self) # Таймер также позволяет дождаться ранее начатого внешнего обмена.
|
||||
self.timer.setSingleShot(True) # Каждое продолжение назначается однократно, исключая накопление событий.
|
||||
self.timer.timeout.connect(self._pump) # Новая операция выбирается только после освобождения соединения.
|
||||
|
||||
def contains(self, key): # Периодический источник проверяет наличие своего предыдущего задания.
|
||||
return any(job["key"] == key for job in ([self.active] if self.active else []) + list(self.manual) + list(self.background)) # Проверка охватывает ожидание и выполнение, поэтому пропущенные циклы не копятся.
|
||||
|
||||
def submit_external(self, start, pending, callback, *, key=None, background=False, immediate=False): # Прежние обработчики SETGUI выполняются под тем же владением соединением.
|
||||
def wait(done): # Адаптер удерживает очередь до завершения внешнего асинхронного обработчика.
|
||||
start() # Параметры внешней операции уже зафиксированы вызывающей панелью.
|
||||
def poll(): # Проверяем завершение через события Qt, не блокируя UART и интерфейс.
|
||||
if pending(): # Прежний обработчик ещё получает ответ или выполняет следующий шаг проверки записи.
|
||||
QTimer.singleShot(10, poll) # Период проверки в миллисекундах не порождает дубликатов команд.
|
||||
else: # Внешний обмен завершился и самостоятельно отобразил результат или ошибку.
|
||||
done(True, None) # Следующая логическая операция теперь может владеть портом.
|
||||
poll() # Синхронное действие без ожидаемого ответа освобождает очередь сразу.
|
||||
def steps(): # Общий исполнитель использует одинаковое завершение обычных и внешних операций.
|
||||
yield wait # Callable получает callback и удерживает очередь до его вызова.
|
||||
return None # Результат внешнего действия уже обработан его существующей панелью.
|
||||
accepted = self.submit(steps, None, callback, key=key, background=background) # Сохраняем приоритет и защиту от повторов для общего соединения.
|
||||
if accepted and immediate: # Прежние обработчики могут сохранить немедленную отправку при свободном соединении.
|
||||
self._pump() # Занятый порт всё равно оставляет действие ожидающим в общей очереди.
|
||||
return accepted # Возвращаем факт принятия задания либо подавления периодического дубликата.
|
||||
|
||||
def submit(self, factory, controller, callback, *, key=None, background=False): # Фабрика создаёт генератор при начале операции, параметры передаются уже зафиксированными.
|
||||
if key is not None and self.contains(key): # Один периодический источник допускает только одно незавершённое повторение.
|
||||
return False # Не добавляем дубликат в очередь и не создаём отложенного повторения.
|
||||
job = {"factory": factory, "controller": controller, "callback": callback, "key": key, "cancelled": False, "waiting": False, "iterator": None} # Снимок контроллера принадлежит всей логической операции.
|
||||
(self.background if background else self.manual).append(job) # Фоновая операция не вытесняет уже ожидающее нажатие пользователя.
|
||||
self.timer.start(0) # Запуск откладывается до следующего события Qt, оставляя интерфейс отзывчивым.
|
||||
return True # Вызывающий код может отличить постановку задания от подавленного дубликата.
|
||||
|
||||
def cancel(self, key): # Остановка задания удаляет будущие команды, сохраняя текущий пакет до ответа или таймаута.
|
||||
for pending in (self.manual, self.background): # Удаляем задания из обеих очередей независимо от их приоритета.
|
||||
removed = [job for job in pending if job["key"] == key] # Сохраняем callbacks отменённых заданий для завершения состояния владельца.
|
||||
pending_copy = [job for job in pending if job["key"] != key] # Остальные источники продолжают ожидать в прежнем порядке.
|
||||
pending.clear() # Пересобираем только контейнер очереди, не изменяя задания других панелей.
|
||||
pending.extend(pending_copy) # Возвращаем ожидающие операции, которые не принадлежат остановленному источнику.
|
||||
for job in removed: # Каждый владелец получает однозначное уведомление об отмене своего ожидания.
|
||||
job["callback"](False, "Остановлено") # Ни один пакет снятой операции ещё не был отправлен.
|
||||
if self.active and self.active["key"] == key: # Выполняющееся задание прекращаем после уже отправленного пакета.
|
||||
self.active["cancelled"] = True # Следующий шаг генератора не будет отправлен, даже при успешном ответе.
|
||||
if not self.active["waiting"]: # Между пакетами отмена может завершиться немедленно.
|
||||
self._complete(False, "Остановлено") # Освобождаем порт без искусственного транспортного таймаута.
|
||||
|
||||
def clear(self): # Отключение соединения удаляет все задания и будущие продолжения.
|
||||
keys = {job["key"] for job in list(self.manual) + list(self.background) + ([self.active] if self.active else [])} # Снимок ключей не меняется при вызове callbacks отмены.
|
||||
for key in keys: # Применяем одинаковую отмену ко всем источникам данного соединения.
|
||||
self.cancel(key) # Активный пакет корректно завершится либо будет отменён транспортом при закрытии порта.
|
||||
|
||||
def _transport_busy(self): # Учитываем прежние операции встроенного окна, начатые до использования очереди.
|
||||
host = getattr(self.protocol, "host", None) # В SETGUI протокол использует общий QSerialPort родительской вкладки.
|
||||
return bool(getattr(self.protocol, "_callback", None) or getattr(self.protocol, "callback", None) or getattr(self.protocol, "_loop", None) or (host and host._pending_address is not None)) # Занятый внешний обмен нельзя замещать новым callback.
|
||||
|
||||
def _pump(self): # Выбираем операцию только между целостными чтениями или записями регистра.
|
||||
if self.active: # Пока генератор удерживает соединение, очередь не начинает другие операции.
|
||||
return # Продолжение активной операции приходит через её транспортный callback.
|
||||
if not self.manual and not self.background: # Пустая очередь не создаёт периодических событий сама по себе.
|
||||
return # Отсутствие заданий позволяет соединению оставаться полностью свободным.
|
||||
if self._transport_busy(): # Ждём завершения внешней операции вместо отказа пользователю.
|
||||
self.timer.start(10) # Короткое ожидание в миллисекундах не блокирует обработку UART и интерфейса.
|
||||
return # Параметры ожидающих нажатий при этом остаются неизменными.
|
||||
self.active = (self.manual if self.manual else self.background).popleft() # FIFO ручных действий имеет приоритет над следующим фоновым циклом.
|
||||
self._advance(None) # Первый шаг генератора отправляется только после получения владения соединением.
|
||||
|
||||
def _advance(self, reply): # Генератор выдаёт следующий запрос только после завершения предыдущего.
|
||||
job = self.active # Сохраняем владельца, чтобы поздний ответ не продолжил другую операцию.
|
||||
if job is None: # Очередь могла быть очищена отключением соединения.
|
||||
return # Без владельца никакие команды не отправляются.
|
||||
delay = job.pop("delay_timer", None) # Завершившийся таймер аппаратного ожидания больше не нужен текущему генератору.
|
||||
if delay is not None: # Обычные UART-продолжения не создают отдельного таймера аппаратной паузы.
|
||||
delay.stop() # Завершённое или отменённое ожидание не назначает повторного события.
|
||||
delay.deleteLater() # Освобождаем QObject после обработки текущего события Qt.
|
||||
if job["cancelled"]: # Остановка отменяет все шаги, которые ещё не переданы контроллеру.
|
||||
self._complete(False, "Остановлено") # Текущий пакет к этому моменту уже получил ответ или таймаут.
|
||||
return # Не возобновляем остановленный генератор.
|
||||
try: # Ошибки параметров и результата завершают логическую операцию без повреждения очереди.
|
||||
if job["iterator"] is None: # Фабрика использует актуальную таблицу PBUS при зафиксированном действии пользователя.
|
||||
job["iterator"] = job["factory"]() # Генератор создаётся один раз на целую операцию.
|
||||
request = next(job["iterator"]) # Первое чтение не получает фиктивного ответа предыдущей операции.
|
||||
else: # Последующие шаги получают реальное подтверждение или считанное слово.
|
||||
request = job["iterator"].send(reply) # Запись подготовки и чтение результата не могут перемешаться с другим заданием.
|
||||
except StopIteration as completed: # Генератор закончил все шаги одной логической операции.
|
||||
self._complete(True, completed.value) # Возвращаем итог владельцу и освобождаем соединение.
|
||||
return # Завершённый генератор больше не используется.
|
||||
except (ValueError, RuntimeError, TypeError, KeyError) as error: # Неверные параметры или подтверждения не должны разрывать цикл событий Qt.
|
||||
self._complete(False, str(error)) # Сохраняем действительную причину ошибки в панели инициатора.
|
||||
return # Следующая ожидающая операция сможет продолжить обмен.
|
||||
if isinstance(request, ExchangeDelay): # После запуска PBUS аппаратуре даётся время обновить слова памяти до первого чтения.
|
||||
delay = QTimer(self) # Отдельный таймер принадлежит данной аппаратной паузе, а не планировщику выбора операций.
|
||||
delay.setSingleShot(True) # Одна пауза заканчивается одним продолжением генератора.
|
||||
delay.setTimerType(Qt.PreciseTimer) # Штатный грубый таймер может сработать раньше, что недопустимо перед чтением нового буфера PBUS.
|
||||
delay.timeout.connect(lambda: self._advance(None) if self.active is job else None) # Продолжение остаётся связано с владельцем всей перенастройки.
|
||||
job["delay_timer"] = delay # Остановка задания сможет немедленно отменить его ещё не завершённую паузу.
|
||||
delay.start(request.milliseconds) # Ожидаем заданное время в миллисекундах после подтверждения запуска контроллером.
|
||||
return # UART-пакет не отправляется; остановка может немедленно отменить эту паузу и все следующие команды.
|
||||
job["waiting"] = True # До вызова транспорта отмечаем ожидание, включая синхронный отказ отключённого порта.
|
||||
try: # Защищаем очередь также от исключения драйвера при отправке.
|
||||
if callable(request): # Прежняя операция SETGUI или RAW выполняется только после получения общего владения UART.
|
||||
request(lambda ok, result: self._reply(job, ok, result)) # Внешний обработчик завершает ожидание через тот же callback очереди.
|
||||
return # Не трактуем внешнее действие как адрес памяти PEEK/POKE.
|
||||
address, value = request # Формат PEEK/POKE остаётся прежним для обеих шин.
|
||||
self.protocol.request_async(address, value, job["controller"], lambda ok, result: self._reply(job, ok, result)) # Существующий протокол проверяет ответ и задаёт таймаут пакета.
|
||||
except (ValueError, RuntimeError, OSError) as error: # Ошибка порта завершает текущую операцию и освобождает очередь.
|
||||
self._reply(job, False, str(error)) # Используем тот же путь, что для асинхронного отказа обмена.
|
||||
|
||||
def _reply(self, job, success, result): # Ответ всегда относится к владельцу пакета, а не к последнему нажатию.
|
||||
if self.active is not job: # Поздний callback закрытого соединения не меняет новое задание.
|
||||
return # Исключаем продвижение чужого генератора устаревшим ответом.
|
||||
job["waiting"] = False # Порт освободился после полного ответа либо обработанного таймаута.
|
||||
if job["cancelled"] or not success: # Остановка и реальная ошибка запрещают отправку следующих пакетов.
|
||||
self._complete(False, "Остановлено" if job["cancelled"] else result) # Отмена не маскируется как успешное чтение.
|
||||
return # Остальные команды остановленного задания не попадут в UART.
|
||||
QTimer.singleShot(0, lambda: self._advance(result) if self.active is job else None) # Даём обработать остановку между пакетами, удерживая владение операцией.
|
||||
|
||||
def _complete(self, success, result): # Завершение освобождает соединение до уведомления владельца операции.
|
||||
job, self.active = self.active, None # Callback может поставить следующую команду макроса в общую очередь.
|
||||
delay = job.pop("delay_timer", None) # Пользовательская остановка может завершить операцию прямо во время аппаратной паузы.
|
||||
if delay is not None: # Завершение обычного пакета не содержит такого таймера.
|
||||
delay.stop() # Отменённая пауза больше не продолжит чтение не применённой таблицы.
|
||||
delay.deleteLater() # Освобождаем временный QObject вместе с завершённым заданием.
|
||||
job["callback"](success, result) # Результат отображается только инициатором данной операции.
|
||||
self.timer.start(0) # Между командами макроса выбираем ожидающее ручное действие раньше фонового продолжения.
|
||||
|
||||
|
||||
def exchange_queue(protocol): # Общая очередь создаётся лениво для существующих и тестовых протоколов.
|
||||
if not hasattr(protocol, "operation_queue"): # Панели одного соединения должны получать один и тот же экземпляр.
|
||||
protocol.operation_queue = ExchangeQueue(protocol) # Владение протоколом сохраняется без изменения формата пакетов.
|
||||
return protocol.operation_queue # Возвращаем единый планировщик ручного обмена и периодических заданий.
|
||||
@@ -155,7 +155,7 @@ class MainWindow(QMainWindow):
|
||||
QMessageBox.warning(self, "Ошибка", f"Ошибка подключения: {e}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.serial_tab.stop_macro()
|
||||
self.serial_tab.stop() # Отключение протокола прекращает общий макрос и последующие фоновые чтения PBUS.
|
||||
self.raw_protocol.cancel_async()
|
||||
self.raw_protocol.disconnect()
|
||||
super().closeEvent(event)
|
||||
|
||||
@@ -30,7 +30,8 @@ class ByteWorkPanel(QScrollArea):
|
||||
self.device_address = device_address
|
||||
self._start_word = start_word
|
||||
self._word_running = False
|
||||
self._word_generation = 0
|
||||
self._word_generation = 0 # Поколение сохраняет совместимость с прежними обработчиками результатов.
|
||||
self._word_keys = set() # Каждый принятый запрос слова получает независимый ключ отмены.
|
||||
self.setWidgetResizable(True)
|
||||
content = QWidget()
|
||||
self.setWidget(content)
|
||||
@@ -112,9 +113,6 @@ class ByteWorkPanel(QScrollArea):
|
||||
self.byte_read_binary.setText(self._binary(value))
|
||||
|
||||
def start_byte(self, write):
|
||||
if self._word_running:
|
||||
self._report("Дождитесь завершения текущего обмена")
|
||||
return
|
||||
try:
|
||||
field = self.byte_write_address if write else self.byte_read_address
|
||||
text = field.text().strip()
|
||||
@@ -132,46 +130,42 @@ class ByteWorkPanel(QScrollArea):
|
||||
if self._start_word is not None:
|
||||
self._start_word(write, address, value)
|
||||
return
|
||||
controller = self.device_address() if callable(self.device_address) else self.device_address
|
||||
self._word_generation += 1
|
||||
generation = self._word_generation
|
||||
self._set_word_running(True)
|
||||
self._report("Запись и проверка чтением…" if write else "Чтение…")
|
||||
self.protocol.request_async(address, value, controller,
|
||||
lambda success, result: self._word_reply(generation, address, value, controller, success, result))
|
||||
controller = self.device_address() if callable(self.device_address) else self.device_address # Адрес контроллера фиксируется до ожидания занятого соединения.
|
||||
key = object() # Повторное нажатие создаёт самостоятельное действие, а не заменяет предыдущее.
|
||||
self._word_keys.add(key) # Все ожидающие слова учитываются для остановки страницы.
|
||||
self._set_word_running(True) # Признак состояния не меняет доступность полей и кнопок.
|
||||
self.bus_widget.queue.submit(lambda: self._word_steps(address, value), controller, lambda ok, reply: self._word_done(key, address, value, ok, reply), key=key) # Запись и проверка чтением выполняются целиком без вмешательства другой шины.
|
||||
|
||||
def _word_reply(self, generation, address, expected, controller, success, result):
|
||||
if generation != self._word_generation or not self._word_running:
|
||||
return
|
||||
if not success:
|
||||
self._set_word_running(False)
|
||||
self._report(str(result))
|
||||
return
|
||||
if expected is not None and "value" not in result:
|
||||
self.protocol.request_async(address, None, controller,
|
||||
lambda ok, reply: self._word_reply(generation, address, expected, controller, ok, reply))
|
||||
return
|
||||
value = result["value"]
|
||||
self.set_read_value(address, value)
|
||||
self._set_word_running(False)
|
||||
if expected is not None and value != expected:
|
||||
self._report(f"Запись 0x{address:08X} не подтверждена: прочитано {value:04X}, ожидалось {expected:04X}")
|
||||
else:
|
||||
self._report("Запись проверена чтением" if expected is not None else "Чтение завершено")
|
||||
def _word_steps(self, address, value): # Существующий протокол сохраняет проверку записанного слова чтением того же адреса.
|
||||
if value is not None: # Чистое чтение не требует предварительного WriteMemory.
|
||||
yield address, value # Подтверждение записи должно прийти до запроса проверки.
|
||||
result = yield address, None # ReadMemory возвращает одно шестнадцатибитное слово по выбранному адресу.
|
||||
if not isinstance(result, dict) or not isinstance(result.get("value"), int) or not 0 <= result["value"] <= 0xFFFF: # Неверный ответ не выдаётся за прочитанное слово.
|
||||
raise ValueError("Некорректный ответ чтения памяти") # Ошибка формата остаётся доступна в статусе страницы.
|
||||
return result # Только финальный ответ используется для отображения HEX и BIN.
|
||||
|
||||
def _set_word_running(self, running):
|
||||
self._word_running = running
|
||||
self.byte_write_button.setEnabled(not running)
|
||||
self.byte_read_button.setEnabled(not running)
|
||||
def _word_done(self, key, address, expected, success, result): # Результат связан с параметрами конкретного нажатия, а не с изменёнными полями.
|
||||
if key not in self._word_keys: # Поздний callback закрытой страницы не перезаписывает её значения.
|
||||
return # Остановленное действие не продолжает обмен.
|
||||
self._word_keys.remove(key) # Этот запрос слова больше не ожидает соединение.
|
||||
self._set_word_running(bool(self._word_keys)) # Признак учитывает остальные принятые нажатия.
|
||||
if not success: # Таймаут, ошибка CRC или отключение отображаются как действительные ошибки связи.
|
||||
self._report(str(result)) # Пользователь видит причину отказа данного запроса.
|
||||
return # Не показываем недостоверное слово памяти.
|
||||
value = result["value"] # Проверенное слово принадлежит завершённому ReadMemory.
|
||||
self.set_read_value(address, value) # Заполняем имеющиеся поля HEX и BIN без пересоздания панели.
|
||||
if expected is not None and value != expected: # Прежняя проверка записи сохраняется для несовпадающего прочитанного значения.
|
||||
self._report(f"Запись 0x{address:08X} не подтверждена: прочитано {value:04X}, ожидалось {expected:04X}") # Указываем адрес, фактическое слово и ожидаемое значение.
|
||||
else: # Успешное чтение либо совпадающая проверка записи завершают действие.
|
||||
self._report("Запись проверена чтением" if expected is not None else "Чтение завершено") # Сохраняем понятный итог операции.
|
||||
|
||||
def stop(self):
|
||||
"""Cancel this page's scheduled work before closing its connection."""
|
||||
self.bus_widget.stop_macro()
|
||||
if self._word_running:
|
||||
self._word_generation += 1
|
||||
self._set_word_running(False)
|
||||
self.protocol.cancel_async()
|
||||
self._report("Обмен прерван; переданные команды могли быть выполнены")
|
||||
def _set_word_running(self, running): # Состояние обмена хранится отдельно от доступности интерфейса.
|
||||
self._word_running = running # Кнопки и поля принимают новые действия даже во время текущего обмена.
|
||||
|
||||
def stop(self): # Отключение страницы отменяет задания всего общего соединения.
|
||||
self._word_keys.clear() # Поздние результаты отключения не меняют поля закрываемой страницы.
|
||||
self._set_word_running(False) # Завершаем состояние ожидания собственных запросов слова.
|
||||
self.bus_widget.stop() # Общая очередь снимает неотправленные задания и отменяет текущий транспорт при закрытии соединения.
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.stop()
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Таблица наблюдаемых регистров PBUS внутри общей панели команд SETGUI."""
|
||||
|
||||
from PySide6.QtCore import Qt, QEvent, QTimer, QItemSelectionModel, QSize, QRect # Геометрия Qt позволяет компактно переносить связанные элементы в узком доке.
|
||||
from PySide6.QtGui import QIntValidator # Проверяем четырёхбитные адреса платы и регистра ещё при вводе.
|
||||
from PySide6.QtWidgets import QWidget, QLayout, QSizePolicy, QGroupBox, QVBoxLayout, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, QTableWidget, QTableWidgetItem, QAbstractItemView, QHeaderView, QPlainTextEdit # Адаптивная строка сохраняет компактные действия независимо от ширины таблицы результатов.
|
||||
from .bus_operations import table_entries_after, pbus_table_steps, pbus_control, pbus_result_address, PBUS_COMMAND, PBUS_SETTLE_MS, PBUS_ERROR_LIMIT # Полная конфигурация, адреса CD и остановка используют единый формат слов памяти TMS.
|
||||
|
||||
|
||||
class _FlowLayout(QLayout): # Связанные элементы располагаются рядом и переносятся целиком при недостатке ширины.
|
||||
def __init__(self, parent, spacing): # Промежуток задаётся в пикселях одинаково для горизонтального и вертикального переноса.
|
||||
super().__init__(parent) # Компоновка принадлежит только своему компактному контейнеру.
|
||||
self.items = [] # Храним порядок полей и действий без изменения их владельцев при переносе.
|
||||
self.setContentsMargins(0, 0, 0, 0) # Контейнер не добавляет второго слоя отступов формы.
|
||||
self.setSpacing(spacing) # Сохраняем небольшое постоянное расстояние между связанными элементами.
|
||||
self.setProperty("fixedPanelLayout", True) # Общий менеджер панелей не должен заменять перенос элементов сплиттером.
|
||||
|
||||
def addItem(self, item): # Qt вызывает этот метод при добавлении виджета в адаптивную строку.
|
||||
self.items.append(item) # Сохраняем порядок элементов, заданный формой пользователя.
|
||||
|
||||
def count(self): # Предоставляем Qt фактическое число элементов компоновки.
|
||||
return len(self.items) # Перенос не создаёт дополнительных или скрытых копий действий.
|
||||
|
||||
def itemAt(self, index): # Менеджер геометрии получает элемент по индексу без удаления.
|
||||
return self.items[index] if 0 <= index < len(self.items) else None # Невалидный индекс завершает стандартный обход компоновки.
|
||||
|
||||
def takeAt(self, index): # Qt извлекает элемент при удалении или уничтожении контейнера.
|
||||
return self.items.pop(index) if 0 <= index < len(self.items) else None # Ссылки на элементы удаляются вместе с компоновкой.
|
||||
|
||||
def expandingDirections(self): # Свободное пространство не должно растягивать промежутки между действиями.
|
||||
return Qt.Orientations(0) # Элементы используют собственные размеры, оставляя лишнюю ширину справа.
|
||||
|
||||
def hasHeightForWidth(self): # Высота зависит от числа строк после переноса по доступной ширине.
|
||||
return True # Владелец учитывает дополнительную строку, не обрезая кнопку или результат.
|
||||
|
||||
def heightForWidth(self, width): # Измеряем необходимую высоту до назначения окончательной геометрии.
|
||||
return self._arrange(QRect(0, 0, width, 0), False) # Расчёт не перемещает виджеты при измерении размеров.
|
||||
|
||||
def minimumSize(self): # Узкий док должен вмещать самый широкий отдельный элемент, а не сумму всех кнопок.
|
||||
size = QSize(0, 0) # Начальная минимальная область не занимает места до добавления элементов.
|
||||
for item in self.items: # Каждая кнопка или карточка переносится целиком и сохраняет читаемость.
|
||||
size = size.expandedTo(item.minimumSize()) # Минимальная ширина равна самому широкому элементу, высота — минимальной строке.
|
||||
return size # Перенос позволяет сохранить равные доли ширины команд и RAW-панели.
|
||||
|
||||
def sizeHint(self): # Естественный размер подстраивается к ширине, которую даст внешний контейнер.
|
||||
return self.minimumSize() # Дополнительная высота вычисляется через heightForWidth при реальном размещении.
|
||||
|
||||
def setGeometry(self, rect): # Qt назначает доступную область адаптивной строки.
|
||||
super().setGeometry(rect) # Сохраняем стандартную геометрию компоновки для родительского виджета.
|
||||
self._arrange(rect, True) # Размещаем элементы по строкам внутри выделенной области.
|
||||
|
||||
def _arrange(self, rect, apply): # Один расчёт используется измерением и реальным размещением элементов.
|
||||
left, x, y, row_height = rect.x(), rect.x(), rect.y(), 0 # Начинаем каждую строку у левого края контейнера.
|
||||
for item in self.items: # Порядок управления сохраняется при любом размере дока.
|
||||
size = item.sizeHint().expandedTo(item.minimumSize()) # Не сжимаем подписи, стрелки интервала или считанное слово.
|
||||
if x > left and x + size.width() > left + rect.width(): # Элемент переносится, если целиком не помещается после предыдущего.
|
||||
x, y, row_height = left, y + row_height + self.spacing(), 0 # Следующая строка начинается без пустого промежутка слева.
|
||||
if apply: # Во время измерения геометрия виджетов остаётся неизменной.
|
||||
item.setGeometry(QRect(x, y, size.width(), size.height())) # Поля и действия занимают свою естественную ширину рядом друг с другом.
|
||||
x += size.width() + self.spacing() # Следующий элемент получает только небольшой постоянный промежуток.
|
||||
row_height = max(row_height, size.height()) # Высота строки вмещает самый высокий её элемент.
|
||||
return y + row_height - rect.y() # Внешняя компоновка получает общую высоту всех занятых строк.
|
||||
|
||||
|
||||
class CompactControls(QWidget): # Компактная строка переносит связанные поля, карточки или действия без растягивания по краям окна.
|
||||
def __init__(self, widgets, parent=None, spacing=8): # По умолчанию элементы разделены восьмью пикселями.
|
||||
super().__init__(parent) # Контейнер остаётся частью существующего блока своей шины.
|
||||
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Доступная ширина используется для переноса, высота соответствует содержимому.
|
||||
policy.setHeightForWidth(True) # Узкий док выделяет высоту для следующей строки вместо увеличения минимальной ширины окна.
|
||||
self.setSizePolicy(policy) # Внешняя область корректно учитывает адаптивную высоту формы.
|
||||
layout = _FlowLayout(self, spacing) # Все элементы используют один расчёт переноса и минимального размера.
|
||||
for widget in widgets: # Связанные действия добавляются в пользовательском порядке.
|
||||
layout.addWidget(widget) # Перенос не изменяет обработчики команд и результаты операции.
|
||||
|
||||
|
||||
class PbusPanel(QGroupBox): # Хранит один общий список источников PBUS и результаты их считывания.
|
||||
def __init__(self, owner): # Владелец предоставляет единую очередь PBUS, SBUS и смешанных макросов.
|
||||
super().__init__("PBUS (Параллельная шина)", owner) # Русская подпись обозначает правый блок существующей панели SETGUI.
|
||||
self.owner, self.entries, self.applied = owner, [], False # Видимый список отдельно учитывает подтверждение его аппаратной конфигурации.
|
||||
self.controller = None # Таблица считается применённой только для контроллера, которому отправлена конфигурация.
|
||||
self._controls, self._applied_signature = {}, None # Последние подтверждённые управляющие слова хранятся отдельно для каждого контроллера.
|
||||
self.table_errors = PBUS_ERROR_LIMIT # Начальный аппаратный порог равен десяти ошибкам, как в рабочем слове запуска 0x8Axx.
|
||||
self.setObjectName("pbusGroup") # Стабильное имя сохраняет поиск панели в интеграциях и проверках интерфейса.
|
||||
layout = QVBoxLayout(self) # Таблица и журнал получают свободную область независимо от ширины полей добавления.
|
||||
layout.setContentsMargins(10, 14, 10, 10) # Отступы в пикселях совпадают с соседним блоком SBUS.
|
||||
layout.setSpacing(8) # Параметры, действия и результаты образуют плотные связанные области.
|
||||
layout.setProperty("fixedPanelLayout", True) # Общий менеджер панелей не должен превращать компактные формы в растягиваемые области.
|
||||
controls = QWidget(self) # Адрес платы и регистр не делят столбцы с широкой таблицей результатов.
|
||||
controls.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Контейнер ограничивается естественной шириной подписей и полей.
|
||||
form = QGridLayout(controls) # Два компактных столбца сохраняют значения рядом с подписями.
|
||||
form.setContentsMargins(0, 0, 0, 0) # Вложенная форма не добавляет второго уровня внешних отступов.
|
||||
form.setHorizontalSpacing(10) # Подпись и её поле разделены десятью пикселями вместо ширины всей панели.
|
||||
form.setVerticalSpacing(6) # Строки адреса и регистра остаются визуально связанными.
|
||||
form.setProperty("fixedPanelLayout", True) # Форма сохраняет компактную геометрию при применении общей раскладки приложения.
|
||||
self.controls = {} # Публичные элементы доступны владельцу без копирования их состояния.
|
||||
for row, (name, caption) in enumerate((("tetrad_edit", "Адрес платы (0–15):"), ("register_edit", "Регистр (0–15):"))): # Для добавления достаточно указать источник данных, индексы буфера вычисляются автоматически.
|
||||
field = QLineEdit("0") # Начальное значение позволяет добавить первый регистр без заполнения дополнительных аппаратных полей.
|
||||
field.setValidator(QIntValidator(0, 15, field)) # Каждый адрес занимает одну тетраду дескриптора x_parallel_bus_add_table.
|
||||
field.setFixedWidth(90) # Девяносто пикселей достаточно для адреса; поле не уезжает к правому краю широкого дока.
|
||||
form.addWidget(QLabel(caption), row, 0) # Подпись объясняет допустимый адрес платы или регистра.
|
||||
form.addWidget(field, row, 1) # Поле находится непосредственно рядом со своей подписью.
|
||||
self.controls[name] = field # Сохраняем адресные поля для кнопки добавления и внешних проверок.
|
||||
field.returnPressed.connect(self.add_register) # Enter в адресных полях добавляет регистр без перемещения к кнопке.
|
||||
self.error_limit = QSpinBox(self) # Отдельный параметр правой колонки задаёт аппаратный порог ошибок PBUS.
|
||||
self.error_limit.setRange(1, 15) # Порог занимает четыре бита управляющего слова; допустимы целые значения от одного до пятнадцати.
|
||||
self.error_limit.setValue(PBUS_ERROR_LIMIT) # Значение десять соответствует шестнадцатеричному порогу 0xA.
|
||||
self.error_limit.setFixedWidth(90) # Поле порога выровнено с существующими компактными адресными полями.
|
||||
self.error_limit.setToolTip("Порог в битах 11:8 регистра 0x200C. 10 = 0xA. Применяется при добавлении, изменении таблицы или нажатии «Обновить».") # Пользователь видит единицы и момент аппаратного применения выбранного числа ошибок.
|
||||
form.addWidget(QLabel("Порог ошибок (1–15):"), 2, 0) # Подпись отличает аппаратный порог от периода чтения и паузы после запуска.
|
||||
form.addWidget(self.error_limit, 2, 1) # Настройка находится в блоке PBUS рядом с платой и регистром.
|
||||
self.controls["error_limit"] = self.error_limit # Порог доступен владельцу как pbus_error_limit вместе с остальными элементами PBUS.
|
||||
layout.addWidget(controls, 0, Qt.AlignLeft) # Компактная форма начинается слева и не растягивается вместе с таблицей.
|
||||
self.add_btn, self.remove_btn = QPushButton("Добавить в таблицу"), QPushButton("Убрать из таблицы") # Подписи соответствуют работе со списком вместо ручного чтения и записи.
|
||||
self.edit_btn = QPushButton("Изменить строку", self) # Адрес и регистр выделенной строки заменяются значениями полей без ручного удаления и добавления.
|
||||
self.edit_btn.setFixedHeight(30) # Действие сохраняет существующую высоту кнопок управления таблицей.
|
||||
self.edit_btn.setToolTip("Заменить выделенную строку адресом платы и регистром из полей выше.") # Пользователь видит, какие параметры будут применены к выбранному источнику.
|
||||
self.edit_btn.clicked.connect(self.edit_selected) # Редактирование ставит полную перенастройку в ту же очередь памяти контроллера.
|
||||
self.add_btn.clicked.connect(self.add_register) # Добавление само применяет таблицу и получает первое считанное значение.
|
||||
self.remove_btn.clicked.connect(self.remove_selected) # Кнопка удаляет все выделенные строки независимо от текущей ячейки.
|
||||
layout.addWidget(CompactControls((self.add_btn, self.edit_btn, self.remove_btn), self)) # Действия находятся рядом под адресом и переносятся в узком доке вместо сжатия RAW-панели.
|
||||
self.auto_update = QCheckBox("Циклическое чтение") # Переключатель управляет запросами буфера, а не созданием независимого макроса.
|
||||
self.auto_update.setChecked(True) # После явного добавления регистра таблица продолжает показывать текущие считанные значения.
|
||||
self.interval = QSpinBox() # Интервал ограничивает частоту запросов ПК к контроллеру.
|
||||
self.interval.setRange(1, 3600000) # Период чтения задаётся в миллисекундах от одной до одного часа. # Интервал от 100 мс до 60 с позволяет выбрать удобную частоту обновления без непрерывного цикла Qt.
|
||||
self.interval.setValue(1000) # По умолчанию обновляем результаты раз в секунду, не перегружая последовательное соединение.
|
||||
self.interval.setSuffix(" мс") # Единицы измерения видны непосредственно в поле интервала.
|
||||
self.interval.setFixedWidth(120) # Сто двадцать пикселей вмещают число, единицы и стрелки темы без растягивания поля по всей панели.
|
||||
self.startup_delay = QSpinBox(self) # Отдельная пауза относится только к аппаратному обновлению после запуска новой таблицы.
|
||||
self.startup_delay.setRange(1, 60000) # Пользователь задаёт ожидание контроллера от одной миллисекунды до минуты.
|
||||
self.startup_delay.setValue(PBUS_SETTLE_MS) # Начальное ожидание составляет сто миллисекунд и не заменяет период чтения.
|
||||
self.startup_delay.setSuffix(" мс") # Поле явно обозначает единицу измерения задержки.
|
||||
self.startup_delay.setFixedWidth(120) # Пауза занимает компактное место рядом со своей подписью.
|
||||
self.startup_delay.setToolTip("После подтверждения запуска PBUS ждать аппаратного обновления памяти; прежние значения буфера до этого не показываются.") # Подсказка объясняет отличие от периода обычного опроса.
|
||||
self.refresh_btn = QPushButton("Обновить") # Ручное обновление полезно при выключенном автоопросе или после подключения порта.
|
||||
self.refresh_btn.clicked.connect(self.refresh) # Получаем все слова текущего списка одной последовательностью запросов.
|
||||
for widget in (self.auto_update, self.interval, self.refresh_btn): # Размещаем все средства обновления в одной строке.
|
||||
widget.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Переключатель и действия занимают необходимую ширину вместо распределения по краям окна.
|
||||
layout.addWidget(CompactControls((self.auto_update, self.interval, self.refresh_btn), self)) # Средства обновления остаются компактной группой и переносятся при недостатке ширины.
|
||||
layout.addWidget(CompactControls((QLabel("После запуска:"), self.startup_delay), self)) # Пауза настройки отделена от строки периодического чтения буфера.
|
||||
self.table = QTableWidget(0, 5) # Одна строка соответствует одному добавленному регистру PBUS.
|
||||
self.table.setObjectName("pbusResultsTable") # Имя позволяет проверять и находить новую таблицу результатов в SETGUI.
|
||||
self.table.setHorizontalHeaderLabels(["Плата", "Регистр", "HEX", "DEC", "Состояние"]) # Показываем источник, оба представления слова и достоверность результата.
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows) # Выделение целой строки делает удаление регистра однозначным.
|
||||
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) # Shift и Ctrl позволяют удалить несколько регистров одним действием.
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) # Изменение считанных значений не должно подменять аппаратный результат.
|
||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) # Короткие адреса и значения занимают только необходимую ширину.
|
||||
self.table.horizontalHeader().setStretchLastSection(True) # Состояние использует оставшуюся ширину, не обрезая описание ошибки.
|
||||
self.table.setMinimumHeight(150) # Таблица остаётся пригодной для нескольких источников даже в компактной раскладке доков.
|
||||
self.table.installEventFilter(self) # Клавиша Delete удаляет выбранные источники, когда таблица имеет фокус.
|
||||
self.table.itemSelectionChanged.connect(self._selection_changed) # Доступность удаления соответствует текущему выделению строк.
|
||||
layout.addWidget(self.table, 1) # Таблица результатов получает свободную высоту внутри блока PBUS.
|
||||
self.result_label = QLabel("Добавьте регистры для считывания") # Начальный статус объясняет первое действие пользователя.
|
||||
self.result_label.setWordWrap(True) # Диагностика остаётся внутри правого блока при изменении размера окна.
|
||||
layout.addWidget(self.result_label) # Последнее состояние относится непосредственно к таблице PBUS.
|
||||
self.output = QPlainTextEdit() # Собственный небольшой журнал сохраняет результаты изменения таблицы и пакеты ответов.
|
||||
self.output.setReadOnly(True) # Пользователь может копировать ответы без изменения истории операций.
|
||||
self.output.setMaximumBlockCount(200) # Ограничиваем историю, чтобы автообновление не увеличивало память бесконечно.
|
||||
self.output.setFixedHeight(55) # Две-три строки журнала не вытесняют общую область макросов.
|
||||
layout.addWidget(self.output) # Ответы PBUS сохраняются в правом блоке, без общей нижней панели результатов.
|
||||
self.timer = QTimer(self) # Таймер принадлежит панели и прекращает существование вместе с ней.
|
||||
self.timer.setTimerType(Qt.PreciseTimer) # Период циклического чтения не должен сокращаться из-за неточности таймера.
|
||||
self.timer.timeout.connect(self._poll_tick) # Каждый такт читает только уже применённую аппаратную таблицу.
|
||||
self.interval.valueChanged.connect(self._update_timer) # Новый интервал применяется к следующим циклам считывания.
|
||||
self.auto_update.toggled.connect(self._auto_changed) # Пользователь может остановить обновление, не удаляя источники таблицы.
|
||||
self.controls.update({"add_btn": self.add_btn, "remove_btn": self.remove_btn, "table": self.table, "result_label": self.result_label, "output": self.output}) # Экспортируем единственную таблицу и её действия для владельца.
|
||||
self._selection_changed() # До выделения первой строки удаление недоступно.
|
||||
|
||||
def command(self, operation, **parameters): # Создаём одинаковые команды для ручных действий и общего исполнителя макросов.
|
||||
return {"bus": "pbus", "type": operation, "tetrad": 0, "register": 0, "errors": self.error_limit.value(), "settle_ms": self.startup_delay.value(), **parameters} # Действие фиксирует текущие параметры таблицы и паузу при постановке в очередь.
|
||||
|
||||
def add_register(self): # Добавляем заданный адрес платы и регистра, не требуя номера строки буфера.
|
||||
try: # Пустое поле не должно запускать аппаратную запись с неверным адресом.
|
||||
command = self.command("add", tetrad=int(self.controls["tetrad_edit"].text()), register=int(self.controls["register_edit"].text())) # Фиксируем источник перед асинхронным обменом.
|
||||
self.owner._start_commands([command], macro=False) # Таблица применяется через тот же транспорт, что SBUS и общие макросы.
|
||||
except ValueError as error: # Ошибка ввода остаётся в области PBUS и не влияет на другой блок.
|
||||
self.result_label.setText(f"PBUS: проверьте адрес и регистр: {error}") # Пользователь видит причину отказа непосредственно возле таблицы.
|
||||
|
||||
def edit_selected(self): # Одна выбранная строка заменяется целым снимком таблицы с сохранением остальных источников и CD.
|
||||
selected = self.table.selectionModel().selectedRows() # Фиксируем выделение в момент нажатия до ожидания очереди.
|
||||
if len(selected) != 1: # Множественное выделение не должно случайно заменить несколько регистров одним адресом.
|
||||
return # Пользователь должен выбрать конкретную редактируемую строку.
|
||||
try: # Проверяем новые адреса ещё до остановки аппаратного опроса.
|
||||
entries = [dict(entry) for entry in self.entries] # Полный снимок сохраняет порядок и явные CD всех остальных записей.
|
||||
entries[selected[0].row()].update(tetrad=int(self.controls["tetrad_edit"].text()), register=int(self.controls["register_edit"].text())) # Меняются только плата и регистр выбранной строки, индекс и назначение результата сохраняются.
|
||||
self.replace_entries(entries) # Очередь выполнит остановку, перепись всех записей и запуск как одну операцию.
|
||||
except (ValueError, TypeError) as error: # Неверный ввод не меняет подтверждённую аппаратную таблицу.
|
||||
self.result_label.setText(f"PBUS: проверьте адрес и регистр: {error}") # Причина отказа остаётся в собственной области PBUS.
|
||||
|
||||
def remove_selected(self): # Удаляем все выделенные регистры кнопкой или клавишей Delete.
|
||||
selected = sorted({index.row() for index in self.table.selectionModel().selectedRows()}) # Множественное выделение собираем один раз до переписи списка.
|
||||
if not selected: # При отсутствии выделения список и аппаратная конфигурация остаются прежними.
|
||||
return # Не создаём пустую аппаратную операцию удаления.
|
||||
keys = [(self.entries[row]["tetrad"], self.entries[row]["register"]) for row in selected] # Удаление фиксируется по адресам, поэтому смещение индексов не удалит соседний регистр.
|
||||
self.owner._start_commands([self.command("remove", remove_keys=keys)], macro=False) # Оставшиеся строки уплотняются и заново записываются в аппаратную таблицу.
|
||||
|
||||
def eventFilter(self, watched, event): # Обеспечиваем привычное удаление строк клавишей Delete в таблице результатов.
|
||||
if watched is self.table and event.type() == QEvent.KeyPress and event.key() == Qt.Key_Delete: # Обрабатываем Delete только внутри своей таблицы PBUS.
|
||||
self.remove_selected() # Используем то же действие, что кнопка удаления выделенных строк.
|
||||
event.accept() # Исключаем дополнительную обработку Delete таблицей Qt.
|
||||
return True # Событие полностью обработано удалением выбранных регистров.
|
||||
return super().eventFilter(watched, event) # Остальные клавиши сохраняют штатное выделение и навигацию таблицы.
|
||||
|
||||
def _selection_changed(self): # Согласуем кнопку удаления с реальным выделением таблицы.
|
||||
self.remove_btn.setEnabled(bool(self.table.selectionModel().selectedRows())) # Пользователь сразу видит, когда выбран регистр для удаления.
|
||||
self.edit_btn.setEnabled(len(self.table.selectionModel().selectedRows()) == 1) # Изменение строки доступно для единственного выделенного источника, независимо от занятости UART.
|
||||
|
||||
def _render_entries(self): # Индексы видимой таблицы всегда соответствуют порядку аппаратного буфера после переписи.
|
||||
selected = {(self.table.item(index.row(), 0).text(), self.table.item(index.row(), 1).text()) for index in self.table.selectionModel().selectedRows() if self.table.item(index.row(), 0) is not None} # Сохраняем выделение по адресам, чтобы обновление и уплотнение не выбирали другой регистр.
|
||||
self.table.blockSignals(True) # Промежуточное заполнение не должно сбрасывать доступность удаления несколько раз за цикл.
|
||||
self.table.setRowCount(len(self.entries)) # Добавление и удаление изменяют единую таблицу результатов.
|
||||
self.table.clearSelection() # Старые числовые индексы после удаления могут относиться уже к другой плате.
|
||||
for row, entry in enumerate(self.entries): # Каждая строка содержит стабильные адреса платы и регистра.
|
||||
values = (str(entry["tetrad"]), str(entry["register"]), "—", "—", "Ожидает считывания") # До подтверждения новой таблицы не показываем старое слово как свежие данные.
|
||||
for column, text in enumerate(values): # Заполняем источник, два представления значения и начальный статус.
|
||||
self.table.setItem(row, column, QTableWidgetItem(text)) # Ячейки доступны для выделения и копирования без редактирования результата.
|
||||
if (str(entry["tetrad"]), str(entry["register"])) in selected: # Сохраняем только те выделенные источники, которые ещё присутствуют в списке.
|
||||
self.table.selectionModel().select(self.table.model().index(row, 0), QItemSelectionModel.Select | QItemSelectionModel.Rows) # Восстанавливаем также множественное выделение после фонового считывания.
|
||||
self.table.blockSignals(False) # Возвращаем обработку пользовательского изменения выделения.
|
||||
self._selection_changed() # Кнопка удаления снова соответствует восстановленному выделению.
|
||||
|
||||
def _signature(self, entries, errors): # Отпечаток содержит именно аппаратно значимые поля и порядок всей таблицы.
|
||||
return (errors, tuple((entry["tetrad"], entry["register"], pbus_result_address(entry, index)) for index, entry in enumerate(entries))) # Различаем индекс записи и фактический адрес 20CD каждой строки.
|
||||
|
||||
def replace_entries(self, entries): # Изменение состава, порядка или адресов передаётся как полный снимок новой таблицы.
|
||||
self.owner._start_commands([self.command("configure", entries=[dict(entry) for entry in entries])], macro=False) # Повторный макрос и фоновый опрос не смогут вмешаться в атомарную перепись всего снимка.
|
||||
|
||||
def sequence(self, command): # Одна логическая операция очереди охватывает остановку, все пары таблицы, запуск, паузу и первое чтение.
|
||||
operation = command["type"] # Действие определяет изменение таблицы либо чтение уже подтверждённого буфера.
|
||||
updated = table_entries_after(self.entries, command) # Полный снимок сохраняет явные CD и проверяет все источники до остановки.
|
||||
if updated != self.entries: # Любое изменение источника, порядка или CD требует применения всей новой таблицы.
|
||||
self.entries, self.applied = updated, False # Старый результат не подтверждает новые дескрипторы аппаратного опроса.
|
||||
self._render_entries() # Очищаем отображение прежних слов ещё до начала первой управляющей записи.
|
||||
controller, errors = self.owner._macro_controller, command.get("errors", self.table_errors) # Контроллер и аппаратный порог фиксируются для всей операции очереди.
|
||||
signature = self._signature(self.entries, errors) # Сравниваем полный порядок и адреса, а не только длину списка.
|
||||
configure = not self.applied or self.controller != controller or self._applied_signature != signature or operation in ("configure", "start") # Простое чтение не переписывает неизменённую применённую таблицу.
|
||||
if command.get("read_only") and configure: # Периодическое обновление не имеет права автоматически переписывать неподтверждённую таблицу.
|
||||
raise ValueError("PBUS: таблица изменена или не применена; выполните явное обновление") # Ошибка не отправляет ни управления, ни запуска контроллеру.
|
||||
if configure: # До первого WriteMemory таблица перестаёт считаться успешно применённой.
|
||||
self.applied, self.table_errors = False, errors # При отказе любая автоматическая попытка запуска останется запрещённой.
|
||||
self._render_entries() # При явном повторном запуске старое слово также не выдаётся за подтверждение новой настройки.
|
||||
self.result_label.setText("Применение таблицы; ожидание обновления аппаратного буфера…") # Состояние объясняет отсутствие прежних значений во время паузы.
|
||||
def confirmed_control(value): # Вызывается только после успешного подтверждения соответствующей записи 0x200C.
|
||||
self._controls[controller] = value # Следующая остановка сохранит реальные параметры прежней активной таблицы.
|
||||
result = yield from pbus_table_steps(self.entries, errors, configure=configure, stop_control=self._controls.get(controller), settle_ms=command.get("settle_ms", self.startup_delay.value()), on_control=confirmed_control) # Существующий транспорт подтверждает каждый пакет, очередь удерживает всю перенастройку целиком.
|
||||
result["controller"], result["signature"] = controller, signature # Применённость относится к точному снимку и тому контроллеру, который подтвердил обмен.
|
||||
result["configured"], result["form_errors"] = configure, command.get("_form_errors", errors) # Сохраняем начало редактирования порога, чтобы не затереть более новое пользовательское значение завершением старой операции.
|
||||
result["operation"], result["key"] = operation, (command["tetrad"], command["register"]) # Видимая строка добавления выбирается по исходным параметрам действия.
|
||||
return result # Отображение подтверждается только после успешного чтения обновлённого аппаратного буфера.
|
||||
|
||||
def stop_sequence(self, command): # Остановка сохраняет всю текущую таблицу и аппаратные параметры, сбрасывая только бит запуска.
|
||||
self.timer.stop() # Будущие периодические чтения остановленной шины больше не назначаются.
|
||||
self._set_cycle_checked(False) # Неотправленные фоновые шаги снимаются обработчиком переключателя.
|
||||
controller = self.owner._macro_controller # Используем тот же контроллер, что зафиксирован общей очередью для этого действия.
|
||||
control = self._controls.get(controller, pbus_control(len(self.entries), self.table_errors)) & 0x7FFF # Подтверждённые порог и последний индекс сохраняются даже при изменённом списке интерфейса.
|
||||
reply = yield PBUS_COMMAND, control # Ждём реального подтверждения остановки через существующий WriteMemory.
|
||||
self._controls[controller] = control # Только подтверждённая запись переводит аппаратное состояние в остановленное.
|
||||
return reply # Считанные значения прежней таблицы не переобъявляются результатом новой конфигурации.
|
||||
|
||||
def show_result(self, result): # Применяем считанные слова к тем строкам, чьи адреса подтверждены аппаратной конфигурацией.
|
||||
self.entries = result["entries"] # Список из завершённого цикла соответствует порядку прочитанных слов буфера.
|
||||
self.applied, self.controller = True, result["controller"] # Подтверждённая таблица допускает дальнейшее чтение без повторной записи дескрипторов.
|
||||
self._applied_signature = result.get("signature", self._signature(self.entries, self.table_errors)) # Дальнейшие чтения разрешены только для того же состава, порядка и CD.
|
||||
if result.get("configured") and self.error_limit.value() == result.get("form_errors"): # Явный порог выполненного макроса отражается в форме только при отсутствии более нового ввода пользователя.
|
||||
self.error_limit.setValue(self.table_errors) # Поле показывает реально применённый порог, не заставляя следующее ручное действие возвращаться к старому значению.
|
||||
self._render_entries() # Перестраиваем ячейки после добавления, удаления или смены контроллера.
|
||||
for row, reply in enumerate(result["rows"]): # Каждое считанное слово отображается рядом с собственным источником.
|
||||
value = reply["value"] # Получаем 16-битное слово из стандартного ответа PEEK транспорта TMS2812.
|
||||
self.table.item(row, 2).setText(f"{value:04X}") # HEX занимает четыре цифры, как данные SBUS.
|
||||
self.table.item(row, 3).setText(str(value)) # Десятичное значение помогает читать числовые регистры без ручного перевода.
|
||||
self.table.item(row, 4).setText("OK") # Успешный цикл подтверждает достоверность слова для данного источника.
|
||||
self.table.item(row, 2).setToolTip(reply.get("raw_response", {}).get("raw", b"").hex(" ").upper()) # Пакет конкретного чтения доступен в подсказке своей строки.
|
||||
if result.get("operation") == "add" and not self.owner._macro_is_macro: # Ручное добавление позволяет сразу выделить новый источник для копирования или Delete.
|
||||
for row, entry in enumerate(self.entries): # Находим добавленный регистр по адресу, независимо от числа уже наблюдаемых строк.
|
||||
if (entry["tetrad"], entry["register"]) == result["key"]: # Выделяем именно выбранный источник, включая повторное добавление существующей строки.
|
||||
self.table.selectRow(row) # Следующая клавиша Delete относится к добавленному или найденному регистру.
|
||||
self.table.setFocus() # Удобное удаление не требует дополнительного щелчка после добавления кнопкой.
|
||||
break # Источник уникален, поэтому поиск завершается после первой найденной строки.
|
||||
self._update_timer() # Возобновляем обновление только для применённого непустого списка регистров.
|
||||
|
||||
def show_error(self, message): # Ошибка аппаратной записи или чтения не должна оставлять прежние слова выглядеть новыми.
|
||||
self.applied = False # После ошибки явное обновление повторно применяет список перед считыванием.
|
||||
self.timer.stop() # Исключаем автоматические повторные записи после ошибки или неопределённого исхода операции.
|
||||
self._set_cycle_checked(False) # Состояние переключателя честно показывает остановку фоновых запросов.
|
||||
for row in range(self.table.rowCount()): # Недостоверность отображается в каждой затронутой строке общей таблицы.
|
||||
self.table.item(row, 2).setText("—") # HEX не сохраняет устаревшее слово после отказа обмена.
|
||||
self.table.item(row, 3).setText("—") # Десятичное значение также очищается, чтобы представления оставались согласованными.
|
||||
self.table.item(row, 4).setText("Ошибка") # Краткий статус остаётся виден рядом с адресом регистра.
|
||||
self.table.item(row, 4).setToolTip(message) # Полная причина доступна для конкретной строки при наведении.
|
||||
|
||||
def refresh(self): # Обновление получает все текущие регистры, повторяя настройку только когда она не подтверждена.
|
||||
self.owner._start_commands([self.command("poll")], macro=False) # Используем единую очередь, поэтому ручное обновление не вмешивается в другой макрос.
|
||||
|
||||
def _update_timer(self, *unused): # Таймер сохраняет период от начала чтения, не перезапускаясь после каждого результата.
|
||||
if self.auto_update.isChecked() and self.entries and self.applied: # Автоматическое чтение разрешено только для подтверждённой непустой таблицы.
|
||||
if not self.timer.isActive() or self.timer.interval() != self.interval.value(): # Завершение текущего чтения не сдвигает уже отсчитываемый период.
|
||||
self.timer.start(self.interval.value()) # Новый интервал применяется при включении или изменении настройки.
|
||||
else: # Пустая таблица и выключенное задание не создают новых чтений.
|
||||
self.timer.stop() # Отменяем дальнейшие срабатывания таймера этого источника.
|
||||
|
||||
def _set_cycle_checked(self, checked): # Внутреннее изменение галочки не является новым нажатием пользователя и не порождает повторных управляющих записей.
|
||||
previous = self.auto_update.blockSignals(True) # Отключение из обработки ошибки или закрытия не должно рекурсивно планировать WriteMemory.
|
||||
self.auto_update.setChecked(checked) # Видимое состояние соответствует фактически разрешённым повторениям.
|
||||
self.auto_update.blockSignals(previous) # Возвращаем прежний режим доставки пользовательских сигналов Qt.
|
||||
self._update_timer() # Таймер согласуется с программно установленным состоянием.
|
||||
if not checked: # Неотправленные периодические чтения больше не нужны выключенному источнику.
|
||||
self.owner._cancel_source(self.owner._pbus_key) # Текущий UART-пакет завершится корректно, последующие шаги этого цикла не отправляются.
|
||||
|
||||
def _auto_changed(self, checked): # Пользовательский переключатель управляет также аппаратной остановкой PBUS.
|
||||
self._update_timer() # Будущие программные повторы выключаются сразу, до ожидания общего порта.
|
||||
if not checked: # Выключение галочки должно прекратить и обновление интерфейса, и опрос самой параллельной шины.
|
||||
controller = self.owner.device_address() if callable(self.owner.device_address) else self.owner.device_address # Остановка относится к выбранному соединению контроллера.
|
||||
active = bool(self._controls.get(controller, 0) & 0x8000) # Подтверждённый бит запуска показывает, что аппаратный PBUS ещё работает.
|
||||
pending_configuration = any(run["controller"] == controller and any(command.get("bus") == "pbus" and command["type"] in ("add", "remove", "configure", "start") for command in run["jobs"][run["index"]:]) for run in self.owner._runs) # Уже принятая полная настройка может ещё завершить запуск перед очередью остановки.
|
||||
self.owner._cancel_source(self.owner._pbus_key) # Снимаем ожидающий цикл и ещё не переданные чтения, не повреждая текущий пакет UART.
|
||||
if active or pending_configuration: # Начальное выключение пустой панели не должно отправлять беспричинный управляющий запрос.
|
||||
self.owner._start_commands([self.command("stop")], macro=False) # Очередь дождётся текущей логической операции и очистит только бит 15 подтверждённых параметров активной таблицы.
|
||||
elif self.entries and self.applied: # Включение ранее остановленной настроенной таблицы должно возобновлять также аппаратный опрос.
|
||||
if not self._controls.get(self.controller, 0) & 0x8000: # После аппаратной остановки одного чтения буфера недостаточно для возобновления обмена.
|
||||
self.owner._start_commands([self.command("start")], macro=False) # Явное включение пользователя полностью применяет таблицу и выбранный порог перед началом чтения.
|
||||
else: # Уже работающая применённая таблица не требует перезапуска для включения периодического чтения интерфейса.
|
||||
self._poll_tick() # Первый обычный цикл читает только назначения результатов 20CD.
|
||||
elif self.entries: # Неподтверждённая после ошибки таблица по-прежнему требует явного применения.
|
||||
self.result_label.setText("Нажмите «Обновить», чтобы применить таблицу перед циклическим чтением.") # Выключенный аппаратный опрос не маскируется чтением старых значений памяти.
|
||||
|
||||
def _poll_tick(self): # Один источник добавляет не более одного незавершённого чтения таблицы PBUS.
|
||||
controller = self.owner.device_address() if callable(self.owner.device_address) else self.owner.device_address # Адрес контроллера снимается для нового цикла.
|
||||
if self.applied and controller != self.controller: # Таблица прежнего контроллера не применяется другому устройству автоматически.
|
||||
self.timer.stop() # Смена адреса прекращает фоновые чтения, сохраняя известные параметры других контроллеров.
|
||||
self.applied = False # Новый контроллер требует явного подтверждения своей таблицы.
|
||||
self._set_cycle_checked(False) # Галочка отражает остановку опроса прежнего устройства.
|
||||
self.result_label.setText("Адрес контроллера изменён. Нажмите «Обновить», чтобы применить таблицу.") # Сообщаем необходимое действие рядом с результатами.
|
||||
return # Новый контроллер не получает неподтверждённую конфигурацию от таймера.
|
||||
if self.applied and self._applied_signature != self._signature(self.entries, self.table_errors): # Изменение порядка, источников или CD не должно пройти незамеченным фоновым чтением.
|
||||
self.applied = False # Старые подтверждения относятся к другому снимку таблицы.
|
||||
self.timer.stop() # Периодическое обновление не пытается автоматически запустить новый список.
|
||||
self._render_entries() # Прежние слова не показываются как результат изменённых строк.
|
||||
self.result_label.setText("Таблица изменена. Нажмите «Обновить», чтобы применить её.") # Пользователь явно подтверждает аппаратную перенастройку.
|
||||
if self.entries and self.applied and self.auto_update.isChecked(): # Чтение буфера использует только подтверждённые строки таблицы.
|
||||
self.owner._start_commands([self.command("poll", read_only=True, errors=self.table_errors)], macro=False, background=True, key=self.owner._pbus_key) # Занятость ставит один цикл в очередь, повторные такты подавляются ключом задания.
|
||||
|
||||
def stop_updates(self): # Отключение и команда остановки запрещают будущие запросы данного источника.
|
||||
self._set_cycle_checked(False) # Отключение соединения не создаёт новую команду остановки уже закрываемому порту.
|
||||
self.timer.stop() # Больше не назначаем периодические чтения.
|
||||
self.owner._cancel_source(self.owner._pbus_key) # Отменяем неотправленные шаги циклического чтения без повреждения UART.
|
||||
self.applied = False # После нового подключения таблица требует явного применения.
|
||||
self._controls.clear() # После отключения нельзя считать прежние управляющие слова состоянием нового соединения.
|
||||
self._applied_signature = None # Новое подключение должно подтвердить весь снимок таблицы заново.
|
||||
@@ -3,7 +3,8 @@ from PySide6.QtWidgets import *
|
||||
from PySide6.QtCore import *
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtSerialPort import QSerialPort
|
||||
from protocan import tms_terminal as memory
|
||||
from protocan import tms_terminal as memory # Существующий кодек сохраняет формат запросов памяти и проверку CRC.
|
||||
from .exchange_queue import exchange_queue # RAW и обращения памяти разделяют очередь с шинами и макросами.
|
||||
|
||||
|
||||
class RawProtocol:
|
||||
@@ -33,8 +34,10 @@ class RawProtocol:
|
||||
self.port.setBaudRate(baudrate)
|
||||
return self.port.open(QIODevice.ReadWrite)
|
||||
|
||||
def disconnect(self):
|
||||
self.port.close()
|
||||
def disconnect(self): # Отключение снимает все задания данного соединения.
|
||||
exchange_queue(self).clear() # Неотправленные операции не должны перейти на новое соединение.
|
||||
self.cancel_async("COM отключён") # Ожидающий callback завершается реальной ошибкой закрытия порта.
|
||||
self.port.close() # После завершения состояния обмена закрываем существующий UART.
|
||||
|
||||
def _receive(self):
|
||||
self.feed(bytes(self.port.readAll()))
|
||||
@@ -138,7 +141,6 @@ class RawProtocol:
|
||||
self._settle.setSingleShot(True)
|
||||
self._settle.timeout.connect(loop.quit)
|
||||
if self.host:
|
||||
self.host.byte_page.setEnabled(False)
|
||||
self.host._pending_address = self._controller
|
||||
self.host._pending_kind = "pysetterminal"
|
||||
self.host._log("TX", request)
|
||||
@@ -162,7 +164,6 @@ class RawProtocol:
|
||||
self._loop = None
|
||||
if self.host:
|
||||
self.host._pending_address = self.host._pending_kind = None
|
||||
self.host.byte_page.setEnabled(True)
|
||||
|
||||
def poke(self, mem_address, data_value, device_address=None):
|
||||
return self._exchange(mem_address, data_value, device_address)
|
||||
@@ -206,10 +207,16 @@ class RawProtocolWidget(QWidget):
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
layout.setProperty("fixedPanelLayout", True)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(4)
|
||||
|
||||
# Группа для ПРЯМОГО ДОСТУПА К ПАМЯТИ (PEEK/POKE)
|
||||
mem_group = QGroupBox("Прямой доступ к памяти (PEEK/POKE)")
|
||||
mem_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
mem_layout = QGridLayout()
|
||||
mem_layout.setContentsMargins(6, 6, 6, 6)
|
||||
mem_layout.setSpacing(4)
|
||||
|
||||
mem_layout.addWidget(QLabel("Адрес памяти (hex):"), 0, 0)
|
||||
self.mem_addr_edit = QLineEdit("2000")
|
||||
@@ -239,6 +246,9 @@ class RawProtocolWidget(QWidget):
|
||||
# Группа для RAW отправки
|
||||
raw_group = QGroupBox("RAW отправка/чтение")
|
||||
raw_layout = QVBoxLayout()
|
||||
raw_layout.setProperty("fixedPanelLayout", True)
|
||||
raw_layout.setContentsMargins(6, 6, 6, 6)
|
||||
raw_layout.setSpacing(4)
|
||||
|
||||
self.raw_send_edit = QLineEdit()
|
||||
self.raw_send_edit.setPlaceholderText("Введите hex данные для отправки")
|
||||
@@ -246,6 +256,7 @@ class RawProtocolWidget(QWidget):
|
||||
raw_layout.addWidget(self.raw_send_edit)
|
||||
|
||||
hbox = QHBoxLayout()
|
||||
hbox.setSpacing(4)
|
||||
self.raw_send_btn = QPushButton("Отправить RAW")
|
||||
self.raw_send_btn.clicked.connect(self.send_raw_data)
|
||||
hbox.addWidget(self.raw_send_btn)
|
||||
@@ -260,66 +271,59 @@ class RawProtocolWidget(QWidget):
|
||||
|
||||
self.raw_output = QTextEdit()
|
||||
self.raw_output.setReadOnly(True)
|
||||
self.raw_output.setMaximumHeight(100)
|
||||
raw_layout.addWidget(self.raw_output)
|
||||
self.raw_output.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
raw_layout.addWidget(self.raw_output, 1)
|
||||
|
||||
|
||||
self.raw_clear_btn = QPushButton("Очистить логи")
|
||||
self.raw_clear_btn.clicked.connect(self.clear_logs)
|
||||
raw_layout.addWidget(self.raw_clear_btn, 0, Qt.AlignLeft)
|
||||
for button in (self.mem_write_btn, self.mem_read_btn, self.raw_send_btn,
|
||||
self.raw_read_btn, self.raw_clear_btn):
|
||||
button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
|
||||
|
||||
raw_group.setLayout(raw_layout)
|
||||
layout.addWidget(raw_group)
|
||||
layout.addWidget(raw_group, 1)
|
||||
|
||||
self.setLayout(layout)
|
||||
def memory_write(self):
|
||||
"""Запись в память (POKE)"""
|
||||
try:
|
||||
device_addr = self.device_address() if callable(self.device_address) else self.device_address
|
||||
mem_addr = int(self.mem_addr_edit.text(), 16)
|
||||
data = int(self.mem_data_edit.text(), 16)
|
||||
def memory_write(self): # Фиксируем адрес и слово при нажатии без блокировки других элементов.
|
||||
self._memory_action(True) # Запись занимает одну логическую операцию общей очереди.
|
||||
|
||||
success, result = self.raw_protocol.poke(mem_addr, data, device_addr)
|
||||
def memory_read(self): # Ручное чтение памяти принимается даже во время циклического опроса шин.
|
||||
self._memory_action(False) # Параметры текущего поля снимаются до ожидания соединения.
|
||||
|
||||
if success:
|
||||
self.raw_output.append(f"POKE 0x{mem_addr:04X} = 0x{data:04X}")
|
||||
self.raw_output.append(f"Ответ: {result['raw'].hex()}")
|
||||
else:
|
||||
self.raw_output.append(f"Ошибка POKE: {result}")
|
||||
def _memory_action(self, write): # Оба направления памяти используют прежний протокол через общий планировщик.
|
||||
try: # Проверка ввода выполняется до передачи любого пакета контроллеру.
|
||||
controller = self.device_address() if callable(self.device_address) else self.device_address # Контроллер сохраняется на момент нажатия.
|
||||
address = int(self.mem_addr_edit.text(), 16) # Адрес фиксируется независимо от последующего редактирования поля.
|
||||
value = int(self.mem_data_edit.text(), 16) if write else None # Значение участвует только в WriteMemory.
|
||||
if not 0 <= address <= 0xFFFFFFFF or (value is not None and not 0 <= value <= 0xFFFF): # Сохраняем разрядность адреса и шестнадцатибитного слова.
|
||||
raise ValueError("Проверьте адрес и данные памяти") # Неверные поля не создают задания UART.
|
||||
except ValueError as error: # Промежуточный неправильный ввод остаётся локальной ошибкой интерфейса.
|
||||
self.mem_result_label.setText(str(error)) # Пользователь видит причину прямо возле результата памяти.
|
||||
return # Очередь текущего соединения не изменяется.
|
||||
def steps(): # Одна операция возвращает итог существующего PEEK или POKE.
|
||||
result = yield address, value # Направление обмена определяется прежним соглашением None для чтения.
|
||||
return result # Результат отправляется только виджету инициатора действия.
|
||||
exchange_queue(self.raw_protocol).submit(steps, controller, lambda ok, reply: self._memory_done(address, value, ok, reply)) # Запрос ожидает завершения текущей логической операции шин или макроса.
|
||||
|
||||
except ValueError as e:
|
||||
self.raw_output.append(f"Ошибка в данных: {e}")
|
||||
def _memory_done(self, address, value, success, result): # Результат отображается по сохранённым параметрам конкретного нажатия.
|
||||
if not success: # Ошибка CRC, таймаут и отключение не подменяются успешным результатом.
|
||||
self.mem_result_label.setText(f"Ошибка: {result}") # Показываем отказ непосредственно в области памяти.
|
||||
self.raw_output.append(f"Ошибка памяти: {result}") # История сохраняет причину неудачной операции.
|
||||
elif value is None: # ReadMemory должен вернуть проверенное шестнадцатибитное слово.
|
||||
word = result["value"] # Слово декодировано существующим кодеком протокола.
|
||||
self.mem_result_label.setText(f"0x{word:04X} ({word})") # Показываем HEX и DEC одного результата чтения.
|
||||
self.raw_output.append(f"PEEK 0x{address:04X} = 0x{word:04X}") # Адрес отражает выполненное действие, даже если поле уже изменилось.
|
||||
else: # Подтверждённая запись сохраняет прежний формат диагностического журнала.
|
||||
self.raw_output.append(f"POKE 0x{address:04X} = 0x{value:04X}") # Отображаем записанное слово и адрес снимка параметров.
|
||||
|
||||
def memory_read(self):
|
||||
"""Чтение из памяти (PEEK)"""
|
||||
try:
|
||||
device_addr = self.device_address() if callable(self.device_address) else self.device_address
|
||||
mem_addr = int(self.mem_addr_edit.text(), 16)
|
||||
|
||||
success, result = self.raw_protocol.peek(mem_addr, device_addr)
|
||||
|
||||
if success:
|
||||
value = result['value']
|
||||
self.mem_result_label.setText(f"0x{value:04X} ({value})")
|
||||
self.raw_output.append(f"PEEK 0x{mem_addr:04X} = 0x{value:04X}")
|
||||
self.raw_output.append(f"Ответ: {result['raw_response']['raw'].hex()}")
|
||||
else:
|
||||
self.mem_result_label.setText(f"Ошибка: {result}")
|
||||
self.raw_output.append(f"Ошибка PEEK: {result}")
|
||||
|
||||
except ValueError as e:
|
||||
self.mem_result_label.setText(f"Ошибка: {e}")
|
||||
self.raw_output.append(f"Ошибка в данных: {e}")
|
||||
|
||||
def send_raw_data(self):
|
||||
"""Отправка сырых данных"""
|
||||
hex_str = self.raw_send_edit.text()
|
||||
success, result = self.raw_protocol.send_raw(hex_str)
|
||||
|
||||
if success:
|
||||
self.raw_output.append(result)
|
||||
else:
|
||||
self.raw_output.append(f"Ошибка: {result}")
|
||||
def send_raw_data(self): # Произвольная ручная отправка также ждёт завершения целой операции шины.
|
||||
text = self.raw_send_edit.text() # HEX-пакет фиксируется при нажатии кнопки отправки.
|
||||
def send(): # Отправка выполняется после получения общей очередью свободного UART.
|
||||
success, result = self.raw_protocol.send_raw(text) # Существующий RAW-протокол сохраняет формат и обработку ошибки порта.
|
||||
self.raw_output.append(result if success else f"Ошибка: {result}") # Итог принадлежит сохранённому пакету данного нажатия.
|
||||
exchange_queue(self.raw_protocol).submit_external(send, lambda: False, lambda ok, reply: None) # RAW не ожидает типизированного ответа, сохраняя прежнее поведение панели.
|
||||
|
||||
def read_raw_data(self):
|
||||
"""Чтение сырых данных"""
|
||||
|
||||
+440
-243
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
import time # Монотонные часы и короткое ожидание позволяют фоновому Python-коду регулярно освобождать GIL для обработчиков Qt.
|
||||
|
||||
|
||||
class WorkBudget: # Длинный расчёт добровольно уступает процессор, чтобы поток интерфейса не ждал GIL после каждого вызова Qt.
|
||||
def __init__(self, cancelled=None): # Необязательное threading.Event отменяет устаревшее задание без зависимости общего алгоритма от Qt.
|
||||
self.cancelled = cancelled # Событие принадлежит одному заданию; его установку проверяем до и после уступки интерфейсу.
|
||||
self.deadline = time.perf_counter() + .002 # Две миллисекунды непрерывной работы ограничивают конкуренцию с короткими Python-обработчиками GUI.
|
||||
|
||||
def __call__(self): # Рабочие проходы вызывают контрольную точку примерно раз в тысячу элементов, без проверки часов на каждом отсчёте.
|
||||
if self.cancelled is not None and self.cancelled.is_set(): # Запрос отмены должен действовать даже до истечения бюджета вычислений.
|
||||
raise InterruptedError("Фоновое задание отменено") # Вызывающий worker освобождает ресурсы и не публикует частичный результат.
|
||||
if time.perf_counter() < self.deadline: # Несколько быстрых контрольных точек не должны создавать лишние ожидания.
|
||||
return # Продолжаем текущую небольшую порцию вычислений до исчерпания её бюджета.
|
||||
if self.cancelled is None: # Неотменяемая подготовка также обязана оставлять время главному потоку.
|
||||
time.sleep(.001) # Пауза в одну миллисекунду освобождает GIL; sleep(0) не гарантирует времени для цепочки обработчиков Qt.
|
||||
elif self.cancelled.wait(.001): # Ожидание освобождает GIL и немедленно заканчивается при отмене вместо полной паузы.
|
||||
raise InterruptedError("Фоновое задание отменено") # Не продолжаем вычисление после отмены, полученной во время уступки интерфейсу.
|
||||
self.deadline = time.perf_counter() + .002 # Новый бюджет начинается после ожидания, чтобы не накапливать пропущенные интервалы.
|
||||
|
||||
|
||||
def work_items(items, checkpoint): # Оборачиваем полный последовательный проход без копирования и без потери исходных элементов.
|
||||
if checkpoint is None: # Обычные синхронные вызовы сохраняют прежнее поведение и не получают искусственных задержек.
|
||||
yield from items # Передаём значения в неизменном порядке без счётчика контрольных точек.
|
||||
return # Ветвь без фонового бюджета не выполняет проверки часов.
|
||||
for index, item in enumerate(items): # Счётчик определяет небольшие порции независимо от типа исходного контейнера.
|
||||
if index % 1024 == 0: # Не более 1024 элементов между проверками ограничивают задержку отмены и удержание GIL.
|
||||
checkpoint() # Уступаем время интерфейсу либо прерываем устаревшее задание.
|
||||
yield item # Отдаём каждый исходный элемент ровно один раз, сохраняя точность и порядок измерений.
|
||||
checkpoint() # Последняя проверка не допускает публикации результата, отменённого в конце короткого блока.
|
||||
@@ -34,16 +34,16 @@ def decode(raw_id: int, data: bytes, *, ide: bool = True,
|
||||
pm=1 if frame.from_device else 0, priority=0,
|
||||
)
|
||||
result = pc.Decoded(pid, len(data), bytes(data), ide=ide, rtr=rtr,
|
||||
protocol="BALZAM 167", device_label=frame.device_name,
|
||||
message_label=("BALZAM данные" if frame.from_device
|
||||
protocol="BALZAM 167", device_label=frame.sender_name, # Все CAN ID 167 подписываются источником: команды — TMS, ответы — именем устройства из таблицы адресов.
|
||||
message_label=("BALZAM Heartbeat" if frame.is_heartbeat else "BALZAM данные" if frame.from_device # Тип сообщения явно выделяет heartbeat по адресу и маске, сохраняя имя отправителя.
|
||||
else "BALZAM команды"))
|
||||
result.summary = frame.summary
|
||||
result.fields = [
|
||||
("Протокол", "BALZAM 167 legacy eCAN"),
|
||||
("Узел", "%d — %s" % (frame.device, frame.device_name)),
|
||||
("Направление", frame.route),
|
||||
("Отправитель", frame.device_name if frame.from_device else "ПМ"),
|
||||
("Получатель", "ПМ" if frame.from_device else frame.device_name),
|
||||
("Отправитель", frame.sender_name), # Используем ту же подпись источника, что в маршруте и имени кадра любого устройства 167.
|
||||
("Получатель", frame.recipient_name), # Показываем адресата отдельно, сохраняя имя периферийного узла в поле «Узел».
|
||||
("Начальный адрес", "0x%04X" % frame.start_address),
|
||||
("Маска трёх слов", "0b%s" % format(frame.present_mask, "03b")),
|
||||
]
|
||||
@@ -51,10 +51,10 @@ def decode(raw_id: int, data: bytes, *, ide: bool = True,
|
||||
for item in frame.registers:
|
||||
result.fields.append((
|
||||
"0x%04X %s" % (item.address, item.name or "регистр"),
|
||||
"0x%04X (%d)" % (item.value, item.signed_value),
|
||||
"0x%04X (%d)" % (item.value, item.value if frame.is_heartbeat else item.signed_value), # Счётчик секунд и идентификаторы heartbeat беззнаковые, в отличие от технологических int16.
|
||||
))
|
||||
if not frame.from_device and item.address == 0x7F:
|
||||
result.fields.append(("Команды ПМ", frame.command_description(item.value)))
|
||||
if item.address == 0x7F:
|
||||
result.fields.append(("Активные биты Commands", frame.command_description(item.value)))
|
||||
if frame.present_mask == 0:
|
||||
result.warnings.append("В адресном слове не отмечен ни один регистр")
|
||||
return result
|
||||
|
||||
@@ -13,15 +13,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from . import can_transport as tr
|
||||
from protocan import protocan as pc
|
||||
from protocan.gas_167 import REGISTERS
|
||||
|
||||
#: Типы сообщений, которые генератор выдаёт по кругу случайно.
|
||||
KINDS = ("pulse", "analog", "modbus", "gas", "status", "error", "discrete")
|
||||
|
||||
|
||||
class UmpDemoSource:
|
||||
"""Two simulated UMP nodes using the assigned global GAS map.
|
||||
|
||||
Call next_frames every 250 ms: four words per node per tick and a
|
||||
heartbeat every second. Device Type=3, Device=5/6 are demo addresses,
|
||||
not discovery results or a claim about firmware 167/v1 support.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tick = 0
|
||||
self._seq = 0
|
||||
|
||||
def _frame(self, node: int, kind: int, body: int, data: bytes) -> bytes:
|
||||
self._seq = (self._seq + 1) & 0xFF
|
||||
identifier = pc.ProtoCanId.build(1, 1, 3, node, kind, body)
|
||||
return tr.Frame(seq=self._seq, flags=tr.FLAG_IDE,
|
||||
can_id=identifier, data=data).encode()
|
||||
|
||||
def next_frames(self) -> list[bytes]:
|
||||
"""Return encoded bridge frames; one full map takes eight seconds."""
|
||||
packets = []
|
||||
# Show LOAD state immediately, then walk the entire dictionary.
|
||||
start = (124 + self._tick * 4) % len(REGISTERS)
|
||||
for node, key in ((5, "ump1_id"), (6, "ump2_id")):
|
||||
if self._tick % 4 == 0:
|
||||
packets.append(self._frame(
|
||||
node, pc.MsgType.PULSE, 0,
|
||||
bytes([(self._tick // 4) & 0xFF])))
|
||||
values = []
|
||||
for entry in REGISTERS[start:start + 4]:
|
||||
offset = entry["modbus"]
|
||||
value = int(2000 + 700 * math.sin(self._tick / 12 + offset / 8 + node))
|
||||
command = 0x80 if (self._tick // 32) % 2 == 0 else 0x40
|
||||
value = {82: 1000, 124: 8, 125: 0, 126: node, 127: command}.get(offset, value)
|
||||
values.append(value.to_bytes(2, "little"))
|
||||
packets.append(self._frame(
|
||||
node, pc.MsgType.GENERAL_ADDRESS_SPACE,
|
||||
REGISTERS[start][key], b"".join(values)))
|
||||
self._tick += 1
|
||||
return packets
|
||||
|
||||
|
||||
class BusDemoSource:
|
||||
"""@brief Источник демонстрационных кадров шины.
|
||||
|
||||
@@ -58,11 +102,15 @@ class BusDemoSource:
|
||||
data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little")
|
||||
for _ in range(cnt))
|
||||
elif kind == "gas":
|
||||
adr = self._rng.randint(0, 0x400)
|
||||
# Exercise the shared named map in the ordinary demo too.
|
||||
dev_type, dev_id = 3, self._rng.choice((5, 6))
|
||||
key = "ump1_id" if dev_id == 5 else "ump2_id"
|
||||
count = self._rng.randint(1, 4)
|
||||
adr = REGISTERS[self._rng.randrange(len(REGISTERS) - count + 1)][key]
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id,
|
||||
pc.MsgType.GENERAL_ADDRESS_SPACE, adr)
|
||||
data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little")
|
||||
for _ in range(self._rng.randint(1, 4)))
|
||||
for _ in range(count))
|
||||
elif kind == "status":
|
||||
now = datetime.now()
|
||||
cid = pc.ProtoCanId.build(1, 1, dev_type, dev_id, pc.MsgType.BROADCAST,
|
||||
|
||||
@@ -32,6 +32,8 @@ from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import chain
|
||||
from typing import Iterable
|
||||
from protocan import protocan as pc
|
||||
from protocan.gas_167 import REGISTERS
|
||||
|
||||
#: Распространённые скорости UART для подключения платы и RS485-приёмника.
|
||||
UART_BAUDS = (
|
||||
@@ -596,7 +598,14 @@ class BoardDemoSource:
|
||||
"""
|
||||
|
||||
#: Идентификаторы, по которым генератор идёт по кругу.
|
||||
IDS = ((True, 0x18FEE000), (True, 0x0CF00400), (False, 0x123), (False, 0x7DF))
|
||||
IDS = (
|
||||
(True, 0x18FEE000), (True, 0x0CF00400), (False, 0x123), (False, 0x7DF),
|
||||
# Assigned UMP map, simulated to demonstrate the common GAS decoder.
|
||||
(True, pc.ProtoCanId.build(1, 1, 3, 5, pc.MsgType.GENERAL_ADDRESS_SPACE,
|
||||
REGISTERS[124]['ump1_id'])),
|
||||
(True, pc.ProtoCanId.build(1, 1, 3, 6, pc.MsgType.GENERAL_ADDRESS_SPACE,
|
||||
REGISTERS[124]['ump2_id'])),
|
||||
)
|
||||
|
||||
def __init__(self, rng: random.Random | None = None) -> None:
|
||||
self._rng = rng or random.Random()
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Проверяемые настройки физических CAN-адаптеров без зависимости от Qt.""" # Общая модель используется транспортами и редактором настроек.
|
||||
|
||||
from __future__ import annotations # Отложенная оценка объединений типов сохраняет импорт в сборке Python 3.8 для Windows 7.
|
||||
from dataclasses import dataclass # Неизменяемые значения исключают смену режима во время работающего сеанса.
|
||||
|
||||
FD_LENGTHS = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64) # Таблица DLC стандарта CAN FD переводит четырёхбитный код в число байтов.
|
||||
|
||||
|
||||
@dataclass(frozen=True) # Полный тайминг передаётся в драйвер одной согласованной структурой.
|
||||
class CanTiming: # Сегменты измеряются в квантах времени CAN, BRP задаёт делитель тактовой частоты.
|
||||
prop_seg: int = 1 # Квант компенсации задержки распространения входит в TSEG1.
|
||||
phase_seg1: int = 12 # Вместе с PROP_SEG даёт TSEG1=13 для обычного тайминга 48 МГц.
|
||||
phase_seg2: int = 2 # Два кванта после точки выборки дают суммарно 16 квантов на бит.
|
||||
sjw: int = 1 # Один квант ограничивает коррекцию при ресинхронизации.
|
||||
brp: int = 6 # 48 МГц / 6 / 16 соответствует скорости 500 кбит/с.
|
||||
|
||||
def validate(self, cap, bitrate: int) -> None: # Проверяем ограничения, реально объявленные выбранным каналом.
|
||||
if cap.fclk_can <= 0 or cap.brp_min < 1 or self.brp < 1: # Нулевая частота или делитель означают повреждённый ответ либо ошибочный ручной ввод.
|
||||
raise ValueError("Некорректная частота CAN или нулевой BRP") # Не допускаем деления на ноль при диагностике скорости.
|
||||
tseg1 = self.prop_seg + self.phase_seg1 # Контроллер ограничивает суммарный сегмент перед выборкой.
|
||||
if self.prop_seg < 0 or self.phase_seg1 < 1: # Нулевая задержка допустима, но фазовый сегмент обязан существовать.
|
||||
raise ValueError("PROP_SEG ≥ 0, PHASE_SEG1 ≥ 1") # Не отправляем в USB недопустимые сегменты.
|
||||
if not cap.tseg1_min <= tseg1 <= cap.tseg1_max or not cap.tseg2_min <= self.phase_seg2 <= cap.tseg2_max: # Пределы различаются для номинальной и быстрой фазы.
|
||||
raise ValueError("Сегменты тайминга вне диапазона адаптера") # Сообщаем об аппаратном ограничении до запуска канала.
|
||||
if not 1 <= self.sjw <= min(cap.sjw_max, self.phase_seg2): # SJW не должен превышать второй фазовый сегмент.
|
||||
raise ValueError("SJW превышает предел адаптера или PHASE_SEG2") # Исключаем некорректную ресинхронизацию.
|
||||
if not cap.brp_min <= self.brp <= cap.brp_max or (self.brp - cap.brp_min) % max(1, cap.brp_inc): # Делитель учитывает шаг аппаратного регистра.
|
||||
raise ValueError("BRP вне диапазона или шага адаптера") # Не округляем вручную заданный делитель.
|
||||
actual = cap.fclk_can / (self.brp * (1 + tseg1 + self.phase_seg2)) # Единицы результата — бит/с; дополнительный квант относится к SYNC_SEG.
|
||||
if abs(actual - bitrate) > max(1, bitrate * 0.0001): # Допуск 0,01 % позволяет записать 83 333 вместо периодической дроби.
|
||||
raise ValueError(f"Тайминг задаёт {actual:g} бит/с вместо {bitrate}") # Статус и запись захвата не должны сообщать ложную скорость.
|
||||
|
||||
|
||||
def calculate_timing(cap, bitrate: int, sample_point: float = 87.5) -> CanTiming: # Подбираем тайминг по частоте и пределам канала вместо фиксированной таблицы 48 МГц.
|
||||
if cap.fclk_can <= 0 or not 1 <= cap.brp_min <= cap.brp_max <= 65536 or cap.tseg1_min < 1 or cap.tseg2_min < 1: # Ограничиваем также объём поиска при повреждённом ответе USB.
|
||||
raise ValueError("Адаптер вернул некорректные пределы тайминга") # Не выдаём мусорные аппаратные поля за рабочую конфигурацию.
|
||||
if bitrate <= 0 or not 50 <= sample_point <= 95: # Процент выборки ограничен практически полезным диапазоном.
|
||||
raise ValueError("Скорость должна быть положительной, точка выборки — 50…95 %") # Не запускаем поиск с бессмысленными параметрами.
|
||||
best = None # Сохраняем наиболее точное приближение скорости и точки выборки.
|
||||
for brp in range(cap.brp_min, cap.brp_max + 1, max(1, cap.brp_inc)): # Рассматриваем только допустимые делители аппаратной частоты.
|
||||
total = round(cap.fclk_can / (brp * bitrate)) # Число квантов в бите включает синхронизацию и оба сегмента.
|
||||
if total < 3: # Минимум нужен для SYNC_SEG и двух ненулевых фазовых сегментов.
|
||||
continue # Этот делитель не позволяет получить корректную структуру бита.
|
||||
lower, upper = max(cap.tseg2_min, total - 1 - cap.tseg1_max), min(cap.tseg2_max, total - 1 - cap.tseg1_min) # Допустимый TSEG2 одновременно ограничивается обоими аппаратными сегментами.
|
||||
if lower > upper: # Выбранное число квантов не раскладывается на допустимые TSEG1 и TSEG2.
|
||||
continue # Другой делитель может дать ту же точную скорость при корректных сегментах.
|
||||
tseg2 = min(upper, max(lower, round(total * (1 - sample_point / 100)))) # Выбираем ближайшую достижимую точку внутри пересечения аппаратных ограничений.
|
||||
tseg1 = total - 1 - tseg2 # Оставшийся бюджет квантов принадлежит сегменту перед выборкой.
|
||||
if not cap.tseg1_min <= tseg1 <= cap.tseg1_max: # Проверяем аппаратные пределы до вычисления оценки.
|
||||
continue # Не пытаемся отправлять неподдерживаемую комбинацию сегментов.
|
||||
error = abs(cap.fclk_can / (brp * total) - bitrate) / bitrate # Относительная ошибка скорости важнее ошибки точки выборки.
|
||||
score = (error, abs((1 + tseg1) / total * 100 - sample_point), -total) # При равенстве предпочитаем больше квантов для точной синхронизации.
|
||||
if best is None or score < best[0]: # Сравнение кортежей сохраняет установленный приоритет критериев.
|
||||
best = (score, CanTiming(0, tseg1, tseg2, 1, brp)) # PROP_SEG=0 допустим в gs_usb: драйвер использует суммарный TSEG1.
|
||||
if best is None or best[0][0] > 0.0001: # Не разрешаем скрытое округление к другой скорости шины.
|
||||
raise ValueError(f"Адаптер не может точно установить {bitrate} бит/с") # Пользователь должен выбрать достижимую скорость.
|
||||
best[1].validate(cap, bitrate) # Одинаково проверяем автоматический и ручной путь установки.
|
||||
return best[1] # Возвращаем параметры, готовые для управляющего USB-запроса.
|
||||
|
||||
|
||||
@dataclass(frozen=True) # Настройки SLCAN фиксируются до отправки первой команды конфигурации.
|
||||
class SlcanOptions: # Различия команд выбираются явным профилем прошивки.
|
||||
profile: str = "lawicel" # Lawicel использует подтверждения CR/BEL и команду L для пассивного режима.
|
||||
loopback: bool = False # Команда l доступна только у прошивок, реализующих это расширение Lawicel.
|
||||
retransmit: bool | None = None # None сохраняет настройку прошивки; A0/A1 применяются только для CANable.
|
||||
timestamps: bool | None = None # None не записывает EEPROM Lawicel и оставляет текущее состояние Z.
|
||||
acceptance_code: int | None = None # Регистр M задаётся вместе с маской и только у Lawicel.
|
||||
acceptance_mask: int | None = None # Биты маски имеют семантику прошивки Lawicel, а не экранного фильтра ID.
|
||||
btr: str = "" # Четыре HEX-цифры BTR0/BTR1 включают нестандартную скорость через команду s.
|
||||
command_timeout_ms: int = 500 # Полсекунды ограничивает ожидание подтверждения отдельной команды.
|
||||
fd: bool = False # Расширенные команды d/D/b/B поддерживает профиль CANable 2.0.
|
||||
data_bitrate: int = 2_000_000 # В документированной CANable 2.0 доступны быстрая фаза 2 или 5 Мбит/с.
|
||||
|
||||
def commands(self, bitrate: int, listen_only: bool) -> list[str]: # Формируем весь план до открытия COM, чтобы ошибка не меняла аппаратное состояние.
|
||||
if self.profile not in ("lawicel", "canable", "canable2") or not 10 <= self.command_timeout_ms <= 10000: # Ограничиваем профиль и тайм-аут 10 мс…10 с.
|
||||
raise ValueError("Неверный профиль SLCAN или тайм-аут команды") # Произвольные команды через настройки не исполняются.
|
||||
if self.loopback and (listen_only or self.profile != "lawicel"): # Для SLCAN нужны отдельные команды открытия l и L.
|
||||
raise ValueError("Loopback требует профиль Lawicel и отключённый режим только приёма") # Не подменяем молча выбранный пользователем режим.
|
||||
rates = {10000: "S0", 20000: "S1", 50000: "S2", 100000: "S3", 125000: "S4", 250000: "S5", 500000: "S6", 1000000: "S8"} # Общая часть стандартных скоростей задана спецификацией ASCII-команд.
|
||||
rates[800000 if self.profile == "lawicel" else 750000] = "S7" # Одинаковая команда означает разные скорости в двух семействах прошивок.
|
||||
if self.profile == "canable2": # Дополнительная скорость описана только для CANable 2.0.
|
||||
rates[83333] = "S9" # Целое значение соответствует округлённым 83,333 кбит/с.
|
||||
if self.btr: # Ручной тайминг имеет приоритет над выбором стандартного Sn.
|
||||
if self.profile != "lawicel" or len(self.btr) != 4 or any(c not in "0123456789abcdefABCDEF" for c in self.btr): # Разрешаем ровно два байта регистров SJA1000.
|
||||
raise ValueError("BTR0/BTR1: четыре HEX-цифры, только профиль Lawicel") # Исключаем внедрение дополнительных строк в команду.
|
||||
speed = "s" + self.btr.upper() # Прошивка сама интерпретирует регистры тайминга своего контроллера.
|
||||
btr0, btr1 = int(self.btr[:2], 16), int(self.btr[2:], 16) # В классическом Lawicel SJA1000 работает от генератора 16 МГц.
|
||||
actual = 16_000_000 / (2 * ((btr0 & 63) + 1) * (3 + (btr1 & 15) + ((btr1 >> 4) & 7))) # SJA1000 делит частоту на 2·BRP и число квантов SYNC+TSEG1+TSEG2.
|
||||
if abs(actual - bitrate) > max(1, bitrate * 0.0001): # Сохраняем честную скорость в статусе и захвате даже при ручном BTR.
|
||||
raise ValueError(f"BTR задаёт {actual:g} бит/с при 16 МГц; выберите эту скорость подключения") # Не маскируем рассогласование ручных регистров и выбранного битрейта.
|
||||
else: # Обычный путь требует наличия точной скорости в выбранном профиле.
|
||||
speed = rates.get(bitrate) # Не используем S7 от другого семейства адаптеров.
|
||||
if speed is None: # Произвольная скорость без BTR не имеет команды в этом профиле.
|
||||
raise ValueError(f"Профиль {self.profile} не поддерживает {bitrate} бит/с") # Сообщаем профиль вместе с отклонённой скоростью.
|
||||
result = [speed] # Настройка начинается после закрытия прежнего канала командой C.
|
||||
if self.profile != "lawicel": # CANable выбирает режим до открытия O и не использует L.
|
||||
result.append("M1" if listen_only else "M0") # Явно сбрасываем сохранённый silent при активном подключении.
|
||||
if self.retransmit is not None: # Сохраняем поведение прошивки, пока пользователь не выбрал A0/A1.
|
||||
result.append("A1" if self.retransmit else "A0") # Отключение повторов даёт однократную попытку передачи.
|
||||
elif self.retransmit is not None: # Буква A у Lawicel имеет другую семантику и не должна отправляться как CANable.
|
||||
raise ValueError("Управление повторами A0/A1 доступно только в профиле CANable") # Предотвращаем смешивание диалектов.
|
||||
if self.timestamps is not None: # Команда Z может сохраняться прошивкой в EEPROM.
|
||||
if self.profile != "lawicel": # CANable не документирует поддержку этого управления.
|
||||
raise ValueError("Временные метки Z0/Z1 доступны только в профиле Lawicel") # Не игнорируем пользовательскую настройку.
|
||||
result.append("Z1" if self.timestamps else "Z0") # Состояние задаётся только при явном выборе.
|
||||
if self.acceptance_code is not None or self.acceptance_mask is not None: # Фильтр задаётся атомарной парой регистров.
|
||||
if self.profile != "lawicel" or any(v is None or not 0 <= v <= 0xFFFFFFFF for v in (self.acceptance_code, self.acceptance_mask)): # Каждый регистр содержит ровно 32 бита.
|
||||
raise ValueError("Фильтр Lawicel требует код и маску в диапазоне 00000000…FFFFFFFF") # M0/M1 CANable не должны трактоваться как фильтры.
|
||||
result.extend((f"M{self.acceptance_code:08X}", f"m{self.acceptance_mask:08X}")) # Порядок байтов регистра соответствует документации Lawicel.
|
||||
if self.fd: # Для FD требуется документированное расширение CANable 2.0.
|
||||
if self.profile != "canable2" or self.data_bitrate not in (2_000_000, 5_000_000): # Y2/Y5 — единственные поддержанные этой прошивкой скорости данных.
|
||||
raise ValueError("CAN FD требует CANable 2.0 и скорость данных 2 либо 5 Мбит/с") # Не открываем несовместимый канал.
|
||||
result.append("Y2" if self.data_bitrate == 2_000_000 else "Y5") # Настраиваем быструю фазу до открытия CAN.
|
||||
result.append("l" if self.loopback else "L" if listen_only and self.profile == "lawicel" else "O") # Выбираем одну команду открытия без опасного возврата к активному режиму.
|
||||
return result # Последовательный транспорт проверит ответ на каждый этап там, где прошивка выдаёт подтверждения.
|
||||
|
||||
|
||||
@dataclass(frozen=True) # Флаги запуска Candle хранятся вместе с таймингами и настройкой терминатора.
|
||||
class CandleOptions: # Все расширения проверяются по маске возможностей конкретного канала.
|
||||
loopback: bool = False # Приём собственных кадров нужен для диагностики контроллера.
|
||||
one_shot: bool = False # Одна попытка передачи вместо автоматических повторов при отсутствии ACK.
|
||||
triple_sample: bool = False # Тройная выборка применяется только при аппаратной поддержке.
|
||||
hardware_timestamp: bool | None = None # None сохраняет автоматическое включение аппаратных меток.
|
||||
termination: bool | None = None # None сохраняет состояние встроенного терминатора, не меняя топологию сети.
|
||||
sample_point: float = 87.5 # Целевой процент номинального бита для автоматического расчёта.
|
||||
timing: CanTiming | None = None # Ручные сегменты заменяют автоматический расчёт номинального тайминга.
|
||||
fd: bool = False # Разрешает FD только после проверки возможности и настройки быстрой фазы.
|
||||
data_bitrate: int = 2_000_000 # Скорость быстрой фазы задаётся в бит/с.
|
||||
data_sample_point: float = 80.0 # Для быстрой фазы обычно нужна более ранняя точка выборки.
|
||||
data_timing: CanTiming | None = None # Ручной тайминг быстрой фазы проверяется по расширенным пределам.
|
||||
bus_errors: bool = True # Сохраняем сообщения об ошибках на шине при наличии управляемого режима.
|
||||
|
||||
def flags(self, listen_only: bool, features: int) -> int: # Маска gs_usb объединяет независимо выбираемые режимы.
|
||||
flags = (1 if listen_only else 0) | (2 if self.loopback else 0) | (4 if self.triple_sample else 0) | (8 if self.one_shot else 0) | (256 if self.fd else 0) # Значения битов заданы протоколом gs_usb.
|
||||
if self.hardware_timestamp is True: # Явный выбор требует возможности аппаратных временных меток.
|
||||
flags |= 16 # Бит 4 означает HW_TIMESTAMP.
|
||||
elif self.hardware_timestamp is None: # Автоматический режим включает метки только при поддержке устройства.
|
||||
flags |= features & 16 # Отсутствие возможности не является ошибкой автоматической настройки.
|
||||
if self.bus_errors: # Старые прошивки выдают ошибки всегда и не требуют этого флага.
|
||||
flags |= features & 4096 # Управляемый BERR_REPORTING включается лишь при объявленном бите 12.
|
||||
elif not features & 4096: # У старых прошивок нет подтверждаемого выключателя сообщений об ошибках.
|
||||
raise ValueError("Прошивка не поддерживает отключение сообщений об ошибках CAN") # Не показываем фактически невыполнимую настройку как применённую.
|
||||
if flags & ~features: # Ни один явный запрос не должен тихо исчезнуть из маски запуска.
|
||||
raise ValueError(f"Канал не поддерживает выбранные режимы: 0x{flags & ~features:X}") # Маска помогает сопоставить отказ с диагностикой адаптера.
|
||||
if self.termination is not None and not features & 2048: # Встроенный переключаемый терминатор объявляется битом 11.
|
||||
raise ValueError("Адаптер не поддерживает программное управление терминатором") # Внешний резистор программой не переключается.
|
||||
return flags # Готовая маска передаётся в CANDLE_DEVMODE_START.
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
from . import can_transport as tr
|
||||
from protocan import protocan as pc
|
||||
from protocan.gas_167 import assigned_metadata
|
||||
from . import balsam_can
|
||||
|
||||
#: Расшифровка поля LEC регистра CAN_ESR.
|
||||
@@ -64,8 +65,27 @@ def decode_frame(frame: tr.Frame) -> pc.Decoded:
|
||||
decoded_balsam = balsam_can.decode(
|
||||
frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
if decoded_balsam is not None:
|
||||
decoded_balsam.fields.append(("Автоопределение", "Balsam 167: идентификатор legacy CAN"))
|
||||
return decoded_balsam
|
||||
return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
result = pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
if (frame.ide and not frame.rtr
|
||||
and result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE
|
||||
and len(frame.data) <= 8 and len(frame.data) % 2 == 0
|
||||
and result.registers is not None):
|
||||
addresses = [address for address, _value in result.registers] or [result.id.body]
|
||||
entries = [assigned_metadata(address) for address in addresses]
|
||||
if all(entries) and len({entry['node'] for entry in entries}) == 1:
|
||||
node = entries[0]['node'] - 4
|
||||
result.protocol = "УМП / ProtoCAN GAS (назначенные ID)"
|
||||
result.device_label = "УМП %d" % node
|
||||
result.message_label = "GAS · УМП %d" % node
|
||||
result.fields.append(("Автоопределение",
|
||||
"УМП %d: все GAS ID входят в назначенную карту; поддержка прошивкой не подтверждена" % node))
|
||||
return result
|
||||
result.fields.append(("Автоопределение", "Разбор ProtoCAN; профиль прибора не определён"))
|
||||
if result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE:
|
||||
result.summary += " · профиль/узел не определены — выберите протокол вручную"
|
||||
return result
|
||||
|
||||
dec = pc.Decoded(id=pc.ProtoCanId.parse(frame.can_id), dlc=len(frame.data),
|
||||
data=frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
|
||||
@@ -10,6 +10,7 @@ from dataclasses import dataclass
|
||||
from protocan.legacycan import PROJECTS, LegacyCanFormat, decode
|
||||
|
||||
from .can485_board import BoardFrame, SdLogImport, parse_sd_log
|
||||
from .trend_limits import MAX_TOTAL_POINTS
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -63,7 +64,7 @@ def _values(frame: BoardFrame):
|
||||
|
||||
|
||||
def read_can_log(lines, *, reference_timestamp: float, capacity: int,
|
||||
max_total_points: int = 1_000_000,
|
||||
max_total_points: int = MAX_TOTAL_POINTS,
|
||||
frame_limit: int = 100_000) -> tuple[list[CanLogChannel], SdLogImport]:
|
||||
imported = parse_sd_log(lines, reference_timestamp, limit=frame_limit)
|
||||
channels = {}
|
||||
|
||||
@@ -8,16 +8,43 @@ from pathlib import Path
|
||||
from protocan import protocan as pc
|
||||
from .can_bridge import decode_frame
|
||||
|
||||
PROTOCOLS = (("auto", "Авто (ProtoCAN / Balsam)"),
|
||||
("devboard", "DevBoard_V1 / SETCAN"),
|
||||
("deif", "DEIF XDi-192 / CANopen"))
|
||||
PROTOCOLS = (("auto", "Авто (ProtoCAN / Balsam)"), # Автоматический разбор выбирает формат по кадру, не требуя ручного указания прибора.
|
||||
("devboard", "DevBoard_V1 / SETCAN"), # Формат DevBoard сохраняет самостоятельный пункт для существующих мониторов платы.
|
||||
("deif", "DEIF XDi-192 / CANopen"), # Прибор DEIF использует собственные правила CANopen и остаётся отдельным форматом.
|
||||
("protocan", "ProtoCAN / GAS"), # Общий ProtoCAN разбирает кадры без ручного назначения карты технологических слов УМП.
|
||||
("ump_balsam", "УМП / Balsam 167 (legacy CAN)"), # Старый Balsam имеет другой формат кадра и требует отдельного декодера.
|
||||
("ump_protocan", "УМП / ProtoCAN GAS")) # Единый формат УМП отделяет выбор карты адресов и номера устройства от протокола.
|
||||
|
||||
UMP_MAPS = (("gas167", "167/v1 (текущая)"), # Действующая карта использует слова 0…127 и окно 0x5000…0x507F из профиля прошивки 167/v1.
|
||||
("assigned", "Новые ID 5000/6000 (планируется)")) # Десятичные базы 5000 и 6000 назначены документацией, но ещё не реализованы прошивкой.
|
||||
|
||||
|
||||
def decode_protocol(frame, protocol="auto"):
|
||||
def decode_protocol(frame, protocol="auto", *, ump_map="gas167", ump_node=1): # Карта 167/v1 и УМП 1 служат исходным выбором единого формата; параметры карты и узла передаются по имени.
|
||||
if protocol == "deif":
|
||||
from .deif_xdi import decode
|
||||
return decode(frame)
|
||||
if protocol == "devboard":
|
||||
if protocol == "ump_balsam":
|
||||
from .balsam_can import decode
|
||||
result = decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
if result is not None:
|
||||
return result
|
||||
result = pc.Decoded(pc.ProtoCanId.parse(frame.can_id), len(frame.data),
|
||||
bytes(frame.data), ide=frame.ide, rtr=frame.rtr,
|
||||
protocol="BALZAM 167", message_label="Не Balsam 167")
|
||||
result.summary = "УМП / Balsam 167: CAN ID или формат кадра не соответствует протоколу"
|
||||
result.warnings.append(result.summary)
|
||||
return result
|
||||
if protocol in ("ump1_gas167", "ump2_gas167"):
|
||||
from .ump_can import decode_gas167
|
||||
return decode_gas167(frame, node=1 if protocol == "ump1_gas167" else 2)
|
||||
if protocol == "ump_protocan": # Новые элементы интерфейса используют единый формат, а старые ключи выше сохраняют прежний разбор.
|
||||
if ump_map == "gas167": # Действующие локальные адреса сами не определяют номер УМП, поэтому он задаётся отдельно.
|
||||
from .ump_can import decode_gas167 # Словарь 167/v1 подключается только при явном выборе этой карты.
|
||||
return decode_gas167(frame, node=ump_node) # Один декодер разбирает технологические слова обоих УМП с выбранной подписью устройства.
|
||||
if ump_map == "assigned": # Планируемая сквозная карта определяет УМП по десятичным диапазонам 5000…5127 и 6000…6127.
|
||||
return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr) # Общий ProtoCAN уже содержит имена назначенных ID и не требует ручного номера УМП.
|
||||
raise ValueError("Неизвестная карта адресов УМП: %s" % ump_map) # Ошибочный ключ карты не должен молча давать расшифровку другого профиля.
|
||||
if protocol in ("devboard", "protocan", "ump_gas"):
|
||||
return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
return decode_frame(frame)
|
||||
|
||||
@@ -44,6 +71,36 @@ def parse_filter(text):
|
||||
raise ValueError("Введите CAN ID в hex, например 701 или 0x701, 201") from None
|
||||
|
||||
|
||||
def parse_gas_filter(text): # GAS ID вводятся десятичными числами либо в HEX с явным префиксом 0x.
|
||||
if not text.strip(): # Пустое поле не добавляет ограничений к независимому фильтру CAN ID.
|
||||
return None # Значение None обозначает отсутствие условия, а не пустой набор разрешённых адресов.
|
||||
try: # Ошибки ввода превращаются в понятное сообщение общей панели монитора.
|
||||
parts = [part.strip() for part in text.replace(";", ",").split(",")] # Поддерживаем те же разделители списка, что и у существующего CAN-фильтра.
|
||||
addresses = {int(part, 16 if part.lower().startswith("0x") else 10) for part in parts} # Число 5000 означает назначенный десятичный GAS ID, а 0x5000 — адрес локального окна.
|
||||
if any(address < 0 or address > 0xFFFF for address in addresses): # Поле Body ProtoCAN содержит 16-битный адрес слова GAS в диапазоне 0…65535.
|
||||
raise ValueError() # Выход за пределы адресного пространства отклоняется вместе с остальными ошибками формата.
|
||||
return addresses # Повторяющиеся адреса объединяются без изменения логики отбора кадров.
|
||||
except ValueError: # Неполные, отрицательные и некорректно записанные значения не должны проходить фильтр молча.
|
||||
raise ValueError("Введите GAS ID: 5000, 5127 или 0x5000; диапазон 0…65535") from None # Сообщаем систему счисления и допустимые границы непосредственно рядом с фильтром.
|
||||
|
||||
|
||||
def matches_filters(identifier, data, extended, rtr=False, *, can_ids=None, gas_ids=None): # Оба условия объединяются по И, а значения внутри одного списка — по ИЛИ.
|
||||
if can_ids is not None and identifier not in can_ids: # Быстрый отбор по CAN ID выполняется до декодирования полезной нагрузки.
|
||||
return False # Несовпадение любого включённого ограничения исключает кадр.
|
||||
if gas_ids is None: # При выключенном или пустом GAS-фильтре сохраняется прежний отбор CAN-кадров всех форматов.
|
||||
return True # CAN ID уже проверен либо не ограничен.
|
||||
if not extended or rtr or len(data) > 8 or len(data) % 2: # GAS использует расширенный кадр данных с полными 16-битными словами и DLC не больше 8 байт.
|
||||
return False # STD, RTR и повреждённая нагрузка не обозначают достоверные GAS-регистры.
|
||||
if pc.ProtoCanId.parse(identifier).msg_type != pc.MsgType.GENERAL_ADDRESS_SPACE: # Совпадение младших битов ID другого типа сообщения не является совпадением адреса GAS.
|
||||
return False # Остальные протоколы остаются видимыми только при выключенном GAS-условии.
|
||||
decoded = pc.decode(identifier, data, ide=extended, rtr=rtr) # Общий декодер определяет все регистры кадра и отличает их от текстового GAS-отклика.
|
||||
if not data: # Пустой кадр данных GAS представляет запрос по начальному адресу Body.
|
||||
return decoded.id.body in gas_ids # Запрос учитывается только для указанного в нём адреса.
|
||||
if not decoded.registers or any(address > 0xFFFF for address, _value in decoded.registers): # Отклоняем служебный текст и пакет, выходящий за конец 16-битного пространства.
|
||||
return False # Такие кадры не содержат допустимого набора регистров для адресного фильтра.
|
||||
return any(address in gas_ids for address, _value in decoded.registers) # Совпадение второго, третьего или четвёртого слова выбирает весь исходный кадр.
|
||||
|
||||
|
||||
class CanSession:
|
||||
"""All frames spool to disk; retain recent frames and unique-frame totals.
|
||||
|
||||
@@ -74,6 +131,7 @@ class CanSession:
|
||||
self.unique_frames[key] = (row, previous[1] + 1 if previous else 1)
|
||||
if self.output is not None:
|
||||
csv.writer(self.output, delimiter=";").writerow(row)
|
||||
return key # Возвращаем ключ уникального кадра, чтобы интерфейс обновлял только поступившие записи без повторного обхода сессии.
|
||||
|
||||
def start(self, directory):
|
||||
directory = Path(directory) / datetime.now().strftime("session_%Y%m%d_%H%M%S_%f")
|
||||
|
||||
@@ -57,6 +57,29 @@ def decode_identity(words):
|
||||
raise ValueError("Некорректные данные версии: " + str(error)) from error
|
||||
|
||||
|
||||
def validate_image_device(meta, expected_product, expected_target="TMS320F28335"): # Сопоставляет назначение в паспорте с настройкой загрузчика без обращения к подключённой плате.
|
||||
product, target = meta.get('product', 'не указана'), meta.get('target', 'не указан') # Отсутствующее назначение тоже даёт понятный отказ, включая Flash RS, где KeyError не является пользовательской ошибкой.
|
||||
if product == expected_product and target == expected_target: # Совпадение обоих полей сохраняет прежнее разрешение на дальнейшие проверки BIN.
|
||||
return # Не заменяет проверку размера, SHA-256 и формата, которую вызывающий код выполняет отдельно.
|
||||
models = {'Balsam-166-PM35': 'Бальзам-166 ПМ35', 'Balsam-167-PM35': 'Бальзам-167 ПМ35'} # Человекочитаемые названия соответствуют двум моделям из списка «Модель платы».
|
||||
selected = models.get(str(expected_product), str(expected_product)) # Для других устройств сохраняет исходное обозначение, не выдавая неизвестную модель за 166 или 167.
|
||||
actual = models.get(str(product), str(product)) # Показывает назначение из паспорта; ошибочный тип поля тоже не мешает вывести причину отказа.
|
||||
differences = [] # Собирает только действительно различающиеся признаки, чтобы общий процессор не выглядел причиной отказа.
|
||||
if product != expected_product: # Различие моделей проверяется независимо от процессора, который может совпадать у разных плат.
|
||||
differences.append('модели платы') # Указывает на расхождение обозначений 166/167 даже при одинаковом TMS320F28335.
|
||||
if target != expected_target: # Отдельно выявляет несовместимый процессор без ошибочного обвинения модели платы.
|
||||
differences.append('процессора') # Дополняет причину вторым признаком, только если его значение отличается.
|
||||
raise ValueError( # Блокирует загрузку с подробным объяснением; метаданные не доказывают аппаратное назначение содержимого BIN.
|
||||
'Назначение прошивки не совпадает с выбранным устройством. Загрузка заблокирована.\n' # Сразу сообщает результат проверки и его влияние на загрузку.
|
||||
f'Выбрано в программе: {selected} ({expected_product}); процессор {expected_target}.\n' # Связывает ожидаемые значения с пользовательской настройкой, а не с результатом опроса платы.
|
||||
f'В паспорте файла: {actual} ({product}); процессор {target}.\n' # Показывает фактические значения полей паспорта рядом с ожидаемыми.
|
||||
f'Причина: несовпадение {" и ".join(differences)}. Модель подключённой платы этой проверкой не определялась.\n' # Разделяет установленное расхождение метаданных и неизвестное состояние оборудования.
|
||||
f'Для платы {selected} выберите BIN с паспортом для этой модели и процессора {expected_target}. ' # Предлагает комплект для нужной платы, сохраняя выбранную пользователем модель.
|
||||
f'Если эта прошивка собрана для {selected}, проверьте назначение в настройках сборки и заново сформируйте выпуск с паспортом. ' # Объясняет исправление ошибочной маркировки сборки без подмены значений готового паспорта.
|
||||
'Поле «Модель платы» меняйте, только если в программе выбрана не ваша плата.' # Не предлагает выбирать 167 ради обхода сообщения при реальной плате 166.
|
||||
) # Завершает единое сообщение, одинаковое для EEPROM BIN и Flash RS BIN.
|
||||
|
||||
|
||||
def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None,
|
||||
expected_product="Balsam-167-PM35", expected_target="TMS320F28335",
|
||||
max_image_size=MAX_IMAGE_SIZE):
|
||||
@@ -82,8 +105,7 @@ def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None
|
||||
if (meta['schema'] != 1 or meta['identity_contract'] != 1
|
||||
or meta['identity_register_count'] != REGISTER_COUNT):
|
||||
raise ValueError("Неизвестный формат паспорта")
|
||||
if meta['product'] != expected_product or meta['target'] != expected_target:
|
||||
raise ValueError(f"Паспорт предназначен для другого устройства (ожидается {expected_product} / {expected_target})")
|
||||
validate_image_device(meta, expected_product, expected_target) # При несовпадении показывает выбранные и паспортные модель/процессор с причиной отказа и способом исправления.
|
||||
if (type(meta['size_bytes']) is not int or meta['size_bytes'] != len(data)
|
||||
or meta['sha256'] != hashlib.sha256(data).hexdigest()):
|
||||
raise ValueError("BIN не соответствует паспорту: размер или SHA-256 не совпадает")
|
||||
|
||||
@@ -8,6 +8,7 @@ import io
|
||||
import math
|
||||
import json
|
||||
from datetime import datetime
|
||||
from functools import lru_cache # Таблица достижимых частот строится один раз для каждого аппаратного таймера MCU.
|
||||
from set_devices.waveform import Waveform, generate as interpolate, load_recipe as load_interpolated_recipe
|
||||
from set_devices.signal_reconstruction import dac12
|
||||
|
||||
@@ -15,13 +16,14 @@ from set_devices.signal_reconstruction import dac12
|
||||
# TIM6 clock of the supported STM32F407 emulator firmware. The device protocol
|
||||
# does not currently expose its timer clock; keep this hardware profile explicit.
|
||||
F407_TIMER_HZ = 72_000_000
|
||||
F407_SAMPLE_RATES = tuple(sorted({
|
||||
rate
|
||||
for divisor in range(1, math.isqrt(F407_TIMER_HZ) + 1)
|
||||
if F407_TIMER_HZ % divisor == 0
|
||||
for rate in (divisor, F407_TIMER_HZ // divisor)
|
||||
if 1 <= rate <= 1_000_000
|
||||
}))
|
||||
@lru_cache(maxsize=16) # Небольшой кэш хранит аппаратные профили без повторного перебора делителей при построении сигнала.
|
||||
def timer_sample_rates(timer_hz, sample_max=1_000_000): # Частоты задаются целочисленным делением TIM6 выбранного MCU, единицы обоих параметров — Гц.
|
||||
if timer_hz <= 0 or sample_max <= 0: # Нулевые и отрицательные аппаратные параметры не описывают рабочий таймер.
|
||||
raise ValueError("Частота таймера и предел выборок должны быть положительными") # Ошибка профиля выявляется до формирования таблицы DAC.
|
||||
return tuple(sorted({rate for divisor in range(1, math.isqrt(timer_hz) + 1) if timer_hz % divisor == 0 for rate in (divisor, timer_hz // divisor) if 1 <= rate <= sample_max})) # Парные делители охватывают все точные частоты без перебора каждого значения до тактовой частоты.
|
||||
|
||||
|
||||
F407_SAMPLE_RATES = timer_sample_rates(F407_TIMER_HZ) # Старый публичный экспорт сохраняет прежние результаты для профиля F407 72 МГц.
|
||||
|
||||
|
||||
def nearest_f407_sample_rate(rate):
|
||||
@@ -29,9 +31,9 @@ def nearest_f407_sample_rate(rate):
|
||||
return min(F407_SAMPLE_RATES, key=lambda candidate: (abs(candidate - rate), candidate))
|
||||
|
||||
|
||||
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
|
||||
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2, *, backend="native"): # Прозрачно передаём выбранный настольный решатель общему генератору.
|
||||
if method != "samples":
|
||||
return interpolate(points, sample_rate, vref, method, degree)
|
||||
return interpolate(points, sample_rate, vref, method, degree, backend=backend) # Расчёт остаётся в нативном порте; обработчик готовых отсчётов не требует МНК.
|
||||
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:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,28 +2,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from itertools import islice # Блоки по 1024 элемента ограничивают задержку отмены длинных измерений без изменения порядка суммирования.
|
||||
from set_devices.plot_ticks import tick_precision # Общая точность подписей позволяет различать малые деления времени на фоне большого абсолютного смещения.
|
||||
|
||||
|
||||
def time_label(milliseconds):
|
||||
def time_label(milliseconds, step=None): # Необязательный шаг в миллисекундах повышает точность только для подписей шкалы; остальные вызовы сохраняют прежний вид.
|
||||
"""Readable time without rounding nanosecond divisions to zero."""
|
||||
magnitude = abs(milliseconds)
|
||||
precision = tick_precision(milliseconds, step, 6) if step is not None else 6 # Шесть значащих цифр остаются стандартом; малый шаг требует дополнительных разрядов до точности double.
|
||||
if magnitude < .001:
|
||||
return f"{milliseconds * 1e6:.6g} нс"
|
||||
return f"{milliseconds * 1e6:.{precision}g} нс" # В миллисекунде 10^6 наносекунд; выбранная точность сохраняет различие соседних малых делений.
|
||||
if magnitude < 1:
|
||||
return f"{milliseconds * 1000:.6g} мкс"
|
||||
return f"{milliseconds * 1000:.{precision}g} мкс" # В миллисекунде 1000 микросекунд; шаг и координата масштабируются одинаково, поэтому число значащих цифр не меняется.
|
||||
if magnitude < 1000:
|
||||
return f"{milliseconds:.6g} мс"
|
||||
return f"{milliseconds / 1000:.6g} с"
|
||||
return f"{milliseconds:.{precision}g} мс" # Средние интервалы остаются в исходных миллисекундах с точностью, достаточной для текущего шага шкалы.
|
||||
return f"{milliseconds / 1000:.{precision}g} с" # Деление на 1000 переводит миллисекунды в секунды без потери нужных значащих цифр.
|
||||
|
||||
|
||||
def measurements(points):
|
||||
values = [value for _, value in points if math.isfinite(value)]
|
||||
def _measurement_items(items, check_cancelled): # Добавляем необязательную отмену к любому проходу статистики, сохраняя все исходные элементы и их порядок.
|
||||
if check_cancelled is None: # Обычные вызовы измерений сохраняют прежний контракт без дополнительных проверок отмены.
|
||||
yield from items # Передаём исходную последовательность без копирования всего ряда или численных преобразований.
|
||||
return # Не создаём блоки для синхронных измерений, которым не передано средство отмены.
|
||||
source = iter(items) # Один итератор обеспечивает последовательный обход без повторов на границах блоков.
|
||||
while True: # Продолжаем обработку до исчерпания исходной последовательности или исключения отмены.
|
||||
check_cancelled() # Новый жест навигации прерывает даже этап среднего, СКЗ или поиска экстремумов.
|
||||
block = tuple(islice(source, 1024)) # Не более 1024 элементов между проверками удерживают задержку отмены малой на многомиллионной записи.
|
||||
if not block: # Пустой блок означает, что все исходные отсчёты уже переданы численной операции.
|
||||
return # Последняя проверка отмены выполнена до завершения прохода.
|
||||
yield from block # Сохраняем порядок элементов для прежней точности math.fsum и поиска пересечений.
|
||||
|
||||
|
||||
def measurements(points, check_cancelled=None): # Необязательный обработчик отмены расширяет API, не меняя существующие вызовы с одним аргументом.
|
||||
values = [value for _, value in _measurement_items(points, check_cancelled) if math.isfinite(value)] # Проверяем отмену и при отборе конечных амплитуд, включая полностью нечисловой ряд.
|
||||
if not values:
|
||||
return None
|
||||
low, high = min(values), max(values)
|
||||
mean = math.fsum(value / len(values) for value in values)
|
||||
low = min(_measurement_items(values, check_cancelled)) # Точный минимум вычисляется по всем конечным значениям с ограниченной задержкой отмены.
|
||||
high = max(_measurement_items(values, check_cancelled)) # Точный максимум сохраняет прежнюю семантику, в том числе порядок равных значений.
|
||||
mean = math.fsum(value / len(values) for value in _measurement_items(values, check_cancelled)) # Отмена проверяется внутри суммирования; порядок и нормировка слагаемых остаются прежними.
|
||||
scale = max(abs(low), abs(high))
|
||||
rms = scale * math.sqrt(math.fsum((value / scale) ** 2 for value in values) / len(values)) if scale else 0.
|
||||
rms = scale * math.sqrt(math.fsum((value / scale) ** 2 for value in _measurement_items(values, check_cancelled)) / len(values)) if scale else 0. # Прерываем длинный расчёт СКЗ без потери прежней защиты от переполнения.
|
||||
# Hysteresis rejects small excursions around the midpoint. Frequency is
|
||||
# only reported after two complete rising crossings, never from one edge.
|
||||
middle = (low + high) / 2
|
||||
@@ -32,7 +49,7 @@ def measurements(points):
|
||||
crossings = []
|
||||
previous = None
|
||||
if high > low:
|
||||
for stamp, value in points:
|
||||
for stamp, value in _measurement_items(points, check_cancelled): # Проверяем отмену и при полном проходе поиска частоты, сохраняя исходную гистерезисную обработку пропусков.
|
||||
if value < middle - hysteresis:
|
||||
armed = True
|
||||
if armed and previous and previous[1] < middle <= value and stamp > previous[0]:
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
"""Pixel-column envelope for drawing; measurement samples are never changed."""
|
||||
import math
|
||||
from bisect import bisect_left, bisect_right
|
||||
from .background_work import work_items # Фоновая подготовка длинных временных массивов должна уступать GIL во время заполнения и проверки порядка.
|
||||
|
||||
|
||||
class PointTimes: # Представление координат X для bisect без параметра key, отсутствующего в Python 3.8 сборки Win7.
|
||||
def __init__(self, points): # Принимаем индексируемые пары (X, Y), порядок которых проверяется вызывающим кодом.
|
||||
self.points = points # Храним ссылку на исходные точки, чтобы не копировать большой ряд при каждом изменении окна.
|
||||
|
||||
def __len__(self): # Предоставляем bisect длину исходного ряда для определения границ двоичного поиска.
|
||||
return len(self.points) # Получаем размер за постоянное время без перебора измерений.
|
||||
|
||||
def __getitem__(self, index): # Извлекаем одну координату по индексу, выбранному алгоритмом двоичного поиска.
|
||||
return self.points[index][0] # В паре (X, Y) индекс 0 обозначает время или частоту; значения Y не участвуют в сравнении.
|
||||
|
||||
|
||||
class IndexedTimes(list):
|
||||
"""Random-access timestamps, with ordering validated once when loaded."""
|
||||
def __init__(self, points):
|
||||
super().__init__(points)
|
||||
self.ordered = all(math.isfinite(x) and (i == 0 or self[i-1] <= x)
|
||||
for i, x in enumerate(self))
|
||||
def __init__(self, points, checkpoint=None): # Необязательная контрольная точка включает кооперативную подготовку только для фоновых файловых заданий.
|
||||
super().__init__(work_items(points, checkpoint)) # Заполняем полный массив, регулярно освобождая GIL при наличии фонового бюджета.
|
||||
self.ordered = all(math.isfinite(x) and (i == 0 or self[i-1] <= x) # Конечность и порядок проверяются по всем исходным меткам без прореживания.
|
||||
for i, x in work_items(enumerate(self), checkpoint)) # Второй длинный проход также уступает интерфейсу и реагирует на отмену.
|
||||
|
||||
|
||||
def viewport_samples(timestamps, values, left, right, neighbours=False, budget=None):
|
||||
"""Select only the viewport in logarithmic time for indexed records."""
|
||||
if isinstance(timestamps, (list, tuple, range)) and getattr(timestamps, "ordered", True):
|
||||
if getattr(timestamps, "ordered", isinstance(timestamps, (list, tuple, range))): # Проверенные индексируемые представления со смещением X также допускают двоичный поиск без копирования меток времени.
|
||||
start = bisect_left(timestamps, left)
|
||||
stop = bisect_right(timestamps, right)
|
||||
if neighbours:
|
||||
@@ -37,6 +49,10 @@ def pixel_envelope(timestamps, values, left, right, width):
|
||||
and zooming retain lines crossing the viewport boundaries.
|
||||
"""
|
||||
width = max(1, int(width))
|
||||
index = getattr(values, "envelope_index", None) # Подготовленный файловый ряд содержит индекс точных экстремумов для быстрого выбора экранных столбцов.
|
||||
if index is not None and index.count == len(values) and getattr(timestamps, "ordered", isinstance(timestamps, (list, tuple, range))): # Индекс применим к неизменённым значениям и проверенным координатам, включая ленивое смещение короткой развёртки.
|
||||
yield from index.points(timestamps, left, right, width) # Выдаём не более четырёх конечных точек на пиксель плюс соседей для отсечения краёв.
|
||||
return # Исключаем повторное линейное сканирование миллионов измерений при каждом изменении масштаба.
|
||||
scale = width / (right - left) if right > left else 1.
|
||||
bucket = None
|
||||
first = low = high = last = None
|
||||
@@ -73,6 +89,61 @@ def pixel_envelope(timestamps, values, left, right, width):
|
||||
yield from emit()
|
||||
|
||||
|
||||
def render_envelope(timestamps, values, left, right, width): # Экранная огибающая ограничивает плотный упорядоченный ряд двумя экстремумами на логический пиксель, сохраняя исходные измерения отдельно.
|
||||
width = max(1, int(width)) # Вызывающий виджет передаёт логическую ширину области: увеличение DPI не должно умножать число почти совпадающих вершин.
|
||||
ordered = getattr(timestamps, 'ordered', isinstance(timestamps, (list, tuple, range))) # Двоичный поиск и экранные столбцы применимы только к проверенному порядку временных координат.
|
||||
if not ordered: # Неупорядоченный поток нельзя безопасно переставлять по экранным столбцам или обрезать двоичным поиском.
|
||||
yield from pixel_envelope(timestamps, values, left, right, width) # Сохраняем прежнюю последовательность и разрывы для нестандартного источника без предположения об упорядоченности.
|
||||
return # Новый ограниченный экранный режим не меняет семантику неупорядоченных данных.
|
||||
count = min(len(timestamps), len(values)) # Общая длина исключает обращения за границу при несовпадающих размерах координат и амплитуд.
|
||||
start, stop = bisect_left(timestamps, left, 0, count), bisect_right(timestamps, right, 0, count) # Находим точное число исходных отсчётов видимого окна без полного обхода файла.
|
||||
if stop - start <= width: # При сильном увеличении все измерения помещаются в экранный бюджет и не требуют никакого сокращения.
|
||||
for position in range(max(0, start - 1), min(count, stop + 1)): # Сохраняем по одному соседу для линий, пересекающих края видимого окна.
|
||||
stamp, value = timestamps[position], values[position] # Извлекаем точную исходную пару, включая нечисловые амплитуды между конечными отсчётами.
|
||||
yield (stamp, value) if math.isfinite(stamp) and math.isfinite(value) else None # Все конечные точки остаются без изменений, а NaN и бесконечности разрывают путь.
|
||||
return # Разреженный зум не должен проходить через экстремумы столбцов и терять близкие исходные точки.
|
||||
index = getattr(values, 'envelope_index', None) # Подготовленный файловый ряд позволяет выбирать экстремумы без повторного линейного обхода миллионов значений.
|
||||
if index is not None and index.count == len(values): # Индекс допустим только для исходной неизменённой длины ряда.
|
||||
yield from index.points(timestamps, left, right, width, extrema_only=True) # Сохраняем максимум два экстремума на столбец, два конца окна и два внешних соседа.
|
||||
return # Быстрый запрос не должен дополнительно запускать линейный запасной путь.
|
||||
columns = {} # Для неиндексированного ряда храним лишь пару экстремумов на экранный столбец, а не полную запись.
|
||||
first_visible = last_visible = None # Дополнительно сохраняем два конца видимой линии для правильного соединения с внешними соседями.
|
||||
segment = 0 # Каждый нечисловой отсчёт увеличивает номер непрерывного участка; разные номера запрещают соединять выбранные точки.
|
||||
seen = False # Полностью нечисловое окно должно вернуть один разрыв, а пустая запись не создаёт искусственных точек.
|
||||
scale = width / (right - left) if right > left else 1. # Масштаб переводит время в столбцы; совпадающие временные координаты не допускают деления на ноль.
|
||||
for ordinal, (stamp, value) in enumerate(viewport_samples(timestamps, values, left, right, neighbours=True)): # Запасной путь читает каждый исходный отсчёт окна ровно один раз и поэтому также сохраняет одиночные пики.
|
||||
seen = True # Фиксируем наличие хотя бы одного конечного либо нечислового исходного измерения.
|
||||
if not math.isfinite(stamp) or not math.isfinite(value): # Пропуски не участвуют в экстремумах, но должны разделять любые выбранные точки по разные стороны.
|
||||
segment += 1 # Номер участка переносит информацию о пропуске без хранения каждого маркера плотного ряда.
|
||||
continue # Нечисловая амплитуда не имеет экранной координаты кривой.
|
||||
column = -1 if stamp < left else width if stamp > right else min(width - 1, int((stamp - left) * scale)) # Соседи вынесены в два отдельных столбца за пределами видимой области.
|
||||
point = (ordinal, stamp, value, segment) # Исходный порядок и номер участка позволяют вернуть экстремумы без ложных соединений через NaN.
|
||||
pair = columns.get(column) # В каждом экранном столбце сохраняются только точные минимум и максимум.
|
||||
if pair is None: # Первый конечный отсчёт одновременно задаёт обе роли нового столбца.
|
||||
columns[column] = [point, point] # Два совпадающих индекса позднее объединяются в одну вершину.
|
||||
else: # Дальнейшие измерения меняют только действительно достигнутые экстремумы.
|
||||
if value < pair[0][2]: # Строгое сравнение сохраняет первое вхождение равного минимума.
|
||||
pair[0] = point # Одиночный отрицательный выброс остаётся в экранной огибающей.
|
||||
if value > pair[1][2]: # Строгое сравнение сохраняет первое вхождение равного максимума.
|
||||
pair[1] = point # Одиночный положительный выброс также остаётся видимым при любом сжатии.
|
||||
if left <= stamp <= right: # Внешние соседи не подменяют начало и конец собственно видимых измерений.
|
||||
first_visible = point if first_visible is None else first_visible # Первый конечный видимый отсчёт сохраняется один раз независимо от числа столбцов.
|
||||
last_visible = point # Последний конечный видимый отсчёт обновляется до правого края окна.
|
||||
selected = {point[0]: point for pair in columns.values() for point in pair} # Уникальные исходные индексы устраняют совпадение ролей без изменения времени или амплитуды.
|
||||
for point in (first_visible, last_visible): # Всего две дополнительные конечные точки сохраняют соединения на краях видимого окна.
|
||||
if point is not None: # Полностью нечисловое окно не имеет конечных краевых измерений.
|
||||
selected[point[0]] = point # Повторное добавление совпавшего экстремума не увеличивает экранный бюджет.
|
||||
previous_segment = 0 # Нулевой участок соответствует началу исходной последовательности до первого NaN.
|
||||
for ordinal in sorted(selected): # Возвращаем выбранные точки в исходном порядке, включая совпадающие метки времени.
|
||||
_, stamp, value, current_segment = selected[ordinal] # Экранная вершина сохраняет исходные координаты и номер непрерывного участка.
|
||||
if current_segment != previous_segment: # Различные номера подтверждают хотя бы один NaN между выбранными конечными отсчётами.
|
||||
yield None # Маркер разрыва запрещает рисовать линию через отсутствующие измерения.
|
||||
yield stamp, value # Амплитуда и время возвращаются точно, без усреднения и сдвига коротких пиков.
|
||||
previous_segment = current_segment # Следующая вершина сравнивается с участком предыдущего выбранного измерения.
|
||||
if seen and (not selected or previous_segment != segment): # Сохраняем полностью нечисловое окно и разрыв после последней конечной точки.
|
||||
yield None # Длинная последовательность хвостовых пропусков ограничивается одним экранным маркером.
|
||||
|
||||
|
||||
def preview_window_count(requested, preview_count, original_count):
|
||||
"""Translate a raw-sample window to a display-envelope window."""
|
||||
if not original_count or original_count <= preview_count:
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import math # Проверяем конечность измерений, чтобы пропуски не соединялись линиями и не участвовали в экстремумах.
|
||||
from array import array # Храним индексы в компактных массивах вместо отдельных объектов Python для каждого узла дерева.
|
||||
from bisect import bisect_left, bisect_right # Находим границы пиксельных столбцов двоичным поиском по упорядоченным меткам времени.
|
||||
|
||||
|
||||
class EnvelopeIndex: # Индекс неизменяемого ряда позволяет выбирать точные экстремумы без повторного обхода миллионов отсчётов.
|
||||
BLOCK_SIZE = 32 # Лист дерева содержит 32 отсчёта: крайние неполные блоки малы, а индекс занимает около 2,5 байта на отсчёт.
|
||||
EMPTY = (-1, -1, -1, -1, 0) # Первые четыре поля обозначают индексы первого, минимального, максимального и последнего отсчёта; пятое считает пропуски.
|
||||
|
||||
def __init__(self, values, checkpoint=None): # Необязательная контрольная точка сохраняет отзывчивость Qt при первоначальном построении большого индекса.
|
||||
self.values = values # Сохраняем исходные значения по ссылке; сам индекс не сокращает и не изменяет измерения.
|
||||
self.count = len(values) # Запоминаем размер снимка для проверки пригодности индекса после очистки графика.
|
||||
blocks = (self.count + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Округление вверх включает неполный последний блок записи.
|
||||
self.leaves = 1 # Для обычного двоичного дерева число листьев должно быть степенью двух.
|
||||
while self.leaves < blocks: # Добавляем пустые листья только до ближайшей степени двух, не удваивая размер без необходимости.
|
||||
self.leaves *= 2 # Два дочерних узла соответствуют объединённому диапазону родителя.
|
||||
self.nodes = tuple(array('q', [field]) * (2 * self.leaves) for field in self.EMPTY) # Пять знаковых 64-битных массивов содержат индексы, включая признак отсутствия -1.
|
||||
for block in range(blocks): # Полный обход исходных значений нужен только при первоначальном построении индекса.
|
||||
if checkpoint is not None and block % 32 == 0: # Тридцать два листа по 32 отсчёта дают контроль примерно через 1024 исходных значения.
|
||||
checkpoint() # Отпускаем GIL и проверяем отмену между ограниченными порциями подготовки экстремумов.
|
||||
start = block * self.BLOCK_SIZE # Переводим номер блока в начальный индекс исходного отсчёта.
|
||||
self._store(self.leaves + block, self._scan(start, min(self.count, start + self.BLOCK_SIZE))) # Сводка листа сохраняет точные экстремумы и число нечисловых отсчётов.
|
||||
for node in range(self.leaves - 1, 0, -1): # Строим родителей снизу вверх, когда сводки обоих детей уже готовы.
|
||||
if checkpoint is not None and node % 1024 == 0: # Объединение большого дерева также делится на порции по 1024 узла.
|
||||
checkpoint() # Не допускаем непрерывного удержания GIL после завершения чтения исходных измерений.
|
||||
self._store(node, self._merge(self._load(2 * node), self._load(2 * node + 1))) # Родитель объединяет соседние диапазоны с сохранением временного порядка.
|
||||
|
||||
def _store(self, node, summary): # Записываем одну сводку в параллельные компактные массивы дерева.
|
||||
for column, field in zip(self.nodes, summary): # Каждое поле имеет одинаковый номер узла во всех пяти массивах.
|
||||
column[node] = field # Индексы ссылаются на исходный ряд, поэтому значения Y не дублируются в памяти.
|
||||
|
||||
def _load(self, node): # Читаем сводку готового узла без обращения ко всем его исходным отсчётам.
|
||||
first, low, high, last, gaps = self.nodes # Разбираем пять массивов один раз, исключая создание генератора при каждом посещении узла дерева.
|
||||
return first[node], low[node], high[node], last[node], gaps[node] # Возвращаем те же пять полей напрямую; тысячи запросов кадра не создают промежуточных итераторов.
|
||||
|
||||
def _scan(self, start, stop): # Сканируем полуинтервал только при построении листа либо на неполных краях запроса.
|
||||
first = low = high = last = -1 # Отрицательный индекс означает, что конечное значение ещё не найдено.
|
||||
gaps = 0 # Подсчёт пропусков позволяет проверять разрыв линии между выбранными экстремумами.
|
||||
values, isfinite = self.values, math.isfinite # Локальные ссылки исключают повторный поиск атрибутов для каждого отсчёта неполного края блока.
|
||||
low_value = high_value = 0. # Значения обоих экстремумов устанавливаются первым конечным отсчётом; ноль не участвует в сравнении до этого момента.
|
||||
for index in range(start, stop): # Полуинтервал исключает stop и совпадает с правилами срезов Python.
|
||||
value = values[index] # Читаем исходное измерение ровно один раз, сохраняя значение для обоих сравнений экстремумов.
|
||||
if not isfinite(value): # NaN и бесконечности должны разрывать путь, а не менять масштаб или экстремумы.
|
||||
gaps += 1 # Учитываем пропуск даже в диапазоне, где совсем нет конечных значений.
|
||||
continue # Исключаем нечисловой отсчёт из поиска первого, последнего, минимального и максимального значений.
|
||||
if first < 0: # Первый конечный отсчёт одновременно задаёт оба начальных экстремума.
|
||||
first = low = high = index # Все три индекса указывают на одно измерение до появления следующих точек.
|
||||
low_value = high_value = value # Сохраняем амплитуды начальных экстремумов, чтобы следующие отсчёты не читали их повторно из большого ряда.
|
||||
else: # Для остальных отсчётов обновляем только реально изменившиеся экстремумы.
|
||||
if value < low_value: # Строгое сравнение с сохранённой амплитудой сохраняет первый по времени минимум при равных значениях.
|
||||
low = index # Запоминаем точный индекс минимального исходного значения.
|
||||
low_value = value # Обновляем амплитуду вместе с индексом, исключая отдельное чтение минимума на следующей итерации.
|
||||
if value > high_value: # Первый по времени максимум также остаётся стабильным при равенстве значений.
|
||||
high = index # Запоминаем точный индекс максимального исходного значения.
|
||||
high_value = value # Сохраняем амплитуду нового максимума для дальнейших сравнений без обращения к исходному контейнеру.
|
||||
last = index # Последний конечный отсчёт нужен для правильного соединения соседних пиксельных столбцов.
|
||||
return first, low, high, last, gaps # Сводка содержит только индексы и счётчик; измерения остаются в исходном ряду.
|
||||
|
||||
def _merge(self, before, after): # Объединяем сводки соседних диапазонов, расположенных в исходном временном порядке.
|
||||
gaps = before[4] + after[4] # Число пропусков аддитивно и не зависит от расположения экстремумов.
|
||||
if before[0] < 0: # Левый диапазон может быть пустым либо полностью состоять из пропусков.
|
||||
return (*after[:4], gaps) # Сохраняем конечные точки правого диапазона и пропуски обоих диапазонов.
|
||||
if after[0] < 0: # Пустой правый диапазон не меняет конечные точки левого диапазона.
|
||||
return (*before[:4], gaps) # Добавляем только число пропусков справа.
|
||||
low = after[1] if self.values[after[1]] < self.values[before[1]] else before[1] # При равных минимумах сохраняем более ранний отсчёт.
|
||||
high = after[2] if self.values[after[2]] > self.values[before[2]] else before[2] # При равных максимумах также сохраняем более ранний отсчёт.
|
||||
return before[0], low, high, after[3], gaps # Крайние точки берём из внешних границ объединённого диапазона.
|
||||
|
||||
def between(self, start, stop): # Возвращаем точную сводку произвольного диапазона индексов без полного сканирования ряда.
|
||||
start, stop = max(0, start), min(self.count, stop) # Ограничиваем запрос существующими измерениями снимка.
|
||||
if stop <= start: # Пустое или обратное окно не содержит ни точек, ни пропусков.
|
||||
return self.EMPTY # Единый пустой результат упрощает объединение диапазонов на границах записи.
|
||||
if not self.nodes[4][1] and isinstance(self.values, (list, tuple, array)): # Нулевой счётчик пропусков корня доказывает конечность всего неизменяемого ряда; обычные контейнеры допускают короткие срезы краёв.
|
||||
return self._finite_between(start, stop) # Плотные записи без NaN обходятся без пяти-польных сводок и проверок конечности на каждом кадре.
|
||||
first_block = (start + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Находим первый полностью включённый блок после левого неполного края.
|
||||
last_block = stop // self.BLOCK_SIZE # Этот блок уже не входит целиком в запрос и задаёт начало правого края.
|
||||
if first_block >= last_block: # Короткий запрос дешевле прочитать напрямую, чем обходить дерево.
|
||||
return self._scan(start, stop) # Здесь читается не более двух неполных блоков исходных отсчётов.
|
||||
before = self._scan(start, first_block * self.BLOCK_SIZE) # Получаем сводку левого неполного края, максимум из 31 отсчёта.
|
||||
after = self._scan(last_block * self.BLOCK_SIZE, stop) # Получаем сводку правого неполного края, также максимум из 31 отсчёта.
|
||||
left, right = self.leaves + first_block, self.leaves + last_block # Переводим границы полных блоков в номера листьев дерева.
|
||||
while left < right: # За логарифмическое число шагов выбираем узлы, полностью покрывающие внутренний диапазон.
|
||||
if left % 2: # Правый ребёнок слева включается отдельно, чтобы не захватить отсчёты перед запросом.
|
||||
before = self._merge(before, self._load(left)) # Добавляем очередной диапазон после уже выбранной левой части.
|
||||
left += 1 # Переходим к следующему соседнему узлу перед подъёмом к родителю.
|
||||
if right % 2: # Правая исключённая граница позволяет включить её левого соседа отдельно.
|
||||
right -= 1 # Перемещаем границу к последнему целиком включённому узлу текущего уровня.
|
||||
after = self._merge(self._load(right), after) # Добавляем узел перед уже выбранной правой частью, сохраняя порядок.
|
||||
left //= 2 # Поднимаем левую границу на уровень объединённых диапазонов.
|
||||
right //= 2 # Правая граница поднимается на тот же уровень дерева.
|
||||
return self._merge(before, after) # Соединяем выбранные части в одну точную сводку запрошенного полуинтервала.
|
||||
|
||||
def _finite_between(self, start, stop): # Быстрый запрос применяется только после проверки отсутствия NaN и бесконечностей во всём исходном ряду.
|
||||
values = self.values # Сохраняем локальную ссылку на полные исходные амплитуды, не создавая их копию.
|
||||
first_block = (start + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Левый неполный край заканчивается перед первым целиком включённым блоком.
|
||||
last_block = stop // self.BLOCK_SIZE # Последний целый блок предшествует правому неполному краю запроса.
|
||||
if first_block >= last_block: # Короткий диапазон занимает не более двух блоков по 32 отсчёта и не требует обхода дерева.
|
||||
chunk = values[start:stop] # Временный срез содержит максимум 63 исходных амплитуды, независимо от полного размера лога.
|
||||
return start, start + chunk.index(min(chunk)), start + chunk.index(max(chunk)), stop - 1, 0 # Встроенные операции находят точные первые экстремумы на C; первый, последний и нулевые пропуски уже известны.
|
||||
low = high = start # Любой непустой конечный диапазон имеет допустимое начальное измерение на левой границе.
|
||||
low_value = high_value = values[start] # Амплитуды экстремумов храним локально, чтобы не перечитывать их из большого массива после каждого узла.
|
||||
for begin, end in ((start, first_block * self.BLOCK_SIZE), (last_block * self.BLOCK_SIZE, stop)): # Сканируем только два неполных края, каждый не длиннее 31 исходного отсчёта.
|
||||
if begin < end: # Совпадение с границей блока исключает создание пустого среза и вызов min/max без элементов.
|
||||
chunk = values[begin:end] # Короткий временный срез позволяет выполнить поиск экстремумов встроенными операциями C.
|
||||
minimum, maximum = min(chunk), max(chunk) # Исходные конечные амплитуды сравниваются без повторной проверки math.isfinite.
|
||||
if minimum < low_value: # Края идут слева направо, поэтому при равенстве оставляем уже найденный более ранний минимум.
|
||||
low, low_value = begin + chunk.index(minimum), minimum # index возвращает первое вхождение точного минимума внутри неполного края.
|
||||
if maximum > high_value: # При равенстве максимумов также сохраняем более раннее измерение.
|
||||
high, high_value = begin + chunk.index(maximum), maximum # Индекс исходной амплитуды вычисляется смещением от начала короткого среза.
|
||||
lows, highs = self.nodes[1], self.nodes[2] # Для конечного ряда нужны только индексы минимумов и максимумов; остальные три массива не читаются.
|
||||
left, right = self.leaves + first_block, self.leaves + last_block # Переводим полуинтервал целых блоков в номера листьев компактного дерева.
|
||||
while left < right: # Как и общий алгоритм, выбираем лишь логарифмическое число готовых сводок диапазона.
|
||||
if left % 2: # Отдельный правый ребёнок слева входит в запрос целиком, а его сосед остаётся за границей.
|
||||
candidate = lows[left] # Готовый индекс минимума узла ссылается непосредственно на исходный ряд.
|
||||
value = values[candidate] # Читаем только амплитуду найденного в узле минимума.
|
||||
if value < low_value or (value == low_value and candidate < low): # Узлы посещаются не строго слева направо, поэтому равные значения сравниваем и по исходному индексу.
|
||||
low, low_value = candidate, value # Обновляем точное первое вхождение минимума без создания промежуточной сводки.
|
||||
candidate = highs[left] # Второй готовый индекс узла соответствует точному исходному максимуму.
|
||||
value = values[candidate] # Амплитуда максимума также читается один раз на выбранный узел.
|
||||
if value > high_value or (value == high_value and candidate < high): # Равные максимумы должны сохранять прежний выбор самого раннего отсчёта.
|
||||
high, high_value = candidate, value # Обновляем максимум и его амплитуду вместе для следующего сравнения.
|
||||
left += 1 # Исключаем уже обработанный узел перед подъёмом обеих границ на следующий уровень.
|
||||
if right % 2: # Левый сосед исключённой правой границы входит в диапазон полностью.
|
||||
right -= 1 # Получаем номер последнего целиком включённого узла текущего уровня дерева.
|
||||
candidate = lows[right] # Читаем индекс готового минимума правого узла без распаковки пяти полей сводки.
|
||||
value = values[candidate] # Только выбранный минимум требует обращения к исходной амплитуде.
|
||||
if value < low_value or (value == low_value and candidate < low): # Порядок обхода не влияет на первый по времени минимум при равенстве амплитуд.
|
||||
low, low_value = candidate, value # Сохраняем лучший исходный минимум для объединённого запроса.
|
||||
candidate = highs[right] # Индекс максимума берём из второго массива готовых экстремумов.
|
||||
value = values[candidate] # Читаем точную амплитуду исходного максимума правого узла.
|
||||
if value > high_value or (value == high_value and candidate < high): # При одинаковых амплитудах выбираем наименьший исходный индекс.
|
||||
high, high_value = candidate, value # Объединяем максимум напрямую, исключая вызовы _load и _merge на каждом узле.
|
||||
left //= 2 # Переходим от листьев или текущих узлов к покрывающим их родителям.
|
||||
right //= 2 # Правая исключённая граница поднимается на тот же уровень дерева.
|
||||
return start, low, high, stop - 1, 0 # Все точки конечны, поэтому внешние отсчёты и отсутствие пропусков не требуют отдельного вычисления.
|
||||
|
||||
def points(self, timestamps, left, right, width, *, extrema_only=False): # Обычная огибающая сохраняет четыре роли на столбец, а отдельный экранный режим оставляет только точные минимум и максимум.
|
||||
count = min(len(timestamps), self.count) # Общий размер защищает от несовпадающих длин координат и значений.
|
||||
start, stop = bisect_left(timestamps, left, 0, count), bisect_right(timestamps, right, 0, count) # Оба края видимого временного окна включаются полностью.
|
||||
ranges = [] # Список содержит только диапазоны пикселей и по одному соседнему отсчёту для отсечения на краях.
|
||||
if start: # Сосед слева сохраняет линию, пересекающую левую границу без отсчёта непосредственно внутри окна.
|
||||
ranges.append((start - 1, start)) # Только один внешний отсчёт нужен для правильного геометрического отсечения.
|
||||
if right <= left: # Совпадающие границы X могут содержать много отсчётов с одной меткой времени, но не допускают деления на ширину окна.
|
||||
if stop > start: # Пустое окно не создаёт искусственный диапазон измерений.
|
||||
ranges.append((start, stop)) # Экстремумы всех совпадающих меток времени ограничены четырьмя точками одного столбца.
|
||||
else: # Любое окно делим по экранным координатам, чтобы неравномерные и повторяющиеся метки также соблюдали бюджет на столбец.
|
||||
edge = start # Первый пиксель начинается с первого отсчёта внутри видимого окна.
|
||||
for column in range(width): # Число итераций зависит только от ширины изображения в физических пикселях.
|
||||
boundary = left + (right - left) * ((column + 1) / width) # Правая граница столбца задана в единицах исходной оси X.
|
||||
end = stop if column == width - 1 else bisect_left(timestamps, boundary, edge, stop) # Последний столбец включает отсчёты точно на правой границе окна.
|
||||
if end > edge: # Пустые пиксели не создают искусственных измерений.
|
||||
ranges.append((edge, end)) # Запоминаем исходный диапазон для выбора первого, минимального, максимального и последнего отсчётов.
|
||||
edge = end # Следующий столбец продолжает ряд без потерь и повторного включения границы.
|
||||
if stop < count: # Сосед справа сохраняет линию, пересекающую правую границу окна.
|
||||
ranges.append((stop, stop + 1)) # Внешний сосед добавляет не более одной конечной точки к пиксельному бюджету.
|
||||
has_gaps = bool(self.nodes[4][1]) # Нулевой счётчик корня доказывает отсутствие пропусков во всём ряду и исключает лишние запросы между компактными экстремумами.
|
||||
previous = None # Индекс предыдущей выбранной конечной точки нужен для проверки разрыва между столбцами.
|
||||
for begin, end in ranges: # Обрабатываем только экранные диапазоны и двух возможных внешних соседей.
|
||||
summary = self.between(begin, end) # Получаем точные экстремумы через индекс, читая лишь неполные края блоков.
|
||||
roles = summary[1:3] if extrema_only else summary[:4] # Новый экранный режим уменьшает число вершин, не изменяя исходные экстремумы или прежний публичный результат.
|
||||
if extrema_only and begin == start: # Первый видимый столбец сохраняет также начало линии, чтобы она правильно пересекала левый край окна.
|
||||
roles += (summary[0],) # Дополнительная роль нужна только одному столбцу, а не каждому пикселю.
|
||||
if extrema_only and end == stop: # Последний видимый столбец сохраняет конец линии для правильного пересечения правого края.
|
||||
roles += (summary[3],) # Два внешних конца увеличивают общий бюджет только на две конечные точки.
|
||||
selected = sorted({index for index in roles if index >= 0}) # Убираем совпадающие роли и восстанавливаем исходный временной порядок выбранных экстремумов.
|
||||
if not selected: # Полностью нечисловой диапазон разрывает линию один раз независимо от числа NaN внутри пикселя.
|
||||
yield None # Маркер разрыва не является измерением и обрабатывается обоими движками отрисовки.
|
||||
previous = None # Следующая конечная точка начинает новый путь.
|
||||
continue # В пустом пикселе нет конечных экстремумов для отрисовки.
|
||||
for index in selected: # На один непустой пиксель приходится не более четырёх исходных измерений.
|
||||
if has_gaps and previous is not None and previous + 1 < index and (summary[4] or previous < begin) and self.between(previous + 1, index)[4]: # Проверяем разрывы только при наличии NaN в записи; соседние индексы и доказанно конечные ряды не требуют дополнительных запросов дерева.
|
||||
yield None # Разрыв сохраняет пропуски между выбранными экстремумами без повторного запроса того же диапазона дерева.
|
||||
yield timestamps[index], self.values[index] # Возвращаем исходные координаты и значения без усреднения или изменения амплитуды.
|
||||
previous = index # Запоминаем конец текущего пути для следующей выбранной точки.
|
||||
@@ -9,9 +9,34 @@ are separate objects, never channels in the acquisition or measurement model.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
from bisect import bisect_left, bisect_right
|
||||
from .plot_decimation import PointTimes # Используем координаты X без копирования ряда и без недоступного в Python 3.8 параметра key.
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from .signal_reconstruction import METHODS, reconstruct
|
||||
from .signal_reconstruction import BACKENDS, METHODS, reconstruct # Общие подписи решателей используются в легенде и CSV, чтобы различать результаты сравнения.
|
||||
|
||||
|
||||
class FrozenPoints(tuple):
|
||||
"""Normalized immutable samples, reusable across processing requests."""
|
||||
|
||||
def __new__(cls, points):
|
||||
if isinstance(points, cls):
|
||||
return points
|
||||
result = super().__new__(cls, ((float(x), float(y)) for x, y in points))
|
||||
result.ordered = all(result[i - 1][0] <= result[i][0] for i in range(1, len(result)))
|
||||
return result
|
||||
|
||||
def between(self, left, right):
|
||||
if self.ordered:
|
||||
timestamps = PointTimes(self) # Создаём индексируемое представление X, сохраняя логарифмическую сложность поиска границ.
|
||||
start = bisect_left(timestamps, left) # Начинаем с первой точки X >= left, включая все повторы левой границы.
|
||||
stop = bisect_right(timestamps, right) # Заканчиваем перед первой точкой X > right, включая все повторы правой границы.
|
||||
if start == 0 and stop == len(self):
|
||||
return self
|
||||
result = tuple.__new__(type(self), self[start:stop])
|
||||
result.ordered = True
|
||||
return result
|
||||
return FrozenPoints((x, y) for x, y in self if left <= x <= right)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -36,7 +61,7 @@ class Series:
|
||||
|
||||
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))
|
||||
object.__setattr__(self, "points", FrozenPoints(self.points))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -71,6 +96,7 @@ class Request:
|
||||
output_count: int
|
||||
degree: int
|
||||
x_range: tuple | None
|
||||
backend: str = "native" # Вариант входит в равенство запросов, поэтому две расчётные кривые могут сосуществовать.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -81,6 +107,7 @@ class Curve:
|
||||
unique_count: int
|
||||
rmse: float
|
||||
pieces: tuple = ()
|
||||
elapsed_ms: float = 0.0 # Сохраняем полное время расчёта отдельно от геометрии и оценки точности.
|
||||
|
||||
def piece_at(self, x):
|
||||
for piece in self.pieces:
|
||||
@@ -90,10 +117,11 @@ class Curve:
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return f"{self.request.series.label} · расчёт ({METHODS[self.request.method]})"
|
||||
variant = " · " + BACKENDS[self.request.backend] if self.request.method == "polynomial" else "" # Дополняем название только у метода, имеющего альтернативный решатель.
|
||||
return f"{self.request.series.label} · расчёт ({METHODS[self.request.method]}{variant})" # Подпись однозначно связывает слой графика и экспорт CSV с вариантом МНК.
|
||||
|
||||
|
||||
def prepare(snapshot, key, method="pchip", output_count=1000, degree=2):
|
||||
def prepare(snapshot, key, method="pchip", output_count=1000, degree=2, backend="native"): # Новый последний параметр не меняет существующие вызовы подготовки снимка.
|
||||
"""Return a comparable request, or None when the source is not processable."""
|
||||
if snapshot.blocked_reason:
|
||||
return None
|
||||
@@ -103,16 +131,16 @@ def prepare(snapshot, key, method="pchip", output_count=1000, degree=2):
|
||||
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),
|
||||
selected.points.between(left, right),
|
||||
y_unit=selected.y_unit)
|
||||
return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range)
|
||||
return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range, backend) # Включаем выбранный решатель в идентичность запроса и проверку устаревших результатов.
|
||||
|
||||
|
||||
def process(request):
|
||||
if request is None:
|
||||
raise ValueError("Нет доступного аналогового канала")
|
||||
result = reconstruct(request.series.points, request.method, request.output_count, request.degree, with_model=True)
|
||||
return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse, result.pieces)
|
||||
result = reconstruct(request.series.points, request.method, request.output_count, request.degree, with_model=True, backend=request.backend) # Передаём явный выбор пользователя в общий тонкий порт без численных вычислений в UI.
|
||||
return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse, result.pieces, result.elapsed_ms) # Результат сохраняет время и метрики, относящиеся к одному конкретному запуску.
|
||||
|
||||
|
||||
def write_csv(curve, stream):
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Точность подписей шкал при приближении до небольших долей исходного диапазона.""" # Форматирование подписей отделено от расчёта координат и не требует Qt.
|
||||
import math # Логарифмы определяют число значащих цифр, необходимое для различения соседних делений.
|
||||
|
||||
|
||||
def tick_precision(value, step, minimum=4): # Значение и шаг задаются в одинаковых единицах; минимум сохраняет обычный вид подписей при крупном масштабе.
|
||||
if not math.isfinite(value) or not math.isfinite(step) or value == 0 or step <= 0: # Для нуля и непригодного шага порядок числа не определён, поэтому используем исходную точность.
|
||||
return minimum # Без вычисления логарифма сохраняем безопасное форматирование специальных значений.
|
||||
value_order = math.floor(math.log10(abs(value))) # Порядок абсолютной координаты учитывает значащие цифры перед десятичной точкой и большие смещения оси.
|
||||
step_order = math.floor(math.log10(step)) # Порядок положительного шага показывает, до какого десятичного разряда нужно различать деления.
|
||||
return min(16, max(minimum, value_order - step_order + 1)) # Ограничение 16 цифрами соответствует полезной точности double, а разность порядков сохраняет отдельную подпись для каждого деления.
|
||||
@@ -2,27 +2,40 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtCore import QTimer, Signal # Таймер обновляет буфер, а общий сигнал сообщает владельцу об изменении любых настроек расшифровки.
|
||||
from PySide6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout, QPushButton,
|
||||
QCheckBox, QComboBox, QLabel, QLineEdit, QTableWidget, QTableWidgetItem,
|
||||
QMessageBox, QApplication, QAbstractItemView, QFileDialog)
|
||||
from ..can_transport import build_frame
|
||||
from ..can_monitor import CanSession, PROTOCOLS, parse_filter, format_data, decode_protocol
|
||||
from ..can_monitor import CanSession, PROTOCOLS, UMP_MAPS, parse_filter, parse_gas_filter, matches_filters, format_data, decode_protocol # Общие декодер, карты и независимые фильтры обеспечивают одинаковое поведение всех CAN-мониторов.
|
||||
|
||||
|
||||
class MonitorControls(QWidget):
|
||||
decoding_changed = Signal() # Владельцы обновляют онлайн и трассировку при смене формата, карты адресов или номера УМП.
|
||||
|
||||
def __init__(self, owner, directory, default="auto"):
|
||||
super().__init__(owner)
|
||||
self.owner = owner
|
||||
self.directory = directory
|
||||
self.session = CanSession()
|
||||
self._dirty = False
|
||||
layout = QHBoxLayout(self)
|
||||
self._pending_frames = {} # Упорядоченные ключи объединяют повторы между тактами таймера и сохраняют порядок первого поступления новых кадров.
|
||||
self._history_rows = {} # Ключ уникального кадра указывает на строку и последний показанный счётчик, исключая поиск по всей таблице.
|
||||
self._history_total = 0 # Сумма показанных повторов обновляется по разнице счётчиков, без перебора накопленной истории.
|
||||
self._history_needs_reset = False # Частичная ошибка записи требует однократной сверки таблицы с фактически сохранённой сессией.
|
||||
controls_layout = QVBoxLayout(self) # Отдельная строка параметров УМП сохраняет компактную ширину основной панели монитора.
|
||||
controls_layout.setContentsMargins(0, 0, 0, 0) # Панель встраивается в существующую вкладку без дополнительных внешних отступов.
|
||||
layout = QHBoxLayout() # Основные кнопки остаются в прежнем горизонтальном порядке над параметрами УМП.
|
||||
controls_layout.addLayout(layout) # Общие элементы занимают первую строку вертикального контейнера.
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.protocol = QComboBox()
|
||||
for key, label in PROTOCOLS:
|
||||
self.protocol.addItem(label, key)
|
||||
self.protocol.setCurrentIndex(self.protocol.findData(default))
|
||||
self.protocol.setMinimumContentsLength(28)
|
||||
self.protocol.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
|
||||
self.protocol.setToolTip("Выберите формат CAN. Для УМП / ProtoCAN GAS карта адресов и устройство выбираются отдельно.") # Подсказка объясняет разделение протокола и прикладного профиля.
|
||||
legacy_ump = default in ("ump1_gas167", "ump2_gas167", "ump_gas") # Старые значения исходного профиля поддерживаются без возврата дублирующихся пунктов в список.
|
||||
self.protocol.setCurrentIndex(self.protocol.findData("ump_protocan" if legacy_ump else default)) # Прежние профили УМП открывают единый формат с отдельно восстановленными параметрами.
|
||||
layout.addWidget(QLabel("Протокол"))
|
||||
layout.addWidget(self.protocol)
|
||||
self.raw = QCheckBox("Без парса")
|
||||
@@ -51,15 +64,52 @@ class MonitorControls(QWidget):
|
||||
self.status = QLabel("Буфер: 0")
|
||||
layout.addWidget(self.status)
|
||||
layout.addStretch(1)
|
||||
self.ump_options = QWidget() # Контейнер дополнительных параметров показывается только для формата УМП / ProtoCAN GAS.
|
||||
ump_layout = QHBoxLayout(self.ump_options) # Карта и номер УМП расположены рядом на второй строке панели.
|
||||
ump_layout.setContentsMargins(0, 0, 0, 0) # Вложенная строка выравнивается с основными элементами монитора.
|
||||
ump_layout.addWidget(QLabel("Карта адресов")) # Подпись отличает нумерацию регистров от формата CAN-кадра.
|
||||
self.ump_map = QComboBox() # Выбор карты определяет словарь параметров при неизменном формате ProtoCAN.
|
||||
for key, label in UMP_MAPS: # Оба монитора используют единый каталог действующей и планируемой карт.
|
||||
self.ump_map.addItem(label, key) # Пользователь видит статус карты, а декодер получает устойчивый ключ профиля.
|
||||
self.ump_map.setToolTip("167/v1: слова 0…127 и 0x5000…0x507F. Новые десятичные ID 5000…5127 и 6000…6127 назначены, но ещё не перенесены в прошивку; устройство определяется по ID.") # Подсказка указывает диапазоны обеих карт и состояние планируемой адресации.
|
||||
self.ump_map.setCurrentIndex(self.ump_map.findData("assigned" if default == "ump_gas" else "gas167")) # Прежний профиль новых ID восстанавливает сквозную карту, остальные начинают с текущей.
|
||||
ump_layout.addWidget(self.ump_map) # Карта выбирается независимо от списка форматов CAN.
|
||||
self.ump_node_label = QLabel("Устройство") # Номер УМП относится к устройству, а не к отдельному протоколу.
|
||||
ump_layout.addWidget(self.ump_node_label) # Подпись находится перед селектором номера УМП.
|
||||
self.ump_node = QComboBox() # Текущая локальная карта требует ручного назначения УМП 1 или УМП 2.
|
||||
self.ump_node.addItem("УМП 1", 1) # Число 1 обозначает первый УМП и передаётся декодеру как номер устройства.
|
||||
self.ump_node.addItem("УМП 2", 2) # Число 2 обозначает второй УМП; карта регистров остаётся общей для обоих приборов.
|
||||
self.ump_node.setCurrentIndex(self.ump_node.findData(2 if default == "ump2_gas167" else 1)) # Старый профиль второго УМП сохраняет выбранный прибор, остальные используют первый.
|
||||
self.ump_node.setToolTip("Номер УМП для карты 167/v1 задаётся вручную. В новой карте устройство определяется по GAS ID.") # Подсказка объясняет, почему ручной выбор скрывается для сквозных ID.
|
||||
ump_layout.addWidget(self.ump_node) # Устройство занимает собственное поле после карты адресов.
|
||||
ump_layout.addStretch(1) # Свободное место справа удерживает дополнительные поля у левого края панели.
|
||||
controls_layout.addWidget(self.ump_options) # Параметры УМП добавляются под основной строкой без расширения списка протоколов.
|
||||
self._update_ump_options() # Исходный формат определяет видимость дополнительной строки до первого показа вкладки.
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(250)
|
||||
self.timer.timeout.connect(self.refresh)
|
||||
self.timer.start()
|
||||
QApplication.instance().aboutToQuit.connect(self.session.close)
|
||||
self.destroyed.connect(lambda: self.session.close())
|
||||
self.protocol.currentIndexChanged.connect(lambda *_: self.refilter())
|
||||
self.protocol.currentIndexChanged.connect(self._change_protocol) # Смена формата обновляет параметры УМП и все представления принятых кадров.
|
||||
self.ump_map.currentIndexChanged.connect(self._change_protocol) # Смена карты перерасшифровывает буфер и обновляет необходимость ручного номера устройства.
|
||||
self.ump_node.currentIndexChanged.connect(self._change_protocol) # Смена устройства применяется к уже принятым кадрам текущей локальной карты.
|
||||
self.raw.toggled.connect(lambda *_: self.refilter())
|
||||
|
||||
def _update_ump_options(self): # Видимость дополнительных полей зависит от формата и способности карты определить устройство по адресу.
|
||||
self.ump_options.setVisible(self.protocol.currentData() == "ump_protocan") # Остальные форматы обходятся без настроек карты УМП.
|
||||
manual_node = self.ump_map.currentData() == "gas167" # Только действующая локальная карта требует вручную выбранного номера УМП.
|
||||
self.ump_node_label.setVisible(manual_node) # Подпись устройства скрывается вместе с ненужным ручным селектором новой карты.
|
||||
self.ump_node.setVisible(manual_node) # Сквозные ID сами задают УМП, поэтому ручной номер не должен противоречить адресу кадра.
|
||||
|
||||
def _change_protocol(self, *_args): # Сигналы трёх селекторов используют общий путь обновления, не зависящий от индекса изменённого поля.
|
||||
self._update_ump_options() # Сначала интерфейс показывает только применимые к выбранному формату параметры.
|
||||
self.refilter() # Таблица уникальных кадров сразу получает расшифровку с новой картой и устройством.
|
||||
self.decoding_changed.emit() # Владелец обновляет онлайн, трассировку и сведения о ранее принятых кадрах.
|
||||
|
||||
def decode(self, frame): # Все таблицы монитора разбирают кадры с одним набором выбранных параметров.
|
||||
return decode_protocol(frame, self.protocol.currentData(), ump_map=self.ump_map.currentData(), ump_node=self.ump_node.currentData()) # Формат, карта и номер УМП передаются независимо, исключая расхождение онлайн и буфера.
|
||||
|
||||
def format_data(self, data):
|
||||
return format_data(data, self.data_format.isChecked())
|
||||
|
||||
@@ -73,13 +123,28 @@ class MonitorControls(QWidget):
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(QLabel("Фильтр ID (hex)"))
|
||||
self.filter_enabled = QCheckBox("Фильтр CAN ID (hex)") # Первый флажок управляет существующим отбором по идентификатору кадра.
|
||||
self.filter_enabled.setChecked(True) # Сохраняем прежнее поведение: ввод CAN ID сразу фильтрует нижнюю таблицу.
|
||||
row.addWidget(self.filter_enabled) # Флажок занимает место прежней подписи фильтра.
|
||||
self.filter = QLineEdit()
|
||||
self.filter.setPlaceholderText("701 или 0x701, 201")
|
||||
self.filter.setToolTip("Фильтр только нижней таблицы; сверху отображаются все ID")
|
||||
self.filter.setToolTip("CAN ID в HEX. Оба включённых фильтра работают по И; фильтруется только нижняя таблица, сверху остаются все кадры.") # Поясняем совместный отбор и сохраняем независимость основных таблиц.
|
||||
self.filter.setClearButtonEnabled(True)
|
||||
self.filter.textChanged.connect(self.refilter)
|
||||
row.addWidget(self.filter)
|
||||
self.gas_filter_enabled = QCheckBox("Фильтр GAS (dec / 0xHEX)") # Второй флажок позволяет включить адреса GAS отдельно или совместно с CAN ID.
|
||||
row.addWidget(self.gas_filter_enabled) # Размещаем новый критерий в той же строке рядом с существующим фильтром.
|
||||
self.gas_filter = QLineEdit() # Поле содержит список адресов слов GAS, независимо от списка идентификаторов кадров.
|
||||
self.gas_filter.setPlaceholderText("5000, 5127 или 0x5000") # Примеры различают десятичные назначенные ID и шестнадцатеричное локальное окно.
|
||||
self.gas_filter.setToolTip("GAS ID: DEC либо HEX с 0x, через запятую. Учитываются все слова кадра. При включённых обоих фильтрах должны совпасть и CAN ID, и GAS ID.") # Объясняем формат и поиск адреса внутри многословной посылки.
|
||||
self.gas_filter.setClearButtonEnabled(True) # Очистка временно убирает GAS-условие без изменения флажка CAN ID.
|
||||
self.gas_filter.setEnabled(False) # Новый фильтр включается пользователем явно и не меняет прежний начальный экран.
|
||||
self.gas_filter.textChanged.connect(self.refilter) # Изменение списка сразу пересчитывает строки и счётчики сохранённого буфера.
|
||||
self.filter_enabled.toggled.connect(self.filter.setEnabled) # Выключенный CAN-критерий сохраняет введённый текст для повторного включения.
|
||||
self.filter_enabled.toggled.connect(self.refilter) # Снятие флажка сразу убирает ограничение CAN ID.
|
||||
self.gas_filter_enabled.toggled.connect(self.gas_filter.setEnabled) # Состояние поля показывает, применяется ли GAS-условие.
|
||||
self.gas_filter_enabled.toggled.connect(self.refilter) # Каждый флажок независимо пересчитывает пересечение двух критериев.
|
||||
row.addWidget(self.gas_filter) # Поле GAS расположено перед общим счётчиком совпавших кадров.
|
||||
self.count = QLabel("Кадров: 0")
|
||||
row.addWidget(self.count)
|
||||
layout.addLayout(row)
|
||||
@@ -100,8 +165,10 @@ class MonitorControls(QWidget):
|
||||
|
||||
def ingest(self, identifier, data, extended, rtr=False, source="CAN", timestamp=None):
|
||||
try:
|
||||
self.session.append(identifier, data, extended, rtr, source, timestamp)
|
||||
key = self.session.append(identifier, data, extended, rtr, source, timestamp) # Сессия возвращает точную идентичность кадра с учётом канала, STD/EXT, RTR и данных.
|
||||
self._pending_frames[key] = None # Один ключ объединяет любое число повторов до следующего обновления экрана.
|
||||
except OSError as exc:
|
||||
self._history_needs_reset = True # Ошибка могла возникнуть после изменения буфера, поэтому следующий такт заново сверит все строки.
|
||||
self.status.setText("Ошибка записи: " + str(exc))
|
||||
self.session.stop()
|
||||
self.record.setText("● Запись")
|
||||
@@ -153,46 +220,68 @@ class MonitorControls(QWidget):
|
||||
if not self.owner._paused and self._dirty:
|
||||
self._dirty = False
|
||||
self.status.setText("Буфер: %d" % sum(self.session.counts.values()))
|
||||
self.refilter()
|
||||
self._update_history(self._history_needs_reset) # Обычный такт обрабатывает лишь новые данные; полная сверка нужна только после ошибки записи.
|
||||
|
||||
def refilter(self):
|
||||
if not hasattr(self, "filter"):
|
||||
return
|
||||
try:
|
||||
ids = parse_filter(self.filter.text())
|
||||
except ValueError as exc:
|
||||
self.count.setText(str(exc))
|
||||
ids = set()
|
||||
else:
|
||||
count = sum(value for key, value in self.session.counts.items()
|
||||
if ids is None or key in ids)
|
||||
unique_count = sum(1 for values, _ in self.session.unique_frames.values()
|
||||
if ids is None or int(values[3], 16) in ids)
|
||||
self.count.setText("Кадров: %d; уникальных: %d" % (count, unique_count))
|
||||
self.history.horizontalHeaderItem(7).setText(
|
||||
"Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)")
|
||||
self.history.setColumnHidden(8, self.raw.isChecked())
|
||||
self.history.setVisible(ids is not None)
|
||||
if ids is None:
|
||||
self.history.setRowCount(0)
|
||||
return
|
||||
frames = [(values, count) for values, count in self.session.unique_frames.values()
|
||||
if int(values[3], 16) in ids]
|
||||
self.history.setRowCount(len(frames))
|
||||
for row, (values, count) in enumerate(frames):
|
||||
data = bytes.fromhex(values[6])
|
||||
summary = ""
|
||||
if not self.raw.isChecked():
|
||||
frame = build_frame(int(values[3], 16), data,
|
||||
ide=values[2] == "EXT", rtr=bool(values[4]),
|
||||
to_can=values[1] == "TX")
|
||||
summary = decode_protocol(frame, self.protocol.currentData()).summary
|
||||
display = values[:6] + (count, self.format_data(data), summary)
|
||||
for column, value in enumerate(display):
|
||||
item = self.history.item(row, column)
|
||||
if item is None:
|
||||
self.history.setItem(row, column, QTableWidgetItem(str(value)))
|
||||
elif item.text() != str(value):
|
||||
item.setText(str(value))
|
||||
if column == 8:
|
||||
self.history.item(row, column).setToolTip(summary)
|
||||
def refilter(self): # Изменение фильтра, протокола или формата требует однократного пересчёта существующих строк.
|
||||
self._update_history(True) # Полная перестройка применяется только по явному изменению настроек либо сбросу буфера.
|
||||
|
||||
def _update_history(self, rebuild): # Таймер и настройки используют один путь отбора, но разный объём входных записей.
|
||||
if not hasattr(self, "filter"): # При сборке панели сигналы могут прийти до создания нижней таблицы.
|
||||
return # Отложим отображение до завершения построения элементов интерфейса.
|
||||
error = None # Ошибка активного фильтра заменяет счётчики понятным сообщением и очищает выборку.
|
||||
try: # Разбор коротких условий выполняется одинаково для полного и добавочного обновления.
|
||||
ids = parse_filter(self.filter.text()) if self.filter_enabled.isChecked() else None # Текст выключенного CAN-фильтра не влияет на выборку и не вызывает ошибок ввода.
|
||||
gas_ids = parse_gas_filter(self.gas_filter.text()) if self.gas_filter_enabled.isChecked() else None # Независимо разбираем только включённое условие адресов GAS.
|
||||
except ValueError as exc: # Неверный ввод не должен оставлять на экране строки от прежнего корректного фильтра.
|
||||
error = str(exc) # Сохраняем объяснение для итоговой подписи после очистки строк.
|
||||
ids, gas_ids = set(), None # Пустое множество CAN ID даёт пустую видимую выборку.
|
||||
filtered = ids is not None or gas_ids is not None # Нижняя таблица нужна лишь при наличии хотя бы одного непустого условия.
|
||||
self.history.horizontalHeaderItem(7).setText("Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)") # Заголовок соответствует выбранному представлению байтов.
|
||||
self.history.setColumnHidden(8, self.raw.isChecked()) # Сырой режим скрывает расшифровку независимо от способа обновления.
|
||||
self.history.setVisible(filtered) # Без ограничений сохраняем компактный вид основной таблицы.
|
||||
updates = self.history.updatesEnabled() # Запоминаем исходный режим отрисовки, чтобы не включать её вопреки владельцу.
|
||||
self.history.setUpdatesEnabled(False) # Пачка изменений не должна вызывать промежуточную перерисовку и перерасчёт геометрии.
|
||||
blocked = self.history.blockSignals(True) # Программное заполнение не должно вызывать обработчики отдельных изменённых ячеек.
|
||||
try: # Восстановление состояния Qt гарантируется также при ошибке декодирования.
|
||||
if rebuild or not filtered or error is not None: # Смена условий и недопустимый ввод отменяют прежнее соответствие ключей строкам.
|
||||
self.history.setRowCount(0) # Полная очистка выполняется по настройке, а не каждые 250 мс при приёме.
|
||||
self._history_rows.clear() # Индексы строк больше не относятся к новой выборке.
|
||||
self._history_total = 0 # Сумма повторов пересчитывается для нового набора подходящих кадров.
|
||||
if filtered and error is None: # Скрытая таблица и некорректные условия не требуют фильтрации или декодирования истории.
|
||||
keys = self.session.unique_frames if rebuild else self._pending_frames # Только явная перестройка перебирает всю сессию; таймер берёт изменённые ключи.
|
||||
for key in keys: # Словари сохраняют порядок первого появления уникальных кадров, даже если между ними пришли повторы.
|
||||
values, count = self.session.unique_frames[key] # Получаем последние время и счётчик с учётом всех повторов в текущей пачке.
|
||||
previous = self._history_rows.get(key) # Уже показанный кадр прошёл неизменные фильтры и не нуждается в повторной расшифровке.
|
||||
if previous is not None: # У повторов меняются только время последнего приёма и число физических кадров.
|
||||
row, old_count = previous # Сохраняем прежнюю сумму, чтобы добавить только новые приёмы.
|
||||
display = ((0, values[0]), (6, count)) # Колонки 0 и 6 содержат время и счётчик, остальные данные идентичны по ключу.
|
||||
else: # Новый ключ необходимо проверить по обоим активным условиям.
|
||||
data = bytes.fromhex(values[6]) # Данные нужны для GAS-отбора и однократной расшифровки новой строки.
|
||||
if not matches_filters(int(values[3], 16), data, values[2] == "EXT", bool(values[4]), can_ids=ids, gas_ids=gas_ids): # Проверяем пересечение CAN/GAS с учётом формата и RTR.
|
||||
continue # Неподходящий кадр остаётся в полной сессии для сохранения и последующей смены фильтров.
|
||||
row, old_count = self.history.rowCount(), 0 # Новая уникальная посылка добавляется в конец с её полным счётчиком с начала сессии.
|
||||
self.history.insertRow(row) # Существующие строки и выделение не пересоздаются при поступлении новой посылки.
|
||||
summary = "" # В режиме без парса не вызываем декодер даже при первом отображении кадра.
|
||||
if not self.raw.isChecked(): # Расшифровка вычисляется только для новой строки либо при явной перестройке настроек.
|
||||
frame = build_frame(int(values[3], 16), data, ide=values[2] == "EXT", rtr=bool(values[4]), to_can=values[1] == "TX") # Восстанавливаем исходную метаинформацию сохранённой посылки.
|
||||
summary = self.decode(frame).summary # Используем выбранные протокол, карту адресов и номер устройства.
|
||||
display = enumerate(values[:6] + (count, self.format_data(data), summary)) # Новая строка заполняется целиком в порядке заголовков таблицы.
|
||||
for column, value in display: # У повторов этот цикл касается двух ячеек вместо всей накопленной таблицы.
|
||||
item = self.history.item(row, column) # Повторно используем существующий объект ячейки для сохранения выделения.
|
||||
if item is None: # Ячейки создаются только при добавлении новой уникальной посылки.
|
||||
item = QTableWidgetItem() # Пустой объект получает текст общим путём ниже.
|
||||
self.history.setItem(row, column, item) # Таблица принимает владение созданной ячейкой.
|
||||
if item.text() != str(value): # Не уведомляем модель Qt о фактически неизменившемся значении.
|
||||
item.setText(str(value)) # Преобразование сохраняет прежнее отображение чисел и меток времени.
|
||||
if column == 8: # Последний столбец содержит длинную расшифровку, которую удобно читать в подсказке.
|
||||
item.setToolTip(str(value)) # Подсказка обновляется вместе с новой расшифровкой при смене настроек.
|
||||
self._history_rows[key] = (row, count) # Запоминаем актуальные индекс и показанный счётчик для следующей пачки повторов.
|
||||
self._history_total += count - old_count # Добавляем только новые физические приёмы без суммирования всей истории.
|
||||
self._pending_frames.clear() # Все поступившие ключи учтены либо скрыты неизменными фильтрами.
|
||||
self._history_needs_reset = False # Сверка после ошибки успешно завершена и больше не требует полного обхода.
|
||||
total = self._history_total if filtered else sum(self.session.counts.values()) # Без фильтра подпись относится ко всей сессии, хотя сама таблица скрыта.
|
||||
unique = len(self._history_rows) if filtered else len(self.session.unique_frames) # Число уникальных кадров берём из готового индекса без повторного отбора.
|
||||
self.count.setText(error if error is not None else "Кадров: %d; уникальных: %d" % (total, unique)) # При ошибке оставляем причину, иначе показываем согласованные счётчики выборки.
|
||||
finally: # Любой выход должен вернуть исходное поведение сигналов и перерисовки Qt.
|
||||
self.history.blockSignals(blocked) # Восстанавливаем состояние, установленное владельцем до обновления.
|
||||
self.history.setUpdatesEnabled(updates) # Готовая пачка отображается за одно обновление виджета.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
|
||||
"""Необязательные известные элементы последовательного эквивалента RLC.""" # Ввод не смешивает номиналы реального RC-снаббера с параметрами идентифицируемых полюсов.
|
||||
try: # Поддерживаем современные и совместимые Windows-сборки.
|
||||
from PySide6.QtCore import Signal # Одно изменение любого элемента делает старый подбор моделей неактуальным.
|
||||
from PySide6.QtWidgets import QDoubleSpinBox, QHBoxLayout, QLabel, QWidget # Используем стандартные числовые поля Qt6.
|
||||
except ImportError: # Qt5 получает тот же интерфейс без дополнительных зависимостей.
|
||||
from PySide2.QtCore import Signal # Контракт сигналов одинаков на Windows 7 и новых системах.
|
||||
from PySide2.QtWidgets import QDoubleSpinBox, QHBoxLayout, QLabel, QWidget # Набор полей и единицы не зависят от версии Qt.
|
||||
|
||||
|
||||
class CircuitParameters(QWidget): # Компонент можно встроить в подробные параметры любого вида сравнения.
|
||||
changed = Signal() # Владелец решает, запускать ли автоматический пересчёт или ожидать кнопку.
|
||||
|
||||
def __init__(self, parent=None): # По умолчанию ни один электрический элемент не предполагается известным.
|
||||
super().__init__(parent) # Жизненным циклом компонента управляет родительская панель сравнения.
|
||||
row = QHBoxLayout(self) # Три поля и пояснение занимают одну строку.
|
||||
row.setContentsMargins(0, 0, 0, 0) # Сохраняем существующие отступы формы.
|
||||
row.addWidget(QLabel('Эквивалентный последовательный RLC:')) # Явно называем топологию, к которой относятся формулы R/L и LC.
|
||||
self.inductance, self.capacitance, self.resistance = QDoubleSpinBox(), QDoubleSpinBox(), QDoubleSpinBox() # Отдельное неизвестное значение разрешено у каждого элемента.
|
||||
self.controls = (self.inductance, self.capacitance, self.resistance) # Общий список позволяет блокировать ввод во время рабочего задания.
|
||||
for title, box, suffix in (('L', self.inductance, ' нГн'), ('C', self.capacitance, ' нФ'), ('R', self.resistance, ' Ом')): # Удобные единицы соответствуют обычным масштабам коммутационного контура.
|
||||
row.addWidget(QLabel(title)) # Подпись отделяет физическую величину от единицы в поле.
|
||||
box.setDecimals(6) # Дробные нГн, нФ и Ом сохраняются без грубого округления.
|
||||
box.setRange(0., 1e12) # Ноль означает неизвестное значение; отрицательные пассивные элементы не допускаются.
|
||||
box.setSpecialValueText('неизвестно') # Не скрываем отсутствие исходных электрических данных за произвольным номиналом.
|
||||
box.setSuffix(suffix) # Единица видна при вводе любого положительного значения.
|
||||
box.setMaximumWidth(170) # Ширина в логических пикселях оставляет место всем трём элементам.
|
||||
box.setToolTip('Введите известный элемент последовательного эквивалента. Ноль — неизвестно. Это не автоматическая модель топологии RC-снаббера.') # Объяснение предотвращает неверную интерпретацию восстановленных номиналов.
|
||||
box.valueChanged.connect(lambda _value: self.changed.emit()) # Любое изменение требует нового численного подбора и отчёта.
|
||||
row.addWidget(box) # Поля располагаются в порядке L, C и R.
|
||||
row.addStretch(1) # Свободная ширина не растягивает числовые поля.
|
||||
|
||||
def values(self): # Снимок значений фиксируется до запуска фоновой идентификации.
|
||||
return dict(inductance_h=self.inductance.value() * 1e-9, capacitance_f=self.capacitance.value() * 1e-9, resistance_ohm=self.resistance.value()) # нГн и нФ переводятся в СИ; Ом уже соответствует внутренним формулам.
|
||||
@@ -2,6 +2,8 @@
|
||||
from collections import deque # Кольцевая история ограничивает память при непрерывном обмене.
|
||||
from html import escape # Текст устройства не должен становиться HTML-разметкой журнала.
|
||||
from pathlib import Path # Экспорт сохраняет явно выбранный пользователем файл в UTF-8.
|
||||
import csv # Стандартный CSV корректно экранирует кавычки, разделители и пустые строки журнала.
|
||||
import json # JSON сохраняет строки с кириллицей без потери символов и дополнительного разбора протоколов.
|
||||
import re # Метки направления извлекаются только из начала строки после необязательного времени.
|
||||
try: # Поддерживаем актуальный Qt и существующие установки на Python для Windows 7.
|
||||
from PySide6.QtCore import QTimer, Signal # Таймер объединяет обновления заполненного журнала.
|
||||
@@ -48,12 +50,12 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе
|
||||
self.search = QLineEdit() # Фильтр работает по обычному тексту всех сохранённых строк.
|
||||
self.search.setPlaceholderText("Поиск / фильтр текста") # Подпись показывает, что скрытые строки остаются в истории.
|
||||
filters.addWidget(self.search, 1) # Поле поиска получает свободную ширину консоли.
|
||||
self.show_tx, self.show_rx = QCheckBox("TX"), QCheckBox("RX") # Служебные сообщения не относятся ни к одному направлению.
|
||||
self.show_tx, self.show_rx = QCheckBox("TX", self.panel), QCheckBox("RX", self.panel) # Родитель назначается до setVisible, чтобы фильтры направлений не появлялись отдельными окнами при сборке консоли.
|
||||
for control in (self.show_tx, self.show_rx): # Оба фильтра действуют независимо и применяются к истории.
|
||||
control.setChecked(True) # Первое открытие показывает весь обмен.
|
||||
control.setVisible(directions) # Потребители со своими фильтрами могут скрыть дублирующие элементы.
|
||||
filters.addWidget(control) # Направления расположены рядом с текстовым фильтром.
|
||||
self.display = QComboBox() # Формат байтов относится только к сообщениям с явно переданным payload.
|
||||
self.display = QComboBox(self.panel) # Родитель исключает отдельное окно при setVisible(payloads); формат относится только к сообщениям с явно переданными байтами.
|
||||
self.display.addItems(("Как получено", "HEX", "ASCII", "HEX + ASCII")) # Обычный текст и ответы устройства не перекодируются.
|
||||
self.display.setToolTip("HEX/ASCII применяется к байтам, переданным через append_message(payload=…).") # Приложение явно определяет границы полезной нагрузки.
|
||||
self.display.setVisible(payloads) # Выбор байтового представления показывается только потребителю с явным payload.
|
||||
@@ -103,7 +105,6 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе
|
||||
def setPlainText(self, text): # Снимок внешней истории полностью заменяет данные этого представления.
|
||||
self.timer.stop() # Старое отложенное обновление не должно вернуть предыдущий снимок.
|
||||
self.records.clear() # Сохраняется только актуальный результат форматирования потребителя.
|
||||
if not self.pause.isChecked(): super().clear() # При паузе прежний экран сохраняется до возобновления.
|
||||
if text: self.append_message(text, _defer=True) # Пустой снимок не создаёт фиктивную запись.
|
||||
self.refresh() # Поиск и фильтры применяются к новому снимку целиком.
|
||||
|
||||
@@ -124,6 +125,7 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе
|
||||
def _append_record(self, record): # Добавляем одну подготовленную запись, учитывая формат и положение прокрутки.
|
||||
text, html, _role, payload = record # Байты присутствуют только при явной передаче payload приложением.
|
||||
bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Запоминаем позицию до изменения размера документа.
|
||||
horizontal, offset = self.horizontalScrollBar(), self.horizontalScrollBar().value() # Автопрокрутка по вертикали не должна сбрасывать просмотр длинной строки по горизонтали.
|
||||
mode = self.display.currentIndex() # Нулевой режим сохраняет текст и цвета, заданные потребителем.
|
||||
if payload is not None and mode: # Формат меняет только подписанную приложением полезную нагрузку.
|
||||
hexadecimal = payload.hex(" ").upper() # HEX показывает каждый исходный байт двумя цифрами.
|
||||
@@ -132,25 +134,47 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе
|
||||
elif html: super().appendHtml(html) # Сохраняем безопасное оформление или прежний фрагмент приложения.
|
||||
else: super().appendPlainText(text) # Обычный текст не интерпретируется как HTML.
|
||||
bar.setValue(bar.maximum() if self.follow.isChecked() else position) # Отключённая автопрокрутка сохраняет место чтения.
|
||||
horizontal.setValue(offset) # Дописывание строки сохраняет выбранный горизонтальный участок журнала.
|
||||
|
||||
def refresh(self, *_args): # Сигналы Qt могут передавать индекс, флаг или текст.
|
||||
self.timer.stop() # Немедленное обновление заменяет ожидающее пакетное перестроение.
|
||||
if self.pause.isChecked(): return # Изменения фильтров во время паузы применятся после её снятия.
|
||||
bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Сохраняем место чтения до очистки документа.
|
||||
horizontal, offset = self.horizontalScrollBar(), self.horizontalScrollBar().value() # Фильтрация и пакетное обновление не должны возвращать длинные строки к левому краю.
|
||||
super().clear() # Очищаем только документ, сохраняя ограниченную историю.
|
||||
for record in self.records: # Показываем оставшиеся записи в исходном порядке.
|
||||
if self._visible(record): self._append_record(record) # Скрытые строки остаются доступными для следующего выбора фильтра.
|
||||
bar.setValue(bar.maximum() if self.follow.isChecked() else min(position, bar.maximum())) # При сокращении документа ограничиваем прежнюю позицию новым диапазоном.
|
||||
horizontal.setValue(offset) # Qt ограничивает прежнее смещение текущей шириной документа после перестроения.
|
||||
self.count.setText("%d строк" % len(self.records)) # Счётчик включает временно скрытые записи.
|
||||
|
||||
def copy_visible(self): # Экспортируется фактически показанный текст, включая состояние паузы и фильтра.
|
||||
QApplication.clipboard().setText(self.toPlainText()) # Копирование всей видимой консоли не требует ручного выделения.
|
||||
|
||||
def save_to(self, path): # Отдельный метод позволяет проверить экспорт без системного диалога.
|
||||
Path(path).write_text(self.toPlainText(), encoding="utf-8") # UTF-8 сохраняет русские сообщения и расшифровки протоколов.
|
||||
def save_to(self, path, file_format=None): # Явный формат используется диалогом, а прямой вызов определяет формат по расширению файла.
|
||||
destination = Path(path) # Путь может поступить строкой от Qt или объектом Path от вызывающего кода.
|
||||
file_format = (file_format or destination.suffix.lstrip(".") or "txt").lower() # Имена расширений нечувствительны к регистру; без расширения сохраняется прежний TXT.
|
||||
if file_format not in ("csv", "json", "txt"): # Не допускаем сохранения текста под видом неподдерживаемого формата.
|
||||
raise ValueError("Выберите формат CSV, JSON или TXT") # Ошибка доступна как прямому вызывающему коду, так и диалогу.
|
||||
text = self.toPlainText() # Единый снимок учитывает фильтры, формат байтов и замороженный на паузе экран.
|
||||
rows = [{"line": index, "text": line} for index, line in enumerate(text.split("\n"), 1)] if text else [] # Нумерация с единицы сохраняет порядок и пустые строки расшифровки.
|
||||
if file_format == "csv": # Табличное представление содержит номер строки и её полный текст.
|
||||
with destination.open("w", encoding="utf-8-sig", newline="") as stream: # BOM помогает Excel распознать кириллицу; newline исключает лишние пустые строки в Windows.
|
||||
writer = csv.DictWriter(stream, fieldnames=("line", "text")) # Стабильные имена столбцов позволяют обрабатывать журналы скриптами.
|
||||
writer.writeheader() # Даже пустой журнал получает корректную схему CSV.
|
||||
writer.writerows(rows) # Стандартный писатель экранирует кавычки и запятые без изменения содержимого.
|
||||
elif file_format == "json": # Массив объектов сохраняет ту же схему строк, что CSV.
|
||||
destination.write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") # Читаемый UTF-8 сохраняет кириллицу и валидный пустой массив.
|
||||
else: # TXT остаётся точной текстовой копией показанного журнала.
|
||||
destination.write_text(text, encoding="utf-8") # Прежний контракт сохранения TXT не добавляет заголовки или номера строк.
|
||||
|
||||
def save_dialog(self): # Пользователь самостоятельно выбирает назначение журнала.
|
||||
path, _filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console.txt", "Текст UTF-8 (*.txt);;Все файлы (*)") # Диалог не выполняет автоматической записи при отмене.
|
||||
filters = "CSV UTF-8 (*.csv);;JSON UTF-8 (*.json);;Текст UTF-8 (*.txt)" # Все консоли предлагают одинаковые три формата; CSV выбран первым.
|
||||
path, selected_filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console", filters) # Имя без расширения позволяет выбрать любой формат без ручной замены суффикса.
|
||||
if path: # Записываем только явно выбранный файл.
|
||||
try: self.save_to(path) # Обрабатываем отказ файловой системы без прерывания приёма данных.
|
||||
except OSError as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору.
|
||||
file_format = Path(path).suffix.lstrip(".").lower() # Явно введённое поддерживаемое расширение имеет приоритет над фильтром диалога.
|
||||
if file_format not in ("csv", "json", "txt"): # Если расширение отсутствует или неизвестно, применяем выбранный формат.
|
||||
file_format = "json" if "*.json" in selected_filter else "txt" if "*.txt" in selected_filter else "csv" # При отсутствии фильтра Qt используется первый предложенный формат CSV.
|
||||
path += "." + file_format # Итоговое расширение всегда соответствует фактически записанному формату.
|
||||
try: self.save_to(path, file_format) # Обрабатываем отказ файловой системы без прерывания приёма данных.
|
||||
except (OSError, ValueError) as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору.
|
||||
|
||||
@@ -14,7 +14,7 @@ except ImportError:
|
||||
|
||||
|
||||
DRAG_HINT = ('ЛКМ рядом с маркером — двигать маркер; ЛКМ на свободном поле — '
|
||||
'перемещение по X; Ctrl + ЛКМ или Shift + ЛКМ — перемещение по Y. '
|
||||
'перемещение по X; Ctrl + ЛКМ — перемещение по Y; Shift + ЛКМ — по Y со стороны начала жеста. ' # Для двух шкал Shift выбирает сторону в точке нажатия и сохраняет её до отпускания кнопки.
|
||||
'Щелчок мимо маркеров — одиночный курсор.')
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def cursor_hit(plot, position, value, event, radius=8):
|
||||
class PlotDragInteraction:
|
||||
"""Capture a hit marker or pan one axis, chosen at the start of the drag."""
|
||||
|
||||
def _capture_y_pan(self):
|
||||
def _capture_y_pan(self, event=None): # Необязательное событие нажатия позволяет владельцу двух шкал выбрать одну Y; клавиатура передаёт только исходную область.
|
||||
return self.viewport
|
||||
|
||||
def _apply_y_pan(self, fraction, initial):
|
||||
@@ -58,7 +58,7 @@ class PlotDragInteraction:
|
||||
self.setFocus(Qt.MouseFocusReason)
|
||||
self._press = QPointF(pos)
|
||||
self._press_viewport = self.viewport
|
||||
self._press_y_pan = self._capture_y_pan()
|
||||
self._press_y_pan = self._capture_y_pan(event) # Фиксируем выбранную шкалу и её исходный диапазон при нажатии, чтобы пересечение центра не меняло цель перетаскивания.
|
||||
self._press_markers = self.markers
|
||||
self._press_bounds = self.visible_bounds
|
||||
self._pan_vertical = vertical_pan_requested(event)
|
||||
|
||||
@@ -8,18 +8,28 @@ except ImportError:
|
||||
from PySide2.QtCore import QPointF, Qt
|
||||
from PySide2.QtGui import QColor
|
||||
from protocan.plot import Bounds, Viewport
|
||||
from set_devices.plot_ticks import tick_precision # Обе амплитудные шкалы должны различать соседние деления даже при сильном приближении.
|
||||
|
||||
|
||||
def amplitude_axis_margin(widget, bounds): # Отступ правой шкалы зависит от ширины её уточнённых подписей в текущем шрифте виджета.
|
||||
step = (bounds.top - bounds.bottom) / max(100, widget.height() / 50) # Консервативная плотность делений даёт запас точности без обращения к геометрии, которая сама вычисляет этот отступ.
|
||||
labels = [f'{value:.{tick_precision(value, step)}g}' for value in (bounds.bottom, bounds.top)] # Оцениваем самые длинные крайние подписи в том же формате, что используется при отрисовке шкалы.
|
||||
return max(72, 14 + max(widget.fontMetrics().horizontalAdvance(label) for label in labels)) # Прежний минимум 72 пикселя и запас 14 пикселей сохраняют обычный вид и предотвращают обрезание длинных чисел.
|
||||
|
||||
|
||||
def small_amplitude(values, threshold=30):
|
||||
extent = getattr(values, "finite_extent", None)
|
||||
if extent is not None:
|
||||
return max(abs(extent[0]), abs(extent[1])) <= threshold
|
||||
magnitude = max((abs(v) for v in values if math.isfinite(v)), default=None)
|
||||
return magnitude is not None and magnitude <= threshold
|
||||
|
||||
|
||||
def split_amplitudes(series, threshold=30):
|
||||
def split_amplitudes(series, threshold=30, *, include_hidden=False): # Порог 30 в единицах канала разделяет шкалы; файловый canvas может сохранить в них скрытые ряды.
|
||||
"""Channels with finite peak magnitude <= 30 use the right amplitude axis."""
|
||||
result = set()
|
||||
for channel in series:
|
||||
if not channel.visible or channel.discrete:
|
||||
if (not channel.visible and not include_hidden) or channel.discrete: # По умолчанию скрытые каналы исключены; независимая политика границ может учитывать их для сохранения масштаба.
|
||||
continue
|
||||
if small_amplitude(channel.values, threshold):
|
||||
result.add(channel.key)
|
||||
@@ -29,7 +39,8 @@ def split_amplitudes(series, threshold=30):
|
||||
class DualAxisPlot:
|
||||
"""Mixin: left axis for large channels, right for small channels.
|
||||
|
||||
Shift-wheel selects the axis by the pointer's half of the analog pane.
|
||||
Ctrl + колесо выбирает шкалу по половине аналоговой области под указателем.
|
||||
Shift + ЛКМ перемещает одну Y, выбранную стороной графика при нажатии.
|
||||
The host supplies series_bounds/project_series rendering hooks.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -40,8 +51,8 @@ class DualAxisPlot:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _sync_bounds(self):
|
||||
keys = split_amplitudes(self._series)
|
||||
if keys != self.right_axis_keys:
|
||||
keys = split_amplitudes(self._bounds_series(), include_hidden=True) # Выбираем правые каналы из того же набора, что задаёт общие границы: LogsPlot сохраняет в нём скрытые источники.
|
||||
if not keys.intersection(self.right_axis_keys): # Сохраняем масштаб при добавлении или временном пересоздании эталона, пока на правой Y остаётся хотя бы один прежний канал.
|
||||
self.right_viewport = Viewport()
|
||||
self.right_axis_keys = keys
|
||||
super()._sync_bounds()
|
||||
@@ -49,10 +60,11 @@ class DualAxisPlot:
|
||||
def set_plot_bounds(self, bounds, has_data=True, x_origin=0):
|
||||
if self.right_axis_keys and self.has_left_axis():
|
||||
groups = [[], []]
|
||||
for series in self._series:
|
||||
if series.visible and not series.discrete:
|
||||
for series in self._bounds_series(): # Обе независимые Y соблюдают политику canvas, чтобы снятие флажка не подменяло диапазон токов диапазоном напряжений.
|
||||
if not series.discrete: # Дискретные дорожки не имеют общей амплитудной шкалы и не участвуют в её пределах.
|
||||
groups[series.key in self.right_axis_keys].extend(
|
||||
v for v in series.values if math.isfinite(v))
|
||||
getattr(series.values, "finite_extent", None) or
|
||||
(v for v in series.values if math.isfinite(v)))
|
||||
ranges = []
|
||||
for values in groups:
|
||||
low, high = (min(values), max(values)) if values else (0, 1)
|
||||
@@ -62,8 +74,8 @@ class DualAxisPlot:
|
||||
super().set_plot_bounds(bounds, has_data, x_origin)
|
||||
|
||||
def has_left_axis(self):
|
||||
return any(s.visible and not s.discrete and s.values and s.key not in self.right_axis_keys
|
||||
for s in self._series)
|
||||
return any(not s.discrete and s.values and s.key not in self.right_axis_keys # Наличие левой координатной системы определяется теми же рядами, что и её границы.
|
||||
for s in self._bounds_series()) # Файловый график сохраняет обе шкалы при скрытии линий; живой график учитывает только видимые каналы.
|
||||
|
||||
@property
|
||||
def right_visible_bounds(self):
|
||||
@@ -111,23 +123,28 @@ class DualAxisPlot:
|
||||
def plot_rect(self):
|
||||
rect = super().plot_rect()
|
||||
if self.right_axis_keys:
|
||||
rect.setWidth(max(1, rect.width() - 72))
|
||||
rect.setWidth(max(1, rect.width() - self.right_axis_margin())) # Уточнённые подписи правой Y получают достаточно места без выхода за границы виджета.
|
||||
return rect
|
||||
|
||||
def right_axis_margin(self):
|
||||
return 72 if self.right_axis_keys else 0
|
||||
return amplitude_axis_margin(self, self.right_visible_bounds) if self.right_axis_keys else 0 # Используем фактический правый диапазон и текущий шрифт; при отсутствии правой шкалы отступ не нужен.
|
||||
|
||||
def reset_viewport(self):
|
||||
self.right_viewport = Viewport()
|
||||
super().reset_viewport()
|
||||
|
||||
def _capture_y_pan(self):
|
||||
return super()._capture_y_pan(), self.right_viewport
|
||||
def _capture_y_pan(self, event=None): # Снимок содержит области выбранных шкал; None исключает другую шкалу из всего текущего жеста.
|
||||
left, right = super()._capture_y_pan(event), self.right_viewport # Сохраняем начальные области до движения, чтобы смещение считалось от точки нажатия без накопления ошибок.
|
||||
if event is not None and event.modifiers() & Qt.ShiftModifier and self.right_axis_keys and self.has_left_axis(): # Выбор стороны нужен только для Shift с двумя отдельными Y; Ctrl, стрелки и единственная Y сохраняют общий перенос.
|
||||
position = event.position() if hasattr(event, 'position') else event.localPos() # Получаем локальную точку нажатия одинаково для Qt 6 и Qt 5.
|
||||
return (None, right) if position.x() >= self.value_rect().center().x() else (left, None) # Центр аналоговой области разделяет стороны; на самой границе выбираем правую Y, как при Ctrl с колесом.
|
||||
return left, right # Общие команды перемещения получают обе области, а график с одной Y использует свою основную область.
|
||||
|
||||
def _apply_y_pan(self, fraction, initial):
|
||||
left, right = initial
|
||||
super()._apply_y_pan(fraction, left)
|
||||
if self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis():
|
||||
if left is not None: # Отсутствующая левая область означает, что Shift выбрал только правую шкалу при нажатии.
|
||||
super()._apply_y_pan(fraction, left) # Общий обработчик перемещает левую Y и учитывает её фиксацию без изменения временной оси.
|
||||
if right is not None and self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis(): # Правая Y сдвигается лишь при включении в снимок жеста, наличии двух шкал и снятой общей фиксации Y.
|
||||
self.right_viewport = right.transform(self.core, pan_y=fraction)
|
||||
|
||||
def reset_interaction(self):
|
||||
@@ -135,7 +152,7 @@ class DualAxisPlot:
|
||||
super().reset_interaction()
|
||||
|
||||
def wheelEvent(self, event):
|
||||
if not event.modifiers() & Qt.ShiftModifier:
|
||||
if not event.modifiers() & Qt.ControlModifier: # Ctrl включает масштаб Y выбранной стороны; колесо без Ctrl, включая Shift, передаётся общей навигации по X.
|
||||
return super().wheelEvent(event)
|
||||
pos = event.position() if hasattr(event, 'position') else event.posF()
|
||||
rect = self.value_rect()
|
||||
@@ -173,7 +190,7 @@ def paint_right_axis(painter, core, rect, bounds):
|
||||
for index in range(count):
|
||||
value = (first + index) * step
|
||||
y = rect.top() + bounds.fraction(core, value, True) * rect.height()
|
||||
painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.4g}')
|
||||
painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.{tick_precision(value, step)}g}') # Сохраняем четыре значащие цифры при обычном масштабе и добавляем разряды по шагу правой шкалы при приближении.
|
||||
painter.restore()
|
||||
|
||||
|
||||
@@ -185,7 +202,7 @@ class SingleAmplitudeAxis:
|
||||
def plot_rect(self):
|
||||
rect = super().plot_rect()
|
||||
if not self.has_left_axis():
|
||||
rect.setWidth(max(1, rect.width() - 72))
|
||||
rect.setWidth(max(1, rect.width() - amplitude_axis_margin(self, self.visible_bounds))) # Единственная правая шкала тренда или спектра получает тот же запас для длинных подписей при приближении.
|
||||
return rect
|
||||
|
||||
def paint_axes(self, painter):
|
||||
|
||||
@@ -10,7 +10,8 @@ except ImportError:
|
||||
from PySide2.QtCore import Qt
|
||||
from PySide2.QtWidgets import QScrollArea
|
||||
|
||||
NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо и Ctrl + +/− — масштаб Y. '
|
||||
NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо — масштаб Y со стороны указателя; ' # Поясняем выбор левой или правой амплитудной шкалы по положению указателя при удерживаемом Ctrl.
|
||||
'Ctrl + +/− — масштаб Y. ' # Клавиатурный масштаб Y сохраняет прежнее поведение без выбора стороны указателем.
|
||||
'ЛКМ — перемещение по X; Ctrl + ЛКМ — по Y; '
|
||||
'←/→ — перемещение по X; ↑/↓ — по Y; Home/End — начало/конец.')
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ except ImportError:
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from set_devices.signal_reconstruction import METHODS
|
||||
from set_devices.signal_reconstruction import BACKENDS, MAX_OUTPUT_POINTS, METHODS # Используем единый список вариантов расчёта для панели и подписей результатов.
|
||||
from set_devices.plot_processing import Snapshot, prepare, process, write_csv
|
||||
|
||||
RESULT_COLORS = ("#FF70D0", "#50D5FF", "#FFD166", "#83E377", "#B99AFF", "#FF9870")
|
||||
@@ -78,6 +78,11 @@ class SignalProcessingPanel(QWidget):
|
||||
self.method.addItem(title, key)
|
||||
self.method.setCurrentIndex(2)
|
||||
row.addWidget(self.method)
|
||||
self.backend = QComboBox() # Явный переключатель позволяет сравнить прежний МНК и новый Eigen на одном снимке данных.
|
||||
for key, title in BACKENDS.items(): # Порядок оставляет исходный решатель значением по умолчанию.
|
||||
self.backend.addItem(title, key) # В данных элемента хранится стабильный ключ, независимый от русского названия.
|
||||
self.backend.setToolTip("Вариант полиномиального МНК. Рассчитайте оба: кривые сохранятся вместе, подписи и время помогут сравнению.") # Объясняем практический способ сравнения непосредственно рядом с выбором.
|
||||
row.addWidget(self.backend) # Располагаем вариант рядом с методом, к которому он относится.
|
||||
row.addStretch(1)
|
||||
layout.addLayout(row)
|
||||
row = QHBoxLayout()
|
||||
@@ -88,8 +93,8 @@ class SignalProcessingPanel(QWidget):
|
||||
row.addWidget(self.degree)
|
||||
row.addWidget(QLabel("Точек результата"))
|
||||
self.count = QSpinBox()
|
||||
self.count.setRange(2, 10000)
|
||||
self.count.setValue(1000)
|
||||
self.count.setRange(2, MAX_OUTPUT_POINTS)
|
||||
self.count.setValue(10000)
|
||||
row.addWidget(self.count)
|
||||
self.apply_button = QPushButton("Рассчитать")
|
||||
self.apply_button.clicked.connect(self.calculate)
|
||||
@@ -110,15 +115,18 @@ class SignalProcessingPanel(QWidget):
|
||||
"Полином МНК — сглаживание шумных данных. Линейная — отрезки между точками.\n"
|
||||
"PCHIP — сохранение формы без выбросов. Сплайн — гладкая кривая, возможны выбросы.\n"
|
||||
"Восстановление — оценка между измерениями; утраченные детали не определяются однозначно.")
|
||||
for widget in (self.channel, self.method):
|
||||
widget.currentIndexChanged.connect(self._parameters_changed)
|
||||
for widget in (self.channel, self.method, self.backend): # Смена решателя делает текущий запрос новым, сохраняя предыдущие слои.
|
||||
widget.currentIndexChanged.connect(self._parameters_changed) # Общая проверка предотвращает принятие результата для уже изменённого выбора.
|
||||
for widget in (self.degree, self.count):
|
||||
widget.valueChanged.connect(self._parameters_changed)
|
||||
self._update_actions()
|
||||
|
||||
def _signature(self):
|
||||
return prepare(self._snapshot, self.channel.currentData(), self.method.currentData(),
|
||||
self.count.value(), self.degree.value())
|
||||
self.count.value(), self.degree.value(), self._backend()) # Вариант участвует в идентичности снимка и результата.
|
||||
|
||||
def _backend(self): # Для методов без альтернативы всегда используем исходный алгоритм, сохраняя последний выбор МНК в списке.
|
||||
return self.backend.currentData() if self.method.currentData() == "polynomial" else "native" # Переключение на интерполяцию не выдаёт её за расчёт Eigen.
|
||||
|
||||
def _current_request_for(self, signature):
|
||||
if not self.preserve_on_view_change or signature is None:
|
||||
@@ -126,7 +134,7 @@ class SignalProcessingPanel(QWidget):
|
||||
# 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())
|
||||
self.count.value(), self.degree.value(), self._backend()) # Сохранённое окно проверяется вместе с текущим вариантом решателя.
|
||||
|
||||
def set_snapshot(self, snapshot):
|
||||
previous = self._job.signature if self._job is not None else self._signature()
|
||||
@@ -154,7 +162,7 @@ class SignalProcessingPanel(QWidget):
|
||||
# depend on samples/units, not the source curve's checkbox.
|
||||
source = replace(source, series=tuple(replace(series, visible=True)
|
||||
for series in source.series))
|
||||
if self.preserve_on_data_change or prepare(source, request.series.key, request.method, request.output_count, request.degree) == request:
|
||||
if self.preserve_on_data_change or prepare(source, request.series.key, request.method, request.output_count, request.degree, request.backend) == request: # Проверяем каждый слой с его собственным решателем, чтобы оба варианта оставались на графике.
|
||||
retained.append(result)
|
||||
if len(retained) != len(self._results):
|
||||
self._results = retained
|
||||
@@ -173,6 +181,7 @@ class SignalProcessingPanel(QWidget):
|
||||
|
||||
def _update_actions(self):
|
||||
self.degree.setEnabled(self.method.currentData() == "polynomial")
|
||||
self.backend.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)
|
||||
@@ -250,9 +259,12 @@ class SignalProcessingPanel(QWidget):
|
||||
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"СКО на измерениях: {result.rmse:.6g}; расчёт: {result.elapsed_ms:.3f} мс. " # Показываем точность и время одного и того же результата, включая преобразование буферов.
|
||||
+ (f"Повторы X усреднены: {duplicates}. " if duplicates else "")
|
||||
+ f"Расчётных кривых: {len(self._results)}. Исходные данные сохранены.")
|
||||
comparable = [curve for curve in self._results if replace(curve.request, backend=signature.backend) == signature] if signature.method == "polynomial" else [] # Сопоставляем только одинаковые данные, степень, сетку и окно; различается исключительно решатель.
|
||||
if len(comparable) > 1: # Сравнение появляется после явного расчёта обоих вариантов на одном наборе данных.
|
||||
self.status.setText(self.status.text() + "\n" + " | ".join(f"{BACKENDS[curve.request.backend]}: СКО {curve.rmse:.6g}, {curve.elapsed_ms:.3f} мс" for curve in comparable)) # Сохраняем метрики обоих запусков рядом, не объявляя победителя по единичному замеру.
|
||||
self._update_actions()
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
"""Reusable background workers for dense plots and viewport measurements."""
|
||||
from collections import deque # Кольцевые очереди живого графика требуют единого снимка между проходами амплитудной статистики и частоты.
|
||||
try:
|
||||
from PySide6.QtCore import QObject, QRunnable, Signal
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QObject, QRunnable, Signal
|
||||
from set_devices.plot_decimation import pixel_envelope, viewport_samples
|
||||
from set_devices.plot_decimation import pixel_envelope, render_envelope, viewport_samples # Рабочее задание поддерживает точный прежний формат и компактную экранную огибающую для аналогового графика.
|
||||
from set_devices.oscilloscope import measurements
|
||||
from set_devices.background_work import WorkBudget # Даже фоновый Python-расчёт должен регулярно отдавать GIL многочисленным обработчикам интерфейса Qt.
|
||||
|
||||
class EnvelopeSignals(QObject):
|
||||
ready = Signal(str, object, object)
|
||||
|
||||
|
||||
class EnvelopeJob(QRunnable):
|
||||
def __init__(self, key, geometry, timestamps, values, left, right, width, signals):
|
||||
def __init__(self, key, geometry, timestamps, values, left, right, width, signals, compact=False): # Необязательный режим оставляет прежний контракт внешних пользователей, а аналоговый график включает два экстремума на столбец.
|
||||
super().__init__()
|
||||
self.args = (timestamps, values, left, right, width)
|
||||
self.key, self.geometry, self.signals = key, geometry, signals
|
||||
self.select = render_envelope if compact else pixel_envelope # Выбор алгоритма выполняется один раз при создании задания, не внутри многомиллионного обхода.
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
points = tuple(pixel_envelope(*self.args))
|
||||
points = tuple(self.select(*self.args)) # Фоновый результат сохраняет точные пики и разрывы в согласованном с рисователем экранном бюджете.
|
||||
except Exception:
|
||||
points = None
|
||||
try:
|
||||
@@ -31,18 +34,43 @@ class NavigationSignals(QObject):
|
||||
ready = Signal(int, object, object)
|
||||
|
||||
|
||||
class _NavigationSamples: # Повторяемая выборка окна позволяет измерять исходные ряды без копии миллионов пар координат.
|
||||
def __init__(self, times, values, bounds, check_cancelled): # Храним источники задания; вызывающий код создаёт снимок изменяемых очередей перед численными проходами.
|
||||
self.times, self.values, self.bounds, self.check_cancelled = times, values, bounds, check_cancelled # Ссылки сохраняют исходную точность и порядок без экранного сокращения.
|
||||
self.count = 0 # Число отсчётов определяется при первом полном проходе и включает нечисловые амплитуды, как прежний список.
|
||||
|
||||
def __iter__(self): # Статистики и частота независимо обходят то же окно, не удерживая промежуточные пары в памяти.
|
||||
count = 0 # Каждый проход считает только точки внутри включительных временных границ.
|
||||
for index, (x, y) in enumerate(viewport_samples(self.times, self.values, self.bounds.left, self.bounds.right)): # Индексируемый источник ищет окно двоичным поиском; потоковый сохраняет прежний порядок.
|
||||
if index % 1024 == 0: # Проверяем отмену через 1024 просмотренных отсчёта, даже когда неупорядоченный ряд лежит вне окна.
|
||||
self.check_cancelled() # Не продолжаем чтение старого диапазона после нового жеста навигации.
|
||||
if self.bounds.left <= x <= self.bounds.right: # Включаем обе границы и все дубликаты временных меток, как исходная реализация.
|
||||
count += 1 # Счётчик соответствует прежнему len(points), независимо от конечности амплитуды.
|
||||
yield x, y # Пара существует только на время текущего численного прохода и не копируется в большой список.
|
||||
self.count = count # Публикуем количество только после полного прохода, чтобы отменённая выборка не выдавала частичный результат.
|
||||
|
||||
|
||||
class NavigationJob(QRunnable):
|
||||
def __init__(self, revision, series, bounds, emitter):
|
||||
def __init__(self, revision, series, bounds, emitter, cancelled=None): # Необязательное событие отмены сохраняет совместимость общих графиков и останавливает устаревшие измерения.
|
||||
super().__init__()
|
||||
self.revision, self.series, self.bounds, self.emitter = revision, series, bounds, emitter
|
||||
self.cancelled = cancelled # Событие принадлежит одному заданию и не сбрасывается при запуске расчёта нового окна.
|
||||
self.work_budget = WorkBudget(cancelled) # Короткие добровольные паузы предотвращают многосекундное ожидание GIL главным потоком.
|
||||
|
||||
def _check_cancelled(self): # Проверяем отмену на границах этапов и небольших блоков исходных отсчётов.
|
||||
self.work_budget() # Одновременно прекращаем устаревшие измерения и регулярно освобождаем GIL для рисования и событий мыши.
|
||||
|
||||
def run(self):
|
||||
stats = {}
|
||||
try:
|
||||
for key, times, values in self.series:
|
||||
points = [(x, y) for x, y in viewport_samples(times, values, self.bounds.left, self.bounds.right)
|
||||
if self.bounds.left <= x <= self.bounds.right]
|
||||
stats[key] = measurements(points), len(points)
|
||||
self._check_cancelled() # Не начинаем обход следующего канала после возобновления перемещения или изменения масштаба.
|
||||
samples = _NavigationSamples(times, values, self.bounds, self._check_cancelled) # Повторяемый вид файлового окна устраняет копирование миллионов пар и сохраняет отмену чтения источника.
|
||||
ordered = getattr(times, "ordered", isinstance(times, (list, tuple, range))) # Применяем тот же контракт упорядоченности, что и viewport_samples, включая смещённые файловые координаты.
|
||||
points = tuple(samples) if isinstance(times, deque) or isinstance(values, deque) or not ordered else samples # Только живые очереди и неупорядоченный путь получают единый снимок для согласованных амплитуд и частоты.
|
||||
result = measurements(points, self._check_cancelled) # Передаём отмену внутрь всех численных этапов, чтобы новый жест не ждал окончания полного среднего или СКЗ.
|
||||
stats[key] = result, samples.count # Количество относится к завершённому проходу того же окна либо к первоначальному снимку живого ряда.
|
||||
self._check_cancelled() # При отмене во время вычислений не продолжаем остальные каналы и не публикуем устаревшие значения.
|
||||
self.emitter.ready.emit(self.revision, stats, self.bounds)
|
||||
except Exception:
|
||||
try:
|
||||
|
||||
@@ -6,6 +6,8 @@ from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from threading import Lock
|
||||
import math # Кодируем разрывы кривой нечисловыми значениями Y, сохраняя упорядоченные координаты X для нативного движка.
|
||||
from set_devices.plot_decimation import pixel_envelope # Передаём движку только точки, различимые при текущем масштабе изображения.
|
||||
try:
|
||||
from PySide6.QtCore import QObject, QRunnable, Signal
|
||||
except ImportError:
|
||||
@@ -44,19 +46,26 @@ class NativePlotEngine:
|
||||
self.process = subprocess.Popen([str(self.executable)], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, bufsize=0,
|
||||
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), cwd=self.executable.parent)
|
||||
key = (id(times), id(values))
|
||||
key = (id(times), id(values), len(times), len(values), bounds.left, bounds.right, width) # Выборка зависит от исходного снимка, окна X и физической ширины изображения; масштаб Y её не меняет.
|
||||
record = self.records.get(key)
|
||||
if record is None:
|
||||
self.serial += 1
|
||||
path = Path(self.directory.name) / f'{self.serial}.bin'
|
||||
points = [] # Временный список ограничен экранной шириной и не содержит миллионов исходных измерений.
|
||||
for point in pixel_envelope(times, values, bounds.left, bounds.right, width): # Сохраняем первый, минимальный, максимальный и последний отсчёты каждого пиксельного столбца.
|
||||
if point is not None: # Конечные измерения передаются с исходными координатами и амплитудами.
|
||||
points.append(point) # Экстремумы остаются видимыми даже для импульса длиной в один исходный отсчёт.
|
||||
elif points and math.isfinite(points[-1][1]): # Повторные маркеры одного разрыва не должны увеличивать размер передаваемого ряда.
|
||||
points.append((points[-1][0], math.nan)) # Дублированная метка времени и NaN в Y разрывают путь без нарушения порядка X.
|
||||
with path.open('wb') as stream:
|
||||
array('d', times).tofile(stream)
|
||||
array('d', values).tofile(stream)
|
||||
record = (path, times, values)
|
||||
array('d', (point[0] for point in points)).tofile(stream) # Записываем только экранные координаты X в прежнем формате 64-битных чисел.
|
||||
array('d', (point[1] for point in points)).tofile(stream) # Массив Y соответствует сокращённому X и включает маркеры разрывов.
|
||||
record = (path, times, values, len(points)) # Ссылки сохраняют идентичность исходного снимка; длина описывает только передаваемый экранный ряд.
|
||||
self.records[key] = record
|
||||
if len(self.records) > 4:
|
||||
self.records.pop(next(iter(self.records)))
|
||||
request = dict(file=str(record[0]), count=len(times), width=width, height=height,
|
||||
expired = self.records.pop(next(iter(self.records))) # Оставляем четыре последние экранные выборки, как и в кэше нативного процесса.
|
||||
expired[0].unlink(missing_ok=True) # Освобождаем устаревший временный файл, чтобы навигация не накапливала данные на диске.
|
||||
request = dict(file=str(record[0]), count=record[3], width=width, height=height, # Нативному процессу сообщается фактическое число отрисовываемых точек вместо полного размера записи.
|
||||
left=bounds.left, right=bounds.right, bottom=bounds.bottom, top=bounds.top, color=color)
|
||||
self.process.stdin.write((json.dumps(request)+'\n').encode())
|
||||
self.process.stdin.flush()
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Переиспользуемые Qt-панель и диалог сравнения с CSV-эталоном.""" # График, контейнер кривой и папки передаются потребителем.
|
||||
import json # Отчёт сохраняет показатели и параметры сравнения в переносимом формате.
|
||||
from pathlib import Path # Имя файла используется в статусе, полный путь — при записи отчёта.
|
||||
import re # Единицы в скобках проверяются до сравнения каналов.
|
||||
|
||||
try: # Один порт поддерживает современные и совместимые Windows-сборки.
|
||||
from PySide6.QtCore import QThread, Qt, Signal # Qt6 доставляет результаты рабочего потока в интерфейс.
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Используем стандартные элементы Qt6; создание ячеек с единицами и подсказками выполняет общий заполнитель таблицы.
|
||||
except ImportError: # Qt5 доступен на системах, где Qt6 не поддерживается.
|
||||
from PySide2.QtCore import QThread, Qt, Signal # Сохраняем тот же контракт сигналов и отмены на Qt5.
|
||||
from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Qt5 использует тот же заполнитель ячеек с единицами и подсказками, что современный порт.
|
||||
|
||||
from set_devices.signal_reference import compare, read_reference, trace_from_pairs # Единственное вычислительное ядро работает по всем исходным отсчётам.
|
||||
from set_devices.qt_ports.circuit_parameters import CircuitParameters # Необязательные известные R/L/C ограничивают идентификацию электрического эквивалента.
|
||||
from protocan.plot import Viewport # Общий контейнер области просмотра не зависит от конкретного виджета графика.
|
||||
from set_devices.qt_ports.reference_results import ComparisonAnalysis, populate_comparison_table # Анализ, единицы показателей и подсказки одинаковы в отдельном и встроенном виде.
|
||||
|
||||
|
||||
def _model_channels(sources, references, cache, cancel): # Подготовка связанных физических каналов выполняется только в рабочем потоке.
|
||||
measured = {} # Сохраняем исходные временные метки для совместной проверки Uce/Uge/Ic.
|
||||
for index, (signal, points) in enumerate(sources): # Истории и преобразования соответствуют одному снимку измерительной записи.
|
||||
if re.match(r'^\s*(uce|uge|ic)\b', signal.name, re.IGNORECASE): # Не копируем произвольные дополнительные каналы, не участвующие в электрической модели.
|
||||
if not points: # Отсутствующий связанный канал не должен ломать корректное сравнение выбранного измерения.
|
||||
continue # Совместный анализ сообщит недостаточный набор физических наблюдений.
|
||||
trace = cache.get(index) # Выбранный канал уже подготовлен основным сравнением.
|
||||
if trace is None: # Остальные физические каналы преобразуются только при первом использовании.
|
||||
trace = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Множители применяются в точности как на исходном графике.
|
||||
cache[index] = trace # Следующая настройка RLC не требует повторной копии полной истории.
|
||||
measured[str(index)] = trace # Индекс сохраняет неоднозначные одинаковые имена для проверки, не затирая их словарём.
|
||||
return dict(measured=measured, reference=references) # Сдвиг и общие начала координат добавляет независимое вычислительное ядро.
|
||||
|
||||
|
||||
def _stop_owned_worker(state): # Уничтожение родителя QWidget может обойти closeEvent вложенной панели.
|
||||
worker = state[0] # Состояние не обращается к уже уничтожаемой Python-обёртке виджета.
|
||||
if worker is not None: # Завершённые задания удаляются из держателя заранее.
|
||||
worker.requestInterruption() # Операция увидит отмену через штатный callback QThread.
|
||||
worker.wait() # QObject удаляет дочерний QThread только после выхода его run.
|
||||
|
||||
|
||||
class ComparisonWorker(QThread): # Чтение CSV и численный проход выполняются вне главного потока Qt.
|
||||
result = Signal(object) # Готовый результат передаётся интерфейсу без изменения исходных данных.
|
||||
failed = Signal(str) # Ошибка передаётся текстом и не оставляет частичный отчёт.
|
||||
|
||||
def __init__(self, operation, parent): # Операция принимает callback проверки отмены.
|
||||
super().__init__(parent) # Диалог владеет временем жизни рабочего потока.
|
||||
self.operation = operation # Одинаковый worker обслуживает загрузку и расчёт.
|
||||
|
||||
def run(self): # Qt вызывает этот метод в отдельном потоке.
|
||||
try: # Любая ошибка должна вернуться в UI, а не прервать главный цикл событий.
|
||||
self.result.emit(self.operation(self.isInterruptionRequested)) # Передаём операции штатный запрос отмены QThread.
|
||||
except (ValueError, OSError, InterruptedError) as error: # Ошибки данных, файлов и отмена уже содержат понятную причину.
|
||||
self.failed.emit(str(error)) # Показываем исходную причину без дополнительной обёртки.
|
||||
except Exception as error: # Неожиданная ошибка также должна завершить состояние занятости.
|
||||
self.failed.emit(f'Ошибка сравнения: {error}') # Уточняем источник неизвестной ошибки для пользователя.
|
||||
|
||||
|
||||
class _ReferenceComparisonView: # Панель и отдельный диалог используют одну реализацию параметров, worker и результата.
|
||||
def __init__(self, sources, parent=None, source_path='', *, plot_factory, series_factory, path_provider=None): # sources содержит пары объекта с name/display_value и неизменяемой полной истории.
|
||||
super().__init__(parent) # Родитель определяет окно и владение диалогом.
|
||||
self._series_factory = series_factory # Фабрика адаптирует общие Trace к кривым графика потребителя.
|
||||
self._dialog_path = path_provider or (lambda filename: filename) # Без callback используем стандартные относительные имена файлов.
|
||||
self.setWindowTitle('Сравнение с эталоном') # Заголовок одинаков для всех приложений, использующих порт.
|
||||
self.sources = sources # История фиксируется вызывающей стороной на время жизни диалога.
|
||||
self.source_path = source_path # Происхождение измерения включается в JSON-отчёт.
|
||||
self.references = {} # Каналы эталона появятся после успешного чтения CSV.
|
||||
self.reference_path = '' # Путь изменяется только после принятия загруженного эталона.
|
||||
self._source_cache = {} # Повторный расчёт не должен повторно применять множители к измерению.
|
||||
self._worker = None # Одновременно допускается только одна загрузка или вычисление.
|
||||
self._worker_state = [None] # Независимый держатель доступен обработчику destroyed без обращения к self.
|
||||
self.destroyed.connect(lambda *_args, state=self._worker_state: _stop_owned_worker(state)) # Останавливаем поток до удаления C++-детей даже при deleteLater родителя.
|
||||
self._closing = False # Флаг откладывает закрытие до завершения отменённого worker.
|
||||
self.comparison = None # До расчёта отчёт и график результата отсутствуют.
|
||||
root = QVBoxLayout(self) # Вертикальный макет объединяет параметры, результат и экспорт.
|
||||
row = QHBoxLayout() # Первая строка отвечает за выбор файлов и каналов.
|
||||
self.load_button = QPushButton('Открыть эталон CSV…') # Эталон загружается отдельно от исходного измерения.
|
||||
self.load_button.clicked.connect(self._open_reference) # Выбор файла запускает фоновое чтение.
|
||||
self.source_combo, self.reference_combo = QComboBox(), QComboBox() # Каналы измерения и эталона выбираются независимо.
|
||||
for signal, _ in sources: # Показываем все переданные каналы с непустой историей.
|
||||
self.source_combo.addItem(signal.name) # Имя берётся из общего контракта источника.
|
||||
row.addWidget(self.load_button) # Кнопка открытия предшествует выбору каналов.
|
||||
for label, control in (('Измерение', self.source_combo), ('Эталон', self.reference_combo)): # Подписи явно различают обе стороны сравнения.
|
||||
row.addWidget(QLabel(label)) # Подпись относится к следующему списку каналов.
|
||||
row.addWidget(control, 1) # Оба списка делят свободное место поровну.
|
||||
root.addLayout(row) # Завершаем строку выбора данных.
|
||||
row = QHBoxLayout() # Вторая строка задаёт временное совмещение и допуски.
|
||||
self.align_start = QCheckBox('Совместить начала записей') # Позволяет сравнивать записи с разными абсолютными началами.
|
||||
self.align_start.setChecked(True) # По умолчанию сравниваем форму от начала каждой записи.
|
||||
self.align_start.setToolTip('Совмещает первые отсчёты. Задержка самого переключения сохраняется.') # Объясняем отличие совмещения начала от поиска фронта.
|
||||
self.shift = self._number(-1e12, 1e12, 6) # Диапазон ±10^12 мкс и шесть знаков сохраняет прежнюю точность ввода сдвига.
|
||||
self.shift.setSuffix(' мкс') # В интерфейсе время вводится в микросекундах.
|
||||
self.shift.setToolTip('Положительное значение сдвигает эталон вправо') # Явно задаём знак временного сдвига.
|
||||
self.absolute = self._number(0., 1e12, 6) # Абсолютный допуск неотрицателен и измеряется в единицах канала.
|
||||
self.relative = self._number(0., 10000., 4) # Относительная часть допуска задаётся в процентах с четырьмя знаками.
|
||||
self.relative.setSuffix(' %') # Единица отделяет проценты от абсолютной амплитуды.
|
||||
row.addWidget(self.align_start) # Опция совмещения находится рядом со сдвигом.
|
||||
for label, control in (('Сдвиг эталона', self.shift), ('Допуск ±', self.absolute), ('+ от |эталона|', self.relative)): # Подписи отражают сумму абсолютной и процентной частей.
|
||||
row.addWidget(QLabel(label)) # Связываем пояснение с соответствующим полем.
|
||||
row.addWidget(control) # Ограниченные по ширине поля оставляют место всем параметрам.
|
||||
root.addLayout(row) # Добавляем настройки совмещения в общий макет.
|
||||
row = QHBoxLayout() # Третья строка управляет интервалом, расчётом и видом результата.
|
||||
self.limit_interval = QCheckBox('Только интервал, мкс') # Флажок включает ограничение расчёта по времени.
|
||||
self.start, self.stop = self._number(-1e12, 1e12, 6), self._number(-1e12, 1e12, 6) # Границы используют ту же шкалу мкс и точность, что сдвиг.
|
||||
self.stop.setValue(300.) # Начальный интервал 0–300 мкс сохраняет прежнее поведение диалога.
|
||||
self.visible_button = QPushButton('Из видимого участка') # Текущий масштаб графика можно использовать как интервал расчёта.
|
||||
self.visible_button.clicked.connect(self._visible_interval) # Переводим границы графика из мс в мкс.
|
||||
self.calculate_button = QPushButton('Сравнить') # Расчёт запускается явно после выбора параметров.
|
||||
self.calculate_button.clicked.connect(self._calculate) # Параметры фиксируются перед запуском worker.
|
||||
self.cancel_button = QPushButton('Отмена расчёта') # Длинную загрузку или расчёт можно прервать.
|
||||
self.cancel_button.clicked.connect(self._cancel) # Используем кооперативную отмену без остановки потока извне.
|
||||
self.mode = QComboBox() # Режим отображения не меняет уже вычисленные показатели.
|
||||
self.mode.addItems(['Наложение', 'Отклонение: измерение − эталон']) # Сначала показываем две кривые в общем масштабе.
|
||||
self.mode.currentIndexChanged.connect(self._show_plot) # Переключаем кривые без повторного численного прохода.
|
||||
for control in (self.limit_interval, self.start, QLabel('…'), self.stop, self.visible_button, self.calculate_button, self.cancel_button, self.mode): # Сохраняем порядок интервал → расчёт → вид.
|
||||
row.addWidget(control) # Каждый элемент относится к одной строке управления результатом.
|
||||
root.addLayout(row) # Завершаем область параметров.
|
||||
self.status = QLabel('Откройте эталон. Сравниваются все отсчёты выбранного канала после преобразования амплитуды.') # Общий порт не предполагает конкретный формат преобразования источника.
|
||||
self.status.setWordWrap(True) # Длинные ошибки CSV не расширяют окно.
|
||||
root.addWidget(self.status) # Состояние видно над графиком.
|
||||
self.circuit = CircuitParameters(self) # Неизвестные элементы по умолчанию не подменяются учебными номиналами.
|
||||
self.circuit.changed.connect(self._invalidate) # Изменение электрического ограничения делает прежние параметры модели неактуальными.
|
||||
root.addWidget(self.circuit) # Опциональные R/L/C доступны перед строкой анализа и результатами.
|
||||
self.analysis = ComparisonAnalysis(self) # Строка возможной причины и подробный отчёт повторяют подход Android GUI.
|
||||
root.addWidget(self.analysis) # Анализ располагается рядом с результатом, не изменяя исходные кривые.
|
||||
self.legend = QLabel() # Цветная легенда поясняет текущие кривые.
|
||||
root.addWidget(self.legend) # Легенда отделена от численной таблицы.
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal) # Пользователь меняет соотношение ширины графика и таблицы.
|
||||
self.plot = plot_factory() # Конкретный canvas создаётся и настраивается адаптером приложения.
|
||||
splitter.addWidget(self.plot) # QSplitter принимает любой совместимый QWidget графика.
|
||||
self.table = QTableWidget(0, 2) # Каждому показателю соответствует название и значение.
|
||||
self.table.setHorizontalHeaderLabels(['Показатель', 'Значение']) # Заголовки поясняют две колонки отчёта.
|
||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Показатели расчёта недоступны для ручной правки.
|
||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) # Названия и числа видны полностью по содержимому.
|
||||
self.table.setMinimumWidth(370) # Минимум 370 логических пикселей сохраняет читаемость показателей.
|
||||
splitter.addWidget(self.table) # Таблица располагается справа от кривых.
|
||||
splitter.setSizes([920, 380]) # Начальные ширины в логических пикселях отдают большую часть окна графику.
|
||||
root.addWidget(splitter, 1) # Результат получает всё свободное место по высоте.
|
||||
row = QHBoxLayout() # Нижняя строка содержит экспорт и пояснение алгоритма.
|
||||
self.report_button = QPushButton('Сохранить отчёт JSON…') # Экспорт доступен только для актуального результата.
|
||||
self.report_button.clicked.connect(self._save_report) # Имя файла предлагает внедрённый provider путей.
|
||||
row.addWidget(self.report_button) # Кнопка экспорта расположена перед пояснением.
|
||||
note = QLabel('Δ = измерение − эталон. Линейная интерполяция эталона; без экстраполяции и заполнения пропусков.') # Формула и ограничения относятся ко всем приложениям-потребителям.
|
||||
note.setWordWrap(True) # Текст остаётся читаемым при изменении размера окна.
|
||||
row.addWidget(note, 1) # Пояснение занимает оставшуюся ширину строки.
|
||||
root.addLayout(row) # Завершаем макет экспорта.
|
||||
self._inputs = [self.load_button, self.source_combo, self.reference_combo, self.align_start, self.shift, self.absolute, self.relative, self.limit_interval, self.start, self.stop, self.visible_button] # Во время расчёта входные данные не меняются.
|
||||
self._inputs.extend(self.circuit.controls) # Электрические ограничения также фиксируются на время рабочего задания.
|
||||
for control in (self.shift, self.absolute, self.relative, self.start, self.stop): # Любое числовое изменение делает результат устаревшим.
|
||||
control.valueChanged.connect(self._invalidate) # Сбрасываем старые кривые и отчёт до следующего расчёта.
|
||||
for control in (self.align_start, self.limit_interval): # Флажки также влияют на вычисляемую временную сетку.
|
||||
control.toggled.connect(self._invalidate) # Показатели не должны относиться к прежним условиям.
|
||||
self.source_combo.currentIndexChanged.connect(self._match_reference) # При смене измерения ищем одноимённый эталон.
|
||||
self.reference_combo.currentIndexChanged.connect(self._invalidate) # Смена эталонного канала требует нового расчёта.
|
||||
self._busy(False) # Настраиваем начальную доступность кнопок.
|
||||
QApplication.instance().aboutToQuit.connect(self._shutdown) # Завершение приложения дожидается отмены рабочего потока.
|
||||
|
||||
@staticmethod # Фабрика числового поля не зависит от состояния диалога.
|
||||
def _number(low, high, decimals): # Границы и число десятичных знаков задаются для конкретной величины.
|
||||
box = QDoubleSpinBox() # Штатный элемент проверяет числовой ввод.
|
||||
box.setDecimals(decimals) # Сохраняем требуемую точность времени или допуска.
|
||||
box.setRange(low, high) # Ограничиваем значения допустимым диапазоном параметра.
|
||||
box.setMaximumWidth(150) # Ширина 150 логических пикселей сохраняет компактную строку параметров.
|
||||
return box # Вызывающий код задаёт единицы и обработчик изменений.
|
||||
|
||||
def _busy(self, busy): # Единый переход состояния используется загрузкой и расчётом.
|
||||
for control in self._inputs: # Все параметры относятся к одному снимку задания.
|
||||
control.setEnabled(not busy) # Исключаем изменение входа работающего worker.
|
||||
self.calculate_button.setEnabled(not busy and bool(self.references) and bool(self.sources)) # Без измерения или эталона расчёт невозможен.
|
||||
self.cancel_button.setEnabled(busy) # Отменять можно только существующее задание.
|
||||
self.report_button.setEnabled(not busy and self.comparison is not None) # Отчёт относится исключительно к завершённому результату.
|
||||
|
||||
def _start_work(self, operation, receive): # Операция и получатель отделяют чтение файла от численного расчёта.
|
||||
self._busy(True) # Блокируем параметры до запуска потока.
|
||||
worker = ComparisonWorker(operation, self) # Диалог сохраняет владение worker до его завершения.
|
||||
self._worker = worker # Ссылка используется для отмены и отложенного закрытия.
|
||||
self._worker_state[0] = worker # Сигнал уничтожения владельца получает актуальную задачу.
|
||||
worker.result.connect(receive) # Результат применяется в главном потоке Qt.
|
||||
worker.failed.connect(self.status.setText) # Ошибка появляется в общей строке состояния.
|
||||
worker.finished.connect(self._finished) # Освобождение worker восстанавливает доступность элементов.
|
||||
worker.start() # Начинаем фоновое чтение или расчёт.
|
||||
|
||||
def _finished(self): # Вызывается после штатного завершения и после отмены.
|
||||
worker, self._worker = self._worker, None # Снимаем состояние занятости до возможного закрытия диалога.
|
||||
self._worker_state[0] = None # Уничтожение родителя больше не обращается к завершённому worker.
|
||||
if worker is None: # Синхронное закрытие могло уже очистить поток до доставки queued-сигнала.
|
||||
return # Не повторяем удаление и не обновляем закрытую панель.
|
||||
worker.deleteLater() # Qt безопасно удаляет завершившийся поток через очередь событий.
|
||||
self._busy(False) # Разрешаем новый расчёт либо экспорт принятого результата.
|
||||
if self._closing: # Запрос закрытия во время работы должен завершиться автоматически.
|
||||
self.close() # Повторное закрытие уже не встречает активный worker.
|
||||
|
||||
def _cancel(self): # Отмена кооперативна и проверяется вычислительным ядром.
|
||||
if self._worker is not None: # Не обращаемся к уже завершённому потоку.
|
||||
self._worker.requestInterruption() # Ядро увидит запрос в ближайшей контрольной точке.
|
||||
self.status.setText('Остановка расчёта…') # Пользователь видит, что запрос принят.
|
||||
|
||||
def _shutdown(self): # Перед выходом процесса нельзя уничтожать работающий QThread.
|
||||
self._closing = True # Поздние сигналы не должны публиковать результат в закрывающемся интерфейсе.
|
||||
if self._worker is not None: # Ожидание требуется только при активном задании.
|
||||
worker = self._worker # Сохраняем объект до очистки общего держателя.
|
||||
worker.requestInterruption() # Просим ядро прервать чтение или расчёт.
|
||||
worker.wait() # Дожидаемся остановки до уничтожения объектов Qt.
|
||||
self._worker = None # Смена записи может немедленно удалить закрытую панель.
|
||||
self._worker_state[0] = None # Сигнал destroyed не обращается к удалённому потоку повторно.
|
||||
worker.deleteLater() # Завершённый QThread безопасно освобождается очередью Qt.
|
||||
|
||||
def _invalidate(self, *_): # Аргументы сигналов Qt не нужны для сброса результата.
|
||||
self.comparison = None # Старые показатели больше не соответствуют выбранным параметрам.
|
||||
self.analysis.set_result(None) # После изменения ввода прежняя гипотеза больше не является актуальной.
|
||||
self.table.setRowCount(0) # Убираем устаревшие численные значения.
|
||||
self.report_button.setEnabled(False) # Запрещаем экспорт прежнего результата.
|
||||
self.plot._series = [] # Старые кривые также не должны выглядеть актуальными.
|
||||
self.legend.clear() # Легенда очищается вместе с кривыми.
|
||||
self.plot._sync_bounds() # Адаптер графика пересчитывает пустую область просмотра.
|
||||
self.plot.update() # Запрашиваем перерисовку в главном потоке.
|
||||
if self.references: # После загрузки эталона подсказываем следующий шаг.
|
||||
self.status.setText('Параметры изменены — нажмите «Сравнить».') # Новый результат получается только явным расчётом.
|
||||
|
||||
def _match_reference(self, *_): # Смена канала измерения ищет одноимённый столбец эталона.
|
||||
name = self.source_combo.currentText().strip().casefold() # Регистр и крайние пробелы не мешают совпадению имён.
|
||||
for index in range(self.reference_combo.count()): # Проверяем доступные каналы текущего эталона.
|
||||
if self.reference_combo.itemText(index).strip().casefold() == name: # Сопоставляем полное имя вместе с единицами.
|
||||
self.reference_combo.setCurrentIndex(index) # Предлагаем канал, соответствующий измерению.
|
||||
break # Уникальные имена не требуют дальнейшего поиска.
|
||||
self._invalidate() # Даже без совпадения старые показатели должны быть удалены.
|
||||
|
||||
def _open_reference(self): # Правила начальной папки задаёт callback приложения.
|
||||
path, _ = QFileDialog.getOpenFileName(self, 'Открыть эталон', self._dialog_path(''), 'CSV (*.csv)') # Общая библиотека не создаёт каталоги самостоятельно.
|
||||
if path: # Отмена выбора файла сохраняет текущий эталон.
|
||||
self.load_reference(path) # Чтение выполняется отдельным worker.
|
||||
|
||||
def load_reference(self, path): # Публичный вход позволяет потребителю открыть эталон программно.
|
||||
self._invalidate() # Новая загрузка не должна показывать старый отчёт как актуальный.
|
||||
self.status.setText('Загрузка эталона…') # Состояние обновляется до запуска чтения.
|
||||
def receive(traces): # Этот обработчик выполняется в потоке интерфейса.
|
||||
if self._closing: # Синхронное закрытие могло опередить доставку готового чтения.
|
||||
return # Не обновляем элементы закрываемой панели поздним результатом.
|
||||
self.references = traces # Принимаем только полностью прочитанный набор каналов.
|
||||
self.reference_path = str(path) # Путь отчёта соответствует принятому файлу.
|
||||
self.reference_combo.clear() # Удаляем имена каналов предыдущего эталона.
|
||||
self.reference_combo.addItems(list(traces)) # Сохраняем порядок столбцов CSV.
|
||||
self._match_reference() # Автоматически выбираем одноимённый канал при наличии.
|
||||
self.status.setText(f'Эталон: {Path(path).name}. Выберите каналы и нажмите «Сравнить».') # Подтверждаем успешную загрузку файла.
|
||||
self._start_work(lambda cancel: read_reference(path, cancel), receive) # Ядро получает callback отмены чтения.
|
||||
|
||||
def _visible_interval(self): # Видимая область графика задаёт границы расчёта.
|
||||
bounds = self.plot.visible_bounds # Контракт графика возвращает время в миллисекундах.
|
||||
left, right = bounds.left * 1000, bounds.right * 1000 # Множитель 1000 переводит миллисекунды в микросекунды UI.
|
||||
self.limit_interval.setChecked(True) # Явно включаем использование выбранных границ.
|
||||
self.start.setValue(left) # Левая граница отсчитывается от начала измерения.
|
||||
self.stop.setValue(right) # Правая граница выражена в той же шкале.
|
||||
|
||||
def _calculate(self): # Фиксируем параметры до передачи задания в поток.
|
||||
if not self.references or not self.sources or self._worker is not None: # Не запускаем неполное или параллельное сравнение.
|
||||
return # Сохраняем существующее задание и состояние входов.
|
||||
source_index = self.source_combo.currentIndex() # Индекс связывает выбранное имя с исходным снимком.
|
||||
signal, points = self.sources[source_index] # Полная история не зависит от экранного окна.
|
||||
reference = self.references[self.reference_combo.currentText()] # Выбираем один канал загруженного эталона.
|
||||
units = [re.search(r'\(([^()]*)\)\s*$', name) for name in (signal.name, reference.name)] # Единицы распознаются по конечным скобкам имени.
|
||||
if all(units) and units[0].group(1) != units[1].group(1): # Явно разные единицы нельзя сравнивать как одинаковые амплитуды.
|
||||
self._invalidate() # Предыдущий результат не относится к новому сочетанию каналов.
|
||||
self.status.setText('Единицы каналов различаются. Выберите каналы с одинаковыми единицами.') # Пользователь выбирает корректную пару.
|
||||
return # Не выполняем численный проход с несовместимыми единицами.
|
||||
options = dict(align_start=self.align_start.isChecked(), shift_ms=self.shift.value() / 1000., # Деление на 1000 переводит микросекунды UI в миллисекунды ядра.
|
||||
absolute_tolerance=self.absolute.value(), relative_tolerance_pct=self.relative.value(), # Допуски уже заданы в единицах ядра.
|
||||
interval=(self.start.value() / 1000., self.stop.value() / 1000.) if self.limit_interval.isChecked() else None) # Ограничение времени также переводится из мкс в мс.
|
||||
self._invalidate() # До расчёта удаляем старые кривые и показатели.
|
||||
self.status.setText('Сравнение всех отсчётов…') # Явно указываем работу с полной записью.
|
||||
options['circuit'] = self.circuit.values() # Значения GUI переводятся в СИ до перехода в рабочий поток.
|
||||
sources, references = self.sources, self.references # Сохраняем единый снимок связанных каналов и выбранного CSV.
|
||||
def operation(cancel): # Подготовка амплитуд и сравнение выполняются в worker.
|
||||
measured = self._source_cache.get(source_index) # Кэш привязан к неизменяемому снимку канала.
|
||||
if measured is None: # Преобразуем исходные значения только при первом использовании канала.
|
||||
measured = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Применяем предоставленное источником преобразование амплитуд.
|
||||
self._source_cache[source_index] = measured # Повторные настройки допуска не требуют новой подготовки измерения.
|
||||
channels = _model_channels(sources, references, self._source_cache, cancel) # Синхронные Uce/Uge/Ic подготавливаются теми же преобразованиями амплитуд.
|
||||
return compare(measured, reference, cancel=cancel, channels=channels, **options) # Общее ядро выполняет полную статистику и идентификацию электрических моделей.
|
||||
self._start_work(operation, self._received) # Получатель публикует целый результат в главном потоке.
|
||||
|
||||
def _received(self, result): # Таблица и график обновляются из одного результата.
|
||||
if self._closing: # Готовый результат мог стоять в очереди во время смены записи.
|
||||
return # Закрытая панель не должна публиковать устаревшие метрики.
|
||||
self.comparison = result # Сохраняем данные для переключения вида и экспорта.
|
||||
self.analysis.set_result(result) # Краткий вывод обновляется вместе с полной таблицей сравнения.
|
||||
populate_comparison_table(self.table, result) # Единицы и подсказки названий и чисел совпадают с боковой таблицей исходного графика.
|
||||
self.status.setText(f'{result.measured.name} ↔ {Path(self.reference_path).name}: {result.reference.name}. ' # Статус указывает фактически сравниваемые каналы и файл.
|
||||
'Время от начала измерения; абсолютные ошибки — в единицах выбранного канала.') # Поясняем шкалу и единицы результата.
|
||||
self._show_plot() # Показываем текущий режим кривых без повторного расчёта.
|
||||
|
||||
def _show_plot(self, *_): # Сигнал выбора режима может передавать неиспользуемый индекс.
|
||||
if self.comparison is None: # До успешного расчёта график результата отсутствует.
|
||||
return # Не создаём кривые из устаревших данных.
|
||||
result = self.comparison # Кривые и остаток принадлежат одному расчёту.
|
||||
curves = [(result.measured, 'Измерение', '#2f91ff'), (result.reference, 'Эталон', '#ffae42')] # Синий и оранжевый различают стороны наложения.
|
||||
self.legend.setText('<span style="color:#2f91ff">━━ Измерение</span> ' # Цвет легенды совпадает с измеренной кривой.
|
||||
'<span style="color:#ffae42">━━ Эталон</span> — общий масштаб Y') # Адаптер обязан сохранить единую амплитудную шкалу.
|
||||
if self.mode.currentIndex() == 1: # Второй режим показывает только рассчитанную разность.
|
||||
curves = [(result.difference, 'Δ', '#ff7b7b')] # Красноватый цвет отделяет остаток от исходных кривых.
|
||||
self.legend.setText('<span style="color:#ff7b7b">━━ Δ = измерение − эталон</span>') # Формула фиксирует знак ошибки.
|
||||
self.plot._series = [self._series_factory(str(i), f'{label}: {trace.name}', color=color, timestamps=trace.times, values=trace.values) # Фабрика создаёт контейнер конкретного canvas без импорта приложения.
|
||||
for i, (trace, label, color) in enumerate(curves)] # Передаём полные массивы без экранного сокращения.
|
||||
self.plot.reset_interaction() # Новый вид начинает с нейтрального состояния навигации.
|
||||
self.plot.axis_lock_x.setChecked(False) # Освобождаем X для корректного показа нового результата.
|
||||
self.plot.axis_lock_y.setChecked(False) # Общий масштаб Y не наследует ручную фиксацию предыдущего режима.
|
||||
self.plot.axis_limits = None # Удаляем прежние ручные границы осей.
|
||||
self.plot._sync_bounds() # Адаптер рассчитывает общую область кривых.
|
||||
interval = result.metrics['interval_ms'] # При ограниченном расчёте показываем выбранный интервал.
|
||||
if interval is not None: # Полная запись остаётся видимой при отсутствии ограничения.
|
||||
bounds = self.plot.visible_bounds # После сброса границы соответствуют полной области результата.
|
||||
span = bounds.right - bounds.left # Нормируем миллисекунды в долю общей длительности.
|
||||
self.plot.viewport = Viewport(x=(interval[0] - bounds.left) / span, width=(interval[1] - interval[0]) / span) # Масштаб X соответствует выбранному интервалу ядра.
|
||||
self.plot.update() # Отложенная перерисовка выполняется главным потоком Qt.
|
||||
|
||||
def report(self): # Структуру можно получить программно без диалога сохранения.
|
||||
if self.comparison is None: # Экспортировать устаревший или отсутствующий результат запрещено.
|
||||
return None # Отсутствие результата обозначается явно.
|
||||
return dict(measured_file=self.source_path, measured_channel=self.comparison.measured.name, # Источник измерения сохраняется рядом с выбранным каналом.
|
||||
reference_file=self.reference_path, reference_channel=self.comparison.reference.name, # Эталонный файл и канал определяют вторую сторону сравнения.
|
||||
time_unit='ms from first measured sample', # Машиночитаемый отчёт использует миллисекунды ядра.
|
||||
interpolation='linear; no extrapolation; gaps excluded', # Ограничения интерполяции сохраняются вместе с результатом.
|
||||
tolerance='absolute + relative_pct / 100 * abs(reference)', # Формула позволяет воспроизвести коридор допуска.
|
||||
metrics=self.comparison.metrics, analysis=self.comparison.analysis) # Отчёт сохраняет также гипотезы, признаки и ограничения анализа.
|
||||
|
||||
def _save_report(self): # Сохранение файла выполняется только по явному действию пользователя.
|
||||
if self.comparison is None: # Защищаемся от программного вызова без расчёта.
|
||||
return # Не создаём пустой отчёт.
|
||||
path, _ = QFileDialog.getSaveFileName(self, 'Отчёт сравнения', self._dialog_path('reference_comparison.json'), 'JSON (*.json)') # Provider определяет начальную папку приложения.
|
||||
if path: # Отмена выбора не меняет состояние результата.
|
||||
try: # Файловые ошибки должны отображаться без закрытия диалога.
|
||||
Path(path).write_text(json.dumps(self.report(), ensure_ascii=False, indent=2) + '\n', encoding='utf-8') # UTF-8 и отступ в два пробела сохраняют читаемость русских имён.
|
||||
self.status.setText(f'Отчёт сохранён: {path}') # Подтверждаем фактический путь записанного отчёта.
|
||||
except OSError as error: # Недоступный каталог или файл не уничтожает рассчитанные данные.
|
||||
self.status.setText(str(error)) # Пользователь может выбрать другое место и повторить сохранение.
|
||||
|
||||
|
||||
class ReferenceComparisonPanel(_ReferenceComparisonView, QWidget): # Встроенный вид занимает область существующего графика без отдельного окна.
|
||||
back_requested = Signal() # Потребитель решает, какую страницу показать при возврате к записи.
|
||||
|
||||
def __init__(self, *args, **kwargs): # Контракт данных и фабрик совпадает с отдельным диалогом.
|
||||
super().__init__(*args, **kwargs) # Создаём общие параметры, таблицу и график через QWidget.
|
||||
self.back_button = QPushButton('К записи') # Возврат сохраняет загруженный эталон и параметры скрытой панели.
|
||||
self.back_button.clicked.connect(self.back_requested.emit) # Не связываем общую библиотеку с компоновкой конкретной вкладки.
|
||||
row = QHBoxLayout() # Отдельная короткая строка не сжимает параметры сравнения.
|
||||
row.addWidget(self.back_button) # Кнопка размещается у начала панели.
|
||||
row.addStretch(1) # Свободная ширина остаётся справа от кнопки возврата.
|
||||
self.layout().insertLayout(0, row) # Навигация предшествует выбору файлов и каналов.
|
||||
|
||||
def closeEvent(self, event): # Родитель может удалить панель сразу после close при смене записи.
|
||||
self._closing = True # Сигнал завершения worker больше не должен возобновлять использование панели.
|
||||
self._shutdown() # Синхронно отменяем и дожидаемся потока перед возможным deleteLater родителя.
|
||||
self.plot.close() # Освобождаем ресурсы графика после завершения вычислений.
|
||||
super().closeEvent(event) # QWidget принимает закрытие встроенной панели.
|
||||
|
||||
|
||||
class ReferenceComparisonDialog(_ReferenceComparisonView, QDialog): # Отдельное окно остаётся доступным другим потребителям общего модуля.
|
||||
def __init__(self, *args, **kwargs): # Используем тот же контракт источников и внедряемых фабрик.
|
||||
super().__init__(*args, **kwargs) # Общая реализация строит элементы внутри QDialog.
|
||||
self.setWindowModality(Qt.WindowModality.WindowModal) # Модальность ограничивается родительским окном.
|
||||
self.resize(1350, 880) # Размер в логических пикселях сохраняет прежний макет отдельного окна.
|
||||
|
||||
def reject(self): # Escape и программное закрытие откладываются до остановки worker.
|
||||
if self._worker is not None: # Работающий поток должен закончиться раньше удаления диалога.
|
||||
self._closing = True # Запоминаем запрос закрытия до сигнала finished.
|
||||
self._cancel() # Кооперативно прерываем операцию ядра.
|
||||
return # Слот _finished завершит закрытие после остановки.
|
||||
self.plot.close() # Освобождаем ресурсы конкретного canvas.
|
||||
super().reject() # Сохраняем стандартный API и сигналы QDialog.
|
||||
|
||||
def closeEvent(self, event): # Системная кнопка закрытия соблюдает тот же порядок отмены.
|
||||
if self._worker is not None: # Не уничтожаем QThread во время операции.
|
||||
self._closing = True # Запоминаем отложенное закрытие.
|
||||
self._cancel() # Запрос будет замечен ядром в контрольной точке.
|
||||
event.ignore() # Диалог существует до завершения worker.
|
||||
else: # Завершённый расчёт не препятствует закрытию.
|
||||
self.plot.close() # График завершает собственную фоновую работу.
|
||||
super().closeEvent(event) # Передаём QDialog штатное закрытие.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -7,8 +7,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections import deque # Храним ограниченную историю FD для отдельного просмотра диагностики.
|
||||
from ..can_adapter_settings import SlcanOptions, FD_LENGTHS # Профиль прошивки определяет команды и допустимые длины CAN FD.
|
||||
import re # Поиск ближайшего служебного байта за один проход сохраняет скорость при пакетном приёме большого числа CAN-кадров.
|
||||
|
||||
from .qt_compat import QObject, Signal
|
||||
from .qt_compat import QObject, Signal, QElapsedTimer, QTimer # Таймеры ограничивают ожидание ответов и откладывают выдачу кадров до окончания настройки.
|
||||
from .qt_compat import QSerialPort, QSerialPortInfo
|
||||
|
||||
|
||||
@@ -33,11 +36,7 @@ class SlcanChannel:
|
||||
return "slcan"
|
||||
|
||||
|
||||
_BITRATE_COMMANDS = {
|
||||
10_000: "S0", 20_000: "S1", 50_000: "S2", 100_000: "S3",
|
||||
125_000: "S4", 250_000: "S5", 500_000: "S6", 800_000: "S7",
|
||||
1_000_000: "S8",
|
||||
}
|
||||
_RECORD_END = re.compile(rb"[\r\n\x07]") # CR, LF и BEL имеют коды ASCII 13, 10 и 7; любой из них завершает запись протокола.
|
||||
|
||||
|
||||
def encode_frame(can_id: int, data: bytes, *, extended: bool | None = None) -> bytes:
|
||||
@@ -80,6 +79,7 @@ class SlcanAdapter(QObject):
|
||||
frame_received = Signal(int, bytes)
|
||||
capture_received = Signal(int, bytes, bool, bool)
|
||||
connection_lost = Signal(str)
|
||||
fd_received = Signal(int, bytes, bool, int, int) # Отдельный маршрут FD исключает ошибочный разбор кадра как Classic CAN.
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -88,10 +88,19 @@ class SlcanAdapter(QObject):
|
||||
self._serial.errorOccurred.connect(self._error)
|
||||
self._buffer = bytearray()
|
||||
self._listen_only = False
|
||||
self.options = SlcanOptions() # Без расширенных параметров остаётся прежний подтверждаемый протокол Lawicel.
|
||||
self.fd_history = deque(maxlen=500) # Не более 500 последних FD-кадров сохраняется между открытиями окна просмотра.
|
||||
self._query_prefix = None # Диагностические ответы отличаем от подтверждений настройки и CAN-кадров.
|
||||
self._query_reply = None # Полная строка ответа сохраняется только во время явного диагностического запроса.
|
||||
self._initializing = False # Открытие COM-порта ещё не подтверждает настройку CAN-контроллера.
|
||||
self._waiting_command = False # Отличаем подтверждение текущей команды от служебных строк рабочего потока.
|
||||
self._command_reply: bool | None = None # None означает ожидание, True — CR/ACK, False — BEL/отказ прошивки.
|
||||
self._after_cr = False # Следующий LF после CR относится к тому же окончанию строки, даже при раздельных USB-пакетах.
|
||||
self._pending_frames: list[tuple[int, bytes, bool]] = [] # Кадры при открытии CAN сохраняются до готовности подписчиков монитора.
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._serial.isOpen()
|
||||
return self._serial.isOpen() and not self._initializing # Считаем соединение готовым только после подтверждения всех команд настройки.
|
||||
|
||||
def scan(self) -> list[SlcanChannel]:
|
||||
# Windows не предоставляет надёжного признака протокола SLCAN.
|
||||
@@ -102,11 +111,11 @@ class SlcanAdapter(QObject):
|
||||
|
||||
def connect_channel(self, channel: SlcanChannel, bitrate: int,
|
||||
host_baudrate: int = 115200,
|
||||
listen_only: bool = False) -> None:
|
||||
listen_only: bool = False, *, options: SlcanOptions | None = None) -> None: # Расширенные параметры передаются отдельно от прежнего контракта подключения.
|
||||
selected = options or SlcanOptions() # Дефолт сохраняет C, Sn и O/L с ожиданием CR/BEL.
|
||||
commands = selected.commands(bitrate, listen_only) # Проверяем весь профиль до закрытия прежнего соединения и открытия COM.
|
||||
self.disconnect_channel()
|
||||
command = _BITRATE_COMMANDS.get(bitrate)
|
||||
if command is None:
|
||||
raise SlcanError(f"SLCAN не поддерживает {bitrate} бит/с")
|
||||
self.options = selected # Настройка ожидания команд нужна уже во время инициализации порта.
|
||||
self._serial.setPortName(channel.path)
|
||||
if host_baudrate not in (9600, 19200, 38400, 57600, 115200,
|
||||
230400, 460800, 921600):
|
||||
@@ -119,23 +128,67 @@ class SlcanAdapter(QObject):
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
raise SlcanError(f"Не удалось открыть {channel.path}: {self._serial.errorString()}")
|
||||
self._buffer.clear()
|
||||
self.fd_history.clear() # Журнал нового подключения не содержит сообщений прежнего устройства.
|
||||
self._listen_only = listen_only
|
||||
# Close first so reconnecting also works after an interrupted session.
|
||||
open_command = "L" if listen_only else "O"
|
||||
if self._serial.write(f"C\r{command}\r{open_command}\r".encode("ascii")) < 0:
|
||||
message = self._serial.errorString()
|
||||
self._serial.close()
|
||||
self._listen_only = False
|
||||
raise SlcanError(f"Не удалось настроить SLCAN: {message}")
|
||||
self._initializing = True # Во время настройки не выдаём ложный статус готовности и первые кадры с прежней скоростью CAN.
|
||||
try: # Любой отказ настройки должен закрыть порт и разрешить повторную попытку подключения.
|
||||
self._command("C", allow_rejection=True) # Закрываем прежний канал; BEL допустим, если канал уже закрыт по правилам Lawicel.
|
||||
for command in commands: # Профиль определяет порядок скорости, режима, фильтров, повторов и открытия.
|
||||
self._command(command) # Любой отказ прекращает настройку без опасного возврата к активному режиму.
|
||||
except SlcanError: # Отказ прошивки, неполная запись и отсутствие ответа одинаково означают неудачное подключение.
|
||||
self._serial.close() # Освобождаем COM-порт, чтобы пользователь мог исправить скорость или тип адаптера.
|
||||
self._buffer.clear() # Остатки неудачного обмена не должны повреждать строки следующего подключения.
|
||||
self._pending_frames.clear() # Не отправляем в монитор данные сеанса, который не удалось открыть.
|
||||
self._listen_only = False # Сбрасываем ограничение режима вместе с неудачным подключением.
|
||||
raise # Сохраняем точную причину отказа для статусной строки вызывающей вкладки.
|
||||
finally: # Признак настройки должен сбрасываться как при успехе, так и при исключении.
|
||||
self._initializing = False # После возврата вызывающая вкладка сможет установить правильную скорость и статус.
|
||||
QTimer.singleShot(0, self._read) # Выдаём накопленные первые кадры в следующем цикле Qt, после обновления настроек монитора.
|
||||
|
||||
def _command(self, command: str, *, allow_rejection: bool = False) -> None: # Последовательный обмен сохраняет синхронный контракт подключения всех вкладок.
|
||||
self._read() # Убираем ранее пришедшие ответы до начала ожидания новой команды.
|
||||
self._command_reply = None # Старое подтверждение не может подтверждать следующую настройку.
|
||||
self._waiting_command = True # Пустая строка CR или отдельный BEL теперь относятся к текущей команде.
|
||||
packet = (command + "\r").encode("ascii") # Каждая команда Lawicel передаётся отдельной строкой с завершающим ASCII CR.
|
||||
elapsed = QElapsedTimer() # Монотонное время не зависит от перевода системных часов Windows.
|
||||
elapsed.start() # Общий срок ожидания включает запись команды и все фрагменты ответа.
|
||||
try: # Флаг ожидания обязательно очищается при любом результате обмена.
|
||||
if self._serial.write(packet) != len(packet): # Частичная локальная запись также не гарантирует передачу полной команды адаптеру.
|
||||
raise SlcanError(f"SLCAN: не удалось передать {command}: {self._serial.errorString()}") # Указываем неотправленную команду и причину драйвера.
|
||||
if self.options.profile in ("canable", "canable2"): # Эти прошивки не предоставляют CR/BEL для подтверждения конфигурации.
|
||||
if self._serial.bytesToWrite() and not self._serial.waitForBytesWritten(self.options.command_timeout_ms): # Ждём фактическую передачу очереди Qt в драйвер, а не выдуманный ACK.
|
||||
raise SlcanError(f"CANable: не удалось отправить {command}") # Ошибка локального драйвера не считается успешным подключением.
|
||||
self._serial.waitForReadyRead(20) # Пауза до 20 мс даёт прошивке обработать отдельную команду USB CDC.
|
||||
self._read() # Сохраняем уже пришедшие CAN-кадры, не требуя неподдерживаемого служебного ответа.
|
||||
if self._command_reply is False or not self._serial.isOpen(): # Не игнорируем явный BEL и отключение даже в профиле без ACK.
|
||||
raise SlcanError(f"CANable: отказ при выполнении {command}") # Пользователь получает конкретный этап неудачной настройки.
|
||||
return # Статус интерфейса отдельно сообщает, что профиль не подтверждает команды.
|
||||
while self._command_reply is None and self._serial.isOpen(): # CAN-кадры между служебными ответами не заменяют подтверждение команды.
|
||||
remaining = self.options.command_timeout_ms - elapsed.elapsed() # Общий выбранный тайм-аут включает все фрагменты ответа команды.
|
||||
if remaining <= 0: # Поток посторонних байтов не должен удерживать подключение в ожидании навсегда.
|
||||
break # Далее сформируем понятную ошибку отсутствия SLCAN-ответа.
|
||||
if self._serial.bytesAvailable() or self._serial.waitForReadyRead(remaining): # Используем уже поступившие байты или ждём только в пределах общего срока.
|
||||
self._read() # Тот же обработчик разбирает ответы CR/BEL и сохраняет встреченные CAN-кадры.
|
||||
else: # Драйвер завершил ожидание без новых данных либо порт был отключён.
|
||||
break # Не запускаем повторный полный тайм-аут на ту же команду.
|
||||
if not self._serial.isOpen(): # Физическое отключение во время настройки не является подтверждением команды.
|
||||
raise SlcanError(f"SLCAN: порт закрыт при выполнении {command}: {self._serial.errorString()}") # Сообщаем о потере порта на конкретном этапе.
|
||||
if self._command_reply is None: # Успешное открытие COM и запись в драйвер сами по себе не доказывают поддержку Lawicel.
|
||||
raise SlcanError(f"SLCAN: нет ответа на {command}; проверьте тип адаптера и скорость COM") # Направляем пользователя к настройкам, влияющим на обмен ASCII-командами.
|
||||
if not self._command_reply and not allow_rejection: # Только отказ повторного C при уже закрытом канале допускается протоколом настройки.
|
||||
raise SlcanError(f"SLCAN: адаптер отклонил команду {command} (BEL)") # Отказ скорости или режима CAN больше не маскируется статусом подключения.
|
||||
finally: # После команды подтверждения рабочего потока больше не меняют результат настройки.
|
||||
self._waiting_command = False # Возвращаем обработчик приёма в обычный режим.
|
||||
|
||||
def disconnect_channel(self) -> None:
|
||||
if not self._serial.isOpen():
|
||||
return
|
||||
self._serial.write(b"C\r")
|
||||
self._serial.waitForBytesWritten(100)
|
||||
self._serial.close()
|
||||
self._buffer.clear()
|
||||
self._listen_only = False
|
||||
if self._serial.isOpen(): # Команда закрытия нужна только для реально открытого COM-порта.
|
||||
self._serial.write(b"C\r") # Просим прошивку остановить CAN до освобождения порта.
|
||||
self._serial.waitForBytesWritten(100) # Ограничиваем ожидание отправки закрытия ста миллисекундами, чтобы не задерживать выход.
|
||||
self._serial.close() # Освобождаем порт независимо от доступности прошивки.
|
||||
self._buffer.clear() # Частичная строка прежнего сеанса не должна приклеиваться к первому кадру нового подключения.
|
||||
self._pending_frames.clear() # Отложенный обработчик Qt не должен выдавать кадры после отключения или переподключения.
|
||||
self._after_cr = False # Новый сеанс начинает разбор окончаний строк без состояния предыдущего CR.
|
||||
self._listen_only = False # Отключённый транспорт не сохраняет ограничение старого режима.
|
||||
|
||||
def send(self, can_id: int, data: bytes, *, extended: bool = True) -> None:
|
||||
if not self.connected:
|
||||
@@ -149,24 +202,109 @@ class SlcanAdapter(QObject):
|
||||
raise SlcanError(f"SLCAN не передал CAN-кадр: {self._serial.errorString()}")
|
||||
|
||||
def _read(self) -> None:
|
||||
self._buffer.extend(bytes(self._serial.readAll()))
|
||||
while b"\r" in self._buffer:
|
||||
raw, _, remainder = self._buffer.partition(b"\r")
|
||||
self._buffer[:] = remainder
|
||||
frame = decode_frame(raw)
|
||||
if frame is not None:
|
||||
self.capture_received.emit(frame[0], frame[1], raw[:1] == b'T', False)
|
||||
self.frame_received.emit(*frame)
|
||||
if not self._serial.isOpen(): # Отложенное чтение после закрытия не должно обращаться к неоткрытому драйверу.
|
||||
return # Отключение уже очистило очередь и неполную строку.
|
||||
self._buffer.extend(bytes(self._serial.readAll())) # Накапливаем фрагменты USB/COM до полного кадра или служебного ответа.
|
||||
while self._buffer: # За одно чтение драйвер может вернуть несколько кадров и подтверждений команд.
|
||||
if self._after_cr: # CR+LF считается одним окончанием, даже если LF пришёл в следующем чтении.
|
||||
self._after_cr = False # Состояние относится только к ближайшему байту после CR.
|
||||
if self._buffer[0] == 10: # ASCII LF имеет код 10 и не является началом следующего SLCAN-кадра.
|
||||
del self._buffer[0] # Удаляем дополнительный LF, чтобы он не мешал распознаванию t/T и не давал второй ACK.
|
||||
continue # Продолжаем с первого байта следующей записи, если он уже поступил.
|
||||
boundary = _RECORD_END.search(self._buffer) # Ищем первый разделитель, не сканируя весь остаток пакета отдельно для отсутствующих LF и BEL.
|
||||
if boundary is None: # Неполную строку нельзя декодировать до поступления окончания.
|
||||
break # Сохраняем её в буфере для следующего readyRead.
|
||||
end = boundary.start() # Ближайшая граница сохраняет исходный порядок кадров и подтверждений команд.
|
||||
raw = bytes(self._buffer[:end]) # Получаем строку без служебного разделителя.
|
||||
separator = self._buffer[end] # Тип разделителя отличает подтверждение команды от BEL.
|
||||
del self._buffer[:end + 1] # Удаляем обработанную запись, не теряя оставшуюся часть потока.
|
||||
self._after_cr = separator == 13 # ASCII CR имеет код 13; следующий LF принадлежит этому же окончанию.
|
||||
if separator == 7: # ASCII BEL имеет код 7 и сообщает об отклонении последней команды.
|
||||
if self._waiting_command and self._command_reply is None: # Принимаем только первый ответ текущего этапа настройки.
|
||||
self._command_reply = False # Сохраняем отказ для проверки в последовательном обмене командами.
|
||||
continue # BEL не должен приклеиваться к следующему CAN-кадру.
|
||||
if self.options.fd and raw[:1] in (b"d", b"D", b"b", b"B") and self._receive_fd(raw): # Только корректная FD-строка забирается отдельным маршрутом; версия CANable может начинаться с HEX-буквы.
|
||||
continue # Даже FD с данными до восьми байтов не меняет тип при передаче подписчикам.
|
||||
frame = decode_frame(raw) # Кадры STD/EXT декодируются прежним публичным кодеком.
|
||||
if frame is not None: # Служебные строки и ответы прошивки не попадают в общий монитор CAN.
|
||||
self._pending_frames.append((frame[0], frame[1], raw[:1] == b"T")) # Сохраняем данные и признак EXT, включая первые кадры во время открытия.
|
||||
elif raw and self._query_prefix is not None and raw.startswith(self._query_prefix): # Диагностические строки распознаются только после исключения CAN-кадров.
|
||||
self._query_reply = raw.decode("ascii", errors="replace") # Версия CANable не имеет префикса V, поэтому допускается пустой ожидаемый префикс.
|
||||
elif not raw and self._waiting_command and self._command_reply is None: # Пустая строка CR означает ACK по Lawicel; строка с CAN-кадром не подтверждает команду.
|
||||
self._command_reply = True # Разрешаем отправку следующей команды только после реального подтверждения.
|
||||
if not self._initializing: # Первые кадры выдаются после возврата подключения, когда вкладка уже установила скорость CAN.
|
||||
pending, self._pending_frames = self._pending_frames, [] # Отделяем очередь до вызова подписчиков, чтобы повторное чтение не дублировало кадры.
|
||||
for can_id, data, extended in pending: # Выдаём все накопленные кадры в исходном порядке.
|
||||
self.capture_received.emit(can_id, data, extended, False) # Полные метаданные наполняют общий CAN-монитор.
|
||||
self.frame_received.emit(can_id, data) # Совместимый сигнал сохраняет работу существующих клиентов ProtoCAN.
|
||||
|
||||
def _error(self, error: QSerialPort.SerialPortError) -> None:
|
||||
if error in (QSerialPort.SerialPortError.NoError,
|
||||
QSerialPort.SerialPortError.NotOpenError):
|
||||
return
|
||||
if error in (QSerialPort.SerialPortError.NoError, # Сигнал NoError не означает потерю соединения.
|
||||
QSerialPort.SerialPortError.NotOpenError, # Обращение после закрытия не требует повторного отключения.
|
||||
QSerialPort.SerialPortError.TimeoutError): # Тайм-аут синхронного ожидания проверяет сама команда; порт при этом остаётся исправным.
|
||||
return # Не превращаем отсутствие ответа на команду в ошибку физического соединения.
|
||||
if self._serial.isOpen():
|
||||
message = self._serial.errorString()
|
||||
self._serial.close()
|
||||
self._listen_only = False
|
||||
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}")
|
||||
if not self._initializing: # При настройке исключение возвращает connect_channel, избегая повторного отключения из обработчика GUI.
|
||||
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}") # Во время обычного приёма уведомляем вкладку о настоящем обрыве.
|
||||
|
||||
def close(self) -> None:
|
||||
self.disconnect_channel()
|
||||
|
||||
def _receive_fd(self, raw: bytes) -> bool: # Результат отличает корректный FD от диагностической строки с похожей первой буквой.
|
||||
extended = raw[:1] in (b"D", b"B") # Заглавная буква означает идентификатор длиной восемь HEX-цифр.
|
||||
width = 8 if extended else 3 # Стандартный ID занимает три HEX-цифры в ASCII-протоколе.
|
||||
try: # Сохраняем устойчивость потока при повреждённых USB/COM-строках.
|
||||
identifier = int(raw[1:1 + width], 16) # Извлекаем числовой ID без префикса команды.
|
||||
length = FD_LENGTHS[int(raw[1 + width:2 + width], 16)] # Четырёхбитный DLC переводится в фактическое число байтов.
|
||||
payload = bytes.fromhex(raw[2 + width:].decode("ascii")) # Для CANable FD не документирован дополнительный суффикс временной метки.
|
||||
except (ValueError, UnicodeError, IndexError): # Неполный ID, DLC или HEX-пара отклоняют только текущую запись.
|
||||
return False # Неподходящая строка ещё может оказаться ответом версии CANable.
|
||||
if len(payload) != length or not 0 <= identifier <= (0x1FFFFFFF if extended else 0x7FF): # Не обрезаем лишние байты и не принимаем отрицательный ID или идентификатор другого формата.
|
||||
return False # Повреждённые данные не должны выглядеть успешным приёмом.
|
||||
record = (identifier, payload, extended, 2 | (4 if raw[:1] in (b"b", b"B") else 0), 0) # CANable не предоставляет аппаратную метку; ноль явно обозначает её отсутствие.
|
||||
self.fd_history.append(("RX", record)) # История ограничена размером deque и доступна после открытия диагностики.
|
||||
self.fd_received.emit(*record) # В мост и прикладной декодер Classic этот кадр не поступает.
|
||||
return True # Вызывающий обработчик не должен повторно трактовать этот кадр как служебный ответ.
|
||||
|
||||
def send_fd(self, can_id: int, data: bytes, *, extended: bool = True, brs: bool = True) -> None: # Используем документированные команды расширения CANable 2.0.
|
||||
if not self.connected or self._listen_only or not self.options.fd: # Проверка защищает передачу независимо от состояния кнопок GUI.
|
||||
raise SlcanError("Передача FD требует активного подключения CAN FD") # Пассивный режим не допускает обхода через API.
|
||||
if len(data) not in FD_LENGTHS or not 0 <= can_id <= (0x1FFFFFFF if extended else 0x7FF): # Разрешаем только точные длины DLC без скрытого дополнения.
|
||||
raise SlcanError("Неверный CAN ID или длина CAN FD (0…8, 12, 16, 20, 24, 32, 48, 64)") # Сообщаем фактический набор допустимых размеров.
|
||||
prefix = ("B" if extended else "b") if brs else ("D" if extended else "d") # BRS выбирается буквой команды, а не полем ASCII-пакета.
|
||||
packet = f"{prefix}{can_id:0{8 if extended else 3}X}{FD_LENGTHS.index(len(data)):X}{data.hex().upper()}\r".encode("ascii") # ID, DLC и данные образуют одну завершённую строку SLCAN.
|
||||
if self._serial.write(packet) != len(packet): # Частичная запись не гарантирует доставку последнего CR в прошивку.
|
||||
raise SlcanError("SLCAN не передал CAN FD") # Причина остаётся видимой в диагностическом окне отправки.
|
||||
self.fd_history.append(("TX queued", (can_id, bytes(data), extended, 2 | (4 if brs else 0), 0))) # Локальная запись не выдаётся за подтверждение передачи на CAN.
|
||||
|
||||
def diagnostics(self) -> str: # Явная диагностика не меняет режим CAN и не выполняется фоновым таймером.
|
||||
if not self.connected: # Чтение версии не должно захватывать ещё не выбранный COM-порт.
|
||||
raise SlcanError("Сначала подключите адаптер") # Настройка и жизненный цикл подключения остаются под управлением пользователя.
|
||||
lines = [] # Ответы V и N читают сведения, F дополнительно сбрасывает индикатор ошибки согласно Lawicel.
|
||||
canable = self.options.profile != "lawicel" # CANable возвращает V без буквенного префикса, а регистр E иногда без завершающего CR.
|
||||
if canable: # Отсутствие подтверждения настроек сообщается независимо от успешного чтения версии.
|
||||
lines.append(f"Профиль {self.options.profile}: команды конфигурации без ACK/NACK; применение настроек не подтверждается прошивкой.") # Диагностика не превращает ответ V в подтверждение предыдущих M/A/Y.
|
||||
for command in (("V", "E") if canable else ("V", "N", "F")): # Набор фиксирован спецификацией выбранной прошивки.
|
||||
self._read() # Сначала выдаём уже накопленные CAN-кадры, не смешивая их с ответом запроса.
|
||||
self._query_prefix, self._query_reply = (b"" if canable else command.encode("ascii")), None # CANable не использует префикс V в строке версии.
|
||||
elapsed = QElapsedTimer() # Ограничиваем полный запрос монотонным временем, а не числом фрагментов.
|
||||
elapsed.start() # Тайм-аут включает отправку и все поступившие строки CAN.
|
||||
try: # Состояние запроса очищается также при ошибке записи или обрыве порта.
|
||||
packet = (command + "\r").encode("ascii") # Все команды завершаются одиночным CR протокола Lawicel.
|
||||
if self._serial.write(packet) != len(packet): # Локальная ошибка передачи должна быть видна вызывающему коду.
|
||||
raise SlcanError(f"Не удалось отправить {command}") # Указываем конкретный отказавший диагностический запрос.
|
||||
while self._query_reply is None and self.connected: # Приём CAN-кадров продолжается тем же обработчиком.
|
||||
remaining = self.options.command_timeout_ms - elapsed.elapsed() # Поток кадров не должен бесконечно продлевать ожидание версии.
|
||||
if remaining <= 0 or not (self._serial.bytesAvailable() or self._serial.waitForReadyRead(remaining)): # Отсутствие ответа завершает только текущий запрос.
|
||||
break # Следующие строки диагностики покажут, какая команда не поддерживается.
|
||||
self._read() # Ответ распознаётся по префиксу, а CAN продолжает поступать обычным подписчикам.
|
||||
if command == "E" and self._buffer.startswith(b"CANable Error Register:"): # Документированная исходная прошивка может возвращать E без CR.
|
||||
self._query_reply = bytes(self._buffer).decode("ascii", errors="replace") # Такой ответ принимаем только после завершения ожидания, не обрезая поток CAN.
|
||||
self._buffer.clear() # Нетерминированная служебная строка не должна приклеиваться к следующему CAN-кадру.
|
||||
lines.append(self._query_reply or f"{command}: нет ответа") # Отсутствие данных не заменяем выдуманными версией или нулевыми ошибками.
|
||||
finally: # Следующий обычный приём не должен сохранять посторонние строки как ответ старого запроса.
|
||||
self._query_prefix = None # Завершаем явную диагностическую транзакцию.
|
||||
return "\n".join(lines) # Текст можно скопировать для анализа состояния устройства.
|
||||
|
||||
@@ -11,12 +11,15 @@ from protocan import tms_firmware as wire
|
||||
class TmsBootloader(QObject):
|
||||
progress = Signal(int, str)
|
||||
finished = Signal(bool, str)
|
||||
request_sent = Signal(bytes)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._serial.readyRead.connect(self._read)
|
||||
self._serial.errorOccurred.connect(self._error)
|
||||
self._owned_serial = self._serial
|
||||
self._owns_serial = True
|
||||
self._timeout = QTimer(self)
|
||||
self._timeout.setSingleShot(True)
|
||||
self._timeout.timeout.connect(lambda: self._finish(False, f"Тайм-аут {self._stage}"))
|
||||
@@ -31,6 +34,7 @@ class TmsBootloader(QObject):
|
||||
self._active = False
|
||||
self._waiting = False
|
||||
self._rx = bytearray()
|
||||
self.readback = bytearray()
|
||||
self._stage = ""
|
||||
|
||||
def start(self, image, port: str, baud: int, target: wire.TmsTarget):
|
||||
@@ -45,6 +49,8 @@ class TmsBootloader(QObject):
|
||||
return
|
||||
self._image, self._target = image, target
|
||||
self._active = True
|
||||
self._serial = self._owned_serial
|
||||
self._owns_serial = True
|
||||
self._serial.setPortName(port)
|
||||
self._serial.setBaudRate(baud)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
@@ -54,8 +60,29 @@ class TmsBootloader(QObject):
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self._finish(False, self._serial.errorString())
|
||||
return
|
||||
self._begin()
|
||||
|
||||
def start_connected(self, image, serial, target: wire.TmsTarget):
|
||||
"""Use an existing port; its owner must forward RX and handle errors."""
|
||||
if self._active:
|
||||
return
|
||||
try:
|
||||
target.validate(len(image.data))
|
||||
if not serial.isOpen() or serial.baudRate() <= 0:
|
||||
raise ValueError("Подключите COM-порт в настройках 2812")
|
||||
except ValueError as error:
|
||||
self.finished.emit(False, str(error))
|
||||
return
|
||||
self._image, self._target = image, target
|
||||
self._serial = serial
|
||||
self._owns_serial = False
|
||||
self._active = True
|
||||
self._begin()
|
||||
|
||||
def _begin(self):
|
||||
self.readback.clear()
|
||||
self._response = None
|
||||
self._steps = wire.programming_steps(image.data, target)
|
||||
self._steps = wire.programming_steps(self._image.data, self._target)
|
||||
self._advance()
|
||||
|
||||
def _advance(self):
|
||||
@@ -66,6 +93,7 @@ class TmsBootloader(QObject):
|
||||
except StopIteration:
|
||||
self._finish(True, "Образ загружен в RAM TMS" if self._target.load_only
|
||||
else "Запись Spartan-6 подтверждена платой" if self._target.kind == "spartan6"
|
||||
else "Прошивка записана; проверка отключена" if not self._target.verify
|
||||
else "Прошивка завершена, записанные данные проверены")
|
||||
return
|
||||
except (ValueError, RuntimeError) as error:
|
||||
@@ -79,11 +107,15 @@ class TmsBootloader(QObject):
|
||||
if self._serial.write(request) != len(request):
|
||||
self._finish(False, "Ошибка передачи " + self._stage)
|
||||
return
|
||||
self.request_sent.emit(request)
|
||||
if self._active:
|
||||
self._timeout.start(timeout + int(len(request) * 10000 / self._serial.baudRate()))
|
||||
self._timeout.start(timeout + int((len(request) + self._size) * 10000 / self._serial.baudRate()))
|
||||
|
||||
def _read(self):
|
||||
chunk = bytes(self._serial.readAll())
|
||||
self.receive(bytes(self._serial.readAll()))
|
||||
|
||||
def receive(self, chunk):
|
||||
"""Forward fragments from a shared port without reading it twice."""
|
||||
if not self._waiting:
|
||||
return
|
||||
self._rx.extend(chunk)
|
||||
@@ -116,6 +148,10 @@ class TmsBootloader(QObject):
|
||||
self._timeout.stop()
|
||||
self._settle.stop()
|
||||
self._waiting = False
|
||||
if self._command == wire.CMD_UPLOAD and self._target.kind == "tms":
|
||||
data = wire.decode_upload_reply(reply, self._target.controller, self._size - 8)
|
||||
remaining = len(self._image.data) - len(self.readback)
|
||||
self.readback.extend(data[:remaining])
|
||||
self._response = reply
|
||||
self._delay.start()
|
||||
|
||||
@@ -133,8 +169,10 @@ class TmsBootloader(QObject):
|
||||
self._active = self._waiting = False
|
||||
for timer in (self._timeout, self._settle, self._delay):
|
||||
timer.stop()
|
||||
if self._serial.isOpen():
|
||||
if self._owns_serial and self._serial.isOpen():
|
||||
self._serial.close()
|
||||
if not success:
|
||||
self.readback.clear()
|
||||
if success:
|
||||
self.progress.emit(100, message)
|
||||
self.finished.emit(success, message)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user