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:
2026-11-07 01:32:27 +03:00
136 changed files with 8828 additions and 821 deletions
+7
View File
@@ -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», сохраняет
+2 -13
View File
@@ -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
View File
@@ -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),
+31 -4
View File
@@ -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 завершена"
+51
View File
@@ -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 ещё не обслуживает'}
+1030
View File
@@ -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"
}
]
}
+40
View File
@@ -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.
+3
View File
@@ -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
+1 -1
View File
@@ -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"]
+116
View File
@@ -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`: перемещаются влево, вправо, вверх, вниз и в общую группу
вкладок со штатной подсветкой области вставки. Разделители меняют размеры.
Перенос сохраняет экземпляры панелей, соединение и выполняемые задания.
Раскладка сохраняется между запусками; «Сбросить раскладку» возвращает
стандартное расположение без пересоздания содержимого.
+113
View File
@@ -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} # Заполненная таблица подтверждается данными после паузы, пустая остаётся остановленной.
+144
View File
@@ -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 # Возвращаем единый планировщик ручного обмена и периодических заданий.
+1 -1
View File
@@ -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)
+35 -41
View File
@@ -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()
+346
View File
@@ -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 # Новое подключение должно подтвердить весь снимок таблицы заново.
+56 -52
View File
@@ -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):
"""Чтение сырых данных"""
File diff suppressed because one or more lines are too long
+29
View File
@@ -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() # Последняя проверка не допускает публикации результата, отменённого в конце короткого блока.
+7 -7
View File
@@ -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
+50 -2
View File
@@ -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,
+10 -1
View File
@@ -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()
+147
View File
@@ -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.
+21 -1
View File
@@ -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)
+2 -1
View File
@@ -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 = {}
+63 -5
View File
@@ -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")
+24 -2
View File
@@ -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 не совпадает")
+11 -9
View File
@@ -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
+28 -11
View File
@@ -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]:
+76 -5
View File
@@ -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:
+174
View File
@@ -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 # Запоминаем конец текущего пути для следующей выбранной точки.
+36 -8
View File
@@ -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):
+10
View File
@@ -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, а разность порядков сохраняет отдельную подпись для каждого деления.
+139 -50
View File
@@ -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()) # нГн и нФ переводятся в СИ; Ом уже соответствует внутренним формулам.
+32 -8
View File
@@ -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)) # Причина ошибки остаётся доступна оператору.
+3 -3
View File
@@ -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)
+36 -19
View File
@@ -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 -9
View File
@@ -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()
+35 -7
View File
@@ -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> &nbsp; ' # Цвет легенды совпадает с измеренной кривой.
'<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
+175 -37
View File
@@ -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) # Текст можно скопировать для анализа состояния устройства.
+42 -4
View File
@@ -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