2 Commits

Author SHA1 Message Date
a5e46d4627 Расширить обмен по шинам, CAN-адаптеры и обработку графиков
Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
2026-10-17 14:36:14 +03:00
95c8b8ac7d Поддержать компактные записи логгера УМП v4
Разрешить 12-словные записи в запросах RTU и CAN. Распознавать статус v4 и декодировать оставшиеся сигналы с сохранением поддержки архивов v2/v3.
2026-10-12 12:26:37 +03:00
72 changed files with 4453 additions and 746 deletions

View File

@@ -9,5 +9,5 @@ if(NOT WIN32)
endif()
add_library(candle SHARED candle.c candle_ctrl_req.c candle.def)
target_compile_definitions(candle PRIVATE UNICODE _UNICODE)
target_link_libraries(candle PRIVATE setupapi winusb ole32 advapi32)
target_link_libraries(candle PRIVATE setupapi winusb ole32 advapi32 cfgmgr32) # Configuration Manager сопоставляет интерфейсы CAN и DFU по родительскому USB-узлу.
target_include_directories(candle PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -34,6 +34,86 @@
#include "candle_defs.h"
#include "candle_ctrl_req.h"
#include "ch_9.h"
#include <cfgmgr32.h> /* Сопоставляет интерфейсы CAN и DFU по общему родительскому USB-устройству Windows. */
static DWORD candle_dfu_parent(const wchar_t *path, DEVINST *parent) /* Возвращает родителя интерфейса, чтобы команда не попала в другой подключённый адаптер. */
{ /* Начало поиска физического USB-устройства по пути выбранного CAN-интерфейса. */
HDEVINFO devices = SetupDiCreateDeviceInfoList(NULL, NULL); /* Создаёт временный набор SetupAPI без перечисления посторонних устройств. */
if (devices == INVALID_HANDLE_VALUE) return GetLastError(); /* Сохраняет причину отказа создания набора Windows. */
SP_DEVICE_INTERFACE_DATA iface = {0}; /* Подготавливает описание интерфейса для открытия по точному пути. */
iface.cbSize = sizeof(iface); /* Передаёт SetupAPI размер структуры текущей разрядности процесса. */
SP_DEVINFO_DATA info = {0}; /* Получает идентификатор узла PnP выбранного интерфейса. */
info.cbSize = sizeof(info); /* Указывает размер структуры устройства согласно контракту SetupAPI. */
DWORD size = 0, error = ERROR_SUCCESS; /* Хранит требуемое число байтов и итоговый код Windows. */
if (!SetupDiOpenDeviceInterfaceW(devices, path, 0, &iface)) error = GetLastError(); /* Открывает только заданный путь, сохраняя ошибку исчезнувшего устройства. */
if (!error) SetupDiGetDeviceInterfaceDetailW(devices, &iface, NULL, 0, &size, NULL); /* Узнаёт объём буфера переменной длины для пути USB-интерфейса. */
PSP_DEVICE_INTERFACE_DETAIL_DATA_W detail = size ? malloc(size) : NULL; /* Выделяет буфер только после успешного определения его размера. */
if (!error && !detail) error = size ? ERROR_NOT_ENOUGH_MEMORY : GetLastError(); /* Отличает нехватку памяти от ошибки поиска описания. */
if (detail) { /* Обрабатывает описание только при наличии выделенного буфера. */
detail->cbSize = sizeof(*detail); /* Согласует размер заголовка с архитектурой Win32 или Win64. */
if (!SetupDiGetDeviceInterfaceDetailW(devices, &iface, detail, size, NULL, &info)) error = GetLastError(); /* Получает узел интерфейса из SetupAPI. */
else if (CM_Get_Parent(parent, info.DevInst, 0) != CR_SUCCESS) error = ERROR_NOT_FOUND; /* Получает общий родительский узел составного USB-адаптера. */
free(detail); /* Освобождает описание после получения идентификатора родителя. */
} /* Завершает работу с описанием интерфейса. */
SetupDiDestroyDeviceInfoList(devices); /* Освобождает набор SetupAPI при любом результате поиска. */
return error; /* Возвращает ноль при успехе либо причину отказа Windows. */
} /* Завершает сопоставление интерфейса с физическим USB-устройством. */
uint32_t __stdcall candle_dev_enter_dfu(const wchar_t *can_path, uint8_t status[6]) /* Возвращает код Windows и шестибайтовый DFU_GETSTATUS согласно DFU 1.1. */
{ /* Начало отдельного сеанса управляющего интерфейса обновления. */
if (!can_path || !status) return ERROR_INVALID_PARAMETER; /* Запрещает отсутствие точного пути адаптера или выходного буфера статуса. */
memset(status, 0, 6); /* Исключает использование неинициализированных полей ответа при ошибке USB. */
DEVINST wanted_parent = 0; /* Запоминает физический адаптер, выбранный пользователем до остановки CAN. */
DWORD error = candle_dfu_parent(can_path, &wanted_parent); /* Ищет родителя по сохранённому пути CAN, не заменяя его первым найденным устройством. */
if (error) return error; /* Прерывает переход, если выбранный адаптер уже исчез из PnP. */
GUID guid = {0xc25b4308, 0x04d3, 0x11e6, {0xb3, 0xea, 0x60, 0x57, 0x18, 0x9e, 0x64, 0x43}}; /* GUID интерфейса обновления из дескриптора CANable 2.5. */
HDEVINFO devices = SetupDiGetClassDevsW(&guid, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); /* Перечисляет только подключённые интерфейсы обновления CANable 2.5. */
if (devices == INVALID_HANDLE_VALUE) return GetLastError(); /* Возвращает отказ SetupAPI без попытки послать команду. */
error = ERROR_NOT_FOUND; /* Отсутствие интерфейса обновления не считается успешным входом в DFU. */
for (DWORD index = 0; ; ++index) { /* Проверяет интерфейсы до точного совпадения физического устройства. */
SP_DEVICE_INTERFACE_DATA iface = {0}; /* Создаёт независимое описание очередного DFU-интерфейса. */
iface.cbSize = sizeof(iface); /* Задаёт обязательный размер структуры SetupAPI. */
if (!SetupDiEnumDeviceInterfaces(devices, NULL, &guid, index, &iface)) { /* Завершает обход либо сообщает ошибку перечисления. */
if (GetLastError() != ERROR_NO_MORE_ITEMS) error = GetLastError(); /* Отличает штатный конец списка от отказа Windows. */
break; /* Выходит из обхода после исчерпания интерфейсов или ошибки. */
} /* Завершает проверку результата перечисления. */
DWORD size = 0; /* Хранит размер описания очередного интерфейса в байтах. */
SetupDiGetDeviceInterfaceDetailW(devices, &iface, NULL, 0, &size, NULL); /* Определяет буфер для полного пути интерфейса обновления. */
if (!size) { error = GetLastError(); break; } /* Не продолжает работу с неполным результатом SetupAPI. */
PSP_DEVICE_INTERFACE_DETAIL_DATA_W detail = malloc(size); /* Выделяет описание с достаточным местом для USB-пути. */
if (!detail) { error = ERROR_NOT_ENOUGH_MEMORY; break; } /* Не допускает обращения к отсутствующему буферу. */
detail->cbSize = sizeof(*detail); /* Сохраняет ABI описания интерфейса Windows. */
if (!SetupDiGetDeviceInterfaceDetailW(devices, &iface, detail, size, NULL, NULL)) { /* Получает путь, который можно передать CreateFileW. */
error = GetLastError(); free(detail); break; /* Сохраняет ошибку и освобождает буфер перед выходом. */
} /* Завершает чтение пути интерфейса. */
DEVINST parent = 0; /* Хранит родителя текущего кандидата на обновление. */
if (candle_dfu_parent(detail->DevicePath, &parent) || parent != wanted_parent) { /* Исключает каждый интерфейс другого физического адаптера. */
free(detail); continue; /* Освобождает описание неподходящего кандидата и продолжает поиск. */
} /* Завершает проверку принадлежности выбранному USB-адаптеру. */
HANDLE file = CreateFileW(detail->DevicePath, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, NULL); /* Эксклюзивно открывает интерфейс обновления выбранного устройства для WinUSB. */
free(detail); /* Путь больше не нужен после открытия системного дескриптора. */
if (file == INVALID_HANDLE_VALUE) { error = GetLastError(); break; } /* Не скрывает отсутствие доступа или занятость интерфейса. */
WINUSB_INTERFACE_HANDLE usb = NULL; /* Хранит отдельный WinUSB-сеанс интерфейса обновления без CAN bulk endpoint. */
error = ERROR_SUCCESS; /* Сбрасывает исходное отсутствие интерфейса после точного совпадения устройства. */
if (!WinUsb_Initialize(file, &usb)) error = GetLastError(); /* Проверяет наличие рабочего WinUSB-драйвера DFU runtime. */
USB_INTERFACE_DESCRIPTOR descriptor = {0}; /* Получает реальный номер и класс открытого интерфейса. */
if (!error && !WinUsb_QueryInterfaceSettings(usb, 0, &descriptor)) error = GetLastError(); /* Не предполагает, что произвольный WinUSB-интерфейс поддерживает DFU. */
if (!error && (descriptor.bInterfaceClass != 0xFE || descriptor.bInterfaceSubClass != 1 || descriptor.bInterfaceProtocol != 1)) error = ERROR_NOT_SUPPORTED; /* Проверяет класс приложения, подкласс DFU и runtime-протокол по DFU 1.1. */
ULONG timeout = 500, transferred = 0; /* Ограничивает управляющий USB-запрос 500 миллисекундами и учитывает длину ответа. */
if (!error && !WinUsb_SetPipePolicy(usb, 0, PIPE_TRANSFER_TIMEOUT, sizeof(timeout), &timeout)) error = GetLastError(); /* Устанавливает тайм-аут endpoint 0, чтобы отсутствующий ответ не блокировал программу. */
WINUSB_SETUP_PACKET packet = {0}; /* Обнуляет поля USB SETUP перед запросом перехода в загрузчик. */
packet.RequestType = 0x21; packet.Request = 0; packet.Value = 1000; packet.Index = descriptor.bInterfaceNumber; /* DFU_DETACH: class/interface OUT, запрос 0, ожидаемое время отключения 1000 мс. */
if (!error && !WinUsb_ControlTransfer(usb, packet, NULL, 0, &transferred, NULL)) error = GetLastError(); /* Отправляет DETACH и требует подтверждённой USB-транзакции. */
packet.RequestType = 0xA1; packet.Request = 3; packet.Value = 0; packet.Length = 6; /* DFU_GETSTATUS: class/interface IN, запрос 3, ответ ровно 6 байт по DFU 1.1. */
if (!error && !WinUsb_ControlTransfer(usb, packet, status, 6, &transferred, NULL)) error = GetLastError(); /* Проверяет результат до отложенного перехода прошивки через 300 мс. */
if (!error && transferred != 6) error = ERROR_INVALID_DATA; /* Не трактует укороченный ответ как подтверждение входа в загрузчик. */
if (usb) WinUsb_Free(usb); /* Освобождает WinUSB до появления загрузчика с другим VID/PID. */
CloseHandle(file); /* Освобождает файловый дескриптор после успеха и при любой ошибке USB. */
break; /* Не отправляет DETACH повторно и не переключается на другой адаптер при отказе. */
} /* Завершает поиск и обработку единственного выбранного адаптера. */
SetupDiDestroyDeviceInfoList(devices); /* Освобождает перечень интерфейсов при любом результате. */
return error; /* Статус прошивки возвращается отдельно от системной ошибки USB. */
} /* Завершает управление DFU runtime без записи flash или изменения образа. */
static bool candle_dev_interal_open(candle_handle hdev);
@@ -656,6 +736,30 @@ bool __stdcall DLL candle_dev_open(candle_handle hdev)
}
bool __stdcall DLL candle_dev_get_product(candle_handle hdev, wchar_t *product, uint32_t capacity) /* Читает USB Product String открытого адаптера в буфер UTF-16; capacity измеряется в wchar_t, включая завершающий ноль. */
{ /* Начало чтения отображаемого имени без изменения режима CAN. */
if (!product || !capacity) return false; /* Не пишет в отсутствующий либо пустой выходной буфер. */
product[0] = 0; /* Сохраняет пустую строку при любом отказе чтения дескриптора. */
candle_device_t *dev = (candle_device_t*)hdev; /* Получает уже открытый WinUSB-сеанс выбранного физического адаптера. */
if (!dev || !dev->winUSBHandle) return false; /* Не открывает устройство заново и не обращается к закрытому интерфейсу. */
USB_DEVICE_DESCRIPTOR descriptor = {0}; /* Получает индекс строки продукта из стандартного дескриптора устройства. */
ULONG received = 0; /* Хранит фактически полученное количество байтов управляющего ответа. */
if (!WinUsb_GetDescriptor(dev->winUSBHandle, 1, 0, 0, (PUCHAR)&descriptor, sizeof(descriptor), &received) || received != sizeof(descriptor)) return false; /* Тип 1 обозначает Device Descriptor по USB 2.0; неполный ответ не используется. */
UCHAR string_index = descriptor.iProduct; /* Для обычных адаптеров использует объявленный устройством индекс USB Product String. */
if (descriptor.idVendor == 0x1D50 && descriptor.idProduct == 0x606F) string_index = 2; /* В CANable Candlelight имя продукта имеет индекс 2; WinUSB может подменять iProduct индексом имени интерфейса, как описано в штатном примере проекта. */
if (!string_index) return false; /* Нулевой iProduct означает, что устройство не предоставляет имя продукта. */
UCHAR raw[256] = {0}; /* Буфер вмещает строковый дескриптор USB, чей однобайтовый bLength ограничен 255 байтами. */
USHORT language = 0x0409; /* Использует английский идентификатор USB как резервный вариант для устройств без списка языков. */
if (WinUsb_GetDescriptor(dev->winUSBHandle, 3, 0, 0, raw, sizeof(raw), &received) && received >= 4 && raw[0] >= 4 && raw[1] == 3) language = (USHORT)(raw[2] | ((USHORT)raw[3] << 8)); /* Тип 3 и индекс 0 возвращают список LANGID; выбирает первый язык, предоставленный адаптером. */
if (!WinUsb_GetDescriptor(dev->winUSBHandle, 3, string_index, language, raw, sizeof(raw), &received)) return false; /* Читает именно строку продукта на поддерживаемом устройством языке. */
if (received < 2 || raw[1] != 3 || raw[0] < 2 || raw[0] > received || (raw[0] & 1)) return false; /* Проверяет тип, размер и чётное число байтов UTF-16 перед копированием в выходной буфер. */
uint32_t count = (raw[0] - 2) / 2; /* Исключает двухбайтовый заголовок и переводит длину UTF-16LE из байтов в число кодовых единиц. */
if (count >= capacity) return false; /* Не обрезает индивидуальный номер в конце имени и оставляет место для завершающего нуля. */
for (uint32_t index = 0; index < count; ++index) product[index] = (wchar_t)(raw[2 + index * 2] | ((USHORT)raw[3 + index * 2] << 8)); /* Явно собирает кодовые единицы UTF-16LE без предположений о выравнивании массива байтов. */
product[count] = 0; /* Завершает строку для безопасного чтения через ctypes и отображения в Qt. */
return count > 0; /* Пустое имя оставляет интерфейсу стандартную резервную подпись адаптера. */
} /* Завершает получение настоящего имени из прошивки USB-адаптера. */
bool __stdcall DLL candle_dev_get_timestamp_us(candle_handle hdev, uint32_t *timestamp_us)
{
return candle_ctrl_get_timestamp(hdev, timestamp_us);
@@ -823,6 +927,38 @@ bool __stdcall DLL candle_channel_stop(candle_handle hdev, uint8_t ch)
return candle_ctrl_set_device_mode(dev, ch, CANDLE_DEVMODE_RESET, 0);
}
bool __stdcall DLL candle_channel_start_configured(candle_handle hdev, uint8_t ch, uint32_t flags) /* Новый вход сохраняет прежний ABI start и позволяет отключать HW_TIMESTAMP. */
{ /* Явно выбранные Python-флаги должны попасть в прошивку без автоматического дополнения. */
return candle_ctrl_set_device_mode((candle_device_t*)hdev, ch, CANDLE_DEVMODE_START, flags); /* Передаём точную маску после проверки возможностей на уровне транспорта. */
} /* Завершение запуска с явно заданными параметрами. */
bool __stdcall DLL candle_channel_get_data_capabilities(candle_handle hdev, uint8_t ch, candle_capability_t *cap) /* Экспортируем чтение FD-пределов для расчёта тайминга на любой частоте CAN. */
{ /* Общая форма capability позволяет повторно использовать валидатор номинального тайминга. */
return candle_ctrl_data_capability((candle_device_t*)hdev, ch, cap); /* USB-запрос возвращает реальные пределы выбранного канала. */
} /* Завершение получения возможностей быстрой фазы. */
bool __stdcall DLL candle_channel_set_termination(candle_handle hdev, uint8_t ch, uint32_t state) /* Значения 0 и 1 соответствуют OFF и ON стандарта gs_usb. */
{ /* Проверка исключает отправку неопределённого состояния в прошивку. */
return state <= 1 && candle_ctrl_termination((candle_device_t*)hdev, ch, &state, true); /* Выполняем запись только для допустимого состояния. */
} /* Завершение переключения терминатора. */
bool __stdcall DLL candle_channel_get_termination(candle_handle hdev, uint8_t ch, uint32_t *state) /* Чтение состояния не меняет топологию CAN. */
{ /* Используем тот же формат 32-битного значения, что и при записи. */
return candle_ctrl_termination((candle_device_t*)hdev, ch, state, false); /* false выбирает USB_DIR_IN и GET_TERMINATION. */
} /* Завершение чтения терминатора. */
bool __stdcall DLL candle_channel_get_diagnostics(candle_handle hdev, uint8_t ch, uint32_t *state, uint32_t *rxerr, uint32_t *txerr) /* Сохраняем счётчики, которые старый get_state отбрасывал. */
{ /* Диагностика выполняется только для уже открытого устройства. */
candle_device_state_t data = {0}; /* Полная структура протокола содержит три слова: состояние, RX и TX. */
if (!candle_ctrl_get_state((candle_device_t*)hdev, ch, &data)) { /* Не возвращаем нулевые счётчики как будто запрос был успешным. */
return false; /* Причина USB-отказа уже записана управляющим запросом. */
} /* Дальнейшее копирование относится только к подтверждённому ответу. */
*state = data.state; /* Сохраняем состояние Error-active, Warning, Passive, Bus-off, Stopped или Sleeping. */
*rxerr = data.rxerr; /* Счётчик ошибок приёма измеряется в единицах CAN-контроллера. */
*txerr = data.txerr; /* Счётчик ошибок передачи помогает отличать отсутствие ACK от ошибок приёма. */
return true; /* Все три выходных значения относятся к одному USB-ответу. */
} /* Завершение расширенной диагностики канала. */
/* Write len bytes from buf to the OUT pipe, aborting after 300 ms.
* Writes are serialised by writeMutex in CandleApiInterface so dev->txEvent
* is never accessed by two threads simultaneously. */
@@ -1017,14 +1153,13 @@ bool __stdcall DLL candle_fd_frame_read(candle_handle hdev, candle_fd_frame_t *f
}
dev->rxurbs[urb_num].pending = false;
/* Minimum: classic CAN header (12 bytes) + at least 8 data bytes = 20 bytes */
static const DWORD classic_min = sizeof(candle_frame_t) - 4;
if (bytes_transfered < classic_min) {
const DWORD header_size = 12; /* Заголовок gs_usb занимает 12 байт в Classic и FD; короткий FD может не содержать восьми байтов данных. */
if (bytes_transfered < header_size) { /* До чтения flags и DLC проверяем наличие полного заголовка. */
candle_prepare_read(dev, urb_num);
candle_logf(L"fd read too small urb=%u bytes=%lu min=%lu",
urb_num,
bytes_transfered,
classic_min);
header_size); /* Минимальная длина здесь относится к заголовку, а payload проверяется после определения формата. */
dev->last_error = CANDLE_ERR_READ_SIZE;
return false;
}
@@ -1047,6 +1182,11 @@ bool __stdcall DLL candle_fd_frame_read(candle_handle hdev, candle_fd_frame_t *f
if (is_fd_frame) {
/* can_dlc is at byte offset 8 in both classic and FD wire frames. */
const uint8_t raw_dlc = dev->rxurbs[urb_num].buf[8];
if (raw_dlc > 15) { /* FD DLC является четырёхбитным кодом и не может содержать значения выше 15. */
candle_prepare_read(dev, urb_num); /* Повреждённый USB-пакет не должен останавливать последующий приём. */
dev->last_error = CANDLE_ERR_READ_SIZE; /* Неверный DLC считается ошибкой формата входного кадра. */
return false; /* Не превращаем повреждённый DLC в пустой успешный FD-кадр. */
} /* Далее длина безопасно вычисляется по таблице CAN FD. */
const DWORD data_len = candle_dlc_to_len(raw_dlc);
const DWORD min_size = 12 + data_len; /* header + data, without timestamp */
@@ -1077,6 +1217,11 @@ bool __stdcall DLL candle_fd_frame_read(candle_handle hdev, candle_fd_frame_t *f
}
/* else: timestamp stays zero from memset above */
} else {
if (bytes_transfered < sizeof(candle_frame_t) - sizeof(uint32_t)) { /* Classic-пакет обязан содержать весь восьмибайтовый буфер данных, даже при меньшем DLC. */
candle_prepare_read(dev, urb_num); /* Возобновляем соответствующий URB после отклонения неполного пакета. */
dev->last_error = CANDLE_ERR_READ_SIZE; /* Не допускаем чтение неполученных данных из локальной структуры. */
return false; /* Повреждённый Classic-пакет не попадает в Python через FD API. */
} /* Минимальный размер Classic теперь проверен отдельно от коротких FD. */
/* Classic CAN frame — copy into FD struct, fixing the timestamp position */
candle_frame_t classic;
DWORD copy_len = (bytes_transfered < sizeof(classic)) ? bytes_transfered : sizeof(classic);

View File

@@ -3,6 +3,8 @@
; прикладные регистры SETProtocol.
EXPORTS
candle_dev_get_product ; Имя продукта из USB-дескриптора прошивки для списка адаптеров SETGUI.
candle_dev_enter_dfu ; Переход через отдельный DFU runtime-интерфейс того же физического USB-адаптера.
candle_list_scan
candle_list_free
candle_list_length
@@ -31,3 +33,13 @@ EXPORTS
candle_frame_data
candle_frame_timestamp_us
candle_dev_last_error
candle_channel_start_configured ; Точная маска запуска с управляемыми аппаратными метками.
candle_channel_get_data_capabilities ; Пределы тайминга быстрой фазы CAN FD.
candle_channel_set_termination ; Переключение встроенного терминатора.
candle_channel_get_termination ; Чтение состояния встроенного терминатора.
candle_channel_get_diagnostics ; Состояние и счётчики ошибок CAN.
candle_channel_set_data_timing ; Тайминг фазы данных CAN FD.
candle_fd_frame_send ; Отправка полного кадра CAN FD до 64 байт.
candle_fd_frame_read ; Приём FD и Classic без обрезания данных.
candle_fd_frame_type ; Различение RX, TX echo и ошибок FD.
candle_fd_frame_id ; Получение CAN ID из структуры FD.

View File

@@ -246,7 +246,9 @@ bool __stdcall DLL candle_list_length(candle_list_handle list, uint8_t *len);
bool __stdcall DLL candle_dev_get(candle_list_handle list, uint8_t dev_num, candle_handle *hdev);
bool __stdcall DLL candle_dev_get_state(candle_handle hdev, candle_devstate_t *state);
wchar_t * __stdcall DLL candle_dev_get_path(candle_handle hdev);
bool __stdcall DLL candle_dev_get_product(candle_handle hdev, wchar_t *product, uint32_t capacity); /* Возвращает USB Product String в UTF-16; capacity включает завершающий ноль и измеряется в wchar_t. */
bool __stdcall DLL candle_dev_open(candle_handle hdev);
uint32_t __stdcall DLL candle_dev_enter_dfu(const wchar_t *can_path, uint8_t status[6]); /* Переводит выбранный физический адаптер в DFU и возвращает Win32-код плюс ответ DFU_GETSTATUS. */
bool __stdcall DLL candle_dev_get_timestamp_us(candle_handle hdev, uint32_t *timestamp_us);
bool __stdcall DLL candle_dev_close(candle_handle hdev);
bool __stdcall DLL candle_dev_free(candle_handle hdev);
@@ -259,6 +261,11 @@ bool __stdcall DLL candle_channel_set_timing(candle_handle hdev, uint8_t ch, can
bool __stdcall DLL candle_channel_set_bitrate(candle_handle hdev, uint8_t ch, uint32_t bitrate);
bool __stdcall DLL candle_channel_start(candle_handle hdev, uint8_t ch, uint32_t flags);
bool __stdcall DLL candle_channel_stop(candle_handle hdev, uint8_t ch);
bool __stdcall DLL candle_channel_start_configured(candle_handle hdev, uint8_t ch, uint32_t flags); /* Запускаем с точной маской без принудительного включения аппаратных меток. */
bool __stdcall DLL candle_channel_get_data_capabilities(candle_handle hdev, uint8_t ch, candle_capability_t *cap); /* Возвращаем пределы тайминга быстрой фазы FD. */
bool __stdcall DLL candle_channel_set_termination(candle_handle hdev, uint8_t ch, uint32_t state); /* Состояние терминатора: 0 — выключен, 1 — включён. */
bool __stdcall DLL candle_channel_get_termination(candle_handle hdev, uint8_t ch, uint32_t *state); /* Чтение не меняет подключение встроенного резистора. */
bool __stdcall DLL candle_channel_get_diagnostics(candle_handle hdev, uint8_t ch, uint32_t *state, uint32_t *rxerr, uint32_t *txerr); /* Передаём состояние CAN и оба счётчика ошибок без потери данных ответа. */
bool __stdcall DLL candle_frame_send(candle_handle hdev, uint8_t ch, candle_frame_t *frame);
bool __stdcall DLL candle_frame_read(candle_handle hdev, candle_frame_t *frame, uint32_t timeout_ms);

View File

@@ -39,8 +39,10 @@ enum {
CANDLE_TIMESTAMP_GET = 6,
/* 7: IDENTIFY, 8: GET_USER_ID, 9: SET_USER_ID (not used here) */
CANDLE_BREQ_DATA_BITTIMING = 10,
/* 11: SET_TERMINATION, not used here */
CANDLE_BREQ_GET_STATE = 12,
CANDLE_BREQ_BT_CONST_EXT = 11, /* Расширенные пределы номинальной и быстрой фазы согласно gs_usb. */
CANDLE_BREQ_SET_TERMINATION = 12, /* Запрос gs_usb 12 переключает встроенный резистор окончания шины. */
CANDLE_BREQ_GET_TERMINATION = 13, /* Запрос gs_usb 13 читает текущее состояние терминатора. */
CANDLE_BREQ_GET_STATE = 14, /* Стандартный GET_STATE имеет номер 14; номер 12 принадлежит терминатору. */
CANDLE_BREQ_BUS_OFF_RECOVERY = 32,
};
@@ -237,3 +239,22 @@ bool candle_ctrl_bus_off_recover(candle_device_t *dev, uint8_t channel)
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SET_DEVICE_MODE;
return rc;
}
bool candle_ctrl_data_capability(candle_device_t *dev, uint8_t channel, candle_capability_t *cap) /* Структура gs_usb BT_CONST_EXT содержит десять общих и восемь быстрых полей uint32. */
{ /* Используем массив, поскольку протокол содержит только однородные 32-битные значения без отступов. */
uint32_t fields[18] = {0}; /* 18 полей по 4 байта образуют стандартный ответ размером 72 байта. */
bool rc = usb_control_msg(dev->winUSBHandle, CANDLE_BREQ_BT_CONST_EXT, USB_DIR_IN|USB_TYPE_VENDOR|USB_RECIP_INTERFACE, channel, 0, fields, sizeof(fields)); /* Запрашиваем пределы именно выбранного CAN-канала. */
if (rc) { /* Выходная структура должна меняться только после успешного USB-ответа. */
memcpy(cap, fields, 2 * sizeof(uint32_t)); /* Маска функций и частота CAN общие для обеих фаз. */
memcpy(&cap->tseg1_min, &fields[10], 8 * sizeof(uint32_t)); /* Поля DTSEG1…DBRP_INC заменяют номинальные пределы структуры capability. */
} /* При отказе вызывающий код получит false и не использует нулевые пределы. */
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_GET_BITTIMING_CONST; /* Сохраняем диагностический код чтения аппаратных ограничений. */
return rc; /* Успех означает наличие полной структуры пределов быстрой фазы. */
} /* Завершение запроса расширенных возможностей FD. */
bool candle_ctrl_termination(candle_device_t *dev, uint8_t channel, uint32_t *state, bool write) /* Переключение терминатора использует стандартные SET=12 и GET=13. */
{ /* Состояние передаётся одним 32-битным словом согласно протоколу gs_usb. */
bool rc = usb_control_msg(dev->winUSBHandle, write ? CANDLE_BREQ_SET_TERMINATION : CANDLE_BREQ_GET_TERMINATION, (write ? USB_DIR_OUT : USB_DIR_IN)|USB_TYPE_VENDOR|USB_RECIP_INTERFACE, channel, 0, state, sizeof(*state)); /* Номер CAN-канала передаётся в wValue, а wIndex равен нулю по спецификации. */
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SET_DEVICE_MODE; /* Ошибка настройки доступна Python через candle_dev_last_error. */
return rc; /* Не выдаём успешное переключение при отказе USB-контроллера. */
} /* Завершение управления встроенным окончанием шины. */

View File

@@ -51,4 +51,6 @@ bool candle_ctrl_set_data_bittiming(candle_device_t *dev, uint8_t channel, candl
bool candle_ctrl_get_timestamp(candle_device_t *dev, uint32_t *current_timestamp);
bool candle_ctrl_get_state(candle_device_t *dev, uint8_t channel, candle_device_state_t *data);
bool candle_ctrl_bus_off_recover(candle_device_t *dev, uint8_t channel);
bool candle_ctrl_data_capability(candle_device_t *dev, uint8_t channel, candle_capability_t *cap); /* Читаем пределы быстрой фазы CAN FD отдельным запросом расширенной структуры. */
bool candle_ctrl_termination(candle_device_t *dev, uint8_t channel, uint32_t *state, bool write); /* Передаём состояние терминатора: 0 — выключен, 1 — включён. */

View File

@@ -6,6 +6,16 @@
`include/balsam167_eeprom_layout.h`; PEEK, CRC и порядок байтов реализованы
в `src/periph28335.c`. Проверка полного диапазона — `test_periph28335_eeprom`.
Декодер `balsam_can` знает служебные слова heartbeat периферии Бальзам-167:
`0x0100` — счётчик секунд по модулю 65536, `0x0101` — аппаратный Mode,
`0x0102` — код типа платы Desk. Назначения соответствуют `CAN_HEARTBEAT_ADDR`
и ветке `CAN_send` в `Balsam_167_periph/Source/Internal/ecan.c`.
Python-адаптер `set_devices.balsam_can` выделяет ответ с адресом `0x0100`
и маской `111` как `BALZAM Heartbeat`, сохраняя исходные регистры и маршрут.
Пример: `0x00BA0017`, `00 08 00 04 E1 00 00 5A` — heartbeat задатчика,
90 секунд, Mode=8, Desk=4. Счётчик отображается без знака, в том числе
при значениях `0x8000–0xFFFF`; обычные технологические int16 сохраняют знак.
Универсальные сервисы эмуляторов: [адресное пространство, API и порт новой платы](docs/EMULATOR_SERVICES.md).
`set_regmap` объединяет УМП, 2812, IGBT и DAC за общим API для RTU, CAN и других оболочек.

View File

@@ -152,14 +152,17 @@ object BalsamCanProtocol {
fun deviceName(device: Int): String = if (NativeSetProtocol.available) {
NativeSetProtocol.nativeBalsamDeviceName(device)
} else listOf(
"Трансформатор 1", "Трансформатор 2", "Силовой блок 1", "Силовой блок 2",
"УМП 1", "УМП 2", "Двигатель", "ВЭП", "Задатчик", "Узел 10", "Узел 11",
"Трансформатор 1", "Трансформатор 2", "Силовой 1", "Силовой 2", // Имена узлов 1..4 повторяют таблицу адресов 167 и общее C-ядро.
"УМП 1", "УМП 2", "Двигатель", "Задатчик", "ВЭП", "ПМУ", "Узел 11", // Узлы 5..10 соответствуют таблице, включая ПМУ на паре 0xBA0009/0xBA0019; узел 11 остаётся служебным.
"Узел 12", "Терминал",
).getOrElse(device - 1) { "Неизвестный узел" }
fun registerName(device: Int, address: Int): String =
if (NativeSetProtocol.available) NativeSetProtocol.nativeBalsamRegisterName(device, address)
else when {
address == 0x0100 -> "Heartbeat: счётчик, с" // CAN_HEARTBEAT_ADDR из ecan.c содержит беззнаковый секундный счётчик по модулю 65536.
address == 0x0101 -> "Heartbeat: Mode" // Второе слово служебного пакета передаёт аппаратный номер Mode платы.
address == 0x0102 -> "Heartbeat: тип платы (Desk)" // Третье слово передаёт Desk, чтобы Android без JNI использовал названия общего C-ядра.
device in 1..2 && address in 0x18..0x2B -> "Показания T° ${address - 0x17}"
device in 3..4 && address in 0x18..0x27 -> "Показания T° ${address - 0x17}"
device == 7 && address in 0x18..0x1F -> "Показания T° ${address - 0x17}"

View File

@@ -61,7 +61,7 @@ int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size,
relative = can_id - BALSAM_CAN_BASE_ID;
output->direction = relative >= BALSAM_CAN_DATA_OFFSET
? BALSAM_CAN_FROM_NODE : BALSAM_CAN_TO_NODE;
output->device = (uint8_t)((relative & 0x0FU) + 1U);
output->device = (uint8_t)((relative & 0x0FU) + 1U); /* В ecan.c перед сборкой CAN ID выполняется DevNum--; маска 0x0F выделяет индекс 0..12, а +1 восстанавливает номер Mode. */
if (can_id == 0x00745009UL || can_id == 0x00745019UL) {
output->device = 10U;
output->direction = can_id == 0x00745019UL
@@ -80,9 +80,9 @@ int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size,
const char *balsam_can_device_name(uint8_t device)
{
static const char *const names[BALSAM_CAN_NODE_COUNT] = {
"Трансформатор 1", "Трансформатор 2", "Силовой блок 1",
"Силовой блок 2", "УМП 1", "УМП 2", "Двигатель", "ВЭП",
"Задатчик", "Пульт (МПУ)", "Узел 11", "Узел 12", "Терминал"
"Трансформатор 1", "Трансформатор 2", "Силовой 1", /* Первые три узла соответствуют строкам 1..3 предоставленной таблицы адресов 167. */
"Силовой 2", "УМП 1", "УМП 2", "Двигатель", "Задатчик", /* Строки 4..8 таблицы; VPU1_CAN_DEVICE=8 соответствует задатчику, а не ВЭП. */
"ВЭП", "ПМУ", "Узел 11", "Узел 12", "Терминал" /* Строка 9 — ВЭП, пара 0xBA0009/0xBA0019 — ПМУ; служебные узлы 11..13 сохраняют прежние назначения. */
};
return (device >= 1U && device <= BALSAM_CAN_NODE_COUNT)
? names[device - 1U] : "Неизвестный узел";
@@ -91,6 +91,13 @@ const char *balsam_can_device_name(uint8_t device)
size_t balsam_can_register_name(uint8_t device, uint16_t address,
char *output, size_t output_size)
{
if (address == 0x0100U) /* CAN_HEARTBEAT_ADDR в ecan.c периферии 167 задаёт служебный адрес вне технологической карты R0..R127. */
return copy_name("Heartbeat: счётчик, с", output, output_size); /* Первое слово hiword содержит секунды после запуска по модулю 65536. */
if (address == 0x0101U) /* Следующее слово heartbeat соответствует старшим 16 битам loword в CAN_send. */
return copy_name("Heartbeat: Mode", output, output_size); /* Прошивка передаёт аппаратный Mode без уменьшения на единицу, применяемого только к CAN ID. */
if (address == 0x0102U) /* Третье слово heartbeat соответствует младшим 16 битам loword. */
return copy_name("Heartbeat: тип платы (Desk)", output, output_size); /* Значение Desk задаёт тип платы и отличается от номера Mode и номера CAN-узла. */
if ((device == 1U || device == 2U) && address < 20U)
return indexed_name("Диагностика T°", address + 1U, output, output_size);
if ((device == 1U || device == 2U) && address >= 0x18U && address <= 0x2BU)
@@ -139,7 +146,7 @@ size_t balsam_can_register_name(uint8_t device, uint16_t address,
if (device == 7U && address >= 0x18U && address <= 0x1FU)
return indexed_name("Показания T°", address - 0x17U, output, output_size);
if (device == 8U) {
if (device == 9U) { /* Карта напряжений принадлежит ВЭП с adr_SHKF=9; задатчик с номером 8 использует другую карту. */
static const char *const vep_names[] = {
"Диагностика 380В Ф1", "Диагностика 380В Ф2",
"Диагностика 31В Ф1", "Диагностика 31В Ф2",
@@ -164,15 +171,15 @@ size_t balsam_can_register_name(uint8_t device, uint16_t address,
return copy_name("Дискретные датчики (14 бит)", output, output_size);
}
if (device == 9U && address == 0U)
if (device == 8U && address == 0U) /* У VPU1_CAN_DEVICE=8 слово 0 передаёт обороты ГЭД, а не диагностику ВЭП. */
return copy_name("Обороты ГЭД, об/мин", output, output_size);
if (device == 9U && address == 1U)
if (device == 8U && address == 1U) /* Слово 1 той же платы задатчика содержит обороты гребного вала. */
return copy_name("Обороты ГВ, об/мин", output, output_size);
if (device == 9U && address == 2U)
if (device == 8U && address == 2U) /* ADR_CAN_LAMPS_ON_VPU=2 задаёт регистр первой группы ламп задатчика. */
return copy_name("Лампы (8 бит)", output, output_size);
if (device == 9U && address == 3U)
if (device == 8U && address == 3U) /* ADR_CAN_LAMPS_2_ON_VPU=3 задаёт регистр второй группы индикаторов задатчика. */
return copy_name("Диоды (16 бит)", output, output_size);
if (device == 9U && address == 0x10U)
if (device == 8U && address == 0x10U) /* ADR_CAN_KEY_ON_VPU=16, то есть 0x10, задаёт слово кнопок VPU; адрес не перенумеровывается. */
return copy_name("Кнопки (12 бит)", output, output_size);
if (address == 0x16U)

View File

@@ -7,8 +7,7 @@
#include "set_plot.h"
#include <math.h>
/* Allow sample-level X navigation in multi-million-point recordings. */
#define MIN_VIEW_WIDTH (1.0 / 10000000)
#define MIN_VIEW_SPAN 1e-12 /* Минимальная безразмерная доля полного диапазона по X и Y задаёт увеличение до 10^12 раз; запас относительно точности double сохраняет различимые границы нормированной области. */
static double clamp(double v, double lo, double hi) { return fmin(hi, fmax(lo, v)); }
static int finite_values(const double *v, size_t n) {
@@ -26,13 +25,13 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t
cap < (op == SET_PLOT_TRANSFORM || op == SET_PLOT_LIMITS ? 4U : 1U)) return 0;
if (op == SET_PLOT_TRANSFORM) {
double w, h, fx, fy;
if (!finite_values(v, 4) || v[2] < MIN_VIEW_WIDTH || v[2] > 1 ||
v[3] < 1.0/128 || v[3] > 1 || v[0] < 0 || v[1] < 0 ||
if (!finite_values(v, 4) || v[2] < MIN_VIEW_SPAN || v[2] > 1 || /* Проверяем конечность смещений и размеров, разрешая ширину до триллионной доли исходного диапазона X. */
v[3] < MIN_VIEW_SPAN || v[3] > 1 || v[0] < 0 || v[1] < 0 || /* Такой же предел для Y сохраняет возможность дальнейшего перемещения и увеличения уже приближённой области. */
v[0] + v[2] > 1.000000000001 || v[1] + v[3] > 1.000000000001) return 0;
out[0] = v[0]; out[1] = v[1]; out[2] = v[2]; out[3] = v[3];
if (!finite_values(v + 4, 6) || v[4] <= 0 || v[5] <= 0) return 4;
w = clamp(v[2] / v[4], MIN_VIEW_WIDTH, 1);
h = clamp(v[3] / v[5], 1.0/128, 1);
w = clamp(v[2] / v[4], MIN_VIEW_SPAN, 1); /* Делим текущую долю X на положительный коэффициент приближения; ограничиваем результат между новым пределом и полным диапазоном. */
h = clamp(v[3] / v[5], MIN_VIEW_SPAN, 1); /* Аналогично масштабируем Y, снимая прежнее ограничение 128 раз и сохраняя ненулевую высоту для координатных преобразований. */
fx = clamp(v[8], 0, 1); fy = clamp(v[9], 0, 1);
out[0] = clamp(v[0] + (v[2] - w) * fx - v[6] * w, 0, 1 - w);
out[1] = clamp(v[1] + (v[3] - h) * fy - v[7] * h, 0, 1 - h);

View File

@@ -18,7 +18,8 @@ unsigned ump_validate(unsigned fn, uint16_t a, uint16_t n, int can)
return can ? ((a == UMP_FIRMWARE_INFO && n == UMP_FIRMWARE_INFO_WORDS) ? 0 : 2)
: ((n && n <= UMP_FIRMWARE_INFO + UMP_FIRMWARE_INFO_WORDS - a) ? 0 : 2);
if (can) return ((a == UMP_BASE && n == 16) ||
(a == UMP_DATA && (n == 40 || n == 80 || n == 120 || n == 42 || n == 84)) ||
/* Разрешает целые 12-словные записи v4 до 120 слов; размеры v2/v3 сохраняются для прежних устройств. */
(a == UMP_DATA && (n == 40 || n == 80 || n == 42 || n == 84 || (n && n <= 120 && !(n % 12U)))) ||
(a == UMP_LIVE && n == 124)) ? 0 : 2;
if (!n || n > 125) return 2;
return ((a >= UMP_BASE && a < UMP_BASE+16 && n <= UMP_BASE+16-a) ||

View File

@@ -4,7 +4,7 @@
{"name":"zoom_x_anchor","op":0,"input":[0,0,1,1,2,1,0,0,0.25,0.5],"output":[0.125,0,0.5,1]},
{"name":"zoom_y_anchor","op":0,"input":[0,0,1,1,1,4,0,0,0.5,0.75],"output":[0,0.5625,1,0.25]},
{"name":"pan_clamp","op":0,"input":[0.25,0.25,0.5,0.5,1,1,20,-20,0.5,0.5],"output":[0,0.5,0.5,0.5]},
{"name":"zoom_limit","op":0,"input":[0,0,1,1,1000,1000,0,0,0.5,0.5],"output":[0.4995,0.49609375,0.001,0.0078125]},
{"name":"zoom_limit","op":0,"input":[0,0,1,1,1e15,1e15,0,0,0.5,0.5],"output":[0.4999999999995,0.4999999999995,1e-12,1e-12]},
{"name":"ignore_invalid_zoom","op":0,"input":[0.25,0.25,0.5,0.5,0,1,0,0,0.5,0.5],"output":[0.25,0.25,0.5,0.5]},
{"name":"horizontal_spread","op":1,"input":[100,2,8],"output":[1]},
{"name":"vertical_contract","op":1,"input":[2,-100,8],"output":[2]},

View File

@@ -24,6 +24,13 @@ Saleae SDK подключается при обращении к устройс
в приложении. Общая панель обработки графиков размещена в `qt_ports.plot_processing`:
[контракт, подключение нового графика и примеры](../c/set-protocol/docs/PLOT_PROCESSING.md).
Конструкторы CAN-адаптера, CAN DevBoard и RS485 DevBoard используют общий
компонент `set_devices.qt_ports.can_frame_builder.CanFrameFields`. Он задаёт
одинаковые поля и исходный режим «Обычный CAN — ID / DLC / Data», сохраняет
альтернативный режим SETCAN и сообщает об изменениях сигналом `changed`.
`frame_values()` возвращает проверенные ID, IDE, Data и необязательный Body;
упаковка конкретного транспорта и отправка остаются у приложения.
Пути к нативным библиотекам задаёт потребитель перед импортом:
`SETPROTOCOL_LIBRARY`, при необходимости `ALTERA_LOGIC_LIBRARY`, `CANDLE_LIBRARY`.
Порты не ищут DLL в каталогах SETGUI. C99-ядро собирается из `c/set-protocol`,

View File

@@ -0,0 +1,46 @@
"""Три автономных примера общей консоли templates без подключения к аппаратуре.""" # Примеры показывают служебный текст, CAN и двоичный RS485 на одном компоненте.
import argparse # Ключ --kind выбирает один пример для отдельного запуска.
import time # Время в модели кадра соответствует обычному источнику пакетов приложения.
from set_devices.can485_board import BoardFrame # Штатная модель строит RS485-пакет с настоящим CRC.
from set_devices.can_monitor import decode_protocol # CAN расшифровывается действующим декодером templates.
from set_devices.can_transport import build_frame # Транспортный кадр не собирается вручную в UI.
from set_devices.qt_ports.console import ConsoleView # Все примеры используют ту же консоль, что SETGUI.
try: # Выбираем доступную версию Qt без зависимости от приложения SETGUI.
from PySide6.QtWidgets import QApplication, QTabWidget # Вкладки позволяют сравнить три независимых журнала.
except ImportError: # Сохраняем совместимость примера с портом Qt 5.
from PySide2.QtWidgets import QApplication, QTabWidget # Интерфейс запуска одинаков для обеих версий Qt.
def create_examples(): # Фабрика доступна также тесту и генератору снимков интерфейса.
service = ConsoleView(limit=4000) # Первый журнал показывает служебные ответы и команды платы.
service.append_message(">>> V", direction="TX", color="#8fd18f") # Метка отправки демонстрирует независимый фильтр TX.
service.append_message("#OK driver started", direction="RX", color="#6fa8dc") # Ответ устройства показывает цвет и фильтр RX.
service.append_message("#ERR transmit failed (no ack / bus busy)", color="#ff7b7b") # Ошибка остаётся видна независимо от фильтров направления.
can = ConsoleView(limit=4000, payloads=True) # Второй пример добавляет переключение представления настоящих байтов CAN.
frame = build_frame(0x701, b"\x05", ide=False, rtr=False) # ID 0x701 и байт 05 задают heartbeat CANopen узла 1 в Operational.
decoded = decode_protocol(frame, "deif") # Подпись события берётся из штатного протокольного декодера.
can.append_message("RX CAN 0x701 · " + decoded.summary, direction="RX", payload=b"\x05") # Исходный payload позволяет сравнить режимы HEX и ASCII.
rs485 = ConsoleView(limit=4000, payloads=True) # Третий пример показывает транспортный пакет платы отдельно от прикладного CAN.
packet = BoardFrame(time.time(), False, 0x701, False, 1, b"\x05", "RS485").rs485_packet() # Пакет AA55 и CRC8 формируются кодеком templates.
rs485.append_message("RX RS485 · пакет CAN485", direction="RX", payload=packet) # Консоль отображает байты, не повторяя алгоритм разбора пакета.
for console in (can, rs485): # Двоичные примеры сразу демонстрируют одновременно байты и печатное представление.
console.display.setCurrentText("HEX + ASCII") # Пользователь может вернуться к исходной подписи или выбрать один формат.
return (("Служебная консоль", service), ("CAN", can), ("RS485", rs485)) # Каждый журнал владеет своей историей и настройками просмотра.
def main(): # Точка входа запускается обычным Python с установленным Qt-портом templates.
parser = argparse.ArgumentParser(description=__doc__) # Справка объясняет назначение примеров без отдельного приложения.
parser.add_argument("--kind", choices=("all", "service", "can", "rs485"), default="all") # Один пример каждого источника доступен отдельной командой.
args = parser.parse_args() # Пользователь выбирает источник до создания окна.
app = QApplication.instance() or QApplication([]) # Одно приложение Qt обслуживает все примеры.
window = QTabWidget() # Общий просмотр не связывает состояния независимых консолей.
for key, (title, console) in zip(("service", "can", "rs485"), create_examples()): # Стабильные ключи соответствуют значениям --kind.
if args.kind in ("all", key): window.addTab(console.panel, title) # Выбранные примеры добавляются с общей панелью инструментов.
window.setWindowTitle("Примеры консоли templates") # Заголовок явно отличает демонстрацию от подключения к плате.
window.resize(1100, 480) # Начальный размер в логических пикселях позволяет видеть все инструменты.
window.show() # Окно появляется только при прямом запуске примера.
return app.exec() # Цикл Qt обрабатывает поиск, паузу, экспорт и изменение масштаба.
if __name__ == "__main__": # Импорт фабрики в тестах не должен открывать окно или запускать цикл событий.
raise SystemExit(main()) # Код завершения Qt возвращается вызывающему процессу.

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,

View File

@@ -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-формата обоими существующими уровнями.

View File

@@ -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.

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,11 +80,14 @@ 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 item.address == 0x7F else "")
for item in self.registers
@@ -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),

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`: перемещаются влево, вправо, вверх, вниз и в общую группу
вкладок со штатной подсветкой области вставки. Разделители меняют размеры.
Перенос сохраняет экземпляры панелей, соединение и выполняемые задания.
Раскладка сохраняется между запусками; «Сбросить раскладку» возвращает
стандартное расположение без пересоздания содержимого.

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} # Заполненная таблица подтверждается данными после паузы, пустая остаётся остановленной.

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 # Возвращаем единый планировщик ручного обмена и периодических заданий.

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)

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()

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 # Новое подключение должно подтвердить весь снимок таблицы заново.

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)
@@ -285,54 +286,44 @@ class RawProtocolWidget(QWidget):
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

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() # Последняя проверка не допускает публикации результата, отменённого в конце короткого блока.

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,7 +51,7 @@ 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 item.address == 0x7F:
result.fields.append(("Активные биты Commands", frame.command_description(item.value)))

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.

View File

@@ -8,17 +8,18 @@ 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"),
("protocan", "ProtoCAN / GAS"),
("ump_balsam", "УМП / Balsam 167 (legacy CAN)"),
("ump1_gas167", "УМП 1 / ProtoCAN — карта GAS 167/v1"),
("ump2_gas167", "УМП 2 / ProtoCAN — карта GAS 167/v1"),
("ump_gas", "УМП / ProtoCAN GAS — новые ID 5000/6000"))
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)
@@ -36,6 +37,13 @@ def decode_protocol(frame, protocol="auto"):
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)
@@ -63,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.
@@ -93,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")

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]:

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:

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 # Запоминаем конец текущего пути для следующей выбранной точки.

View File

@@ -10,7 +10,7 @@ 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 operator import itemgetter
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
@@ -28,8 +28,9 @@ class FrozenPoints(tuple):
def between(self, left, right):
if self.ordered:
start = bisect_left(self, left, key=itemgetter(0))
stop = bisect_right(self, right, key=itemgetter(0))
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])

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, а разность порядков сохраняет отдельную подпись для каждого деления.

View File

@@ -0,0 +1,68 @@
# Общая консоль Qt
`set_devices.qt_ports.console.ConsoleView` — общий порт представления для
журналов SETGUI. Он не управляет портами, опросом и отправкой команд.
Приложение → `ConsoleView` → Qt 6 / Qt 5. Протокольные декодеры передают
готовые подписи и необязательные исходные байты; консоль не вычисляет CRC.
| Файл | Назначение и зависимости |
|---|---|
| `set_devices/qt_ports/console.py` | Виджет и файловый экспорт; stdlib и PySide6 либо PySide2 |
| `examples/console_examples.py` | По одному примеру служебной консоли, CAN и RS485; действующие кодеки templates |
| `tests/test_console.py` | Проверки истории, паузы, фильтров, очистки и UTF-8; pytest и Qt |
## Возможности, собранные из журналов SETGUI
| Возможность | Источник / поведение общего компонента |
|---|---|
| Очистка | Канальные консоли платы, TX/RX, Modbus; удаляет также скрытые строки и очередь обновления |
| Пауза | Эмуляторы CAN; экран заморожен, история продолжает принимать данные до заданного предела |
| TX/RX | Диагностика и эмуляторы; служебные ошибки без направления остаются видны |
| Автопрокрутка | Мониторы CAN и RS; её можно отключить для чтения истории |
| Цвета | Консоль платы и монитор шины; сохраняются при переразборе фильтра |
| HEX/ASCII | Диагностика RS; формат применяется к явно переданному payload |
| Сохранение | Мониторы CAN и RS; экспорт фактически видимого текста в UTF-8 |
| Поиск, копирование, перенос, масштаб | Общие инструменты всех подключённых текстовых журналов |
## Подключение
```python
from set_devices.qt_ports.console import ConsoleView # Подключаем общий порт представления templates.
console = ConsoleView(limit=4000, payloads=True) # Храним не более 4000 строк, включая скрытые и поступившие на паузе.
layout.addWidget(console.panel, 1) # В layout вставляется контейнер с инструментами и редактором.
console.append_message("RX ответ", direction="RX", payload=b"OK") # Передаём готовую подпись и байты без повторного разбора протокола.
console.append_message("Ошибка связи", color="#ff7b7b") # Служебная ошибка остаётся видна при выключенных TX и RX.
console.userCleared.connect(clear_frame_history) # Отдельный буфер кадров очищается тем же пользовательским действием.
```
Старые вызовы `appendPlainText`, `appendHtml`, `setPlainText`, `clear`,
`toPlainText` и `setMaximumBlockCount` поддерживаются. `setPlainText` заменяет
снимок истории, учитывая текущую паузу. Для внешней истории кадров подключите
`userCleared`: программный `clear()` повторно этот сигнал не испускает.
`directions=False` скрывает встроенные флажки, если приложение уже имеет свои
фильтры TX/RX. `payloads=True` показывает форматы HEX/ASCII. События должны
поступать в GUI-потоке Qt, как при работе с обычным `QPlainTextEdit`.
При заполнении история обновляет экран пачками с интервалом 100 мс.
Количество строк на панели относится ко всей ограниченной истории.
На паузе сохранение и копирование используют замороженный видимый экран.
## Три запускаемых примера
Из каталога `python`, с установленными зависимостями `.[qt]`:
```powershell
python examples/console_examples.py --kind service
python examples/console_examples.py --kind can
python examples/console_examples.py --kind rs485
```
Без `--kind` открываются все три примера. Аппаратура не требуется.
В каждом примере можно проверить очистку, паузу, поиск, TX/RX, экспорт,
копирование, автопрокрутку, перенос и масштаб; в CAN и RS485 — HEX/ASCII.
Используется в SETGUI: CAN/RS485 и служебная консоль ESP, журналы соединения
и TX/RX, эмуляторы CAN, CAN FD, Modbus, диагностика ПМ35 и ТМС2812,
спектр и логический анализатор Altera.

View File

@@ -0,0 +1,175 @@
"""Общие поля конструктора CAN и SETCAN для адаптеров и обоих каналов DevBoard."""
from PySide6.QtCore import Signal # Сигнал изменений позволяет владельцу сбрасывать готовый кадр без зависимости от транспорта.
from PySide6.QtGui import QFont # Моноширинный шрифт сохраняет читаемость HEX-идентификатора и данных.
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QComboBox, QSpinBox, QLineEdit, QLabel # Один набор виджетов определяет форму всех потребителей.
from protocan import protocan as pc # Сборка полей протокола выполняется существующими обёртками общего C-ядра.
from set_devices import can_transport as tr # Проверка CAN ID и разбор HEX используют действующий общий транспорт.
DESCRIPTION = "Обычный CAN: задайте ID, DLC и Data. Для сборки ID по полям протокола выберите SETCAN." # Одинаковое пояснение режимов используется во всех панелях.
class CanFrameFields(QWidget): # Компонент содержит только ввод кадра; подключение и отправка остаются у владельца.
changed = Signal() # Любое изменение ввода отменяет ранее собранный кадр в соответствующем канале.
def __init__(self, settings_factory, parent=None): # Редактор SETTINGS передаётся владельцем как визуальный компонент без копирования его реализации.
super().__init__(parent) # Родитель Qt управляет временем жизни всех полей формы.
layout = QVBoxLayout(self) # Единый вертикальный порядок исключает различия между вкладками.
layout.setContentsMargins(0, 0, 0, 0) # Внешние отступы задаёт панель приложения.
self.mode = QComboBox() # Переключатель сохраняет оба набора введённых значений.
self.mode.addItem("Обычный CAN — ID / DLC / Data", "can") # Исходный режим одинаков для всех трёх конструкторов.
self.mode.addItem("SETCAN — поля протокола", "setcan") # Протокольный режим всегда создаёт расширенный идентификатор.
mode_form = QFormLayout() # Первая строка остаётся видимой в обоих режимах.
mode_form.addRow("Режим", self.mode) # Подпись поясняет назначение переключателя.
layout.addLayout(mode_form) # Выбор режима предшествует зависимым полям.
self.can_panel = QWidget() # Контейнер скрывает вместе подписи и редакторы обычного CAN.
can_form = QFormLayout(self.can_panel) # Формат, идентификатор и длина выровнены общей формой.
can_form.setContentsMargins(0, 0, 0, 0) # Поля обоих режимов начинаются на одной вертикали.
self.format = QComboBox() # IDE задаётся явно независимо от численного значения идентификатора.
self.format.addItem("Стандартный — 11 бит (STD)", False) # STD соответствует диапазону CAN ID 0..0x7FF.
self.format.addItem("Расширенный — 29 бит (EXT)", True) # EXT соответствует диапазону CAN ID 0..0x1FFFFFFF.
self.can_id = QLineEdit("123") # Исходный пример идентификатора записан в HEX.
self.can_id.setToolTip("HEX, с префиксом 0x или без него. STD: 0..7FF; EXT: 0..1FFFFFFF.") # Указываем пределы CAN ID без неявного усечения.
self.dlc = QSpinBox() # Пользователь явно задаёт длину обычного CAN-кадра.
self.dlc.setRange(0, 8) # Классический CAN допускает от нуля до восьми байтов Data.
self.dlc.setToolTip("Количество байтов Data должно точно совпадать с DLC; дополнение и обрезка не выполняются.") # Описываем строгую проверку длины.
can_form.addRow("Формат ID (IDE)", self.format) # Формат отражает фактический флаг IDE.
can_form.addRow("CAN ID (HEX)", self.can_id) # Явно обозначаем систему счисления идентификатора.
can_form.addRow("DLC (0..8 байт)", self.dlc) # Длина дана в байтах полезной нагрузки.
layout.addWidget(self.can_panel) # Обычный CAN занимает то же место, что альтернативная форма SETCAN.
self.setcan_panel = QWidget() # Общий контейнер хранит поля составного 29-битного идентификатора.
form = QFormLayout(self.setcan_panel) # Подписи протокола выравниваются с остальными формами.
form.setContentsMargins(0, 0, 0, 0) # Убираем дополнительные внутренние отступы.
self.priority = QComboBox() # Приоритет соответствует старшему полю SETCAN ID.
self.priority.addItems(["0 — критический", "1 — стандартный"]) # Подписи сохраняют существующие значения приоритета протокола.
self.priority.setCurrentIndex(1) # По умолчанию используется стандартный приоритет.
self.route = QComboBox() # Направление отличает запрос мастера от сообщения устройства.
self.route.addItems(["0 — от мастера (PM)", "1 — от устройства"]) # Сохраняем совместимые значения поля ПМ.
self.dtype = QSpinBox() # Тип устройства выбирается отдельно от номера экземпляра.
self.dtype.setRange(0, 7) # Поле Device Type занимает три бита SETCAN ID.
self.dtype.setValue(1) # Сохраняем прежний исходный тип устройства.
self.did = QSpinBox() # Номер устройства задаёт адрес внутри выбранного типа.
self.did.setRange(0, 15) # Поле Device занимает четыре бита SETCAN ID.
self.did.setValue(2) # Сохраняем прежний исходный адрес устройства.
self.mtype = QComboBox() # Тип сообщения определяет подтип и назначение Body.
self.mtype.addItems([item.name for item in pc.MsgType]) # Каталог типов берётся из общего протокольного модуля.
self.mtype.setCurrentText(pc.MsgType.ANALOG.name) # Начальный протокольный пример остаётся аналоговым сообщением.
self.sub = QComboBox() # Набор подтипов меняется вместе с MsgType.
self.arg = QLineEdit("1") # Числовой аргумент поддерживает десятичный ввод и префикс 0x.
self.settings_body = settings_factory() # Используем переданный редактор байтов Z и Y без зависимости от приложения.
self.arg_panel = QWidget() # Альтернативные редакторы аргумента занимают одну строку.
arg_layout = QHBoxLayout(self.arg_panel) # Горизонтальная компоновка сохраняет левое выравнивание SETTINGS.
arg_layout.setContentsMargins(0, 0, 0, 0) # Исключаем сдвиг аргумента относительно остальных редакторов.
arg_layout.addWidget(self.arg) # Обычный аргумент виден для всех типов кроме SETTINGS.
arg_layout.addWidget(self.settings_body) # Два байта SETTINGS заменяют единое поле аргумента.
arg_layout.addStretch(1) # Свободная ширина остаётся справа от редакторов.
self.count = QSpinBox() # RegCount используется протокольными типами Modbus.
self.count.setRange(0, 15) # Поле количества занимает четыре бита Body Modbus.
self.count.setValue(1) # Исходный запрос относится к одному регистру.
self.sub_label = QLabel("Подтип") # Подпись уточняется при выборе типа сообщения.
self.arg_label = QLabel("SensorID / адрес") # Назначение аргумента зависит от MsgType.
rows = [("Priority", self.priority), ("ПМ", self.route), ("Device Type (0..7)", self.dtype), ("Device (0..15)", self.did), ("Msg Type", self.mtype), (self.sub_label, self.sub), (self.arg_label, self.arg_panel), ("RegCount (Modbus)", self.count)] # Единый порядок полей исключает расхождение форм.
for label, control in rows: # Все протокольные строки добавляются из одного визуального описания.
form.addRow(label, control) # Подпись связана с соответствующим редактором.
layout.addWidget(self.setcan_panel) # Протокольная форма показывается только в режиме SETCAN.
self.data = QLineEdit() # Полезная нагрузка общая для обоих способов ввода идентификатора.
self.data.setPlaceholderText("01 02 03 04 05 06 07 08") # Пример показывает формат ввода HEX-байтов.
data_form = QFormLayout() # Общая строка Data остаётся видимой при смене режима.
data_form.addRow("Data (HEX, до 8 байт)", self.data) # Указываем ограничение классического CAN.
layout.addLayout(data_form) # Данные завершают форму перед кнопками владельца.
for control in (self.mode, self.data): # Длинные поля получают достаточную ширину в обоих режимах.
control.setMaximumWidth(420) # Предел 420 пикселей сохраняет компактность исходного конструктора.
self.format.setMaximumWidth(340) # Название формата целиком помещается в строке шириной до 340 пикселей.
for control in (self.priority, self.route, self.mtype, self.sub, self.arg, self.can_id): # Одинаковая ширина полей ID задаётся в общем компоненте.
control.setMaximumWidth(220) # Предел 220 пикселей соответствует прежней форме SETCAN.
for control in (self.dtype, self.did, self.count, self.dlc): # Короткие числовые поля не растягиваются по ширине панели.
control.setMaximumWidth(100) # Предел 100 пикселей достаточен для всех допустимых значений.
font = QFont("Consolas", 10) # Шрифт с фиксированной шириной знаков упрощает чтение HEX.
for control in (self.can_id, self.data): # Числовой HEX и полезная нагрузка оформляются одинаково.
control.setFont(font) # Используем один шрифт для всех владельцев компонента.
self._subtype() # Настраиваем первоначальные подписи до вычисления ширины столбца.
labels = [part.labelForField(part.itemAt(row, QFormLayout.ItemRole.FieldRole).widget()) for part in (mode_form, can_form, form, data_form) for row in range(part.rowCount())] # Собираем подписи всех форм для общего выравнивания.
width = max(label.sizeHint().width() for label in labels) # Ширина столбца рассчитывается в пикселях по текущему шрифту Qt.
for label in labels: # Поля обоих режимов должны начинаться в одной позиции.
label.setMinimumWidth(width) # Самая длинная подпись задаёт единый левый столбец.
for container in (self, self.can_panel, self.setcan_panel, self.arg_panel): # Промежуточные контейнеры наследуют фон панели без тёмных полос.
container.setObjectName("canBuilderFields") # Локальный селектор ограничивает правило контейнерами формы.
container.setStyleSheet("QWidget#canBuilderFields { background: transparent; }") # Редакторы сохраняют собственный штатный фон.
self.mode.currentIndexChanged.connect(self._mode_changed) # Переключение режима одновременно меняет видимость и отменяет готовность кадра.
self.mtype.currentIndexChanged.connect(self._subtype) # Новый MsgType обновляет подтипы и редактор Body.
for control in (self.format, self.priority, self.route, self.mtype, self.sub): # Любой выбор из списка меняет будущий кадр.
control.currentIndexChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем индекс Qt, поскольку владельцу нужен только сигнал устаревания результата.
for control in (self.can_id, self.arg, self.data): # Все текстовые поля участвуют в сборке кадра.
control.textChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем текст сигнала Qt и отменяем старый кадр даже при неверном промежуточном вводе.
for control in (self.dlc, self.dtype, self.did, self.count, self.settings_body.z, self.settings_body.y): # Слежение включает оба байта SETTINGS.
control.valueChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем числовой аргумент Qt, сохраняя единый сигнал необходимости новой сборки.
self._mode_changed() # Начальная видимость одинакова у всех потребителей.
def _mode_changed(self): # Режим определяет только видимость; введённые значения сохраняются.
raw = self.mode.currentData() == "can" # Полный CAN ID вводится только в обычном режиме.
self.can_panel.setVisible(raw) # Показываем формат, ID и DLC для обычного CAN.
self.setcan_panel.setVisible(not raw) # Альтернативно показываем составные поля SETCAN.
self.changed.emit() # Смена способа ввода отменяет ранее собранный кадр.
def _subtype(self): # Общие подписи и список подтипов используются всеми экземплярами формы.
msg_type = pc.MsgType[self.mtype.currentText()] # Значение выбирается из существующего общего каталога.
settings = msg_type == pc.MsgType.SETTINGS # Только SETTINGS редактируется отдельными байтами Z и Y.
self.arg.setVisible(not settings) # Единый числовой аргумент скрывается для SETTINGS.
self.settings_body.setVisible(settings) # Двухбайтовый редактор появляется вместо числового аргумента.
table = {pc.MsgType.BROADCAST: (pc.BroadcastType, "Broadcast.Type", "Body (0..15)"), pc.MsgType.DISCRETE: (pc.DiscreteType, "Discrete.Type", "Body (0..4095)"), pc.MsgType.ANALOG: (pc.AnalogType, "Analog.Type", "SensorID (0..4095)")} # Таблица определяет визуальные подписи существующих протокольных перечислений.
current = self.sub.currentText() # Сохраняем выбранный подтип, если он допустим для нового сообщения.
self.sub.clear() # Старые варианты не должны остаться в новом типе сообщения.
self.sub.setEnabled(msg_type in table) # Отдельный подтип доступен только у трёх перечисленных типов.
if msg_type in table: # Подписи и значения перечислений берутся из одного места.
enum, sub_label, arg_label = table[msg_type] # Получаем визуальное описание выбранного MsgType.
self.sub.addItems([item.name for item in enum]) # Значения берутся из общего протокольного каталога.
if current in [item.name for item in enum]: # Совместимый выбор пользователя сохраняется.
self.sub.setCurrentText(current) # Восстанавливаем допустимый подтип после заполнения списка.
else: # Типы без отдельного подтипа используют Body по собственному назначению.
sub_label = "—" # Подпись явно показывает отсутствие подтипа.
arg_label = "Body (обычно 0)" # Для PULSE сохраняем исходное описание аргумента.
if msg_type in (pc.MsgType.MODBUS_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # Все четыре вида Modbus задают начальный адрес.
arg_label = "StrAdr (0..4095)" # Двенадцатибитный адрес показан в прежних единицах регистров.
elif msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE: # GAS использует всё поле Body под адрес.
arg_label = "Адрес регистра (0..65535)" # Полный 16-битный диапазон указан в подписи.
elif msg_type == pc.MsgType.ERROR: # Код ошибки вводится в отдельный байт Body.
arg_label = "ErrorCode (0..255)" # Показываем допустимый однобайтовый диапазон.
elif settings: # SETTINGS редактируется двухбайтовым компонентом.
arg_label = "Body: Z | Y" # Подпись соответствует порядку байтов протокола.
self.sub_label.setText(sub_label) # Одинаковая подпись подтипа обновляется во всех вкладках.
self.arg_label.setText(arg_label) # Аргумент всегда подписан по выбранному MsgType.
def body(self, msg_type, arg, count): # Общая диспетчеризация вызывает существующие обёртки C-ядра без второй реализации wire-кодека.
if msg_type == pc.MsgType.BROADCAST: # BROADCAST объединяет подтип и четырёхбитный аргумент.
return pc.merge_broadcast(pc.BroadcastType[self.sub.currentText()], arg & 0xF) # Маска 0xF сохраняет прежнее ограничение аргумента четырьмя битами.
if msg_type == pc.MsgType.DISCRETE: # Дискретное сообщение использует собственное перечисление подтипов.
return pc.merge_discrete(pc.DiscreteType[self.sub.currentText()], arg) # Поле собирается общим протокольным модулем.
if msg_type == pc.MsgType.ANALOG: # Аналоговое сообщение передаёт SensorID в Body.
return pc.merge_analog(pc.AnalogType[self.sub.currentText()], arg) # Существующий кодек задаёт расположение подтипа и SensorID.
if msg_type in (pc.MsgType.MODBUS_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # У четырёх видов Modbus одинаковый формат Body.
return pc.merge_modbus(arg, count) # Адрес и количество объединяются общей функцией протокола.
if msg_type == pc.MsgType.ERROR: # Сохраняем существующую категорию ошибки с нулевым кодом источника.
return pc.merge_error(0, arg) # Размещение кода ошибки остаётся ответственностью общего ядра.
return arg & 0xFFFF # Остальные типы сохраняют прежнее ограничение Body шестнадцатью битами.
def frame_values(self): # Возвращаем проверенные параметры CAN без упаковки конкретного транспорта.
data = tr.parse_hex(self.data.text()) # Общий разбор сохраняет поддержку пробелов, запятых и префиксов 0x.
if len(data) > 8: # Классический CAN не допускает более восьми байтов данных.
raise ValueError("данные длиннее 8 байт") # Сообщаем об ошибке до передачи в транспорт.
if self.mode.currentData() == "can": # Обычный режим использует полный идентификатор и явный DLC.
identifier = int(self.can_id.text().strip(), 16) # Поле ID всегда интерпретируется как HEX.
extended = bool(self.format.currentData()) # Малый числовой ID также может иметь выбранный EXT.
tr.validate_can_id(identifier, extended) # Проверка общего транспорта запрещает усечение ID до допустимого диапазона.
if len(data) != self.dlc.value(): # Количество введённых байтов обязано точно совпадать с DLC.
raise ValueError(f"DLC = {self.dlc.value()}, а Data содержит {len(data)} байт; количество байтов должно совпадать.") # Оператор видит обе длины и может исправить ввод.
return identifier, extended, data, None # У обычного CAN нет отдельного протокольного Body.
msg_type = pc.MsgType[self.mtype.currentText()] # SETCAN использует выбранный тип сообщения.
arg = self.settings_body.value() if msg_type == pc.MsgType.SETTINGS else int(self.arg.text().strip() or "0", 0) # SETTINGS читается из Z/Y, остальные аргументы поддерживают привычные числовые префиксы.
body = self.body(msg_type, arg, self.count.value()) # Один путь сборки Body применяется ко всем владельцам формы.
identifier = pc.ProtoCanId.build(self.priority.currentIndex(), self.route.currentIndex(), self.dtype.value(), self.did.value(), msg_type, body) # Идентификатор собирается существующей обёрткой общего C-ядра.
return identifier, True, data, body # SETCAN всегда передаётся с расширенным 29-битным идентификатором.
def bind(self, owner, prefix): # Сохраняем публичные имена полей приложения при переходе на общий компонент.
names = ("mode", "can_panel", "setcan_panel", "format", "can_id", "dlc", "priority", "route", "dtype", "did", "mtype", "sub", "arg", "settings_body", "count", "data", "sub_label", "arg_label") # Совместимые атрибуты позволяют не менять интеграцию транспорта и внешние потребители.
for name in names: # Каждое старое имя ссылается на настоящий виджет общего компонента.
setattr(owner, prefix + name, getattr(self, name)) # Префиксы build_, builder_ и rs485_builder_ разделяют три независимых состояния.

View File

@@ -2,33 +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.setMinimumContentsLength(28)
self.protocol.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
self.protocol.setToolTip(
"Выберите формат CAN вручную. УМП / Balsam — legacy CAN; "
"карта 167/v1 — имена слов 0…127 и 0x5000…0x507F в ProtoCAN; "
"новые ID — назначенные диапазоны 5000…5127 и 6000…6127.")
self.protocol.setCurrentIndex(self.protocol.findData(default))
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("Без парса")
@@ -57,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())
@@ -79,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)
@@ -106,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("● Запись")
@@ -159,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

View File

@@ -0,0 +1,180 @@
"""Общая консоль Qt: ограниченная история и инструменты просмотра без управления транспортом.""" # Модуль относится к порту представления templates и не реализует протоколы устройств.
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 # Таймер объединяет обновления заполненного журнала.
from PySide6.QtGui import QFont, QTextDocument # Документ извлекает обычный текст из уже оформленных сообщений приложения.
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Панель использует только стандартные элементы Qt.
except ImportError: # Старый порт сохраняет тот же публичный интерфейс консоли.
from PySide2.QtCore import QTimer, Signal # В Qt 5 доступны те же сигналы и таймеры.
from PySide2.QtGui import QFont, QTextDocument # Цвета и извлечение текста не зависят от версии Qt.
from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Состав инструментов одинаков в обоих портах.
class ConsoleView(QPlainTextEdit): # Наследование сохраняет привычные appendPlainText, appendHtml и toPlainText у потребителей.
userCleared = Signal() # Владелец отдельной истории может удалить её при явном нажатии «Очистить».
def __init__(self, parent=None, limit=4000, directions=True, payloads=False): # Предел задан в строках, включая скрытые и поступившие на паузе.
super().__init__(parent) # Владельца редактора впоследствии принимает контейнер panel.
self.records = deque(maxlen=max(1, int(limit))) # Каждая запись хранит текст, оформление, направление и необязательные байты.
self.setReadOnly(True) # Журнал показывает полученные события и не допускает ручной подмены текста.
self.setFont(QFont("Cascadia Mono", 9)) # Моноширинный шрифт выравнивает HEX, адреса и метки времени.
super().setMaximumBlockCount(self.records.maxlen) # Документ ограничен тем же количеством строк, что история.
self.timer = QTimer(self) # Таймер уничтожается вместе с редактором и не переживает закрытие вкладки.
self.timer.setSingleShot(True) # За одну пачку входящих строк достаточно одного полного обновления.
self.timer.setInterval(100) # Интервал 100 мс ограничивает перестроения десятью в секунду при полном буфере.
self.timer.timeout.connect(self.refresh) # Перестроение всегда читает последние записи и текущие фильтры.
self.panel = QWidget(parent) # Контейнер можно вставить в layout или вкладку вместо самого редактора.
layout = QVBoxLayout(self.panel) # Две компактные строки инструментов остаются над областью текста.
layout.setContentsMargins(0, 0, 0, 0) # Внешние отступы задаёт приложение-потребитель.
actions = QHBoxLayout() # Основные операции сгруппированы отдельно от фильтров просмотра.
self.clear_button = QPushButton("Очистить") # Очистка удаляет также скрытую историю и накопленное на паузе.
self.clear_button.clicked.connect(self._user_clear) # Сигнал владельцу позволяет очистить дополнительный буфер кадров.
actions.addWidget(self.clear_button) # Очистка постоянно доступна у левого края консоли.
self.pause = QCheckBox("Пауза") # Пауза замораживает экран, продолжая сохранять входящие сообщения.
actions.addWidget(self.pause) # Пауза относится только к этому экземпляру журнала.
self.follow = QCheckBox("Автопрокрутка") # Прокрутку можно отключить для чтения старых записей.
self.follow.setChecked(True) # По умолчанию новые события видны сразу.
actions.addWidget(self.follow) # Управление расположено рядом с паузой просмотра.
for title, callback in (("Копировать", self.copy_visible), ("Сохранить…", self.save_dialog), ("−", lambda: self.zoomOut(1)), ("+", lambda: self.zoomIn(1))): # Масштаб меняется на один шаг Qt без изменения данных.
button = QPushButton(title) # Кнопки используют тему и шрифт приложения.
button.clicked.connect(callback) # Обработчик вызывается только по явному действию пользователя.
actions.addWidget(button) # Одинаковый порядок действий используется всеми консолями.
actions.addStretch(1) # Свободное место не растягивает подписи инструментов.
layout.addLayout(actions) # Основная строка отделена от параметров отображения.
filters = QHBoxLayout() # Поиск и направления занимают вторую строку для умеренной минимальной ширины.
self.search = QLineEdit() # Фильтр работает по обычному тексту всех сохранённых строк.
self.search.setPlaceholderText("Поиск / фильтр текста") # Подпись показывает, что скрытые строки остаются в истории.
filters.addWidget(self.search, 1) # Поле поиска получает свободную ширину консоли.
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(self.panel) # Родитель исключает отдельное окно при setVisible(payloads); формат относится только к сообщениям с явно переданными байтами.
self.display.addItems(("Как получено", "HEX", "ASCII", "HEX + ASCII")) # Обычный текст и ответы устройства не перекодируются.
self.display.setToolTip("HEX/ASCII применяется к байтам, переданным через append_message(payload=…).") # Приложение явно определяет границы полезной нагрузки.
self.display.setVisible(payloads) # Выбор байтового представления показывается только потребителю с явным payload.
filters.addWidget(self.display) # Представление можно менять без повторной передачи данных.
self.wrap = QCheckBox("Перенос") # Перенос длинных строк удобен на узких панелях.
filters.addWidget(self.wrap) # Переключатель не изменяет исходный текст при экспорте.
self.count = QLabel() # Счётчик показывает объём ограниченной истории, включая скрытые строки.
filters.addWidget(self.count) # Количество остаётся видно при включённом фильтре.
layout.addLayout(filters) # Параметры находятся непосредственно над текстом.
layout.addWidget(self, 1) # Редактор занимает оставшуюся высоту контейнера.
for control in (self.pause, self.show_tx, self.show_rx, self.follow): # Изменения параметров применяются к уже накопленным данным.
control.toggled.connect(self.refresh) # Снятие паузы показывает сохранённые во время неё события.
self.search.textChanged.connect(self.refresh) # Поиск немедленно обновляет видимую часть истории.
self.display.currentIndexChanged.connect(self.refresh) # Выбор HEX/ASCII заново форматирует сохранённый payload.
self.wrap.toggled.connect(lambda checked: self.setLineWrapMode(QPlainTextEdit.WidgetWidth if checked else QPlainTextEdit.NoWrap)) # Отключённый перенос сохраняет горизонтальную прокрутку.
self.setLineWrapMode(QPlainTextEdit.NoWrap) # Начальный режим совпадает с выключенным флажком переноса.
self.refresh() # Пустой журнал сразу получает корректный счётчик.
def setMaximumBlockCount(self, limit): # Сохраняем совместимость с существующими настройками журналов SETGUI.
self.records = deque(self.records, maxlen=max(1, int(limit))) # Предел ограничивает и скрытую историю, а не только документ.
super().setMaximumBlockCount(self.records.maxlen) # Согласуем вместимость редактора с кольцевым буфером.
self.refresh() # Уменьшение лимита сразу удаляет вытесненные строки с экрана.
def append_message(self, text, direction="", color=None, payload=None, _html=None, _defer=False): # Принимаем готовый текст и данные, не выполняя разбор протокола.
for line in str(text).split("\n"): # Многострочные расшифровки также подчиняются общему лимиту строк.
match = re.match(r"^(?:\[[^\]]+\]\s*|\d\d:\d\d:\d\d(?:\.\d+)?\s*)?(TX|RX|>>>)\s", line) # Направление распознаётся только у начальной метки, а не по словам внутри ошибки.
role = direction or (("TX" if match[1] == ">>>" else match[1]) if match else "") # Явная роль имеет приоритет над текстовой эвристикой.
html = _html or ('<span style="color:%s;white-space:pre">%s</span>' % (escape(color, quote=True), escape(line)) if color else "") # Безопасное оформление сохраняет исходный текст устройства.
record = (line, html, role, bytes(payload) if payload is not None else None) # Снимок payload не зависит от последующих изменений входного bytearray.
expired = len(self.records) == self.records.maxlen # Вытеснение требует убрать старую строку даже при скрытых новых событиях.
self.records.append(record) # На паузе и под фильтрами данные продолжают сохраняться в пределах лимита.
if not self.pause.isChecked() and not _defer: # Пауза и пакетная замена замораживают документ, но не историю.
if expired or self.timer.isActive(): # Заполненный буфер обновляется пачками для устойчивого интенсивного приёма.
if not self.timer.isActive(): self.timer.start() # Следующие события не отодвигают уже запланированное обновление.
elif self._visible(record): self._append_record(record) # Обычный поток дописывает одну строку без переразбора всей истории.
self.count.setText("%d строк" % len(self.records)) # Счётчик истории обновляется даже при замороженном экране.
def appendPlainText(self, text): # Старые потребители могут продолжать писать обычный текст.
self.append_message(text) # Ограничения, пауза и фильтры применяются через единый путь записи.
def appendHtml(self, html): # Прежнее цветное оформление приложения остаётся доступно через API QPlainTextEdit.
document = QTextDocument() # Временный документ преобразует форматированный фрагмент в строки для поиска.
document.setHtml(html) # Разметка поступает от приложения; внешние данные оно должно экранировать.
text = document.toPlainText() # Обычный текст используется для поиска и определения направления.
self.append_message(text, _html=html if "\n" not in text else None) # Однострочное оформление сохраняется без полного обновления истории.
def setPlainText(self, text): # Снимок внешней истории полностью заменяет данные этого представления.
self.timer.stop() # Старое отложенное обновление не должно вернуть предыдущий снимок.
self.records.clear() # Сохраняется только актуальный результат форматирования потребителя.
if text: self.append_message(text, _defer=True) # Пустой снимок не создаёт фиктивную запись.
self.refresh() # Поиск и фильтры применяются к новому снимку целиком.
def clear(self): # Программная очистка удаляет все данные и отменяет ожидающее обновление.
self.timer.stop() # Запланированное обновление больше не требуется.
self.records.clear() # Скрытые записи и накопленное на паузе не должны вернуться.
super().clear() # Явная очистка действует сразу даже при замороженном экране.
self.count.setText("0 строк") # Счётчик согласован с удалённой историей.
def _user_clear(self): # Только действие оператора уведомляет владельца внешней истории.
self.clear() # Сначала очищаем собственный буфер и видимый документ.
self.userCleared.emit() # Потребитель может безопасно вызвать clear повторно без рекурсивного сигнала.
def _visible(self, record): # Единое условие применяется при добавлении и полном обновлении.
text, _html, role, _payload = record # Поиск использует исходный текст сообщения независимо от выбранного формата байтов.
return (role != "TX" or self.show_tx.isChecked()) and (role != "RX" or self.show_rx.isChecked()) and self.search.text().casefold() in text.casefold() # Служебные сообщения сохраняются при отключённых направлениях.
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 показывает каждый исходный байт двумя цифрами.
ascii_text = "".join(chr(value) if 32 <= value <= 126 else "." for value in payload) # Диапазон печатного ASCII задан стандартом; управляющие байты заменяются точками.
super().appendPlainText(text + " · " + (hexadecimal if mode == 1 else ascii_text if mode == 2 else hexadecimal + " | " + ascii_text)) # Исходная подпись не теряется при смене представления байтов.
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, 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): # Пользователь самостоятельно выбирает назначение журнала.
filters = "CSV UTF-8 (*.csv);;JSON UTF-8 (*.json);;Текст UTF-8 (*.txt)" # Все консоли предлагают одинаковые три формата; CSV выбран первым.
path, selected_filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console", filters) # Имя без расширения позволяет выбрать любой формат без ручной замены суффикса.
if path: # Записываем только явно выбранный файл.
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)) # Причина ошибки остаётся доступна оператору.

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)

View File

@@ -8,6 +8,13 @@ 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):
@@ -32,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):
@@ -115,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):
@@ -139,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()
@@ -177,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()
@@ -189,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):

View File

@@ -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 — начало/конец.')

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:

View File

@@ -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()

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) # Текст можно скопировать для анализа состояния устройства.

View File

@@ -1,5 +1,6 @@
"""Debounced viewport statistics shared by desktop charts."""
import time
from threading import Event # Сигнал отмены позволяет фоновому расчёту прекращать обработку старого окна при продолжении масштабирования.
from dataclasses import replace
try:
from PySide6.QtCore import QObject, QTimer, QThreadPool, Qt
@@ -16,6 +17,7 @@ class ViewportNavigation(QObject):
self.chart = chart
self._navigation_revision = 0
self._navigation_busy = False
self._navigation_cancel = Event() # Отдельное потокобезопасное событие относится к текущему фоновому расчёту измерений.
self._navigation_emitter = _NavigationSignals(self)
self._navigation_emitter.ready.connect(self._navigation_ready, Qt.ConnectionType.QueuedConnection)
self._navigation_timer = QTimer(self)
@@ -25,6 +27,7 @@ class ViewportNavigation(QObject):
def _navigation_changed(self):
self._navigation_revision += 1
self._navigation_cancel.set() # Сразу отменяем статистику предыдущего окна, чтобы она не конкурировала с новой отрисовкой миллионов отсчётов.
if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000:
self.chart._scope_measurements()
self.chart._refresh_processing()
@@ -43,15 +46,16 @@ class ViewportNavigation(QObject):
self._navigation_timer.start()
return
self._navigation_busy = True
self._navigation_cancel = Event() # После окончания перемещения создаём новый независимый сигнал отмены для актуального окна.
bounds = self.chart.plot.visible_bounds
panel = self.chart.processing
offset = self.chart._plot_time_offset if not self.chart.fft.isChecked() else 0.
# Navigation changes the next calculation's domain; retained curves keep
# their original domain. Avoid preparing millions of inputs on the UI.
panel._snapshot = replace(panel._snapshot, x_range=(bounds.left + offset, bounds.right + offset))
series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if not s.discrete]
QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds,
self._navigation_emitter))
series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if s.visible and not s.discrete] # Скрытые каналы не занимают процессор повторным расчётом статистики при перемещении графика.
QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds, # Фоновый расчёт использует полные исходные значения только актуального видимого окна.
self._navigation_emitter, self._navigation_cancel)) # Передаём событие отмены вместе с существующими ревизией и каналом результата.
def _navigation_ready(self, revision, stats, bounds):
self._navigation_busy = False

View File

@@ -43,9 +43,9 @@ CRC_TAIL = 3
#: Границы настройки в GUI: меньше одного поля смысла нет, больше не бывает.
MIN_DIGIT_BYTES = 1
MAX_DIGIT_BYTES = 64
MAX_DIGIT_BYTES = 96 # Ответ 22220 содержит 70 дискретных байт; предел 96 оставляет запас в пределах буфера UART прошивки на 512 байт.
MIN_ANALOG_COUNT = 1
MAX_ANALOG_COUNT = 64
MAX_ANALOG_COUNT = 128 # Ответ 22220 содержит 80 знаковых слов; 128 слов вместе с дискретами помещаются в 512-байтовый буфер прошивки.
#: Номера команд, enum в конце RS_Functions.h.
COMMAND_NAMES: Dict[int, str] = {

View File

@@ -22,6 +22,12 @@ FAST_CAPABILITY = 0x1220
FAST_DATA = 0x2000
WORDS = 40
CAPACITY = 64000 // (2 * WORDS)
# Компактная запись v4 имеет 12 слов; старые 40/42 слова остаются поддержанными для чтения архивов.
COMPACT_WORDS = 12
# Ёмкость 760 точек сохраняет прежнее число записей постинтервала и поведение кольцевых банков.
COMPACT_CAPACITY = 760
# Ключи соответствуют индексам оставшихся полей v2/v3, значения — индексам этих же каналов в компактном v4.
COMPACT_INDEX = {2: 2, 3: 3, 4: 4, 5: 5, 9: 6, 24: 7, 25: 8, 40: 9, 41: 10}
FIELDS = (
('off_trigger', 'Событие отключения', 2, 8),
('post_truncated', 'Усечение постинтервала', 2, 9),
@@ -33,68 +39,37 @@ FIELDS = (
('start_out', 'Выход START_DPTB', 5, 0),
('stop_out', 'Выход STOP_DPTB', 5, 1),
('res_out', 'Выход RES_OUT_2', 5, 2),
('impulse', 'Остаток импульса, мс', 6, None),
('wake', 'Инициализация ЦАП, тики ЦАП', 7, None),
('dac', 'Код ЦАП', 8, None),
('current', 'Расчётное задание тока, мА', 9, None),
('sensor0', 'Измерение 0', 10, None),
('sensor1', 'Измерение 1', 11, None),
('sensor2', 'Измерение 2', 12, None),
('sensor3', 'Измерение 3', 13, None),
('error0', 'Ошибки 0 (маска)', 14, None),
('error1', 'Ошибки 1 (маска)', 15, None),
('error2', 'Ошибки 2 (маска)', 16, None),
('error3', 'Ошибки 3 (маска)', 17, None),
('can_header', 'CAN адрес/маска', 18, None),
('can0', 'CAN слово 0', 19, None),
('can1', 'CAN слово 1', 20, None),
('can2', 'CAN слово 2', 21, None),
('can_count', 'Количество CAN RX (mod 65536)', 22, None),
('can_timeout', 'Таймауты CAN TX (mod 65536)', 23, None),
('commands', 'Команды (маска)', 3, None),
('go', 'Начальная уставка, мА', 24, None),
('stop', 'Конечная уставка, мА', 25, None),
('dac_min', 'Код калибровки 4 мА', 26, None),
('dac_max', 'Код калибровки 20 мА', 27, None),
('dac_cal', 'Калибровочный код ЦАП', 28, None),
('dac04', 'DAC_04 (регистр)', 29, None),
('dac20', 'DAC_20 (регистр)', 30, None),
('ramp', 'Счётчик разгона', 31, None),
('duration', 'Длительность разгона, тики ЦАП', 32, None),
('effective_go', 'Начальная уставка после ограничений, мА', 33, None),
('effective_stop', 'Конечная уставка после ограничений, мА', 34, None),
('position', 'Положение разгона (kod)', 35, None),
('adc0', 'АЦП канал 0', 36, None),
('adc1', 'АЦП канал 1', 37, None),
('adc2', 'АЦП канал 2', 38, None),
('adc3', 'АЦП канал 3', 39, None),
('zpt1', 'ЗПТ1: U1 + U2 (ПМ67, рег. 306)', 40, None),
('zpt2', 'ЗПТ2: U3 + U4 (ПМ67, рег. 9)', 41, None),
)
def request(controller: int, function: int, address: int, value: int, *, record_words: int = WORDS) -> bytes:
if not 0 <= controller <= 255 or not 0 <= value <= 65535:
raise ValueError('Недопустимый адрес контроллера или значение')
if record_words not in (40, 42):
raise ValueError('Неверный размер записи логгера')
capacity = {40: 800, 42: 760}[record_words]
if function == 3:
valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
(address == DATA and 1 <= value <= 120 and (value % WORDS == 0 or value in (42, 84))) or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4)
or (address == FAST_CAPABILITY and value == 4)
or (FAST_DATA <= address < FAST_DATA + capacity * record_words
and (address - FAST_DATA) % record_words == 0
and 1 <= value <= 120 and value % record_words == 0
and address + value <= FAST_DATA + capacity * record_words))
else:
valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) or
(address == PROTECTION + 3 and 1 <= value <= 60000) or
address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
(address == EMU_CONTROL + 2 and value in (0, 1)))
if not valid:
raise ValueError('Недопустимый запрос логгера УМП')
return protocol.with_crc(bytes((controller, function)) + address.to_bytes(2, 'big') + value.to_bytes(2, 'big'))
def request(controller: int, function: int, address: int, value: int, *, record_words: int = WORDS) -> bytes: # Проверяет запрос с учётом ширины записи, прочитанной из статуса платы.
if not 0 <= controller <= 255 or not 0 <= value <= 65535: # Ограничивает адрес одним октетом, значение — 16-битным словом.
raise ValueError('Недопустимый адрес контроллера или значение') # Запрещает усечение входных параметров при упаковке.
if record_words not in (COMPACT_WORDS, 40, 42): # Допускает только известные компактный и прежние форматы записи.
raise ValueError('Неверный размер записи логгера') # Не позволяет строить адреса по неизвестной ширине.
capacity = {COMPACT_WORDS: COMPACT_CAPACITY, 40: 800, 42: 760}[record_words] # Выбирает реальную границу банка по формату.
if function == 3: # Разрешает только существующие диагностические блоки чтения.
valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) # Читает полный блок защиты либо дескриптор журнала.
or (address == DATA and 1 <= value <= 120 and (value % COMPACT_WORDS == 0 or value in (40, 80, 42, 84))) # Принимает целые записи v4 и исторические блоки v2/v3.
or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4) # Сохраняет фиксированные размеры онлайн-блока и управления эмуляцией.
or (address == FAST_CAPABILITY and value == 4) # Проверяет возможность ускоренного архивного чтения.
or (FAST_DATA <= address < FAST_DATA + capacity * record_words # Ограничивает адрес началом и концом реального банка.
and (address - FAST_DATA) % record_words == 0 # Требует начала блока на границе записи.
and 1 <= value <= 120 and value % record_words == 0 # Передаёт до 120 слов, содержащих целое число записей.
and address + value <= FAST_DATA + capacity * record_words)) # Исключает пересечение конца банка.
else: # Запись разрешена только прежним управляющим адресам.
valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) # Проверяет маску эмуляции защиты.
or (address == PROTECTION + 3 and 1 <= value <= 60000) # Проверяет тайм-аут свежести CAN в миллисекундах.
or address in (BASE, BASE + 1, BASE + 2, BASE + 3) # Оставляет команды управления журналом без запуска УМП.
or (address == EMU_CONTROL + 2 and value in (0, 1))) # Сохраняет двоичное включение эмулятора.
if not valid: # Запрещает запросы вне согласованных адресов и размеров.
raise ValueError('Недопустимый запрос логгера УМП') # Возвращает прежнее понятное сообщение вызывающему коду.
return protocol.with_crc(bytes((controller, function)) + address.to_bytes(2, 'big') + value.to_bytes(2, 'big')) # Передаёт 16-битный адрес и добавляет общий Modbus CRC.
@dataclass(frozen=True)
@@ -114,27 +89,27 @@ class Status:
@classmethod
def decode(cls, words):
if (len(words) != 16 or words[0] != 0x554D or
(words[1], words[4], words[5]) not in ((2, 40, 800), (3, 42, 760))):
raise ValueError('Прошивка не поддерживает логгер УМП v2/v3')
(words[1], words[4], words[5]) not in ((2, 40, 800), (3, 42, 760), (4, COMPACT_WORDS, COMPACT_CAPACITY))): # Проверяет согласованные версию, ширину и ёмкость.
raise ValueError('Прошивка не поддерживает логгер УМП v2/v3/v4') # Перечисляет известные форматы без разрешения неизвестной прошивки.
if words[9] != 6:
raise ValueError('Логгер УМП доступен только на плате нагрузки dsk_LOAD')
if words[2] not in (0, 1, 2, 3) or words[3] > words[5] or not 1 <= words[6] <= 1000 or words[11] != 1000 or words[13] > words[5] or words[14] > 1000 or words[15] not in (0, 1, 3):
post_limit = max(1000, min(65535, words[5] // 10 * words[6])) if words[1] >= 3 else 1000 # Постинтервал v3/v4 занимает 10% точек; при 760 точках и 20 мс его оценка достигает 1520 мс.
if words[2] not in (0, 1, 2, 3) or words[3] > words[5] or not 1 <= words[6] <= 1000 or words[11] != 1000 or words[13] > words[5] or words[14] > post_limit or words[15] not in (0, 1, 3):
raise ValueError('Повреждён статус логгера')
return cls(words[2], words[3], words[6], words[10], words[11], *words[12:16], words[4], words[5])
def sample(words, frequency=1000):
if len(words) not in (40, 42) or frequency <= 0:
if len(words) not in (COMPACT_WORDS, 40, 42) or frequency <= 0: # Допускает только известную ширину записи и положительную частоту времени.
raise ValueError('Неверная запись логгера')
result = {'time_ms': (words[0] | words[1] << 16) * 1000 / frequency, 'event': words[2]}
for key, _label, index, bit in FIELDS:
value = words[index] if index < len(words) else float('nan')
physical_index = COMPACT_INDEX[index] if len(words) == COMPACT_WORDS else index # Выбирает индекс оставшегося поля по фактической версии записи.
value = words[physical_index] if physical_index < len(words) else float('nan') # ЗПТ в старых 40-словных архивах остаётся неизвестным.
if bit is not None:
value = (value >> bit) & 1
elif 10 <= index <= 13 and value >= 32768:
value -= 65536
# Физические единицы приводим здесь, одинаково для CSV и графика.
if key in ('current', 'effective_go', 'effective_stop'):
if key == 'current': # Сохранившееся задание имеет масштаб 0,1 мА; уставки и напряжения передаются целыми единицами.
value /= 10.0
result[key] = value
return result

View File

@@ -27,7 +27,7 @@ def build_request(mode, token, function, address, value):
if not 1 <= token <= 255:
raise ValueError('Неверный номер CAN-запроса')
valid = ((function == 3 and ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
(address == DATA and value in (40, 80, 120, 42, 84)) or
(address == DATA and (value in (40, 80, 42, 84) or 0 < value <= 120 and value % 12 == 0)) or # Читает целые компактные записи и прежние архивы.
(address == LIVE and value == MAX_WORDS) or (address == EMU_CONTROL and value == 4))) or
(function == 6 and ((BASE <= address <= BASE+3 and 0 <= value <= 65535) or
(address == PROTECTION+2 and value in (0, 1, 2, 3)) or

Some files were not shown because too many files have changed in this diff Show More